@ai-agent-forge/plugin-workflow 0.86.4 → 0.86.5
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/dist/engine.d.ts +4 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +21 -12
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -6
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate.d.ts +11 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +7 -1
- package/dist/validate.js.map +1 -1
- package/package.json +2 -2
package/dist/engine.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface AdvanceInput {
|
|
|
53
53
|
stageId?: string;
|
|
54
54
|
evidence?: EvidenceItem[];
|
|
55
55
|
note?: string;
|
|
56
|
+
/** Advisory executor id for record-evidence; written to StageRuntime.executor last-write-wins (§4). */
|
|
57
|
+
executor?: string;
|
|
56
58
|
}
|
|
57
59
|
export interface ClosureEvidenceItem {
|
|
58
60
|
stageId: string;
|
|
@@ -80,7 +82,8 @@ export declare function maxActiveOf(workflow: WorkflowRecord): number;
|
|
|
80
82
|
/** Snapshot reevaluation over every workflow in the state (one budget per workflow per run). */
|
|
81
83
|
export declare function reevaluateAll(state: WorkflowState, deps: EngineDeps, trigger: WorkflowTransitionTrigger): EngineRun;
|
|
82
84
|
/**
|
|
83
|
-
* Applies a graded full-submit update: workflow-level fields
|
|
85
|
+
* Applies a graded full-submit update: workflow-level fields (v8 §6: omitted
|
|
86
|
+
* optional fields keep their stored values), the new stage
|
|
84
87
|
* sequence (runtime aligned by stageId — existing runtimes are preserved,
|
|
85
88
|
* appended stages get fresh pending runtimes), evidence linkage pruning for
|
|
86
89
|
* removed acceptance ids, one stage.spec-amended transition per amended
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Workflow advance engine: pure state-machine derivation over WorkflowState\n * (§5 of docs/design/工作流编排插件设计.md).\n *\n * Everything here is deterministic and derived from the persisted snapshot\n * plus a plan-lookup callback: no clocks, no CapabilityAPI access — index.ts\n * owns persistence (§5 总则 1 snapshot-driven reevaluation; events are only\n * trigger signals, the truth always comes from the current branch entries).\n * Every conversion appends exactly one transition; held/rejected activation\n * attempts are audited too, deduplicated when the previous transition for the\n * stage already carries the same held code and unchanged wait condition\n * (§5 总则 5).\n *\n * Budget note (§5 总则 4): the per-workflow cascade limit\n * (max(32, 2 x stageCount), recomputed per run from the workflow's current\n * stage count — see cascadeLimitFor) covers every transition of one run —\n * the explicit action's own conversions plus the derived reevaluation chain.\n * Logical operations reserve their maximum\n * bump count up front (canBump) so a mid-chain stop never leaves a mutated\n * status without its audit entry; hitting the cap is not terminal — any\n * workflow_advance action resumes the chain.\n */\n\nimport type {\n\tStageRuntime,\n\tStageSpec,\n\tTransitionReason,\n\tTransitionReasonCode,\n\tWorkflowRecord,\n\tWorkflowState,\n\tWorkflowTransitionEntry,\n\tWorkflowTransitionTrigger,\n} from \"./types.ts\";\nimport type { ParsedWorkflowSet, WorkflowSetDiff } from \"./validate.ts\";\n\n/** Minimal plan projection read from the current branch's latest plan.state entry. */\nexport interface PlanFacts {\n\treadonly id: string;\n\treadonly status: \"draft\" | \"active\" | \"completed\" | \"cancelled\";\n\treadonly evidence: readonly string[];\n}\n\nexport interface EngineDeps {\n\t/** Plan lookup against the current branch entries (latest plan.state snapshot). */\n\tgetPlan(planId: string): PlanFacts | undefined;\n\t/**\n\t * Last transition per stage (`${workflowId}::${stageId}`), seeded from the\n\t * branch audit stream at load and maintained by the engine — the input to\n\t * the held-dedup rule (§5 总则 5).\n\t */\n\tlastStageTransition: Map<string, { code: TransitionReasonCode; detail?: string }>;\n}\n\n/** Evidence item accepted by workflow_advance (acceptanceId is required in acceptance mode). */\nexport interface EvidenceItem {\n\tacceptanceId?: string;\n\tref: string;\n}\n\nexport type AdvanceAction =\n\t| \"start\"\n\t| \"record-evidence\"\n\t| \"inspect\"\n\t| \"approve\"\n\t| \"reject\"\n\t| \"retreat\"\n\t| \"complete\"\n\t| \"skip\"\n\t| \"cancel\";\n\nexport interface AdvanceInput {\n\tworkflowId: string;\n\taction: AdvanceAction;\n\tstageId?: string;\n\tevidence?: EvidenceItem[];\n\tnote?: string;\n}\n\nexport interface ClosureEvidenceItem {\n\tstageId: string;\n\tacceptanceId: string;\n\tref: string;\n}\n\n/** Rule 7 closure summary: per-acceptance evidence of completed stages, as goal-achievement candidates. */\nexport interface WorkflowClosure {\n\tworkflowId: string;\n\tgoalId?: string;\n\tevidence: ClosureEvidenceItem[];\n}\n\nexport interface EngineRun {\n\tstate: WorkflowState;\n\ttransitions: WorkflowTransitionEntry[];\n\tcascadeLimitReached: boolean;\n\tclosures: WorkflowClosure[];\n}\n\nexport const DEFAULT_RETRY_LIMIT = 2;\nexport const DEFAULT_MAX_RETREATS = 3;\nexport const DEFAULT_MAX_ACTIVE_STAGES = 4;\nconst DEFAULT_CASCADE_LIMIT = 32;\nconst DIAGNOSTICS_CAP = 50;\n\nexport function cascadeLimitFor(workflow: WorkflowRecord): number {\n\treturn Math.max(DEFAULT_CASCADE_LIMIT, 2 * workflow.stages.length);\n}\n\n// ---------------------------------------------------------------------------\n// Small lookups\n// ---------------------------------------------------------------------------\n\nfunction runtimeOf(workflow: WorkflowRecord, stageId: string): StageRuntime | undefined {\n\treturn workflow.runtime.find((item) => item.stageId === stageId);\n}\n\nfunction specOf(workflow: WorkflowRecord, stageId: string): StageSpec | undefined {\n\treturn workflow.stages.find((item) => item.id === stageId);\n}\n\nfunction retryLimitOf(spec: StageSpec): number {\n\treturn spec.retryLimit ?? DEFAULT_RETRY_LIMIT;\n}\n\nexport function maxRetreatsOf(workflow: WorkflowRecord): number {\n\treturn workflow.maxRetreats ?? DEFAULT_MAX_RETREATS;\n}\n\nexport function maxActiveOf(workflow: WorkflowRecord): number {\n\treturn workflow.maxActiveStages ?? DEFAULT_MAX_ACTIVE_STAGES;\n}\n\nfunction activeCount(workflow: WorkflowRecord): number {\n\treturn workflow.runtime.filter((item) => item.status === \"active\").length;\n}\n\nfunction uncoveredAcceptanceIds(spec: StageSpec, runtime: StageRuntime): string[] {\n\tconst covered = new Set(runtime.evidence.map((item) => item.acceptanceId));\n\treturn spec.objective.acceptance.map((item) => item.id).filter((id) => !covered.has(id));\n}\n\nfunction pushDiagnostic(workflow: WorkflowRecord, text: string): void {\n\tif (workflow.diagnostics.length >= DIAGNOSTICS_CAP) workflow.diagnostics.shift();\n\tworkflow.diagnostics.push(text);\n}\n\nconst stageKey = (workflowId: string, stageId: string): string => `${workflowId}::${stageId}`;\n\n// ---------------------------------------------------------------------------\n// Run context and transition emission\n// ---------------------------------------------------------------------------\n\ninterface WorkflowRun {\n\tworkflow: WorkflowRecord; // mutable working copy\n\tdeps: EngineDeps;\n\tlimit: number;\n\tused: number;\n\ttransitions: WorkflowTransitionEntry[];\n\tclosures: WorkflowClosure[];\n\thitLimit: boolean;\n}\n\nfunction newWorkflowRun(workflow: WorkflowRecord, deps: EngineDeps, closures: WorkflowClosure[]): WorkflowRun {\n\treturn { workflow, deps, limit: cascadeLimitFor(workflow), used: 0, transitions: [], closures, hitLimit: false };\n}\n\n/** Reserves `needed` transition slots so an operation never mutates without its audit entry. */\nfunction canBump(run: WorkflowRun, needed: number): boolean {\n\tif (run.used + needed <= run.limit) return true;\n\trun.hitLimit = true;\n\treturn false;\n}\n\nfunction appendTransition(\n\trun: WorkflowRun,\n\tstageId: string | undefined,\n\tfrom: string,\n\tto: string,\n\ttrigger: WorkflowTransitionTrigger,\n\treason: TransitionReason,\n): boolean {\n\tif (run.used >= run.limit) {\n\t\trun.hitLimit = true;\n\t\treturn false;\n\t}\n\trun.workflow.revision += 1;\n\trun.transitions.push({\n\t\tworkflowId: run.workflow.id,\n\t\t...(stageId !== undefined ? { stageId } : {}),\n\t\tfrom,\n\t\tto,\n\t\ttrigger,\n\t\treason,\n\t\trevision: run.workflow.revision,\n\t});\n\trun.used += 1;\n\tif (stageId !== undefined) {\n\t\trun.deps.lastStageTransition.set(stageKey(run.workflow.id, stageId), {\n\t\t\tcode: reason.code,\n\t\t\tdetail: reason.detail,\n\t\t});\n\t}\n\treturn true;\n}\n\n/**\n * A held (not activated) pending stage: appends the held transition unless\n * the stage's previous transition already carries the same held code and\n * unchanged wait condition (§5 总则 5 dedup — reevaluation becomes a no-op).\n * plan-dangling and write-scope-conflict additionally raise a diagnostic\n * (§5 总则 2 conditions b/d); deps-incomplete and active-limit are normal\n * graph waiting and stay transition-only.\n */\nfunction holdStage(\n\trun: WorkflowRun,\n\tstageId: string,\n\ttrigger: WorkflowTransitionTrigger,\n\tcode: TransitionReasonCode,\n\tmessage: string,\n\tdetail: string,\n): boolean {\n\tconst key = stageKey(run.workflow.id, stageId);\n\tconst last = run.deps.lastStageTransition.get(key);\n\tif (last !== undefined && last.code === code && last.detail === detail) return false;\n\tconst runtime = runtimeOf(run.workflow, stageId);\n\tconst from = runtime?.status ?? \"pending\";\n\tif (!appendTransition(run, stageId, from, from, trigger, { code, message, detail })) return false;\n\tif (code === \"stage.activated.held-plan-dangling\" || code === \"stage.held.write-scope-conflict\") {\n\t\tpushDiagnostic(run.workflow, message);\n\t}\n\treturn true;\n}\n\n// ---------------------------------------------------------------------------\n// Scope guards (§5 总则 2 condition d)\n// ---------------------------------------------------------------------------\n\n/**\n * An undeclared (or empty) scope on a write stage is global: it intersects\n * every other write stage. Read stages are never scope-checked.\n */\nfunction scopesIntersect(a: string[] | undefined, b: string[] | undefined): boolean {\n\tif (a === undefined || a.length === 0 || b === undefined || b.length === 0) return true;\n\treturn a.some((tag) => b.includes(tag));\n}\n\nfunction findWriteConflict(workflow: WorkflowRecord, spec: StageSpec): StageSpec | undefined {\n\treturn workflow.stages.find((other) => {\n\t\tif (other.id === spec.id || other.kind !== \"write\") return false;\n\t\treturn runtimeOf(workflow, other.id)?.status === \"active\" && scopesIntersect(spec.scope, other.scope);\n\t});\n}\n\n/** Every stage that transitively depends on `stageId` (reverse dependsOn reachability). */\nfunction transitiveDependents(workflow: WorkflowRecord, stageId: string): string[] {\n\tconst dependents = new Set<string>();\n\tlet grew = true;\n\twhile (grew) {\n\t\tgrew = false;\n\t\tfor (const spec of workflow.stages) {\n\t\t\tif (dependents.has(spec.id)) continue;\n\t\t\tif (spec.dependsOn.some((dep) => dep === stageId || dependents.has(dep))) {\n\t\t\t\tdependents.add(spec.id);\n\t\t\t\tgrew = true;\n\t\t\t}\n\t\t}\n\t}\n\treturn [...dependents];\n}\n\n// ---------------------------------------------------------------------------\n// Evaluation (§5 rule 3) and failure disposal (§5 rule 5)\n// ---------------------------------------------------------------------------\n\n/** Settlement of a passed evaluation: auto -> completed, review -> awaiting_review. */\nfunction settlePassedStage(\n\trun: WorkflowRun,\n\tspec: StageSpec,\n\truntime: StageRuntime,\n\ttrigger: WorkflowTransitionTrigger,\n): number {\n\tconst from = runtime.status;\n\tif (spec.advance === \"review\") {\n\t\truntime.status = \"awaiting_review\";\n\t\t// §5 rule 3: awaiting_review does not count as completed — dependents stay locked.\n\t\treturn appendTransition(run, spec.id, from, \"awaiting_review\", trigger, {\n\t\t\tcode: \"stage.awaiting-review\",\n\t\t\tmessage: `stage ${spec.id} passed evaluation and awaits review`,\n\t\t})\n\t\t\t? 1\n\t\t\t: 0;\n\t}\n\truntime.status = \"completed\";\n\tconst covered = spec.objective.acceptance.length > 0;\n\treturn appendTransition(run, spec.id, from, \"completed\", trigger, {\n\t\tcode: covered ? \"stage.completed.acceptance-covered\" : \"stage.completed\",\n\t\tmessage: `stage ${spec.id} completed (${covered ? \"acceptance covered\" : \"plan lower bound\"})`,\n\t})\n\t\t? 1\n\t\t: 0;\n}\n\n/**\n * §5 rule 3 objective evaluation of one ACTIVE stage against its plan: the\n * lower bound is the completed plan's own evidence; declared acceptance adds\n * per-item evidence coverage. Cancelled plan or unmet evaluation -> rule 5\n * failure disposal.\n */\nfunction evaluateActiveStage(\n\trun: WorkflowRun,\n\tspec: StageSpec,\n\truntime: StageRuntime,\n\ttrigger: WorkflowTransitionTrigger,\n): number {\n\tif (spec.planId === undefined) return 0; // checkpoint stages settle via the explicit complete action\n\tconst plan = run.deps.getPlan(spec.planId);\n\tif (plan === undefined) return 0; // unresolvable after activation (plans are append-only); nothing to evaluate\n\tif (plan.status === \"cancelled\") {\n\t\treturn failStage(run, spec, runtime, trigger, \"stage.failed.plan-cancelled\", `plan ${spec.planId} is cancelled`);\n\t}\n\tif (plan.status !== \"completed\") return 0; // waiting: plan-incomplete\n\tconst missing = uncoveredAcceptanceIds(spec, runtime);\n\tconst lowerBoundUnmet = spec.objective.acceptance.length === 0 && plan.evidence.length === 0;\n\tif (missing.length > 0 || lowerBoundUnmet) {\n\t\treturn failStage(\n\t\t\trun,\n\t\t\tspec,\n\t\t\truntime,\n\t\t\ttrigger,\n\t\t\t\"stage.failed.acceptance-missing\",\n\t\t\t`missing acceptance: [${missing.join(\", \")}]${lowerBoundUnmet ? \" (plan evidence empty)\" : \"\"}`,\n\t\t);\n\t}\n\treturn settlePassedStage(run, spec, runtime, trigger);\n}\n\n/**\n * §5 rule 5 failure disposal: the stage transition to failed is audited, then\n * either in-place retry (evidence retained, attempts + 1), cross-stage\n * retreat via the stage's onFail edge (when retries are exhausted and onFail\n * is declared), or workflow failure. A failed workflow keeps every stage in\n * its current state — no forced settlement (rule 5, contrast with cancel).\n */\nfunction failStage(\n\trun: WorkflowRun,\n\tspec: StageSpec,\n\truntime: StageRuntime,\n\ttrigger: WorkflowTransitionTrigger,\n\tcode: TransitionReasonCode,\n\tdetail: string,\n): number {\n\tif (!canBump(run, 2)) return 0; // failure transition + one disposal transition\n\tconst from = runtime.status;\n\truntime.status = \"failed\";\n\tconst appended = appendTransition(run, spec.id, from, \"failed\", trigger, {\n\t\tcode,\n\t\tmessage: `stage ${spec.id} failed`,\n\t\tdetail,\n\t})\n\t\t? 1\n\t\t: 0;\n\tpushDiagnostic(run.workflow, `stage ${spec.id} failed: ${detail}`);\n\truntime.attempts += 1;\n\tif (runtime.attempts > retryLimitOf(spec)) {\n\t\tif (spec.onFail !== undefined) {\n\t\t\tconst target = specOf(run.workflow, spec.onFail);\n\t\t\tif (target !== undefined)\n\t\t\t\treturn appended + crossStageRetreat(run, target, trigger, `stage ${spec.id} exhausted retries`, spec);\n\t\t}\n\t\tconst message = `workflow failed: stage ${spec.id} exhausted retry limit ${retryLimitOf(spec)}`;\n\t\tpushDiagnostic(run.workflow, message);\n\t\trun.workflow.status = \"failed\";\n\t\treturn appendTransition(run, undefined, \"running\", \"failed\", trigger, {\n\t\t\tcode: \"workflow.failed.stage-retries\",\n\t\t\tmessage,\n\t\t\tdetail: `stage:${spec.id}`,\n\t\t})\n\t\t\t? appended + 1\n\t\t\t: appended;\n\t}\n\truntime.status = \"pending\";\n\treturn appendTransition(run, spec.id, \"failed\", \"pending\", trigger, {\n\t\tcode: \"stage.retry.queued\",\n\t\tmessage: `stage ${spec.id} queued for in-place retry (attempt ${runtime.attempts}/${retryLimitOf(spec)}); evidence retained`,\n\t})\n\t\t? appended + 1\n\t\t: appended;\n}\n\n/**\n * §5 rule 5 cross-stage retreat (explicit onFail, retreat, or reject): the\n * retreat budget is checked first — over budget the workflow fails and the\n * scene is preserved as-is; otherwise the target resets to pending\n * unconditionally (a retry-waiting target loses its evidence too) and every\n * non-pending transitive dependent resets to pending with evidence cleared\n * (their inputs are invalidated — DAG propagation). The failing/rejected\n * `origin` stage itself always resets as well (rule 5 is unconditional): when\n * the onFail target sits on a parallel branch the origin is outside the\n * dependent closure and joins the reset set explicitly. attempts are\n * retained (§4); `skipped` dependents never reset (explicit scope ruling).\n */\nfunction crossStageRetreat(\n\trun: WorkflowRun,\n\ttarget: StageSpec,\n\ttrigger: WorkflowTransitionTrigger,\n\tnote: string,\n\torigin?: StageSpec,\n): number {\n\tconst dependents = transitiveDependents(run.workflow, target.id);\n\tconst originOutside = origin !== undefined && origin.id !== target.id && !dependents.includes(origin.id);\n\t// Retreat audit transition (+ at most one more disposal transition when the\n\t// origin sits outside the dependent closure and needs its own audit slot).\n\tif (!canBump(run, originOutside ? 2 : 1)) return 0;\n\trun.workflow.retreatCount += 1;\n\tif (run.workflow.retreatCount > maxRetreatsOf(run.workflow)) {\n\t\tconst message = `workflow failed: retreat budget exhausted (${run.workflow.retreatCount}/${maxRetreatsOf(run.workflow)})`;\n\t\tpushDiagnostic(run.workflow, `${message} — ${note}`);\n\t\trun.workflow.status = \"failed\";\n\t\treturn appendTransition(run, undefined, \"running\", \"failed\", trigger, {\n\t\t\tcode: \"workflow.failed.retreat-budget\",\n\t\t\tmessage,\n\t\t\tdetail: note,\n\t\t})\n\t\t\t? 1\n\t\t\t: 0;\n\t}\n\tconst targetFrom = runtimeOf(run.workflow, target.id)?.status ?? \"pending\";\n\tconst resetIds: string[] = [];\n\tfor (const stageId of [target.id, ...(originOutside ? [origin.id] : []), ...dependents]) {\n\t\tconst runtime = runtimeOf(run.workflow, stageId);\n\t\tif (runtime === undefined) continue;\n\t\tif (\n\t\t\tstageId === target.id ||\n\t\t\tstageId === origin?.id ||\n\t\t\truntime.status === \"completed\" ||\n\t\t\truntime.status === \"active\" ||\n\t\t\truntime.status === \"awaiting_review\" ||\n\t\t\truntime.status === \"failed\"\n\t\t) {\n\t\t\truntime.status = \"pending\";\n\t\t\truntime.evidence = [];\n\t\t\tresetIds.push(stageId);\n\t\t}\n\t}\n\tconst detail = `target:${target.id} reset:[${resetIds.join(\", \")}]${note !== \"\" ? ` — ${note}` : \"\"}`;\n\treturn appendTransition(run, target.id, targetFrom, \"pending\", trigger, {\n\t\tcode: \"stage.retreated\",\n\t\tmessage: `retreat to stage ${target.id}: reset [${resetIds.join(\", \")}]`,\n\t\tdetail,\n\t})\n\t\t? 1\n\t\t: 0;\n}\n\n// ---------------------------------------------------------------------------\n// Activation scan (§5 总则 2) and the reevaluation loop (§5 总则 1)\n// ---------------------------------------------------------------------------\n\n/**\n * One activation pass over pending stages in declaration order. A pending\n * stage activates only when (a) dependsOn are all completed/skipped (skip\n * counts as completed for unlocking — §5 rule 6), (b) its planId resolves in\n * the current branch entries, (c) the active count is below the cap, (d) a\n * write stage's scope does not intersect an active write stage, and (e)\n * attempts === 0 — retry-waiting stages never auto-activate (anti\n * burnout-spiral, §5 总则 2 e). Each freshly activated stage is immediately\n * re-evaluated once (§5 rule 2), which covers \"plan already completed at\n * activation\" and pure-checkpoint restore flows.\n */\nfunction activationPass(run: WorkflowRun, trigger: WorkflowTransitionTrigger): number {\n\tlet appended = 0;\n\tfor (const spec of run.workflow.stages) {\n\t\tconst runtime = runtimeOf(run.workflow, spec.id);\n\t\tif (runtime === undefined || runtime.status !== \"pending\" || runtime.attempts !== 0) continue; // (e)\n\t\tconst incomplete = spec.dependsOn.filter((dep) => {\n\t\t\tconst dependency = runtimeOf(run.workflow, dep);\n\t\t\treturn !(dependency !== undefined && (dependency.status === \"completed\" || dependency.status === \"skipped\"));\n\t\t});\n\t\tif (incomplete.length > 0) {\n\t\t\tif (\n\t\t\t\tholdStage(\n\t\t\t\t\trun,\n\t\t\t\t\tspec.id,\n\t\t\t\t\ttrigger,\n\t\t\t\t\t\"stage.held.deps-incomplete\",\n\t\t\t\t\t`stage ${spec.id} waiting: dependencies incomplete [${incomplete.join(\", \")}]`,\n\t\t\t\t\t`missing:[${incomplete.join(\", \")}]`,\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tappended += 1;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (spec.planId !== undefined && run.deps.getPlan(spec.planId) === undefined) {\n\t\t\tif (\n\t\t\t\tholdStage(\n\t\t\t\t\trun,\n\t\t\t\t\tspec.id,\n\t\t\t\t\ttrigger,\n\t\t\t\t\t\"stage.activated.held-plan-dangling\",\n\t\t\t\t\t`stage ${spec.id} held: plan ${spec.planId} not found in the session branch`,\n\t\t\t\t\t`plan:${spec.planId}`,\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tappended += 1;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (activeCount(run.workflow) >= maxActiveOf(run.workflow)) {\n\t\t\tif (\n\t\t\t\tholdStage(\n\t\t\t\t\trun,\n\t\t\t\t\tspec.id,\n\t\t\t\t\ttrigger,\n\t\t\t\t\t\"stage.held.active-limit\",\n\t\t\t\t\t`stage ${spec.id} waiting: active stage limit (${maxActiveOf(run.workflow)}) reached`,\n\t\t\t\t\t`limit:${maxActiveOf(run.workflow)}`,\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tappended += 1;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (spec.kind === \"write\") {\n\t\t\tconst conflict = findWriteConflict(run.workflow, spec);\n\t\t\tif (conflict !== undefined) {\n\t\t\t\tconst own = spec.scope === undefined || spec.scope.length === 0 ? \"global\" : spec.scope.join(\",\");\n\t\t\t\tconst other =\n\t\t\t\t\tconflict.scope === undefined || conflict.scope.length === 0 ? \"global\" : conflict.scope.join(\",\");\n\t\t\t\tif (\n\t\t\t\t\tholdStage(\n\t\t\t\t\t\trun,\n\t\t\t\t\t\tspec.id,\n\t\t\t\t\t\ttrigger,\n\t\t\t\t\t\t\"stage.held.write-scope-conflict\",\n\t\t\t\t\t\t`stage ${spec.id} blocked: write scope [${own}] intersects active stage ${conflict.id} scope [${other}]`,\n\t\t\t\t\t\t`conflict:${conflict.id} scope:[${own}] vs [${other}]`,\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\tappended += 1;\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\t\tif (!canBump(run, 3)) break; // activation + immediate evaluation chain (worst case 2 more)\n\t\truntime.status = \"active\";\n\t\tif (\n\t\t\tappendTransition(run, spec.id, \"pending\", \"active\", trigger, {\n\t\t\t\tcode: \"stage.activated\",\n\t\t\t\tmessage: `stage ${spec.id} activated`,\n\t\t\t})\n\t\t) {\n\t\t\tappended += 1;\n\t\t}\n\t\t// §5 rule 2: immediate single-stage snapshot reevaluation of the activated stage.\n\t\tappended += evaluateActiveStage(run, spec, runtime, \"activation-reeval\");\n\t}\n\treturn appended;\n}\n\n/** §5 rule 7 closure: all stages completed/skipped -> workflow completed, closure evidence recorded. */\nfunction checkClosure(run: WorkflowRun, trigger: WorkflowTransitionTrigger): number {\n\tif (run.workflow.status !== \"running\") return 0;\n\tif (!run.workflow.runtime.every((item) => item.status === \"completed\" || item.status === \"skipped\")) return 0;\n\tif (!canBump(run, 1)) return 0;\n\trun.workflow.status = \"completed\";\n\tconst evidence = run.workflow.runtime\n\t\t.filter((item) => item.status === \"completed\")\n\t\t.flatMap((item) =>\n\t\t\titem.evidence.map((entry) => ({ stageId: item.stageId, acceptanceId: entry.acceptanceId, ref: entry.ref })),\n\t\t);\n\trun.closures.push({\n\t\tworkflowId: run.workflow.id,\n\t\t...(run.workflow.goalId !== undefined ? { goalId: run.workflow.goalId } : {}),\n\t\tevidence,\n\t});\n\treturn appendTransition(run, undefined, \"running\", \"completed\", trigger, {\n\t\tcode: \"workflow.completed\",\n\t\tmessage: `workflow completed: all ${run.workflow.stages.length} stage(s) settled`,\n\t\tdetail: `evidence items: ${evidence.length}`,\n\t})\n\t\t? 1\n\t\t: 0;\n}\n\n/** §5 总则 4: the cascade marker is appended beyond the budget by design (it is the stop record, not a chain step). */\nfunction markCascadeLimit(run: WorkflowRun, trigger: WorkflowTransitionTrigger): void {\n\tconst message = `cascade limit ${run.limit} reached; transitions stopped — resumable via any workflow_advance action`;\n\tpushDiagnostic(run.workflow, message);\n\trun.workflow.revision += 1;\n\trun.transitions.push({\n\t\tworkflowId: run.workflow.id,\n\t\tfrom: run.workflow.status,\n\t\tto: run.workflow.status,\n\t\ttrigger,\n\t\treason: { code: \"cascade.limit-reached\", message },\n\t\trevision: run.workflow.revision,\n\t});\n}\n\n/**\n * The reevaluation loop for one running workflow: evaluate active stages ->\n * scan activation -> check closure, repeating to a fixed point (no new\n * persisted transitions). Condition-not-met passes are idempotent no-ops;\n * the held dedup keeps repeat passes audit-silent (§5 总则 1 / 总则 5).\n */\nfunction reevaluateWorkflow(run: WorkflowRun, trigger: WorkflowTransitionTrigger): void {\n\twhile (run.workflow.status === \"running\") {\n\t\tif (run.used >= run.limit) {\n\t\t\trun.hitLimit = true;\n\t\t\tbreak;\n\t\t}\n\t\tlet appended = 0;\n\t\tfor (const spec of run.workflow.stages) {\n\t\t\tconst runtime = runtimeOf(run.workflow, spec.id);\n\t\t\tif (runtime === undefined || runtime.status !== \"active\") continue;\n\t\t\tif (!canBump(run, 2)) break;\n\t\t\tappended += evaluateActiveStage(run, spec, runtime, trigger);\n\t\t}\n\t\tif (run.hitLimit) break;\n\t\tappended += activationPass(run, trigger);\n\t\tif (run.hitLimit) break;\n\t\tappended += checkClosure(run, trigger);\n\t\tif (run.hitLimit) break;\n\t\tif (appended === 0) return; // fixed point\n\t}\n\tif (run.hitLimit) markCascadeLimit(run, trigger);\n}\n\n/** Snapshot reevaluation over every workflow in the state (one budget per workflow per run). */\nexport function reevaluateAll(state: WorkflowState, deps: EngineDeps, trigger: WorkflowTransitionTrigger): EngineRun {\n\tconst workflows = state.workflows.map((workflow) => structuredClone(workflow));\n\tconst run: EngineRun = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };\n\tfor (const workflow of workflows) {\n\t\tif (workflow.status !== \"running\") continue;\n\t\tconst workflowRun = newWorkflowRun(workflow, deps, run.closures);\n\t\treevaluateWorkflow(workflowRun, trigger);\n\t\trun.transitions.push(...workflowRun.transitions);\n\t\tif (workflowRun.hitLimit) run.cascadeLimitReached = true;\n\t}\n\treturn run;\n}\n\n// ---------------------------------------------------------------------------\n// workflow_set application (§6)\n// ---------------------------------------------------------------------------\n\n/**\n * Applies a graded full-submit update: workflow-level fields, the new stage\n * sequence (runtime aligned by stageId — existing runtimes are preserved,\n * appended stages get fresh pending runtimes), evidence linkage pruning for\n * removed acceptance ids, one stage.spec-amended transition per amended\n * active/awaiting_review stage, then the post-change reevaluation chain\n * (§5 总则 1: conversions settle -> same-call reevaluation).\n */\nexport function applyWorkflowSet(\n\tstate: WorkflowState,\n\tnext: ParsedWorkflowSet,\n\tdiff: WorkflowSetDiff,\n\tdeps: EngineDeps,\n): EngineRun {\n\tconst workflows = state.workflows.map((workflow) => structuredClone(workflow));\n\tconst run: EngineRun = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };\n\tif (diff.created) {\n\t\tworkflows.push({\n\t\t\tid: next.id,\n\t\t\ttitle: next.title,\n\t\t\t...(next.goalId !== undefined ? { goalId: next.goalId } : {}),\n\t\t\tstatus: \"draft\",\n\t\t\trevision: next.revision,\n\t\t\tstages: next.stages.map((stage) => structuredClone(stage)),\n\t\t\truntime: next.stages.map((stage) => ({ stageId: stage.id, status: \"pending\", attempts: 0, evidence: [] })),\n\t\t\tretreatCount: 0,\n\t\t\t...(next.maxRetreats !== undefined ? { maxRetreats: next.maxRetreats } : {}),\n\t\t\t...(next.maxActiveStages !== undefined ? { maxActiveStages: next.maxActiveStages } : {}),\n\t\t\tdiagnostics: [],\n\t\t});\n\t\treturn run;\n\t}\n\tconst record = workflows.find((workflow) => workflow.id === next.id);\n\tif (record === undefined) throw new Error(`unknown workflow: ${next.id}`);\n\t// Workflow-level fields are modifiable while running (§6), revision is the\n\t// caller's monotonic value; engine conversions below increment past it.\n\trecord.title = next.title;\n\tif (next.goalId === undefined) delete record.goalId;\n\telse record.goalId = next.goalId;\n\tif (next.maxRetreats === undefined) delete record.maxRetreats;\n\telse record.maxRetreats = next.maxRetreats;\n\tif (next.maxActiveStages === undefined) delete record.maxActiveStages;\n\telse record.maxActiveStages = next.maxActiveStages;\n\trecord.revision = next.revision;\n\trecord.stages = next.stages.map((stage) => structuredClone(stage));\n\tconst previousRuntime = new Map(record.runtime.map((item) => [item.stageId, item]));\n\trecord.runtime = record.stages.map(\n\t\t(spec) => previousRuntime.get(spec.id) ?? { stageId: spec.id, status: \"pending\", attempts: 0, evidence: [] },\n\t);\n\t// Evidence linkage (§6): an evidence item is dropped when its acceptanceId\n\t// is non-empty and no longer references the amended acceptance set — this\n\t// holds even when the amend emptied acceptance entirely, so no dead linkage\n\t// survives a switch to lower-bound mode. Ref-only items (empty\n\t// acceptanceId, checkpoint completion records) survive regardless of the\n\t// spec shape.\n\tfor (const spec of record.stages) {\n\t\tconst runtime = runtimeOf(record, spec.id);\n\t\tif (runtime === undefined || runtime.evidence.length === 0) continue;\n\t\tconst ids = new Set(spec.objective.acceptance.map((item) => item.id));\n\t\truntime.evidence = runtime.evidence.filter((entry) => entry.acceptanceId === \"\" || ids.has(entry.acceptanceId));\n\t}\n\tconst workflowRun = newWorkflowRun(record, deps, run.closures);\n\tfor (const change of diff.amendChanges) {\n\t\tconst runtime = runtimeOf(record, change.stageId);\n\t\tconst from = runtime?.status ?? \"pending\";\n\t\tconst detail =\n\t\t\t`fields:[${change.fields.join(\", \")}] note:${change.amendNote}` +\n\t\t\t(change.prunedEvidence.length > 0 ? ` pruned evidence for [${change.prunedEvidence.join(\", \")}]` : \"\");\n\t\tif (canBump(workflowRun, 1)) {\n\t\t\tappendTransition(workflowRun, change.stageId, from, from, \"tool\", {\n\t\t\t\tcode: \"stage.spec-amended\",\n\t\t\t\tmessage: `stage ${change.stageId} spec amended`,\n\t\t\t\tdetail,\n\t\t\t});\n\t\t}\n\t}\n\tif (record.status === \"running\") reevaluateWorkflow(workflowRun, \"tool\");\n\trun.transitions.push(...workflowRun.transitions);\n\tif (workflowRun.hitLimit) run.cascadeLimitReached = true;\n\treturn run;\n}\n\n// ---------------------------------------------------------------------------\n// workflow_advance application (§5 rule 6)\n// ---------------------------------------------------------------------------\n\nfunction requireStage(workflow: WorkflowRecord, stageId: string | undefined): StageSpec {\n\tif (stageId === undefined) throw new Error(\"stageId is required for this action\");\n\tconst spec = specOf(workflow, stageId);\n\tif (spec === undefined) throw new Error(`unknown stage: ${stageId} (workflow ${workflow.id})`);\n\treturn spec;\n}\n\nfunction normalizeEvidence(items: EvidenceItem[]): { acceptanceId: string; ref: string }[] {\n\treturn items.map((item) => ({ acceptanceId: item.acceptanceId ?? \"\", ref: item.ref }));\n}\n\n/**\n * Applies one explicit workflow_advance action, then runs the post-action\n * reevaluation chain (§5 总则 1). Illegal conversions throw structured\n * errors (§5 rule 6) — index.ts surfaces them as tool errors unchanged.\n */\nexport function applyAdvance(state: WorkflowState, input: AdvanceInput, deps: EngineDeps): EngineRun {\n\tconst workflows = state.workflows.map((workflow) => structuredClone(workflow));\n\tconst run: EngineRun = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };\n\tif (input.action === \"inspect\") {\n\t\t// Read-only and allowed on every workflow status (§5 rule 6), but still\n\t\t// scoped to one workflow: an unknown id is a structured error.\n\t\tif (!workflows.some((workflow) => workflow.id === input.workflowId)) {\n\t\t\tthrow new Error(`unknown workflow: ${input.workflowId}`);\n\t\t}\n\t\treturn run; // response decorations are added by the caller\n\t}\n\tconst record = workflows.find((workflow) => workflow.id === input.workflowId);\n\tif (record === undefined) throw new Error(`unknown workflow: ${input.workflowId}`);\n\tif (record.status === \"completed\" || record.status === \"failed\" || record.status === \"cancelled\") {\n\t\tthrow new Error(`workflow is ${record.status} and accepts no further actions: ${input.workflowId}`);\n\t}\n\tconst workflowRun = newWorkflowRun(record, deps, run.closures);\n\tconst requireRunning = (): void => {\n\t\tif (record.status !== \"running\") {\n\t\t\tthrow new Error(`workflow is ${record.status}; this action requires a running workflow`);\n\t\t}\n\t};\n\tconst noteSuffix = input.note !== undefined && input.note !== \"\" ? `: ${input.note}` : \"\";\n\n\tswitch (input.action) {\n\t\tcase \"start\": {\n\t\t\tif (record.status !== \"draft\") throw new Error(`workflow is already ${record.status}; start requires draft`);\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\tconst roots = record.stages.filter((spec) => spec.dependsOn.length === 0).length;\n\t\t\trecord.status = \"running\";\n\t\t\tappendTransition(workflowRun, undefined, \"draft\", \"running\", \"tool\", {\n\t\t\t\tcode: \"workflow.started\",\n\t\t\t\tmessage: `workflow started with ${roots} root stage(s)`,\n\t\t\t\tdetail: `roots:${roots}`,\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t\tcase \"record-evidence\": {\n\t\t\trequireRunning();\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (runtime.status === \"completed\" || runtime.status === \"skipped\" || runtime.status === \"failed\") {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`stage ${spec.id} is ${runtime.status}; record-evidence accepts pending (retry wait), active, and awaiting_review stages`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (spec.objective.acceptance.length === 0) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`stage ${spec.id} declares no acceptance (lower-bound mode); record-evidence does not apply — the plan's completion evidence is the lower bound`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst items = input.evidence ?? [];\n\t\t\tif (items.length === 0) throw new Error(\"record-evidence requires at least one evidence item\");\n\t\t\tfor (const item of items) {\n\t\t\t\tif (item.acceptanceId === undefined || item.acceptanceId.trim() === \"\") {\n\t\t\t\t\tthrow new Error(`stage ${spec.id}: evidence items must carry acceptanceId in acceptance mode`);\n\t\t\t\t}\n\t\t\t\tif (!spec.objective.acceptance.some((entry) => entry.id === item.acceptanceId)) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`stage ${spec.id}: acceptanceId ${item.acceptanceId} does not reference the current spec's acceptance`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 2)) break; // evidence.recorded + possible single-stage wake settlement\n\t\t\tconst from = runtime.status;\n\t\t\truntime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];\n\t\t\tappendTransition(workflowRun, spec.id, from, from, \"tool\", {\n\t\t\t\tcode: \"evidence.recorded\",\n\t\t\t\tmessage: `stage ${spec.id}: recorded ${items.length} evidence item(s)`,\n\t\t\t\tdetail: items.map((item) => `${item.acceptanceId} <- ${item.ref}`).join(\"; \"),\n\t\t\t});\n\t\t\tif (runtime.status === \"pending\" && runtime.attempts > 0) {\n\t\t\t\t// §5 rule 4 exception — the only automatic wake channel for\n\t\t\t\t// retry-waiting stages: a single-stage evaluation by the current\n\t\t\t\t// spec and latest evidence. Pass -> settle (completion propagates\n\t\t\t\t// through the reevaluation below); still missing -> keep waiting,\n\t\t\t\t// no attempts consumed.\n\t\t\t\tconst plan = spec.planId !== undefined ? workflowRun.deps.getPlan(spec.planId) : undefined;\n\t\t\t\tconst passed =\n\t\t\t\t\tplan !== undefined &&\n\t\t\t\t\tplan.status === \"completed\" &&\n\t\t\t\t\tplan.evidence.length > 0 &&\n\t\t\t\t\tuncoveredAcceptanceIds(spec, runtime).length === 0;\n\t\t\t\tif (passed) settlePassedStage(workflowRun, spec, runtime, \"tool\");\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t\tcase \"approve\": {\n\t\t\trequireRunning();\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (runtime.status !== \"awaiting_review\") {\n\t\t\t\tthrow new Error(`stage ${spec.id} is ${runtime.status}; approve only settles awaiting_review stages`);\n\t\t\t}\n\t\t\tconst items = input.evidence ?? [];\n\t\t\tif (spec.objective.acceptance.length > 0) {\n\t\t\t\t// Same strong validation as record-evidence/complete: in acceptance\n\t\t\t\t// mode every carried evidence item must reference the current spec.\n\t\t\t\tfor (const item of items) {\n\t\t\t\t\tif (item.acceptanceId === undefined || item.acceptanceId.trim() === \"\") {\n\t\t\t\t\t\tthrow new Error(`stage ${spec.id}: evidence items must carry acceptanceId in acceptance mode`);\n\t\t\t\t\t}\n\t\t\t\t\tif (!spec.objective.acceptance.some((entry) => entry.id === item.acceptanceId)) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`stage ${spec.id}: acceptanceId ${item.acceptanceId} does not reference the current spec's acceptance`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\tif (items.length > 0) runtime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];\n\t\t\truntime.status = \"completed\";\n\t\t\tappendTransition(workflowRun, spec.id, \"awaiting_review\", \"completed\", \"tool\", {\n\t\t\t\tcode: spec.objective.acceptance.length > 0 ? \"stage.completed.acceptance-covered\" : \"stage.completed\",\n\t\t\t\tmessage: `stage ${spec.id} approved in review`,\n\t\t\t\tdetail: input.note,\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t\tcase \"reject\": {\n\t\t\trequireRunning();\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (runtime.status !== \"awaiting_review\") {\n\t\t\t\tthrow new Error(`stage ${spec.id} is ${runtime.status}; reject only applies to awaiting_review stages`);\n\t\t\t}\n\t\t\tif (spec.onFail !== undefined) {\n\t\t\t\t// §5 rule 6: reject walks the stage's onFail edge; the rejected\n\t\t\t\t// stage itself is the retreat origin and always resets (rule 5).\n\t\t\t\tconst target = requireStage(record, spec.onFail);\n\t\t\t\tcrossStageRetreat(workflowRun, target, \"tool\", `review rejection of stage ${spec.id}${noteSuffix}`, spec);\n\t\t\t} else {\n\t\t\t\t// No onFail edge: in-place retry semantics (rule 6 / rule 5).\n\t\t\t\truntime.attempts += 1;\n\t\t\t\tif (runtime.attempts > retryLimitOf(spec)) {\n\t\t\t\t\tconst message = `workflow failed: stage ${spec.id} exhausted retry limit ${retryLimitOf(spec)} (rejected in review)`;\n\t\t\t\t\tif (canBump(workflowRun, 1)) {\n\t\t\t\t\t\tpushDiagnostic(record, message);\n\t\t\t\t\t\trecord.status = \"failed\";\n\t\t\t\t\t\tappendTransition(workflowRun, undefined, \"running\", \"failed\", \"tool\", {\n\t\t\t\t\t\t\tcode: \"workflow.failed.stage-retries\",\n\t\t\t\t\t\t\tmessage,\n\t\t\t\t\t\t\tdetail: `stage:${spec.id}${noteSuffix}`,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t} else if (canBump(workflowRun, 1)) {\n\t\t\t\t\truntime.status = \"pending\";\n\t\t\t\t\tappendTransition(workflowRun, spec.id, \"awaiting_review\", \"pending\", \"tool\", {\n\t\t\t\t\t\tcode: \"stage.retry.queued\",\n\t\t\t\t\t\tmessage: `stage ${spec.id} rejected in review; queued for in-place retry (attempt ${runtime.attempts}/${retryLimitOf(spec)})`,\n\t\t\t\t\t\tdetail: input.note,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t\tcase \"retreat\": {\n\t\t\trequireRunning();\n\t\t\tconst target = requireStage(record, input.stageId);\n\t\t\tconst targetRuntime = runtimeOf(record, target.id);\n\t\t\tif (targetRuntime === undefined) throw new Error(`unknown stage: ${target.id}`);\n\t\t\tconst wasActivated =\n\t\t\t\ttargetRuntime.status === \"active\" ||\n\t\t\t\ttargetRuntime.status === \"awaiting_review\" ||\n\t\t\t\ttargetRuntime.status === \"completed\" ||\n\t\t\t\ttargetRuntime.attempts > 0;\n\t\t\tif (!wasActivated) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`retreat target ${target.id} has never been activated; retreat targets a previously activated earlier stage`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tcrossStageRetreat(workflowRun, target, \"tool\", input.note ?? \"explicit retreat\");\n\t\t\tbreak;\n\t\t}\n\t\tcase \"complete\": {\n\t\t\trequireRunning();\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (spec.planId !== undefined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`stage ${spec.id} references plan ${spec.planId}; complete only applies to checkpoint stages without planId — plan-backed stages settle via plan completion and evaluation`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (runtime.status !== \"active\") {\n\t\t\t\tthrow new Error(`stage ${spec.id} is ${runtime.status}; complete requires an active checkpoint stage`);\n\t\t\t}\n\t\t\tconst items = input.evidence ?? [];\n\t\t\tif (items.length === 0) throw new Error(`stage ${spec.id}: complete requires evidence`);\n\t\t\tconst covered = new Set<string>();\n\t\t\tfor (const item of items) {\n\t\t\t\tif (spec.objective.acceptance.length > 0) {\n\t\t\t\t\tif (\n\t\t\t\t\t\titem.acceptanceId === undefined ||\n\t\t\t\t\t\t!spec.objective.acceptance.some((entry) => entry.id === item.acceptanceId)\n\t\t\t\t\t) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`stage ${spec.id}: evidence acceptanceId must reference the declared acceptance items`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\tcovered.add(item.acceptanceId);\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst missing = spec.objective.acceptance.filter((item) => !covered.has(item.id)).map((item) => item.id);\n\t\t\tif (missing.length > 0) {\n\t\t\t\tthrow new Error(`stage ${spec.id}: evidence does not cover acceptance [${missing.join(\", \")}]`);\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\truntime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];\n\t\t\truntime.status = \"completed\";\n\t\t\tappendTransition(workflowRun, spec.id, \"active\", \"completed\", \"tool\", {\n\t\t\t\tcode: spec.objective.acceptance.length > 0 ? \"stage.completed.acceptance-covered\" : \"stage.completed\",\n\t\t\t\tmessage: `stage ${spec.id} completed at checkpoint`,\n\t\t\t\tdetail: items.map((item) => item.ref).join(\"; \"),\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t\tcase \"skip\": {\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (runtime.status !== \"pending\") {\n\t\t\t\tthrow new Error(`stage ${spec.id} is ${runtime.status}; only pending stages can be skipped`);\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\t// §5 rule 6: skipped counts as completed for dependency unlocking —\n\t\t\t// an explicit scope ruling, applied uniformly in closure checks.\n\t\t\truntime.status = \"skipped\";\n\t\t\tappendTransition(workflowRun, spec.id, \"pending\", \"skipped\", \"tool\", {\n\t\t\t\tcode: \"stage.skipped\",\n\t\t\t\tmessage: `stage ${spec.id} skipped (explicit scope ruling)`,\n\t\t\t\tdetail: input.note,\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t\tcase \"cancel\": {\n\t\t\tconst from = record.status;\n\t\t\t// §5 rule 6: every non-terminal stage becomes skipped; referenced\n\t\t\t// active plans are left untouched (soft reference — the plan domain\n\t\t\t// keeps its own sovereignty).\n\t\t\tfor (const spec of record.stages) {\n\t\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\t\tif (runtime === undefined || runtime.status === \"completed\" || runtime.status === \"skipped\") continue;\n\t\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\t\tconst stageFrom = runtime.status;\n\t\t\t\truntime.status = \"skipped\";\n\t\t\t\tappendTransition(workflowRun, spec.id, stageFrom, \"skipped\", \"tool\", {\n\t\t\t\t\tcode: \"stage.skipped\",\n\t\t\t\t\tmessage: `stage ${spec.id} skipped by workflow cancellation`,\n\t\t\t\t});\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\trecord.status = \"cancelled\";\n\t\t\tappendTransition(workflowRun, undefined, from, \"cancelled\", \"tool\", {\n\t\t\t\tcode: \"workflow.cancelled\",\n\t\t\t\tmessage: `workflow cancelled (non-terminal stages skipped)`,\n\t\t\t\tdetail: input.note,\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t}\n\tif (record.status === \"running\") reevaluateWorkflow(workflowRun, \"tool\");\n\trun.transitions.push(...workflowRun.transitions);\n\tif (workflowRun.hitLimit) run.cascadeLimitReached = true;\n\treturn run;\n}\n\n// ---------------------------------------------------------------------------\n// Inspection (§8.1 waiting conditions + acceptance coverage)\n// ---------------------------------------------------------------------------\n\n/**\n * The §8.1 waiting-condition label for one stage, or null when nothing is\n * blocking it. Label ownership: `acceptance-missing` is exclusively the\n * acceptance-mode retry wait (pending with attempts > 0); `retry-wait` is the\n * lower-bound retry wait (pending with attempts > 0 and no acceptance\n * declared — the plan completed but its evidence is empty); an active stage\n * whose plan is not completed yet is `plan-incomplete`. The check order\n * mirrors activationPass (§8.1: active-limit precedes write-scope conflict),\n * so the inspect label always names the same condition the audit recorded.\n */\nexport function computeWaitingOn(\n\tworkflow: WorkflowRecord,\n\tspec: StageSpec,\n\truntime: StageRuntime,\n\tdeps: EngineDeps,\n): string | null {\n\tif (runtime.status === \"awaiting_review\") return \"review\";\n\tif (runtime.status === \"pending\") {\n\t\tif (workflow.status === \"draft\") return \"not-yet-active\";\n\t\tif (runtime.attempts > 0) {\n\t\t\tif (spec.objective.acceptance.length === 0) return \"retry-wait\"; // lower-bound retry wait\n\t\t\treturn `acceptance-missing:[${uncoveredAcceptanceIds(spec, runtime).join(\", \")}]`;\n\t\t}\n\t\tconst incomplete = spec.dependsOn.filter((dep) => {\n\t\t\tconst dependency = runtimeOf(workflow, dep);\n\t\t\treturn !(dependency !== undefined && (dependency.status === \"completed\" || dependency.status === \"skipped\"));\n\t\t});\n\t\tif (incomplete.length > 0) return `deps-incomplete:[${incomplete.join(\", \")}]`;\n\t\tif (spec.planId !== undefined && deps.getPlan(spec.planId) === undefined) return \"plan-dangling\";\n\t\tif (activeCount(workflow) >= maxActiveOf(workflow)) return \"active-limit\";\n\t\tif (spec.kind === \"write\") {\n\t\t\tconst conflict = findWriteConflict(workflow, spec);\n\t\t\tif (conflict !== undefined) return `write-scope-conflict:[${conflict.id}]`;\n\t\t}\n\t\treturn \"not-yet-active\";\n\t}\n\tif (runtime.status === \"active\") {\n\t\tif (spec.planId === undefined) return null; // checkpoint waiting for the explicit complete action\n\t\tconst plan = deps.getPlan(spec.planId);\n\t\tif (plan === undefined) return \"plan-dangling\";\n\t\tif (plan.status !== \"completed\") return \"plan-incomplete\";\n\t\treturn null; // completed plan, evaluation pending the next reevaluation trigger\n\t}\n\treturn null;\n}\n\nexport interface AcceptanceCoverage {\n\tid: string;\n\tstatement: string;\n\tcovered: boolean;\n\trefs: string[];\n}\n\nexport interface StageInspection {\n\tstageId: string;\n\tstatus: StageRuntime[\"status\"];\n\tattempts: number;\n\twaitingOn: string | null;\n\tplanId?: string;\n\tplanStatus?: string;\n\tacceptance: AcceptanceCoverage[];\n}\n\nexport interface WorkflowInspection {\n\tworkflowId: string;\n\tstatus: WorkflowRecord[\"status\"];\n\tstages: StageInspection[];\n}\n\n/** §5 rule 6 inspect payload: full snapshot plus per-stage waiting conditions and acceptance coverage (§8.1). */\nexport function inspectWorkflow(workflow: WorkflowRecord, deps: EngineDeps): WorkflowInspection {\n\treturn {\n\t\tworkflowId: workflow.id,\n\t\tstatus: workflow.status,\n\t\tstages: workflow.stages.map((spec) => {\n\t\t\tconst runtime = runtimeOf(workflow, spec.id);\n\t\t\tconst safeRuntime: StageRuntime = runtime ?? {\n\t\t\t\tstageId: spec.id,\n\t\t\t\tstatus: \"pending\",\n\t\t\t\tattempts: 0,\n\t\t\t\tevidence: [],\n\t\t\t};\n\t\t\tconst plan = spec.planId !== undefined ? deps.getPlan(spec.planId) : undefined;\n\t\t\treturn {\n\t\t\t\tstageId: spec.id,\n\t\t\t\tstatus: safeRuntime.status,\n\t\t\t\tattempts: safeRuntime.attempts,\n\t\t\t\twaitingOn: computeWaitingOn(workflow, spec, safeRuntime, deps),\n\t\t\t\t...(spec.planId !== undefined ? { planId: spec.planId, planStatus: plan?.status ?? \"missing\" } : {}),\n\t\t\t\tacceptance: spec.objective.acceptance.map((item) => {\n\t\t\t\t\tconst refs = safeRuntime.evidence\n\t\t\t\t\t\t.filter((entry) => entry.acceptanceId === item.id)\n\t\t\t\t\t\t.map((entry) => entry.ref);\n\t\t\t\t\treturn { id: item.id, statement: item.statement, covered: refs.length > 0, refs };\n\t\t\t\t}),\n\t\t\t};\n\t\t}),\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Workflow advance engine: pure state-machine derivation over WorkflowState\n * (§5 of docs/design/工作流编排插件设计.md).\n *\n * Everything here is deterministic and derived from the persisted snapshot\n * plus a plan-lookup callback: no clocks, no CapabilityAPI access — index.ts\n * owns persistence (§5 总则 1 snapshot-driven reevaluation; events are only\n * trigger signals, the truth always comes from the current branch entries).\n * Every conversion appends exactly one transition; held/rejected activation\n * attempts are audited too, deduplicated when the previous transition for the\n * stage already carries the same held code and unchanged wait condition\n * (§5 总则 5).\n *\n * Budget note (§5 总则 4): the per-workflow cascade limit\n * (max(32, 2 x stageCount), recomputed per run from the workflow's current\n * stage count — see cascadeLimitFor) covers every transition of one run —\n * the explicit action's own conversions plus the derived reevaluation chain.\n * Logical operations reserve their maximum\n * bump count up front (canBump) so a mid-chain stop never leaves a mutated\n * status without its audit entry; hitting the cap is not terminal — any\n * workflow_advance action resumes the chain.\n */\n\nimport type {\n\tStageRuntime,\n\tStageSpec,\n\tTransitionReason,\n\tTransitionReasonCode,\n\tWorkflowRecord,\n\tWorkflowState,\n\tWorkflowTransitionEntry,\n\tWorkflowTransitionTrigger,\n} from \"./types.ts\";\nimport type { ParsedWorkflowSet, WorkflowSetDiff } from \"./validate.ts\";\n\n/** Minimal plan projection read from the current branch's latest plan.state entry. */\nexport interface PlanFacts {\n\treadonly id: string;\n\treadonly status: \"draft\" | \"active\" | \"completed\" | \"cancelled\";\n\treadonly evidence: readonly string[];\n}\n\nexport interface EngineDeps {\n\t/** Plan lookup against the current branch entries (latest plan.state snapshot). */\n\tgetPlan(planId: string): PlanFacts | undefined;\n\t/**\n\t * Last transition per stage (`${workflowId}::${stageId}`), seeded from the\n\t * branch audit stream at load and maintained by the engine — the input to\n\t * the held-dedup rule (§5 总则 5).\n\t */\n\tlastStageTransition: Map<string, { code: TransitionReasonCode; detail?: string }>;\n}\n\n/** Evidence item accepted by workflow_advance (acceptanceId is required in acceptance mode). */\nexport interface EvidenceItem {\n\tacceptanceId?: string;\n\tref: string;\n}\n\nexport type AdvanceAction =\n\t| \"start\"\n\t| \"record-evidence\"\n\t| \"inspect\"\n\t| \"approve\"\n\t| \"reject\"\n\t| \"retreat\"\n\t| \"complete\"\n\t| \"skip\"\n\t| \"cancel\";\n\nexport interface AdvanceInput {\n\tworkflowId: string;\n\taction: AdvanceAction;\n\tstageId?: string;\n\tevidence?: EvidenceItem[];\n\tnote?: string;\n\t/** Advisory executor id for record-evidence; written to StageRuntime.executor last-write-wins (§4). */\n\texecutor?: string;\n}\n\nexport interface ClosureEvidenceItem {\n\tstageId: string;\n\tacceptanceId: string;\n\tref: string;\n}\n\n/** Rule 7 closure summary: per-acceptance evidence of completed stages, as goal-achievement candidates. */\nexport interface WorkflowClosure {\n\tworkflowId: string;\n\tgoalId?: string;\n\tevidence: ClosureEvidenceItem[];\n}\n\nexport interface EngineRun {\n\tstate: WorkflowState;\n\ttransitions: WorkflowTransitionEntry[];\n\tcascadeLimitReached: boolean;\n\tclosures: WorkflowClosure[];\n}\n\nexport const DEFAULT_RETRY_LIMIT = 2;\nexport const DEFAULT_MAX_RETREATS = 3;\nexport const DEFAULT_MAX_ACTIVE_STAGES = 4;\nconst DEFAULT_CASCADE_LIMIT = 32;\nconst DIAGNOSTICS_CAP = 50;\n\nexport function cascadeLimitFor(workflow: WorkflowRecord): number {\n\treturn Math.max(DEFAULT_CASCADE_LIMIT, 2 * workflow.stages.length);\n}\n\n// ---------------------------------------------------------------------------\n// Small lookups\n// ---------------------------------------------------------------------------\n\nfunction runtimeOf(workflow: WorkflowRecord, stageId: string): StageRuntime | undefined {\n\treturn workflow.runtime.find((item) => item.stageId === stageId);\n}\n\nfunction specOf(workflow: WorkflowRecord, stageId: string): StageSpec | undefined {\n\treturn workflow.stages.find((item) => item.id === stageId);\n}\n\nfunction retryLimitOf(spec: StageSpec): number {\n\treturn spec.retryLimit ?? DEFAULT_RETRY_LIMIT;\n}\n\nexport function maxRetreatsOf(workflow: WorkflowRecord): number {\n\treturn workflow.maxRetreats ?? DEFAULT_MAX_RETREATS;\n}\n\nexport function maxActiveOf(workflow: WorkflowRecord): number {\n\treturn workflow.maxActiveStages ?? DEFAULT_MAX_ACTIVE_STAGES;\n}\n\nfunction activeCount(workflow: WorkflowRecord): number {\n\treturn workflow.runtime.filter((item) => item.status === \"active\").length;\n}\n\nfunction uncoveredAcceptanceIds(spec: StageSpec, runtime: StageRuntime): string[] {\n\tconst covered = new Set(runtime.evidence.map((item) => item.acceptanceId));\n\treturn spec.objective.acceptance.map((item) => item.id).filter((id) => !covered.has(id));\n}\n\nfunction pushDiagnostic(workflow: WorkflowRecord, text: string): void {\n\tif (workflow.diagnostics.length >= DIAGNOSTICS_CAP) workflow.diagnostics.shift();\n\tworkflow.diagnostics.push(text);\n}\n\nconst stageKey = (workflowId: string, stageId: string): string => `${workflowId}::${stageId}`;\n\n// ---------------------------------------------------------------------------\n// Run context and transition emission\n// ---------------------------------------------------------------------------\n\ninterface WorkflowRun {\n\tworkflow: WorkflowRecord; // mutable working copy\n\tdeps: EngineDeps;\n\tlimit: number;\n\tused: number;\n\ttransitions: WorkflowTransitionEntry[];\n\tclosures: WorkflowClosure[];\n\thitLimit: boolean;\n}\n\nfunction newWorkflowRun(workflow: WorkflowRecord, deps: EngineDeps, closures: WorkflowClosure[]): WorkflowRun {\n\treturn { workflow, deps, limit: cascadeLimitFor(workflow), used: 0, transitions: [], closures, hitLimit: false };\n}\n\n/** Reserves `needed` transition slots so an operation never mutates without its audit entry. */\nfunction canBump(run: WorkflowRun, needed: number): boolean {\n\tif (run.used + needed <= run.limit) return true;\n\trun.hitLimit = true;\n\treturn false;\n}\n\nfunction appendTransition(\n\trun: WorkflowRun,\n\tstageId: string | undefined,\n\tfrom: string,\n\tto: string,\n\ttrigger: WorkflowTransitionTrigger,\n\treason: TransitionReason,\n): boolean {\n\tif (run.used >= run.limit) {\n\t\trun.hitLimit = true;\n\t\treturn false;\n\t}\n\trun.workflow.revision += 1;\n\trun.transitions.push({\n\t\tworkflowId: run.workflow.id,\n\t\t...(stageId !== undefined ? { stageId } : {}),\n\t\tfrom,\n\t\tto,\n\t\ttrigger,\n\t\treason,\n\t\trevision: run.workflow.revision,\n\t});\n\trun.used += 1;\n\tif (stageId !== undefined) {\n\t\trun.deps.lastStageTransition.set(stageKey(run.workflow.id, stageId), {\n\t\t\tcode: reason.code,\n\t\t\tdetail: reason.detail,\n\t\t});\n\t}\n\treturn true;\n}\n\n/**\n * A held (not activated) pending stage: appends the held transition unless\n * the stage's previous transition already carries the same held code and\n * unchanged wait condition (§5 总则 5 dedup — reevaluation becomes a no-op).\n * plan-dangling and write-scope-conflict additionally raise a diagnostic\n * (§5 总则 2 conditions b/d); deps-incomplete and active-limit are normal\n * graph waiting and stay transition-only.\n */\nfunction holdStage(\n\trun: WorkflowRun,\n\tstageId: string,\n\ttrigger: WorkflowTransitionTrigger,\n\tcode: TransitionReasonCode,\n\tmessage: string,\n\tdetail: string,\n): boolean {\n\tconst key = stageKey(run.workflow.id, stageId);\n\tconst last = run.deps.lastStageTransition.get(key);\n\tif (last !== undefined && last.code === code && last.detail === detail) return false;\n\tconst runtime = runtimeOf(run.workflow, stageId);\n\tconst from = runtime?.status ?? \"pending\";\n\tif (!appendTransition(run, stageId, from, from, trigger, { code, message, detail })) return false;\n\tif (code === \"stage.activated.held-plan-dangling\" || code === \"stage.held.write-scope-conflict\") {\n\t\tpushDiagnostic(run.workflow, message);\n\t}\n\treturn true;\n}\n\n// ---------------------------------------------------------------------------\n// Scope guards (§5 总则 2 condition d)\n// ---------------------------------------------------------------------------\n\n/**\n * An undeclared (or empty) scope on a write stage is global: it intersects\n * every other write stage. Read stages are never scope-checked.\n */\nfunction scopesIntersect(a: string[] | undefined, b: string[] | undefined): boolean {\n\tif (a === undefined || a.length === 0 || b === undefined || b.length === 0) return true;\n\treturn a.some((tag) => b.includes(tag));\n}\n\nfunction findWriteConflict(workflow: WorkflowRecord, spec: StageSpec): StageSpec | undefined {\n\treturn workflow.stages.find((other) => {\n\t\tif (other.id === spec.id || other.kind !== \"write\") return false;\n\t\treturn runtimeOf(workflow, other.id)?.status === \"active\" && scopesIntersect(spec.scope, other.scope);\n\t});\n}\n\n/** Every stage that transitively depends on `stageId` (reverse dependsOn reachability). */\nfunction transitiveDependents(workflow: WorkflowRecord, stageId: string): string[] {\n\tconst dependents = new Set<string>();\n\tlet grew = true;\n\twhile (grew) {\n\t\tgrew = false;\n\t\tfor (const spec of workflow.stages) {\n\t\t\tif (dependents.has(spec.id)) continue;\n\t\t\tif (spec.dependsOn.some((dep) => dep === stageId || dependents.has(dep))) {\n\t\t\t\tdependents.add(spec.id);\n\t\t\t\tgrew = true;\n\t\t\t}\n\t\t}\n\t}\n\treturn [...dependents];\n}\n\n// ---------------------------------------------------------------------------\n// Evaluation (§5 rule 3) and failure disposal (§5 rule 5)\n// ---------------------------------------------------------------------------\n\n/** Settlement of a passed evaluation: auto -> completed, review -> awaiting_review. */\nfunction settlePassedStage(\n\trun: WorkflowRun,\n\tspec: StageSpec,\n\truntime: StageRuntime,\n\ttrigger: WorkflowTransitionTrigger,\n): number {\n\tconst from = runtime.status;\n\tif (spec.advance === \"review\") {\n\t\truntime.status = \"awaiting_review\";\n\t\t// §5 rule 3: awaiting_review does not count as completed — dependents stay locked.\n\t\treturn appendTransition(run, spec.id, from, \"awaiting_review\", trigger, {\n\t\t\tcode: \"stage.awaiting-review\",\n\t\t\tmessage: `stage ${spec.id} passed evaluation and awaits review`,\n\t\t})\n\t\t\t? 1\n\t\t\t: 0;\n\t}\n\truntime.status = \"completed\";\n\tconst covered = spec.objective.acceptance.length > 0;\n\treturn appendTransition(run, spec.id, from, \"completed\", trigger, {\n\t\tcode: covered ? \"stage.completed.acceptance-covered\" : \"stage.completed\",\n\t\tmessage: `stage ${spec.id} completed (${covered ? \"acceptance covered\" : \"plan lower bound\"})`,\n\t})\n\t\t? 1\n\t\t: 0;\n}\n\n/**\n * §5 rule 3 objective evaluation of one ACTIVE stage against its plan: the\n * lower bound is the completed plan's own evidence; declared acceptance adds\n * per-item evidence coverage. Cancelled plan or unmet evaluation -> rule 5\n * failure disposal.\n */\nfunction evaluateActiveStage(\n\trun: WorkflowRun,\n\tspec: StageSpec,\n\truntime: StageRuntime,\n\ttrigger: WorkflowTransitionTrigger,\n): number {\n\tif (spec.planId === undefined) return 0; // checkpoint stages settle via the explicit complete action\n\tconst plan = run.deps.getPlan(spec.planId);\n\tif (plan === undefined) return 0; // unresolvable after activation (plans are append-only); nothing to evaluate\n\tif (plan.status === \"cancelled\") {\n\t\treturn failStage(run, spec, runtime, trigger, \"stage.failed.plan-cancelled\", `plan ${spec.planId} is cancelled`);\n\t}\n\tif (plan.status !== \"completed\") return 0; // waiting: plan-incomplete\n\tconst missing = uncoveredAcceptanceIds(spec, runtime);\n\tconst lowerBoundUnmet = spec.objective.acceptance.length === 0 && plan.evidence.length === 0;\n\tif (missing.length > 0 || lowerBoundUnmet) {\n\t\treturn failStage(\n\t\t\trun,\n\t\t\tspec,\n\t\t\truntime,\n\t\t\ttrigger,\n\t\t\t\"stage.failed.acceptance-missing\",\n\t\t\t`missing acceptance: [${missing.join(\", \")}]${lowerBoundUnmet ? \" (plan evidence empty)\" : \"\"}`,\n\t\t);\n\t}\n\treturn settlePassedStage(run, spec, runtime, trigger);\n}\n\n/**\n * §5 rule 5 failure disposal: the stage transition to failed is audited, then\n * either in-place retry (evidence retained, attempts + 1), cross-stage\n * retreat via the stage's onFail edge (when retries are exhausted and onFail\n * is declared), or workflow failure. A failed workflow keeps every stage in\n * its current state — no forced settlement (rule 5, contrast with cancel).\n */\nfunction failStage(\n\trun: WorkflowRun,\n\tspec: StageSpec,\n\truntime: StageRuntime,\n\ttrigger: WorkflowTransitionTrigger,\n\tcode: TransitionReasonCode,\n\tdetail: string,\n): number {\n\tif (!canBump(run, 2)) return 0; // failure transition + one disposal transition\n\tconst from = runtime.status;\n\truntime.status = \"failed\";\n\tconst appended = appendTransition(run, spec.id, from, \"failed\", trigger, {\n\t\tcode,\n\t\tmessage: `stage ${spec.id} failed`,\n\t\tdetail,\n\t})\n\t\t? 1\n\t\t: 0;\n\tpushDiagnostic(run.workflow, `stage ${spec.id} failed: ${detail}`);\n\truntime.attempts += 1;\n\tif (runtime.attempts > retryLimitOf(spec)) {\n\t\tif (spec.onFail !== undefined) {\n\t\t\tconst target = specOf(run.workflow, spec.onFail);\n\t\t\tif (target !== undefined)\n\t\t\t\treturn appended + crossStageRetreat(run, target, trigger, `stage ${spec.id} exhausted retries`, spec);\n\t\t}\n\t\tconst message = `workflow failed: stage ${spec.id} exhausted retry limit ${retryLimitOf(spec)}`;\n\t\tpushDiagnostic(run.workflow, message);\n\t\trun.workflow.status = \"failed\";\n\t\treturn appendTransition(run, undefined, \"running\", \"failed\", trigger, {\n\t\t\tcode: \"workflow.failed.stage-retries\",\n\t\t\tmessage,\n\t\t\tdetail: `stage:${spec.id}`,\n\t\t})\n\t\t\t? appended + 1\n\t\t\t: appended;\n\t}\n\truntime.status = \"pending\";\n\treturn appendTransition(run, spec.id, \"failed\", \"pending\", trigger, {\n\t\tcode: \"stage.retry.queued\",\n\t\tmessage: `stage ${spec.id} queued for in-place retry (attempt ${runtime.attempts}/${retryLimitOf(spec)}); evidence retained`,\n\t})\n\t\t? appended + 1\n\t\t: appended;\n}\n\n/**\n * §5 rule 5 cross-stage retreat (explicit onFail, retreat, or reject): the\n * retreat budget is checked first — over budget the workflow fails and the\n * scene is preserved as-is; otherwise the target resets to pending\n * unconditionally (a retry-waiting target loses its evidence too) and every\n * non-pending transitive dependent resets to pending with evidence cleared\n * (their inputs are invalidated — DAG propagation). The failing/rejected\n * `origin` stage itself always resets as well (rule 5 is unconditional): when\n * the onFail target sits on a parallel branch the origin is outside the\n * dependent closure and joins the reset set explicitly. attempts are\n * retained (§4); `skipped` dependents never reset (explicit scope ruling).\n */\nfunction crossStageRetreat(\n\trun: WorkflowRun,\n\ttarget: StageSpec,\n\ttrigger: WorkflowTransitionTrigger,\n\tnote: string,\n\torigin?: StageSpec,\n): number {\n\tconst dependents = transitiveDependents(run.workflow, target.id);\n\tconst originOutside = origin !== undefined && origin.id !== target.id && !dependents.includes(origin.id);\n\t// Retreat audit transition (+ at most one more disposal transition when the\n\t// origin sits outside the dependent closure and needs its own audit slot).\n\tif (!canBump(run, originOutside ? 2 : 1)) return 0;\n\trun.workflow.retreatCount += 1;\n\tif (run.workflow.retreatCount > maxRetreatsOf(run.workflow)) {\n\t\tconst message = `workflow failed: retreat budget exhausted (${run.workflow.retreatCount}/${maxRetreatsOf(run.workflow)})`;\n\t\tpushDiagnostic(run.workflow, `${message} — ${note}`);\n\t\trun.workflow.status = \"failed\";\n\t\treturn appendTransition(run, undefined, \"running\", \"failed\", trigger, {\n\t\t\tcode: \"workflow.failed.retreat-budget\",\n\t\t\tmessage,\n\t\t\tdetail: note,\n\t\t})\n\t\t\t? 1\n\t\t\t: 0;\n\t}\n\tconst targetFrom = runtimeOf(run.workflow, target.id)?.status ?? \"pending\";\n\tconst resetIds: string[] = [];\n\tfor (const stageId of [target.id, ...(originOutside ? [origin.id] : []), ...dependents]) {\n\t\tconst runtime = runtimeOf(run.workflow, stageId);\n\t\tif (runtime === undefined) continue;\n\t\tif (\n\t\t\tstageId === target.id ||\n\t\t\tstageId === origin?.id ||\n\t\t\truntime.status === \"completed\" ||\n\t\t\truntime.status === \"active\" ||\n\t\t\truntime.status === \"awaiting_review\" ||\n\t\t\truntime.status === \"failed\"\n\t\t) {\n\t\t\truntime.status = \"pending\";\n\t\t\truntime.evidence = [];\n\t\t\tresetIds.push(stageId);\n\t\t}\n\t}\n\tconst detail = `target:${target.id} reset:[${resetIds.join(\", \")}]${note !== \"\" ? ` — ${note}` : \"\"}`;\n\treturn appendTransition(run, target.id, targetFrom, \"pending\", trigger, {\n\t\tcode: \"stage.retreated\",\n\t\tmessage: `retreat to stage ${target.id}: reset [${resetIds.join(\", \")}]`,\n\t\tdetail,\n\t})\n\t\t? 1\n\t\t: 0;\n}\n\n// ---------------------------------------------------------------------------\n// Activation scan (§5 总则 2) and the reevaluation loop (§5 总则 1)\n// ---------------------------------------------------------------------------\n\n/**\n * One activation pass over pending stages in declaration order. A pending\n * stage activates only when (a) dependsOn are all completed/skipped (skip\n * counts as completed for unlocking — §5 rule 6), (b) its planId resolves in\n * the current branch entries, (c) the active count is below the cap, (d) a\n * write stage's scope does not intersect an active write stage, and (e)\n * attempts === 0 — retry-waiting stages never auto-activate (anti\n * burnout-spiral, §5 总则 2 e). Each freshly activated stage is immediately\n * re-evaluated once (§5 rule 2), which covers \"plan already completed at\n * activation\" and pure-checkpoint restore flows.\n */\nfunction activationPass(run: WorkflowRun, trigger: WorkflowTransitionTrigger): number {\n\tlet appended = 0;\n\tfor (const spec of run.workflow.stages) {\n\t\tconst runtime = runtimeOf(run.workflow, spec.id);\n\t\tif (runtime === undefined || runtime.status !== \"pending\" || runtime.attempts !== 0) continue; // (e)\n\t\tconst incomplete = spec.dependsOn.filter((dep) => {\n\t\t\tconst dependency = runtimeOf(run.workflow, dep);\n\t\t\treturn !(dependency !== undefined && (dependency.status === \"completed\" || dependency.status === \"skipped\"));\n\t\t});\n\t\tif (incomplete.length > 0) {\n\t\t\tif (\n\t\t\t\tholdStage(\n\t\t\t\t\trun,\n\t\t\t\t\tspec.id,\n\t\t\t\t\ttrigger,\n\t\t\t\t\t\"stage.held.deps-incomplete\",\n\t\t\t\t\t`stage ${spec.id} waiting: dependencies incomplete [${incomplete.join(\", \")}]`,\n\t\t\t\t\t`missing:[${incomplete.join(\", \")}]`,\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tappended += 1;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (spec.planId !== undefined && run.deps.getPlan(spec.planId) === undefined) {\n\t\t\tif (\n\t\t\t\tholdStage(\n\t\t\t\t\trun,\n\t\t\t\t\tspec.id,\n\t\t\t\t\ttrigger,\n\t\t\t\t\t\"stage.activated.held-plan-dangling\",\n\t\t\t\t\t`stage ${spec.id} held: plan ${spec.planId} not found in the session branch`,\n\t\t\t\t\t`plan:${spec.planId}`,\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tappended += 1;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (activeCount(run.workflow) >= maxActiveOf(run.workflow)) {\n\t\t\tif (\n\t\t\t\tholdStage(\n\t\t\t\t\trun,\n\t\t\t\t\tspec.id,\n\t\t\t\t\ttrigger,\n\t\t\t\t\t\"stage.held.active-limit\",\n\t\t\t\t\t`stage ${spec.id} waiting: active stage limit (${maxActiveOf(run.workflow)}) reached`,\n\t\t\t\t\t`limit:${maxActiveOf(run.workflow)}`,\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tappended += 1;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\t\tif (spec.kind === \"write\") {\n\t\t\tconst conflict = findWriteConflict(run.workflow, spec);\n\t\t\tif (conflict !== undefined) {\n\t\t\t\tconst own = spec.scope === undefined || spec.scope.length === 0 ? \"global\" : spec.scope.join(\",\");\n\t\t\t\tconst other =\n\t\t\t\t\tconflict.scope === undefined || conflict.scope.length === 0 ? \"global\" : conflict.scope.join(\",\");\n\t\t\t\tif (\n\t\t\t\t\tholdStage(\n\t\t\t\t\t\trun,\n\t\t\t\t\t\tspec.id,\n\t\t\t\t\t\ttrigger,\n\t\t\t\t\t\t\"stage.held.write-scope-conflict\",\n\t\t\t\t\t\t`stage ${spec.id} blocked: write scope [${own}] intersects active stage ${conflict.id} scope [${other}]`,\n\t\t\t\t\t\t`conflict:${conflict.id} scope:[${own}] vs [${other}]`,\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\tappended += 1;\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\t\tif (!canBump(run, 3)) break; // activation + immediate evaluation chain (worst case 2 more)\n\t\truntime.status = \"active\";\n\t\tif (\n\t\t\tappendTransition(run, spec.id, \"pending\", \"active\", trigger, {\n\t\t\t\tcode: \"stage.activated\",\n\t\t\t\tmessage: `stage ${spec.id} activated`,\n\t\t\t})\n\t\t) {\n\t\t\tappended += 1;\n\t\t}\n\t\t// §5 rule 2: immediate single-stage snapshot reevaluation of the activated stage.\n\t\tappended += evaluateActiveStage(run, spec, runtime, \"activation-reeval\");\n\t}\n\treturn appended;\n}\n\n/** §5 rule 7 closure: all stages completed/skipped -> workflow completed, closure evidence recorded. */\nfunction checkClosure(run: WorkflowRun, trigger: WorkflowTransitionTrigger): number {\n\tif (run.workflow.status !== \"running\") return 0;\n\tif (!run.workflow.runtime.every((item) => item.status === \"completed\" || item.status === \"skipped\")) return 0;\n\tif (!canBump(run, 1)) return 0;\n\trun.workflow.status = \"completed\";\n\tconst evidence = run.workflow.runtime\n\t\t.filter((item) => item.status === \"completed\")\n\t\t.flatMap((item) =>\n\t\t\titem.evidence.map((entry) => ({ stageId: item.stageId, acceptanceId: entry.acceptanceId, ref: entry.ref })),\n\t\t);\n\trun.closures.push({\n\t\tworkflowId: run.workflow.id,\n\t\t...(run.workflow.goalId !== undefined ? { goalId: run.workflow.goalId } : {}),\n\t\tevidence,\n\t});\n\treturn appendTransition(run, undefined, \"running\", \"completed\", trigger, {\n\t\tcode: \"workflow.completed\",\n\t\tmessage: `workflow completed: all ${run.workflow.stages.length} stage(s) settled`,\n\t\tdetail: `evidence items: ${evidence.length}`,\n\t})\n\t\t? 1\n\t\t: 0;\n}\n\n/** §5 总则 4: the cascade marker is appended beyond the budget by design (it is the stop record, not a chain step). */\nfunction markCascadeLimit(run: WorkflowRun, trigger: WorkflowTransitionTrigger): void {\n\tconst message = `cascade limit ${run.limit} reached; transitions stopped — resumable via any workflow_advance action`;\n\tpushDiagnostic(run.workflow, message);\n\trun.workflow.revision += 1;\n\trun.transitions.push({\n\t\tworkflowId: run.workflow.id,\n\t\tfrom: run.workflow.status,\n\t\tto: run.workflow.status,\n\t\ttrigger,\n\t\treason: { code: \"cascade.limit-reached\", message },\n\t\trevision: run.workflow.revision,\n\t});\n}\n\n/**\n * The reevaluation loop for one running workflow: evaluate active stages ->\n * scan activation -> check closure, repeating to a fixed point (no new\n * persisted transitions). Condition-not-met passes are idempotent no-ops;\n * the held dedup keeps repeat passes audit-silent (§5 总则 1 / 总则 5).\n */\nfunction reevaluateWorkflow(run: WorkflowRun, trigger: WorkflowTransitionTrigger): void {\n\twhile (run.workflow.status === \"running\") {\n\t\tif (run.used >= run.limit) {\n\t\t\trun.hitLimit = true;\n\t\t\tbreak;\n\t\t}\n\t\tlet appended = 0;\n\t\tfor (const spec of run.workflow.stages) {\n\t\t\tconst runtime = runtimeOf(run.workflow, spec.id);\n\t\t\tif (runtime === undefined || runtime.status !== \"active\") continue;\n\t\t\tif (!canBump(run, 2)) break;\n\t\t\tappended += evaluateActiveStage(run, spec, runtime, trigger);\n\t\t}\n\t\tif (run.hitLimit) break;\n\t\tappended += activationPass(run, trigger);\n\t\tif (run.hitLimit) break;\n\t\tappended += checkClosure(run, trigger);\n\t\tif (run.hitLimit) break;\n\t\tif (appended === 0) return; // fixed point\n\t}\n\tif (run.hitLimit) markCascadeLimit(run, trigger);\n}\n\n/** Snapshot reevaluation over every workflow in the state (one budget per workflow per run). */\nexport function reevaluateAll(state: WorkflowState, deps: EngineDeps, trigger: WorkflowTransitionTrigger): EngineRun {\n\tconst workflows = state.workflows.map((workflow) => structuredClone(workflow));\n\tconst run: EngineRun = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };\n\tfor (const workflow of workflows) {\n\t\tif (workflow.status !== \"running\") continue;\n\t\tconst workflowRun = newWorkflowRun(workflow, deps, run.closures);\n\t\treevaluateWorkflow(workflowRun, trigger);\n\t\trun.transitions.push(...workflowRun.transitions);\n\t\tif (workflowRun.hitLimit) run.cascadeLimitReached = true;\n\t}\n\treturn run;\n}\n\n// ---------------------------------------------------------------------------\n// workflow_set application (§6)\n// ---------------------------------------------------------------------------\n\n/**\n * Applies a graded full-submit update: workflow-level fields (v8 §6: omitted\n * optional fields keep their stored values), the new stage\n * sequence (runtime aligned by stageId — existing runtimes are preserved,\n * appended stages get fresh pending runtimes), evidence linkage pruning for\n * removed acceptance ids, one stage.spec-amended transition per amended\n * active/awaiting_review stage, then the post-change reevaluation chain\n * (§5 总则 1: conversions settle -> same-call reevaluation).\n */\nexport function applyWorkflowSet(\n\tstate: WorkflowState,\n\tnext: ParsedWorkflowSet,\n\tdiff: WorkflowSetDiff,\n\tdeps: EngineDeps,\n): EngineRun {\n\tconst workflows = state.workflows.map((workflow) => structuredClone(workflow));\n\tconst run: EngineRun = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };\n\tif (diff.created) {\n\t\tif (next.title === undefined) {\n\t\t\t// v8 §6: the create path still requires a title (updates keep the\n\t\t\t// stored value instead, see below).\n\t\t\tthrow new Error(\"title is required to create a workflow (updates keep the stored value when omitted)\");\n\t\t}\n\t\tworkflows.push({\n\t\t\tid: next.id,\n\t\t\ttitle: next.title,\n\t\t\t...(next.goalId !== undefined ? { goalId: next.goalId } : {}),\n\t\t\tstatus: \"draft\",\n\t\t\trevision: next.revision,\n\t\t\tstages: next.stages.map((stage) => structuredClone(stage)),\n\t\t\truntime: next.stages.map((stage) => ({ stageId: stage.id, status: \"pending\", attempts: 0, evidence: [] })),\n\t\t\tretreatCount: 0,\n\t\t\t...(next.maxRetreats !== undefined ? { maxRetreats: next.maxRetreats } : {}),\n\t\t\t...(next.maxActiveStages !== undefined ? { maxActiveStages: next.maxActiveStages } : {}),\n\t\t\tdiagnostics: [],\n\t\t});\n\t\treturn run;\n\t}\n\tconst record = workflows.find((workflow) => workflow.id === next.id);\n\tif (record === undefined) throw new Error(`unknown workflow: ${next.id}`);\n\t// Workflow-level fields are modifiable while running (§6), revision is the\n\t// caller's monotonic value; engine conversions below increment past it.\n\t// v8 keep-stored-value semantics (§6): an omitted optional field (title/\n\t// goalId/maxRetreats/maxActiveStages) keeps the stored value — the model\n\t// full-resends from memory and a dropped field would silently sever the\n\t// closure evidence chain (2026-10-01 dogfood). Explicit clearing is not\n\t// supported; rebinding goes through an explicit set.\n\tif (next.title !== undefined) record.title = next.title;\n\tif (next.goalId !== undefined) record.goalId = next.goalId;\n\tif (next.maxRetreats !== undefined) record.maxRetreats = next.maxRetreats;\n\tif (next.maxActiveStages !== undefined) record.maxActiveStages = next.maxActiveStages;\n\trecord.revision = next.revision;\n\trecord.stages = next.stages.map((stage) => structuredClone(stage));\n\tconst previousRuntime = new Map(record.runtime.map((item) => [item.stageId, item]));\n\trecord.runtime = record.stages.map(\n\t\t(spec) => previousRuntime.get(spec.id) ?? { stageId: spec.id, status: \"pending\", attempts: 0, evidence: [] },\n\t);\n\t// Evidence linkage (§6): an evidence item is dropped when its acceptanceId\n\t// is non-empty and no longer references the amended acceptance set — this\n\t// holds even when the amend emptied acceptance entirely, so no dead linkage\n\t// survives a switch to lower-bound mode. Ref-only items (empty\n\t// acceptanceId, checkpoint completion records) survive regardless of the\n\t// spec shape.\n\tfor (const spec of record.stages) {\n\t\tconst runtime = runtimeOf(record, spec.id);\n\t\tif (runtime === undefined || runtime.evidence.length === 0) continue;\n\t\tconst ids = new Set(spec.objective.acceptance.map((item) => item.id));\n\t\truntime.evidence = runtime.evidence.filter((entry) => entry.acceptanceId === \"\" || ids.has(entry.acceptanceId));\n\t}\n\tconst workflowRun = newWorkflowRun(record, deps, run.closures);\n\tfor (const change of diff.amendChanges) {\n\t\tconst runtime = runtimeOf(record, change.stageId);\n\t\tconst from = runtime?.status ?? \"pending\";\n\t\tconst detail =\n\t\t\t`fields:[${change.fields.join(\", \")}] note:${change.amendNote}` +\n\t\t\t(change.prunedEvidence.length > 0 ? ` pruned evidence for [${change.prunedEvidence.join(\", \")}]` : \"\");\n\t\tif (canBump(workflowRun, 1)) {\n\t\t\tappendTransition(workflowRun, change.stageId, from, from, \"tool\", {\n\t\t\t\tcode: \"stage.spec-amended\",\n\t\t\t\tmessage: `stage ${change.stageId} spec amended`,\n\t\t\t\tdetail,\n\t\t\t});\n\t\t}\n\t}\n\tif (record.status === \"running\") reevaluateWorkflow(workflowRun, \"tool\");\n\trun.transitions.push(...workflowRun.transitions);\n\tif (workflowRun.hitLimit) run.cascadeLimitReached = true;\n\treturn run;\n}\n\n// ---------------------------------------------------------------------------\n// workflow_advance application (§5 rule 6)\n// ---------------------------------------------------------------------------\n\nfunction requireStage(workflow: WorkflowRecord, stageId: string | undefined): StageSpec {\n\tif (stageId === undefined) throw new Error(\"stageId is required for this action\");\n\tconst spec = specOf(workflow, stageId);\n\tif (spec === undefined) throw new Error(`unknown stage: ${stageId} (workflow ${workflow.id})`);\n\treturn spec;\n}\n\nfunction normalizeEvidence(items: EvidenceItem[]): { acceptanceId: string; ref: string }[] {\n\treturn items.map((item) => ({ acceptanceId: item.acceptanceId ?? \"\", ref: item.ref }));\n}\n\n/**\n * Applies one explicit workflow_advance action, then runs the post-action\n * reevaluation chain (§5 总则 1). Illegal conversions throw structured\n * errors (§5 rule 6) — index.ts surfaces them as tool errors unchanged.\n */\nexport function applyAdvance(state: WorkflowState, input: AdvanceInput, deps: EngineDeps): EngineRun {\n\tconst workflows = state.workflows.map((workflow) => structuredClone(workflow));\n\tconst run: EngineRun = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };\n\tif (input.action === \"inspect\") {\n\t\t// Read-only and allowed on every workflow status (§5 rule 6), but still\n\t\t// scoped to one workflow: an unknown id is a structured error.\n\t\tif (!workflows.some((workflow) => workflow.id === input.workflowId)) {\n\t\t\tthrow new Error(`unknown workflow: ${input.workflowId}`);\n\t\t}\n\t\treturn run; // response decorations are added by the caller\n\t}\n\tconst record = workflows.find((workflow) => workflow.id === input.workflowId);\n\tif (record === undefined) throw new Error(`unknown workflow: ${input.workflowId}`);\n\tif (record.status === \"completed\" || record.status === \"failed\" || record.status === \"cancelled\") {\n\t\tthrow new Error(`workflow is ${record.status} and accepts no further actions: ${input.workflowId}`);\n\t}\n\tconst workflowRun = newWorkflowRun(record, deps, run.closures);\n\tconst requireRunning = (): void => {\n\t\tif (record.status !== \"running\") {\n\t\t\tthrow new Error(`workflow is ${record.status}; this action requires a running workflow`);\n\t\t}\n\t};\n\tconst noteSuffix = input.note !== undefined && input.note !== \"\" ? `: ${input.note}` : \"\";\n\n\tswitch (input.action) {\n\t\tcase \"start\": {\n\t\t\tif (record.status !== \"draft\") throw new Error(`workflow is already ${record.status}; start requires draft`);\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\tconst roots = record.stages.filter((spec) => spec.dependsOn.length === 0).length;\n\t\t\trecord.status = \"running\";\n\t\t\tappendTransition(workflowRun, undefined, \"draft\", \"running\", \"tool\", {\n\t\t\t\tcode: \"workflow.started\",\n\t\t\t\tmessage: `workflow started with ${roots} root stage(s)`,\n\t\t\t\tdetail: `roots:${roots}`,\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t\tcase \"record-evidence\": {\n\t\t\trequireRunning();\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (runtime.status === \"completed\" || runtime.status === \"skipped\" || runtime.status === \"failed\") {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`stage ${spec.id} is ${runtime.status}; record-evidence accepts pending (retry wait), active, and awaiting_review stages`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (spec.objective.acceptance.length === 0) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`stage ${spec.id} declares no acceptance (lower-bound mode); record-evidence does not apply — the plan's completion evidence is the lower bound`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst items = input.evidence ?? [];\n\t\t\tif (items.length === 0) throw new Error(\"record-evidence requires at least one evidence item\");\n\t\t\tfor (const item of items) {\n\t\t\t\tif (item.acceptanceId === undefined || item.acceptanceId.trim() === \"\") {\n\t\t\t\t\tthrow new Error(`stage ${spec.id}: evidence items must carry acceptanceId in acceptance mode`);\n\t\t\t\t}\n\t\t\t\tif (!spec.objective.acceptance.some((entry) => entry.id === item.acceptanceId)) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`stage ${spec.id}: acceptanceId ${item.acceptanceId} does not reference the current spec's acceptance`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 2)) break; // evidence.recorded + possible single-stage wake settlement\n\t\t\tconst from = runtime.status;\n\t\t\truntime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];\n\t\t\tif (input.executor !== undefined && input.executor.trim() !== \"\") {\n\t\t\t\truntime.executor = input.executor.trim();\n\t\t\t}\n\t\t\tappendTransition(workflowRun, spec.id, from, from, \"tool\", {\n\t\t\t\tcode: \"evidence.recorded\",\n\t\t\t\tmessage: `stage ${spec.id}: recorded ${items.length} evidence item(s)`,\n\t\t\t\tdetail: `${items.map((item) => `${item.acceptanceId} <- ${item.ref}`).join(\"; \")}${runtime.executor === undefined ? \"\" : ` | executor: ${runtime.executor}`}`,\n\t\t\t});\n\t\t\tif (runtime.status === \"pending\" && runtime.attempts > 0) {\n\t\t\t\t// §5 rule 4 exception — the only automatic wake channel for\n\t\t\t\t// retry-waiting stages: a single-stage evaluation by the current\n\t\t\t\t// spec and latest evidence. Pass -> settle (completion propagates\n\t\t\t\t// through the reevaluation below); still missing -> keep waiting,\n\t\t\t\t// no attempts consumed.\n\t\t\t\tconst plan = spec.planId !== undefined ? workflowRun.deps.getPlan(spec.planId) : undefined;\n\t\t\t\tconst passed =\n\t\t\t\t\tplan !== undefined &&\n\t\t\t\t\tplan.status === \"completed\" &&\n\t\t\t\t\tplan.evidence.length > 0 &&\n\t\t\t\t\tuncoveredAcceptanceIds(spec, runtime).length === 0;\n\t\t\t\tif (passed) settlePassedStage(workflowRun, spec, runtime, \"tool\");\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t\tcase \"approve\": {\n\t\t\trequireRunning();\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (runtime.status !== \"awaiting_review\") {\n\t\t\t\tthrow new Error(`stage ${spec.id} is ${runtime.status}; approve only settles awaiting_review stages`);\n\t\t\t}\n\t\t\tconst items = input.evidence ?? [];\n\t\t\tif (spec.objective.acceptance.length > 0) {\n\t\t\t\t// Same strong validation as record-evidence/complete: in acceptance\n\t\t\t\t// mode every carried evidence item must reference the current spec.\n\t\t\t\tfor (const item of items) {\n\t\t\t\t\tif (item.acceptanceId === undefined || item.acceptanceId.trim() === \"\") {\n\t\t\t\t\t\tthrow new Error(`stage ${spec.id}: evidence items must carry acceptanceId in acceptance mode`);\n\t\t\t\t\t}\n\t\t\t\t\tif (!spec.objective.acceptance.some((entry) => entry.id === item.acceptanceId)) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`stage ${spec.id}: acceptanceId ${item.acceptanceId} does not reference the current spec's acceptance`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\tif (items.length > 0) runtime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];\n\t\t\truntime.status = \"completed\";\n\t\t\tappendTransition(workflowRun, spec.id, \"awaiting_review\", \"completed\", \"tool\", {\n\t\t\t\tcode: spec.objective.acceptance.length > 0 ? \"stage.completed.acceptance-covered\" : \"stage.completed\",\n\t\t\t\tmessage: `stage ${spec.id} approved in review`,\n\t\t\t\tdetail: input.note,\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t\tcase \"reject\": {\n\t\t\trequireRunning();\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (runtime.status !== \"awaiting_review\") {\n\t\t\t\tthrow new Error(`stage ${spec.id} is ${runtime.status}; reject only applies to awaiting_review stages`);\n\t\t\t}\n\t\t\tif (spec.onFail !== undefined) {\n\t\t\t\t// §5 rule 6: reject walks the stage's onFail edge; the rejected\n\t\t\t\t// stage itself is the retreat origin and always resets (rule 5).\n\t\t\t\tconst target = requireStage(record, spec.onFail);\n\t\t\t\tcrossStageRetreat(workflowRun, target, \"tool\", `review rejection of stage ${spec.id}${noteSuffix}`, spec);\n\t\t\t} else {\n\t\t\t\t// No onFail edge: in-place retry semantics (rule 6 / rule 5).\n\t\t\t\truntime.attempts += 1;\n\t\t\t\tif (runtime.attempts > retryLimitOf(spec)) {\n\t\t\t\t\tconst message = `workflow failed: stage ${spec.id} exhausted retry limit ${retryLimitOf(spec)} (rejected in review)`;\n\t\t\t\t\tif (canBump(workflowRun, 1)) {\n\t\t\t\t\t\tpushDiagnostic(record, message);\n\t\t\t\t\t\trecord.status = \"failed\";\n\t\t\t\t\t\tappendTransition(workflowRun, undefined, \"running\", \"failed\", \"tool\", {\n\t\t\t\t\t\t\tcode: \"workflow.failed.stage-retries\",\n\t\t\t\t\t\t\tmessage,\n\t\t\t\t\t\t\tdetail: `stage:${spec.id}${noteSuffix}`,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t} else if (canBump(workflowRun, 1)) {\n\t\t\t\t\truntime.status = \"pending\";\n\t\t\t\t\tappendTransition(workflowRun, spec.id, \"awaiting_review\", \"pending\", \"tool\", {\n\t\t\t\t\t\tcode: \"stage.retry.queued\",\n\t\t\t\t\t\tmessage: `stage ${spec.id} rejected in review; queued for in-place retry (attempt ${runtime.attempts}/${retryLimitOf(spec)})`,\n\t\t\t\t\t\tdetail: input.note,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t\tcase \"retreat\": {\n\t\t\trequireRunning();\n\t\t\tconst target = requireStage(record, input.stageId);\n\t\t\tconst targetRuntime = runtimeOf(record, target.id);\n\t\t\tif (targetRuntime === undefined) throw new Error(`unknown stage: ${target.id}`);\n\t\t\tconst wasActivated =\n\t\t\t\ttargetRuntime.status === \"active\" ||\n\t\t\t\ttargetRuntime.status === \"awaiting_review\" ||\n\t\t\t\ttargetRuntime.status === \"completed\" ||\n\t\t\t\ttargetRuntime.attempts > 0;\n\t\t\tif (!wasActivated) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`retreat target ${target.id} has never been activated; retreat targets a previously activated earlier stage`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tcrossStageRetreat(workflowRun, target, \"tool\", input.note ?? \"explicit retreat\");\n\t\t\tbreak;\n\t\t}\n\t\tcase \"complete\": {\n\t\t\trequireRunning();\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (spec.planId !== undefined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`stage ${spec.id} references plan ${spec.planId}; complete only applies to checkpoint stages without planId — plan-backed stages settle via plan completion and evaluation`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (runtime.status !== \"active\") {\n\t\t\t\tthrow new Error(`stage ${spec.id} is ${runtime.status}; complete requires an active checkpoint stage`);\n\t\t\t}\n\t\t\tconst items = input.evidence ?? [];\n\t\t\tif (items.length === 0) throw new Error(`stage ${spec.id}: complete requires evidence`);\n\t\t\tconst covered = new Set<string>();\n\t\t\tfor (const item of items) {\n\t\t\t\tif (spec.objective.acceptance.length > 0) {\n\t\t\t\t\tif (\n\t\t\t\t\t\titem.acceptanceId === undefined ||\n\t\t\t\t\t\t!spec.objective.acceptance.some((entry) => entry.id === item.acceptanceId)\n\t\t\t\t\t) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`stage ${spec.id}: evidence acceptanceId must reference the declared acceptance items`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\tcovered.add(item.acceptanceId);\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst missing = spec.objective.acceptance.filter((item) => !covered.has(item.id)).map((item) => item.id);\n\t\t\tif (missing.length > 0) {\n\t\t\t\tthrow new Error(`stage ${spec.id}: evidence does not cover acceptance [${missing.join(\", \")}]`);\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\truntime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];\n\t\t\truntime.status = \"completed\";\n\t\t\tappendTransition(workflowRun, spec.id, \"active\", \"completed\", \"tool\", {\n\t\t\t\tcode: spec.objective.acceptance.length > 0 ? \"stage.completed.acceptance-covered\" : \"stage.completed\",\n\t\t\t\tmessage: `stage ${spec.id} completed at checkpoint`,\n\t\t\t\tdetail: items.map((item) => item.ref).join(\"; \"),\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t\tcase \"skip\": {\n\t\t\tconst spec = requireStage(record, input.stageId);\n\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\tif (runtime === undefined) throw new Error(`unknown stage: ${spec.id}`);\n\t\t\tif (runtime.status !== \"pending\") {\n\t\t\t\tthrow new Error(`stage ${spec.id} is ${runtime.status}; only pending stages can be skipped`);\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\t// §5 rule 6: skipped counts as completed for dependency unlocking —\n\t\t\t// an explicit scope ruling, applied uniformly in closure checks.\n\t\t\truntime.status = \"skipped\";\n\t\t\tappendTransition(workflowRun, spec.id, \"pending\", \"skipped\", \"tool\", {\n\t\t\t\tcode: \"stage.skipped\",\n\t\t\t\tmessage: `stage ${spec.id} skipped (explicit scope ruling)`,\n\t\t\t\tdetail: input.note,\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t\tcase \"cancel\": {\n\t\t\tconst from = record.status;\n\t\t\t// §5 rule 6: every non-terminal stage becomes skipped; referenced\n\t\t\t// active plans are left untouched (soft reference — the plan domain\n\t\t\t// keeps its own sovereignty).\n\t\t\tfor (const spec of record.stages) {\n\t\t\t\tconst runtime = runtimeOf(record, spec.id);\n\t\t\t\tif (runtime === undefined || runtime.status === \"completed\" || runtime.status === \"skipped\") continue;\n\t\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\t\tconst stageFrom = runtime.status;\n\t\t\t\truntime.status = \"skipped\";\n\t\t\t\tappendTransition(workflowRun, spec.id, stageFrom, \"skipped\", \"tool\", {\n\t\t\t\t\tcode: \"stage.skipped\",\n\t\t\t\t\tmessage: `stage ${spec.id} skipped by workflow cancellation`,\n\t\t\t\t});\n\t\t\t}\n\t\t\tif (!canBump(workflowRun, 1)) break;\n\t\t\trecord.status = \"cancelled\";\n\t\t\tappendTransition(workflowRun, undefined, from, \"cancelled\", \"tool\", {\n\t\t\t\tcode: \"workflow.cancelled\",\n\t\t\t\tmessage: `workflow cancelled (non-terminal stages skipped)`,\n\t\t\t\tdetail: input.note,\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t}\n\tif (record.status === \"running\") reevaluateWorkflow(workflowRun, \"tool\");\n\trun.transitions.push(...workflowRun.transitions);\n\tif (workflowRun.hitLimit) run.cascadeLimitReached = true;\n\treturn run;\n}\n\n// ---------------------------------------------------------------------------\n// Inspection (§8.1 waiting conditions + acceptance coverage)\n// ---------------------------------------------------------------------------\n\n/**\n * The §8.1 waiting-condition label for one stage, or null when nothing is\n * blocking it. Label ownership: `acceptance-missing` is exclusively the\n * acceptance-mode retry wait (pending with attempts > 0); `retry-wait` is the\n * lower-bound retry wait (pending with attempts > 0 and no acceptance\n * declared — the plan completed but its evidence is empty); an active stage\n * whose plan is not completed yet is `plan-incomplete`. The check order\n * mirrors activationPass (§8.1: active-limit precedes write-scope conflict),\n * so the inspect label always names the same condition the audit recorded.\n */\nexport function computeWaitingOn(\n\tworkflow: WorkflowRecord,\n\tspec: StageSpec,\n\truntime: StageRuntime,\n\tdeps: EngineDeps,\n): string | null {\n\tif (runtime.status === \"awaiting_review\") return \"review\";\n\tif (runtime.status === \"pending\") {\n\t\tif (workflow.status === \"draft\") return \"not-yet-active\";\n\t\tif (runtime.attempts > 0) {\n\t\t\tif (spec.objective.acceptance.length === 0) return \"retry-wait\"; // lower-bound retry wait\n\t\t\treturn `acceptance-missing:[${uncoveredAcceptanceIds(spec, runtime).join(\", \")}]`;\n\t\t}\n\t\tconst incomplete = spec.dependsOn.filter((dep) => {\n\t\t\tconst dependency = runtimeOf(workflow, dep);\n\t\t\treturn !(dependency !== undefined && (dependency.status === \"completed\" || dependency.status === \"skipped\"));\n\t\t});\n\t\tif (incomplete.length > 0) return `deps-incomplete:[${incomplete.join(\", \")}]`;\n\t\tif (spec.planId !== undefined && deps.getPlan(spec.planId) === undefined) return \"plan-dangling\";\n\t\tif (activeCount(workflow) >= maxActiveOf(workflow)) return \"active-limit\";\n\t\tif (spec.kind === \"write\") {\n\t\t\tconst conflict = findWriteConflict(workflow, spec);\n\t\t\tif (conflict !== undefined) return `write-scope-conflict:[${conflict.id}]`;\n\t\t}\n\t\treturn \"not-yet-active\";\n\t}\n\tif (runtime.status === \"active\") {\n\t\tif (spec.planId === undefined) return null; // checkpoint waiting for the explicit complete action\n\t\tconst plan = deps.getPlan(spec.planId);\n\t\tif (plan === undefined) return \"plan-dangling\";\n\t\tif (plan.status !== \"completed\") return \"plan-incomplete\";\n\t\treturn null; // completed plan, evaluation pending the next reevaluation trigger\n\t}\n\treturn null;\n}\n\nexport interface AcceptanceCoverage {\n\tid: string;\n\tstatement: string;\n\tcovered: boolean;\n\trefs: string[];\n}\n\nexport interface StageInspection {\n\tstageId: string;\n\tstatus: StageRuntime[\"status\"];\n\tattempts: number;\n\twaitingOn: string | null;\n\tplanId?: string;\n\tplanStatus?: string;\n\tacceptance: AcceptanceCoverage[];\n}\n\nexport interface WorkflowInspection {\n\tworkflowId: string;\n\tstatus: WorkflowRecord[\"status\"];\n\tstages: StageInspection[];\n}\n\n/** §5 rule 6 inspect payload: full snapshot plus per-stage waiting conditions and acceptance coverage (§8.1). */\nexport function inspectWorkflow(workflow: WorkflowRecord, deps: EngineDeps): WorkflowInspection {\n\treturn {\n\t\tworkflowId: workflow.id,\n\t\tstatus: workflow.status,\n\t\tstages: workflow.stages.map((spec) => {\n\t\t\tconst runtime = runtimeOf(workflow, spec.id);\n\t\t\tconst safeRuntime: StageRuntime = runtime ?? {\n\t\t\t\tstageId: spec.id,\n\t\t\t\tstatus: \"pending\",\n\t\t\t\tattempts: 0,\n\t\t\t\tevidence: [],\n\t\t\t};\n\t\t\tconst plan = spec.planId !== undefined ? deps.getPlan(spec.planId) : undefined;\n\t\t\treturn {\n\t\t\t\tstageId: spec.id,\n\t\t\t\tstatus: safeRuntime.status,\n\t\t\t\tattempts: safeRuntime.attempts,\n\t\t\t\twaitingOn: computeWaitingOn(workflow, spec, safeRuntime, deps),\n\t\t\t\t...(spec.planId !== undefined ? { planId: spec.planId, planStatus: plan?.status ?? \"missing\" } : {}),\n\t\t\t\tacceptance: spec.objective.acceptance.map((item) => {\n\t\t\t\t\tconst refs = safeRuntime.evidence\n\t\t\t\t\t\t.filter((entry) => entry.acceptanceId === item.id)\n\t\t\t\t\t\t.map((entry) => entry.ref);\n\t\t\t\t\treturn { id: item.id, statement: item.statement, covered: refs.length > 0, refs };\n\t\t\t\t}),\n\t\t\t};\n\t\t}),\n\t};\n}\n"]}
|
package/dist/engine.js
CHANGED
|
@@ -464,7 +464,8 @@ export function reevaluateAll(state, deps, trigger) {
|
|
|
464
464
|
// workflow_set application (§6)
|
|
465
465
|
// ---------------------------------------------------------------------------
|
|
466
466
|
/**
|
|
467
|
-
* Applies a graded full-submit update: workflow-level fields
|
|
467
|
+
* Applies a graded full-submit update: workflow-level fields (v8 §6: omitted
|
|
468
|
+
* optional fields keep their stored values), the new stage
|
|
468
469
|
* sequence (runtime aligned by stageId — existing runtimes are preserved,
|
|
469
470
|
* appended stages get fresh pending runtimes), evidence linkage pruning for
|
|
470
471
|
* removed acceptance ids, one stage.spec-amended transition per amended
|
|
@@ -475,6 +476,11 @@ export function applyWorkflowSet(state, next, diff, deps) {
|
|
|
475
476
|
const workflows = state.workflows.map((workflow) => structuredClone(workflow));
|
|
476
477
|
const run = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };
|
|
477
478
|
if (diff.created) {
|
|
479
|
+
if (next.title === undefined) {
|
|
480
|
+
// v8 §6: the create path still requires a title (updates keep the
|
|
481
|
+
// stored value instead, see below).
|
|
482
|
+
throw new Error("title is required to create a workflow (updates keep the stored value when omitted)");
|
|
483
|
+
}
|
|
478
484
|
workflows.push({
|
|
479
485
|
id: next.id,
|
|
480
486
|
title: next.title,
|
|
@@ -495,18 +501,18 @@ export function applyWorkflowSet(state, next, diff, deps) {
|
|
|
495
501
|
throw new Error(`unknown workflow: ${next.id}`);
|
|
496
502
|
// Workflow-level fields are modifiable while running (§6), revision is the
|
|
497
503
|
// caller's monotonic value; engine conversions below increment past it.
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
504
|
+
// v8 keep-stored-value semantics (§6): an omitted optional field (title/
|
|
505
|
+
// goalId/maxRetreats/maxActiveStages) keeps the stored value — the model
|
|
506
|
+
// full-resends from memory and a dropped field would silently sever the
|
|
507
|
+
// closure evidence chain (2026-10-01 dogfood). Explicit clearing is not
|
|
508
|
+
// supported; rebinding goes through an explicit set.
|
|
509
|
+
if (next.title !== undefined)
|
|
510
|
+
record.title = next.title;
|
|
511
|
+
if (next.goalId !== undefined)
|
|
502
512
|
record.goalId = next.goalId;
|
|
503
|
-
if (next.maxRetreats
|
|
504
|
-
delete record.maxRetreats;
|
|
505
|
-
else
|
|
513
|
+
if (next.maxRetreats !== undefined)
|
|
506
514
|
record.maxRetreats = next.maxRetreats;
|
|
507
|
-
if (next.maxActiveStages
|
|
508
|
-
delete record.maxActiveStages;
|
|
509
|
-
else
|
|
515
|
+
if (next.maxActiveStages !== undefined)
|
|
510
516
|
record.maxActiveStages = next.maxActiveStages;
|
|
511
517
|
record.revision = next.revision;
|
|
512
518
|
record.stages = next.stages.map((stage) => structuredClone(stage));
|
|
@@ -631,10 +637,13 @@ export function applyAdvance(state, input, deps) {
|
|
|
631
637
|
break; // evidence.recorded + possible single-stage wake settlement
|
|
632
638
|
const from = runtime.status;
|
|
633
639
|
runtime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];
|
|
640
|
+
if (input.executor !== undefined && input.executor.trim() !== "") {
|
|
641
|
+
runtime.executor = input.executor.trim();
|
|
642
|
+
}
|
|
634
643
|
appendTransition(workflowRun, spec.id, from, from, "tool", {
|
|
635
644
|
code: "evidence.recorded",
|
|
636
645
|
message: `stage ${spec.id}: recorded ${items.length} evidence item(s)`,
|
|
637
|
-
detail: items.map((item) => `${item.acceptanceId} <- ${item.ref}`).join("; ")
|
|
646
|
+
detail: `${items.map((item) => `${item.acceptanceId} <- ${item.ref}`).join("; ")}${runtime.executor === undefined ? "" : ` | executor: ${runtime.executor}`}`,
|
|
638
647
|
});
|
|
639
648
|
if (runtime.status === "pending" && runtime.attempts > 0) {
|
|
640
649
|
// §5 rule 4 exception — the only automatic wake channel for
|