showdar-skills 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,36 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.6.0]
8
+
9
+ ### Added
10
+
11
+ - Portable workflow execution state (`src/workflow-state.js`): versioned
12
+ JSON checkpoint schema (schemaVersion 1) with stage selection, evidence
13
+ receipts, structured skip, interruption, and resume. State never persists
14
+ authority; resume always re-resolves through Phase 6G. Caller/harness
15
+ owns persistence; no filesystem store, backend, or telemetry.
16
+ - Workflow checkpoint documentation in all four workflow skills
17
+ (feature, bugfix, release, incident): serialization,
18
+ interruption/resume, stale-checkpoint blocking, and stage vs workflow
19
+ completion semantics.
20
+ - Workflow-state policy validation in `src/validate.js`: catalog coverage,
21
+ no authority fields in checkpoints, no harness or storage coupling.
22
+
23
+ ### Changed
24
+
25
+ - Four workflow skills now describe adaptive state semantics instead of
26
+ ephemeral-only tracking.
27
+ - Workflow completion explicitly distinguishes stage completion (primitive
28
+ evidence and stop conditions) from workflow completion (all selected
29
+ stages completed or validly skipped, no blockers, required verification
30
+ satisfied).
31
+
32
+ ### Safety
33
+
34
+ - Checkpoint state never persists authority-derived fields.
35
+ - Caller owns checkpoint persistence; Showdar chooses no storage.
36
+ - Stale checkpoints block with `replanRequired` rather than continuing.
8
37
 
9
38
  ## [0.5.0]
10
39
 
package/MIGRATION.md CHANGED
@@ -1,3 +1,25 @@
1
+ # Migrating to 0.6.0
2
+
3
+ 0.6.0 adds portable workflow execution state (`src/workflow-state.js`,
4
+ schemaVersion 1) over the unchanged 15 primitives, 4 workflows, and Phase 6G
5
+ authority.
6
+
7
+ - Existing 0.5 installs and configs remain valid; no adapter or config
8
+ migration is required.
9
+ - The four workflows now support explicit portable execution state:
10
+ stage selection and progression, evidence-backed skipping, stage vs
11
+ workflow completion, interruption, and resume.
12
+ - Checkpoints are plain JSON at schemaVersion 1. Persistence is owned by
13
+ the caller or harness; Showdar 0.6 defines no state directory, backend,
14
+ session registry, or telemetry.
15
+ - Resume always re-resolves current context through Phase 6G. Stale
16
+ checkpoints return `BLOCKED` with `replanRequired` instead of silently
17
+ continuing.
18
+ - Checkpoints never persist authority. No migration of prior route or
19
+ authority state exists or is needed.
20
+ - No config change is required. Callers that never persist a checkpoint
21
+ keep the previous ephemeral-only behavior.
22
+
1
23
  # Migrating to 0.5.0
2
24
 
3
25
  0.5.0 adds a thin native adapter layer over the unchanged portable core.
package/README.md CHANGED
@@ -385,6 +385,64 @@ primitive (`showdar-review`, `showdar-debug`, `showdar-test`). Phase 6G remains
385
385
  the authority source; workflows consume it and never mint it. Workflows are
386
386
  opt-in through `showdar add <workflow>`; profiles install primitive sets only.
387
387
 
388
+ ## Workflow execution state (0.6.0)
389
+
390
+ ```text
391
+ portable workflow
392
+ -> workflow-state
393
+ -> primitive evidence/stop conditions
394
+ -> current Phase 6G resolution
395
+ -> next stage / blocked / complete
396
+ ```
397
+
398
+ Workflow state (`src/workflow-state.js`, schemaVersion 1) is a portable,
399
+ versioned JSON checkpoint: `workflowId`, selected stages, active stage,
400
+ completed stages, skipped stages, evidence receipts, blockers, next stage,
401
+ `status` (`NEW`/`READY`/`ACTIVE`/`COMPLETE`/`INTERRUPTED`/`BLOCKED`), and a
402
+ deterministic monotonic `revision` counter. Timestamps
403
+ (`createdAt`/`updatedAt`/receipt timestamps) are metadata and provenance only.
404
+
405
+ Workflow state is NOT authority, memory, router intent, or a persistence
406
+ backend. It never persists authority-derived fields
407
+ (`primaryCapability`, `authorizedAction`, `mutationPermission`,
408
+ `routeAuthority` or equivalents). Checkpoint JSON may be stored by a
409
+ caller or harness anywhere; Showdar 0.6 does not choose or manage storage.
410
+
411
+ Evidence receipts are compact copies of primitive evidence at stage
412
+ completion (`kind`, `quality`, `source`, `detail`, timestamp, optional
413
+ provenance). Quality follows `claimed < observed < verified`; `failed` and
414
+ `missing` remain meaningful negative states. High-sensitivity evidence
415
+ (change, tests, build, package, compatibility, regression proof, release
416
+ readiness) may require re-verification after resume; old evidence is not
417
+ permanently valid.
418
+
419
+ Safe resume is always:
420
+
421
+ ```text
422
+ checkpoint
423
+ -> deserialize + validate
424
+ -> resolve current request/context through Phase 6G
425
+ -> compatibility/freshness checks
426
+ -> READY or BLOCKED + replanRequired
427
+ ```
428
+
429
+ A checkpoint alone can never authorize continuation. Previously authorized
430
+ mutation is never restored from a checkpoint. When the current Phase 6G
431
+ resolution no longer matches the checkpoint, resume returns `BLOCKED` with
432
+ `replanRequired` instead of silently continuing.
433
+
434
+ Per-workflow skip policy (evidence-backed, never severity or wording alone):
435
+
436
+ - Feature: requirements/plan/design may be skipped only with policy-backed
437
+ evidence; build, test, and review always run.
438
+ - Bugfix: debug may be skipped only with verified root-cause evidence;
439
+ symptom description alone is insufficient; investigation-only requests stop
440
+ without mutation.
441
+ - Release: readiness does not imply deployment; ops loads only with explicit
442
+ target plus execution authorization.
443
+ - Incident: severity never grants production mutation; recovery and
444
+ verification remain gated by current authority.
445
+
388
446
  ## A typical software workflow
389
447
 
390
448
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "showdar-skills",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Production-grade software engineering lifecycle skills for coding agents.",
5
5
  "type": "module",
6
6
  "bin": { "showdar": "./bin/showdar.js" },
@@ -53,8 +53,12 @@ description: Use when resolving an observed defect end-to-end, adaptively sequen
53
53
 
54
54
  - Load one primitive at a time; hand off only when its stop condition is met.
55
55
  - Each primitive's own SKILL.md governs its stage; do not copy primitive instructions here.
56
- - Track ephemeral state only: candidate stages, selected stages, active stage, completed evidence, next stage or complete.
57
- - No persistent checkpoint or resume infrastructure in this version.
56
+ - Track portable workflow state (`src/workflow-state.js`): candidate stages, selected stages, active stage, completed evidence receipts, skipped stages with structured reason plus evidence plus policy, next stage or complete, status, revision.
57
+ - Checkpoint by serializing state to plain JSON; caller or harness owns persistence. No filesystem or backend store is implied.
58
+ - Interrupt explicitly to preserve completed plus skipped plus evidence state without inventing completion.
59
+ - Resume by validating the checkpoint, re-resolving current context through Phase 6G, checking workflow compatibility, then continuing or blocking with replan-required.
60
+ - Stored state never authorizes continuation; debug may be skipped only with proven root-cause evidence under policy, never from symptom description alone.
61
+ - Stage completion comes from primitive evidence and stop conditions; workflow completion requires root cause proven plus fix implemented plus verification plus review.
58
62
 
59
63
  ## Decision points
60
64
 
@@ -56,8 +56,12 @@ description: Use when implementing a complete feature end-to-end, adaptively seq
56
56
 
57
57
  - Load one primitive at a time; hand off only when its stop condition is met.
58
58
  - Each primitive's own SKILL.md governs its stage; do not copy primitive instructions here.
59
- - Track ephemeral state only: candidate stages, selected stages, active stage, completed evidence, next stage or complete.
60
- - No persistent checkpoint or resume infrastructure in this version.
59
+ - Track portable workflow state (`src/workflow-state.js`): candidate stages, selected stages, active stage, completed evidence receipts, skipped stages with structured reason plus evidence plus policy, next stage or complete, status, revision.
60
+ - Checkpoint by serializing state to plain JSON; caller or harness owns persistence. No filesystem or backend store is implied.
61
+ - Interrupt explicitly to preserve completed plus skipped plus evidence state without inventing completion.
62
+ - Resume by validating the checkpoint, re-resolving current context through Phase 6G, checking workflow compatibility, then continuing or blocking with replan-required.
63
+ - Stored state never authorizes continuation; skip requires policy plus evidence, never severity or wording alone.
64
+ - Stage completion comes from primitive evidence and stop conditions; workflow completion requires every selected stage completed or validly skipped, no blockers, and required verification satisfied.
61
65
 
62
66
  ## Decision points
63
67
 
@@ -57,8 +57,12 @@ description: Use when investigating and recovering from an active operational in
57
57
 
58
58
  - Load one primitive at a time; hand off only when its stop condition is met.
59
59
  - Each primitive's own SKILL.md governs its stage; do not copy primitive instructions here.
60
- - Track ephemeral state only: candidate stages, selected stages, active stage, completed evidence, next stage or complete.
61
- - No persistent checkpoint or resume infrastructure in this version.
60
+ - Track portable workflow state (`src/workflow-state.js`): candidate stages, selected stages, active stage, completed evidence receipts, skipped stages with structured reason plus evidence plus policy, next stage or complete, status, revision.
61
+ - Checkpoint by serializing state to plain JSON; caller or harness owns persistence. No filesystem or backend store is implied.
62
+ - Interrupt explicitly to preserve completed plus skipped plus evidence state without inventing completion.
63
+ - Resume by validating the checkpoint, re-resolving current context through Phase 6G, checking workflow compatibility, then continuing or blocking with replan-required.
64
+ - Stored state never authorizes continuation; severity never grants production mutation, and ops loads only with explicit environment plus action authority.
65
+ - Stage completion comes from primitive evidence and stop conditions; workflow completion requires recovery plus verification per policy, not merely diagnosis.
62
66
 
63
67
  ## Decision points
64
68
 
@@ -54,8 +54,12 @@ description: Use when preparing, validating, or executing a release lifecycle, a
54
54
 
55
55
  - Load one primitive at a time; hand off only when its stop condition is met.
56
56
  - Each primitive's own SKILL.md governs its stage; do not copy primitive instructions here.
57
- - Track ephemeral state only: candidate stages, selected stages, active stage, completed evidence, next stage or complete.
58
- - No persistent checkpoint or resume infrastructure in this version.
57
+ - Track portable workflow state (`src/workflow-state.js`): candidate stages, selected stages, active stage, completed evidence receipts, skipped stages with structured reason plus evidence plus policy, next stage or complete, status, revision.
58
+ - Checkpoint by serializing state to plain JSON; caller or harness owns persistence. No filesystem or backend store is implied.
59
+ - Interrupt explicitly to preserve completed plus skipped plus evidence state without inventing completion.
60
+ - Resume by validating the checkpoint, re-resolving current context through Phase 6G, checking workflow compatibility, then continuing or blocking with replan-required.
61
+ - Stored state never authorizes continuation; ops is skipped under readiness-only policy and never implied by release completion.
62
+ - Stage completion comes from primitive evidence and stop conditions; workflow completion means readiness verification per policy, not implicit deployment.
59
63
 
60
64
  ## Decision points
61
65
 
package/src/validate.js CHANGED
@@ -221,6 +221,54 @@ async function validateCsvFiles(skillDir, errors) {
221
221
  }
222
222
  }
223
223
 
224
+ export async function validateWorkflowStatePolicy(packageRoot) {
225
+ const errors = [];
226
+ const primitives = new Set(SKILLS.map((skill) => skill.id));
227
+ let policy;
228
+ try {
229
+ policy = await import('./workflow-state.js');
230
+ } catch (error) {
231
+ return { ok: false, errors: [`workflow-state policy unavailable: ${error.message}`] };
232
+ }
233
+ for (const workflow of WORKFLOW_SKILLS) {
234
+ const stages = policy.selectableStages(workflow.id);
235
+ if (!Array.isArray(stages) || !stages.length) {
236
+ errors.push(`workflow-state: ${workflow.id} has no state policy coverage`);
237
+ continue;
238
+ }
239
+ for (const stage of stages) {
240
+ if (!primitives.has(stage)) errors.push(`workflow-state: ${workflow.id} policy references unknown stage ${stage}`);
241
+ }
242
+ const catalogStages = new Set(workflow.stages);
243
+ for (const stage of stages) {
244
+ if (!catalogStages.has(stage)) errors.push(`workflow-state: ${workflow.id} policy stage ${stage} not in catalog`);
245
+ }
246
+ for (const stage of workflow.stages) {
247
+ if (!stages.includes(stage)) errors.push(`workflow-state: ${workflow.id} policy missing catalog stage ${stage}`);
248
+ }
249
+ }
250
+ const moduleText = await readFile(path.join(packageRoot, 'src', 'workflow-state.js'), 'utf8').catch(() => '');
251
+ const forbiddenPersisted = ['primaryCapability', 'authorizedAction', 'mutationPermission', 'routeAuthority'];
252
+ for (const forbidden of forbiddenPersisted) {
253
+ const pattern = new RegExp(`['"]${forbidden}['"]\\s*:|\\b${forbidden}\\b\\s*[:=]`, 'g');
254
+ const matches = moduleText.match(pattern) ?? [];
255
+ const rejectionList = (moduleText.match(/FORBIDDEN_AUTHORITY_KEYS[\s\S]{0,400}/) ?? [''])[0];
256
+ const outsideRejection = matches.filter((m) => !rejectionList.includes(forbidden.toLowerCase()));
257
+ if (outsideRejection.length) {
258
+ errors.push(`workflow-state: checkpoint schema must not persist ${forbidden}`);
259
+ }
260
+ }
261
+ for (const harness of ['adapters.js', 'adapter-renderers', '.opencode', '.claude', '.cursor']) {
262
+ if (moduleText.includes(`from './${harness}`) || moduleText.includes(`from "./${harness}`) || moduleText.includes(`import '${harness}`)) {
263
+ errors.push(`workflow-state: must not import harness module ${harness}`);
264
+ }
265
+ }
266
+ for (const storage of ['.showdar/state', '~/.showdar', 'sqlite', 'telemetry']) {
267
+ if (moduleText.toLowerCase().includes(storage.toLowerCase())) errors.push(`workflow-state: no storage layer reference ${storage}`);
268
+ }
269
+ return { ok: errors.length === 0, errors };
270
+ }
271
+
224
272
  export async function validateRepository(packageRoot) {
225
273
  const errors = [];
226
274
  const warnings = [];
@@ -254,6 +302,8 @@ export async function validateRepository(packageRoot) {
254
302
  if (new Set(workflow.stages).size !== workflow.stages.length) errors.push(`${workflow.id}: candidate stages contain duplicates`);
255
303
  }
256
304
 
305
+ for (const error of (await validateWorkflowStatePolicy(packageRoot)).errors) errors.push(error);
306
+
257
307
  let canonicalRuntime;
258
308
  try {
259
309
  canonicalRuntime = await readFile(path.join(packageRoot, CANONICAL_RUNTIME_FILE));
@@ -0,0 +1,628 @@
1
+ import { WORKFLOW_SKILLS, getWorkflow, SKILLS } from './catalog.js';
2
+ import { EVIDENCE_KINDS, EVIDENCE_QUALITIES, getStopConditions } from './evidence-state.js';
3
+
4
+ export const WORKFLOW_SCHEMA_VERSION = 1;
5
+
6
+ export const WORKFLOW_STATUSES = Object.freeze([
7
+ 'NEW',
8
+ 'READY',
9
+ 'ACTIVE',
10
+ 'COMPLETE',
11
+ 'INTERRUPTED',
12
+ 'BLOCKED',
13
+ ]);
14
+
15
+ export const BLOCKER_TYPES = Object.freeze([
16
+ 'authorization',
17
+ 'business-rule',
18
+ 'evidence',
19
+ 'external-decision',
20
+ 'other',
21
+ ]);
22
+
23
+ export const SKIP_POLICIES = Object.freeze([
24
+ 'adaptive-skip',
25
+ 'known-root-cause',
26
+ 'readiness-only',
27
+ 'no-ops-authority',
28
+ 'no-security-risk',
29
+ 'behavior-defined',
30
+ 'local-low-risk',
31
+ 'no-ux-decision',
32
+ ]);
33
+
34
+ export const SKIP_REASONS = Object.freeze([
35
+ 'behavior-defined',
36
+ 'local-low-risk',
37
+ 'no-ux-decision',
38
+ 'known-root-cause',
39
+ 'readiness-only',
40
+ 'no-ops-authority',
41
+ 'no-security-risk',
42
+ ]);
43
+
44
+ const FORBIDDEN_AUTHORITY_KEYS = Object.freeze([
45
+ 'primarycapability',
46
+ 'authorizedaction',
47
+ 'mutationpermission',
48
+ 'routeauthority',
49
+ 'governingaction',
50
+ 'cachedauthority',
51
+ 'authoritydecision',
52
+ ]);
53
+
54
+ const STALE_REASONS = Object.freeze([
55
+ 'workflow-no-longer-applicable',
56
+ 'primary-skill-shifted',
57
+ 'verification-evidence-stale',
58
+ ]);
59
+
60
+ const HIGH_SENSITIVITY_KINDS = new Set([
61
+ 'change-implemented',
62
+ 'targeted-tests-passed',
63
+ 'relevant-suite-passed',
64
+ 'build-passed',
65
+ 'package-verified',
66
+ 'compatibility-verified',
67
+ 'regression-proof-added',
68
+ 'release-readiness-verified',
69
+ ]);
70
+
71
+ const MEDIUM_SENSITIVITY_KINDS = new Set([
72
+ 'failure-reproduced',
73
+ 'typecheck-passed',
74
+ 'lint-passed',
75
+ 'security-reviewed',
76
+ ]);
77
+
78
+ const RECEIPT_KEYS = new Set(['kind', 'quality', 'source', 'detail', 'timestamp', 'provenance']);
79
+ const SKIPPED_KEYS = new Set(['stage', 'reason', 'evidence', 'policy', 'skippedAt']);
80
+ const COMPLETED_KEYS = new Set(['stage', 'completedAt', 'evidenceReceipts', 'stopConditionMet']);
81
+ const BLOCKER_KEYS = new Set(['id', 'reason', 'type', 'detail', 'blockedAt']);
82
+
83
+ const WORKFLOW_IDS = new Set(WORKFLOW_SKILLS.map((w) => w.id));
84
+ const PRIMITIVE_IDS = new Set(SKILLS.map((s) => s.id));
85
+
86
+ const SELECTABLE_STAGES = Object.freeze({
87
+ 'showdar-feature': ['showdar-understand', 'showdar-requirements', 'showdar-plan', 'showdar-design', 'showdar-build', 'showdar-test', 'showdar-review'],
88
+ 'showdar-bugfix': ['showdar-understand', 'showdar-debug', 'showdar-build', 'showdar-test', 'showdar-review'],
89
+ 'showdar-release': ['showdar-quality', 'showdar-security', 'showdar-ship', 'showdar-ops'],
90
+ 'showdar-incident': ['showdar-understand', 'showdar-debug', 'showdar-recover', 'showdar-test', 'showdar-ops'],
91
+ });
92
+
93
+ const SKIP_RULES = Object.freeze({
94
+ 'showdar-feature': Object.freeze({
95
+ 'showdar-requirements': { reason: 'behavior-defined', policy: 'behavior-defined', evidence: ['behavior-defined'] },
96
+ 'showdar-plan': { reason: 'local-low-risk', policy: 'local-low-risk', evidence: ['architecture-understood'] },
97
+ 'showdar-design': { reason: 'no-ux-decision', policy: 'no-ux-decision', evidence: [] },
98
+ }),
99
+ 'showdar-bugfix': Object.freeze({
100
+ 'showdar-debug': { reason: 'known-root-cause', policy: 'known-root-cause', evidence: ['root-cause-proven'] },
101
+ }),
102
+ 'showdar-release': Object.freeze({
103
+ 'showdar-security': { reason: 'no-security-risk', policy: 'no-security-risk', evidence: [] },
104
+ 'showdar-ops': { reason: 'readiness-only', policy: 'readiness-only', evidence: [] },
105
+ }),
106
+ 'showdar-incident': Object.freeze({
107
+ 'showdar-ops': { reason: 'no-ops-authority', policy: 'no-ops-authority', evidence: [] },
108
+ }),
109
+ });
110
+
111
+ const REQUIRED_STAGES = Object.freeze({
112
+ 'showdar-feature': ['showdar-understand', 'showdar-build', 'showdar-test', 'showdar-review'],
113
+ 'showdar-bugfix': ['showdar-understand', 'showdar-test', 'showdar-review'],
114
+ 'showdar-release': ['showdar-quality', 'showdar-ship'],
115
+ 'showdar-incident': ['showdar-understand', 'showdar-debug', 'showdar-recover', 'showdar-test'],
116
+ });
117
+
118
+ function isRecord(value) {
119
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
120
+ }
121
+
122
+ function isIsoString(value) {
123
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value));
124
+ }
125
+
126
+ function containsForbiddenAuthorityKey(value, path = '$') {
127
+ if (Array.isArray(value)) {
128
+ for (let i = 0; i < value.length; i++) {
129
+ const hit = containsForbiddenAuthorityKey(value[i], `${path}[${i}]`);
130
+ if (hit) return hit;
131
+ }
132
+ return null;
133
+ }
134
+ if (isRecord(value)) {
135
+ for (const key of Object.keys(value)) {
136
+ const normalized = String(key).toLowerCase().replace(/[\s_-]+/g, '');
137
+ if (FORBIDDEN_AUTHORITY_KEYS.some((f) => normalized.includes(f))) return `${path}.${key}`;
138
+ const hit = containsForbiddenAuthorityKey(value[key], `${path}.${key}`);
139
+ if (hit) return hit;
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function validateReceipt(entry) {
146
+ const errors = [];
147
+ if (!isRecord(entry)) return ['evidence receipt must be an object'];
148
+ if (!EVIDENCE_KINDS.includes(entry.kind)) errors.push(`receipt kind must be one of: ${EVIDENCE_KINDS.join(', ')}`);
149
+ if (!EVIDENCE_QUALITIES.includes(entry.quality)) errors.push(`receipt quality must be one of: ${EVIDENCE_QUALITIES.join(', ')}`);
150
+ if (typeof entry.source !== 'string' || !PRIMITIVE_IDS.has(entry.source)) errors.push('receipt source must be a known primitive skill id');
151
+ if (typeof entry.detail !== 'string' || !entry.detail.trim()) errors.push('receipt detail must be a non-empty string');
152
+ if (!isIsoString(entry.timestamp)) errors.push('receipt timestamp must be ISO8601');
153
+ if (entry.provenance !== undefined && !isRecord(entry.provenance)) errors.push('receipt provenance must be an object');
154
+ for (const key of Object.keys(entry)) {
155
+ if (!RECEIPT_KEYS.has(key)) errors.push(`receipt contains unknown key: ${key}`);
156
+ }
157
+ return errors;
158
+ }
159
+
160
+ function validateSkipped(entry, workflowId, selectedStages) {
161
+ const errors = [];
162
+ if (!isRecord(entry)) return ['skipped stage must be an object'];
163
+ const catalogStages = SELECTABLE_STAGES[workflowId] ?? [];
164
+ if (typeof entry.stage !== 'string' || !catalogStages.includes(entry.stage)) errors.push(`skipped stage must be a catalog stage of ${workflowId}`);
165
+ if (selectedStages.includes(entry.stage)) errors.push(`skipped stage ${entry.stage} must not be selected`);
166
+ if (!SKIP_REASONS.includes(entry.reason)) errors.push(`skip reason must be one of: ${SKIP_REASONS.join(', ')}`);
167
+ if (!Array.isArray(entry.evidence) || entry.evidence.some((e) => typeof e !== 'string')) errors.push('skip evidence must be an array of strings');
168
+ if (!SKIP_POLICIES.includes(entry.policy)) errors.push(`skip policy must be one of: ${SKIP_POLICIES.join(', ')}`);
169
+ if (!isIsoString(entry.skippedAt)) errors.push('skip skippedAt must be ISO8601');
170
+ for (const key of Object.keys(entry)) {
171
+ if (!SKIPPED_KEYS.has(key)) errors.push(`skipped stage contains unknown key: ${key}`);
172
+ }
173
+ const rule = (SKIP_RULES[workflowId] ?? {})[entry.stage];
174
+ if (!rule) errors.push(`stage ${entry.stage} is not skippable under ${workflowId} policy`);
175
+ else {
176
+ if (entry.reason !== rule.reason) errors.push(`skip reason for ${entry.stage} must be ${rule.reason}`);
177
+ if (entry.policy !== rule.policy) errors.push(`skip policy for ${entry.stage} must be ${rule.policy}`);
178
+ }
179
+ return errors;
180
+ }
181
+
182
+ function validateCompleted(entry, selectedStages) {
183
+ const errors = [];
184
+ if (!isRecord(entry)) return ['completed stage must be an object'];
185
+ if (typeof entry.stage !== 'string' || !selectedStages.includes(entry.stage)) errors.push('completed stage must be a selected stage');
186
+ if (!isIsoString(entry.completedAt)) errors.push('completed completedAt must be ISO8601');
187
+ if (!Array.isArray(entry.evidenceReceipts) || !entry.evidenceReceipts.length) errors.push('completed evidenceReceipts must be a non-empty array');
188
+ else for (const receipt of entry.evidenceReceipts) errors.push(...validateReceipt(receipt).map((e) => `completed ${entry.stage}: ${e}`));
189
+ if (typeof entry.stopConditionMet !== 'boolean') errors.push('completed stopConditionMet must be boolean');
190
+ for (const key of Object.keys(entry)) {
191
+ if (!COMPLETED_KEYS.has(key)) errors.push(`completed stage contains unknown key: ${key}`);
192
+ }
193
+ return errors;
194
+ }
195
+
196
+ function validateBlocker(blocker) {
197
+ const errors = [];
198
+ if (!isRecord(blocker)) return ['blocker must be an object'];
199
+ if (typeof blocker.id !== 'string' || !blocker.id.trim()) errors.push('blocker id must be a non-empty string');
200
+ if (typeof blocker.reason !== 'string' || !blocker.reason.trim()) errors.push('blocker reason must be a non-empty string');
201
+ if (!BLOCKER_TYPES.includes(blocker.type)) errors.push(`blocker type must be one of: ${BLOCKER_TYPES.join(', ')}`);
202
+ if (blocker.detail !== undefined && typeof blocker.detail !== 'string') errors.push('blocker detail must be a string');
203
+ if (!isIsoString(blocker.blockedAt)) errors.push('blocker blockedAt must be ISO8601');
204
+ for (const key of Object.keys(blocker)) {
205
+ if (!BLOCKER_KEYS.has(key)) errors.push(`blocker contains unknown key: ${key}`);
206
+ }
207
+ return errors;
208
+ }
209
+
210
+ function uniqueSortedStages(stages) {
211
+ return [...new Set(stages)].sort();
212
+ }
213
+
214
+ function computeNext(selectedStages, completedStages, skippedStages) {
215
+ const done = new Set([...completedStages.map((c) => c.stage), ...skippedStages.map((s) => s.stage)]);
216
+ return selectedStages.find((s) => !done.has(s)) ?? null;
217
+ }
218
+
219
+ function now() {
220
+ return new Date().toISOString();
221
+ }
222
+
223
+ function freezeState(state) {
224
+ return Object.freeze({
225
+ ...state,
226
+ candidateStages: Object.freeze([...state.candidateStages]),
227
+ selectedStages: Object.freeze([...state.selectedStages]),
228
+ completedStages: Object.freeze(state.completedStages.map((c) => Object.freeze({ ...c, evidenceReceipts: Object.freeze(c.evidenceReceipts.map((r) => Object.freeze({ ...r }))) }))),
229
+ skippedStages: Object.freeze(state.skippedStages.map((s) => Object.freeze({ ...s, evidence: Object.freeze([...s.evidence]) }))),
230
+ evidenceReceipts: Object.freeze(state.evidenceReceipts.map((r) => Object.freeze({ ...r }))),
231
+ blockers: Object.freeze(state.blockers.map((b) => Object.freeze({ ...b }))),
232
+ });
233
+ }
234
+
235
+ function bump(state, patch) {
236
+ const next = {
237
+ ...state,
238
+ ...patch,
239
+ revision: state.revision + 1,
240
+ updatedAt: now(),
241
+ };
242
+ next.nextStage = computeNext(next.selectedStages, next.completedStages, next.skippedStages);
243
+ return freezeState(next);
244
+ }
245
+
246
+ export function validateWorkflowState(input) {
247
+ const errors = [];
248
+ if (!isRecord(input)) return { ok: false, errors: ['workflow state must be an object'] };
249
+ const forbidden = containsForbiddenAuthorityKey(input);
250
+ if (forbidden) errors.push(`workflow state must not persist authority-derived fields (found at ${forbidden})`);
251
+ if (input.schemaVersion !== WORKFLOW_SCHEMA_VERSION) errors.push(`schemaVersion must be ${WORKFLOW_SCHEMA_VERSION}`);
252
+ if (typeof input.workflowId !== 'string' || !WORKFLOW_IDS.has(input.workflowId)) errors.push(`workflowId must be one of: ${[...WORKFLOW_IDS].join(', ')}`);
253
+ if (!WORKFLOW_STATUSES.includes(input.status)) errors.push(`status must be one of: ${WORKFLOW_STATUSES.join(', ')}`);
254
+ if (!Number.isInteger(input.revision) || input.revision < 0) errors.push('revision must be a non-negative integer');
255
+ if (!isIsoString(input.createdAt)) errors.push('createdAt must be ISO8601');
256
+ if (!isIsoString(input.updatedAt)) errors.push('updatedAt must be ISO8601');
257
+ if (!Array.isArray(input.candidateStages) || input.candidateStages.some((s) => !PRIMITIVE_IDS.has(s))) errors.push('candidateStages must be known primitive skill ids');
258
+ if (!Array.isArray(input.selectedStages) || input.selectedStages.some((s) => !PRIMITIVE_IDS.has(s))) errors.push('selectedStages must be known primitive skill ids');
259
+ if (new Set(input.selectedStages ?? []).size !== (input.selectedStages ?? []).length) errors.push('selectedStages must not contain duplicates');
260
+
261
+ const workflowId = input.workflowId;
262
+ const catalogStages = workflowId && SELECTABLE_STAGES[workflowId] ? SELECTABLE_STAGES[workflowId] : [];
263
+ if (Array.isArray(input.candidateStages) && catalogStages.length) {
264
+ for (const stage of input.candidateStages) {
265
+ if (!catalogStages.includes(stage)) errors.push(`candidate stage ${stage} is not declared by ${workflowId}`);
266
+ }
267
+ }
268
+ if (Array.isArray(input.selectedStages) && Array.isArray(input.candidateStages)) {
269
+ for (const stage of input.selectedStages) {
270
+ if (!input.candidateStages.includes(stage)) errors.push(`selected stage ${stage} must be a candidate stage`);
271
+ }
272
+ }
273
+ if (input.activeStage !== null && input.activeStage !== undefined) {
274
+ if (typeof input.activeStage !== 'string' || !(input.selectedStages ?? []).includes(input.activeStage)) errors.push('activeStage must be a selected stage or null');
275
+ }
276
+ if (input.nextStage !== null && input.nextStage !== undefined) {
277
+ if (typeof input.nextStage !== 'string' || !(input.selectedStages ?? []).includes(input.nextStage)) errors.push('nextStage must be a selected stage or null');
278
+ }
279
+ if (!Array.isArray(input.completedStages)) errors.push('completedStages must be an array');
280
+ else for (const entry of input.completedStages) errors.push(...validateCompleted(entry, input.selectedStages ?? []));
281
+ if (!Array.isArray(input.skippedStages)) errors.push('skippedStages must be an array');
282
+ else for (const entry of input.skippedStages) errors.push(...validateSkipped(entry, workflowId, input.selectedStages ?? []));
283
+ if (!Array.isArray(input.evidenceReceipts)) errors.push('evidenceReceipts must be an array');
284
+ else for (const receipt of input.evidenceReceipts) errors.push(...validateReceipt(receipt));
285
+ if (!Array.isArray(input.blockers)) errors.push('blockers must be an array');
286
+ else for (const blocker of input.blockers) errors.push(...validateBlocker(blocker));
287
+
288
+ const accounted = new Set([...(input.completedStages ?? []).map((c) => c.stage), ...(input.skippedStages ?? []).map((s) => s.stage)]);
289
+ if ((input.completedStages ?? []).some((c, i, arr) => arr.findIndex((x) => x.stage === c.stage) !== i)) errors.push('completedStages must not contain duplicate stages');
290
+ if ((input.skippedStages ?? []).some((s, i, arr) => arr.findIndex((x) => x.stage === s.stage) !== i)) errors.push('skippedStages must not contain duplicate stages');
291
+ for (const stage of accounted) {
292
+ if (!(input.selectedStages ?? []).includes(stage) && !(input.skippedStages ?? []).some((s) => s.stage === stage)) {
293
+ errors.push(`completed stage ${stage} must be selected`);
294
+ }
295
+ }
296
+ const expectedNext = Array.isArray(input.selectedStages) ? computeNext(input.selectedStages, input.completedStages ?? [], input.skippedStages ?? []) : null;
297
+ if ((input.nextStage ?? null) !== expectedNext) errors.push(`nextStage must be ${expectedNext ?? 'null'}`);
298
+ if (input.status === 'ACTIVE' && (input.activeStage === null || input.activeStage === undefined)) errors.push('ACTIVE status requires an activeStage');
299
+ if (input.status !== 'ACTIVE' && input.activeStage !== null && input.activeStage !== undefined && (input.completedStages ?? []).some((c) => c.stage === input.activeStage)) errors.push('activeStage must not be completed');
300
+ return { ok: errors.length === 0, errors };
301
+ }
302
+
303
+ export function createWorkflowState(workflowId, options = {}) {
304
+ const errors = [];
305
+ if (!WORKFLOW_IDS.has(workflowId)) return { ok: false, errors: [`workflowId must be one of: ${[...WORKFLOW_IDS].join(', ')}`] };
306
+ const catalogStages = [...SELECTABLE_STAGES[workflowId]];
307
+ const candidates = options.candidateStages ?? catalogStages;
308
+ if (!Array.isArray(candidates) || !candidates.length) errors.push('candidateStages must be a non-empty array');
309
+ for (const stage of candidates ?? []) {
310
+ if (!catalogStages.includes(stage)) errors.push(`candidate stage ${stage} is not declared by ${workflowId}`);
311
+ }
312
+ const selected = options.selectedStages ?? candidates;
313
+ if (!Array.isArray(selected) || !selected.length) errors.push('selectedStages must be a non-empty array');
314
+ for (const stage of selected ?? []) {
315
+ if (!(candidates ?? []).includes(stage)) errors.push(`selected stage ${stage} must be a candidate stage`);
316
+ }
317
+ for (const required of REQUIRED_STAGES[workflowId]) {
318
+ if (!(selected ?? []).includes(required) && !((options.skippedStages ?? []).some((s) => s.stage === required))) {
319
+ errors.push(`required stage ${required} must be selected or explicitly skipped under policy`);
320
+ }
321
+ }
322
+ if (errors.length) return { ok: false, errors };
323
+ const timestamp = now();
324
+ const state = {
325
+ schemaVersion: WORKFLOW_SCHEMA_VERSION,
326
+ workflowId,
327
+ candidateStages: [...candidates],
328
+ selectedStages: [...selected],
329
+ activeStage: null,
330
+ completedStages: [],
331
+ skippedStages: (options.skippedStages ?? []).map((s) => ({ ...s, evidence: [...(s.evidence ?? [])] })),
332
+ evidenceReceipts: [],
333
+ blockers: [],
334
+ nextStage: null,
335
+ status: 'NEW',
336
+ revision: 0,
337
+ createdAt: timestamp,
338
+ updatedAt: timestamp,
339
+ };
340
+ state.nextStage = computeNext(state.selectedStages, state.completedStages, state.skippedStages);
341
+ for (const skipped of state.skippedStages) {
342
+ const skippedErrors = validateSkipped({ ...skipped, skippedAt: skipped.skippedAt ?? timestamp }, workflowId, state.selectedStages);
343
+ if (skippedErrors.length) return { ok: false, errors: skippedErrors };
344
+ }
345
+ const validation = validateWorkflowState(freezeState(state));
346
+ if (!validation.ok) return validation;
347
+ return { ok: true, errors: [], value: freezeState({ ...state, status: 'READY', nextStage: state.nextStage }) };
348
+ }
349
+
350
+ export function startStage(state, stage) {
351
+ if (state.status === 'COMPLETE') throw new Error('cannot start a stage on a COMPLETE workflow');
352
+ if (state.status === 'INTERRUPTED') throw new Error('cannot start a stage on an INTERRUPTED workflow; resume first');
353
+ if (state.status !== 'READY') throw new Error(`startStage requires READY status; got ${state.status}`);
354
+ if (state.blockers.length > 0) throw new Error('cannot start a stage while blockers remain');
355
+ if (stage !== state.nextStage) throw new Error(`stage ${stage} is not next; expected ${state.nextStage ?? 'null'}`);
356
+ return bump(state, { status: 'ACTIVE', activeStage: stage });
357
+ }
358
+
359
+ export function completeStage(state, stage, receipts) {
360
+ if (state.status !== 'ACTIVE') throw new Error(`completeStage requires ACTIVE status; got ${state.status}`);
361
+ if (stage !== state.activeStage) throw new Error(`stage ${stage} is not active; active is ${state.activeStage ?? 'null'}`);
362
+ if (!Array.isArray(receipts) || !receipts.length) throw new Error('completeStage requires at least one evidence receipt');
363
+ const stamped = receipts.map((r) => ({ ...r, timestamp: r.timestamp ?? now() }));
364
+ for (const receipt of stamped) {
365
+ const receiptErrors = validateReceipt(receipt);
366
+ if (receiptErrors.length) throw new Error(`Invalid evidence receipt: ${receiptErrors.join('; ')}`);
367
+ if (receipt.source !== stage) throw new Error(`receipt source ${receipt.source} must match completing stage ${stage}`);
368
+ }
369
+ const qualities = stamped.map((r) => r.quality);
370
+ if (qualities.some((q) => q === 'failed' || q === 'missing')) throw new Error('completeStage requires receipts with observed or verified quality');
371
+ if (!qualities.some((q) => q === 'observed' || q === 'verified')) throw new Error('completeStage requires at least one observed or verified receipt');
372
+ const completedEntry = {
373
+ stage,
374
+ completedAt: now(),
375
+ evidenceReceipts: stamped,
376
+ stopConditionMet: true,
377
+ };
378
+ return bump(state, {
379
+ status: 'READY',
380
+ activeStage: null,
381
+ completedStages: [...state.completedStages, completedEntry],
382
+ evidenceReceipts: [...state.evidenceReceipts, ...stamped].sort((a, b) => a.kind.localeCompare(b.kind)),
383
+ });
384
+ }
385
+
386
+ export function skipStage(state, stage, { reason, evidence = [], policy } = {}) {
387
+ if (state.status === 'COMPLETE') throw new Error('cannot skip a stage on a COMPLETE workflow');
388
+ if (state.status !== 'READY') throw new Error(`skipStage requires READY status; got ${state.status}`);
389
+ if (!SELECTABLE_STAGES[state.workflowId]?.includes(stage)) throw new Error(`stage ${stage} is not declared by ${state.workflowId}`);
390
+ if (state.selectedStages.includes(stage)) throw new Error(`stage ${stage} is selected and cannot be skipped; remove from selection at creation`);
391
+ if (state.completedStages.some((c) => c.stage === stage) || state.skippedStages.some((s) => s.stage === stage)) throw new Error(`stage ${stage} is already accounted`);
392
+ const rule = (SKIP_RULES[state.workflowId] ?? {})[stage];
393
+ if (!rule) throw new Error(`stage ${stage} is not skippable under ${state.workflowId} policy`);
394
+ if (reason !== rule.reason) throw new Error(`skip reason for ${stage} must be ${rule.reason}`);
395
+ if (policy !== rule.policy) throw new Error(`skip policy for ${stage} must be ${rule.policy}`);
396
+ for (const required of rule.evidence) {
397
+ const found = [...state.evidenceReceipts, ...(evidenceReceiptsFromArray(evidence) ?? [])].some((r) => r.kind === required && ['observed', 'verified'].includes(r.quality));
398
+ if (!found) throw new Error(`skip of ${stage} requires evidence ${required} with observed or verified quality`);
399
+ }
400
+ const skipped = {
401
+ stage,
402
+ reason,
403
+ evidence: [...evidence.map((e) => (typeof e === 'string' ? e : e.kind))],
404
+ policy,
405
+ skippedAt: now(),
406
+ };
407
+ return bump(state, { skippedStages: [...state.skippedStages, skipped] });
408
+ }
409
+
410
+ function evidenceReceiptsFromArray(evidence) {
411
+ if (!Array.isArray(evidence)) return [];
412
+ return evidence.filter((e) => isRecord(e) && typeof e.kind === 'string');
413
+ }
414
+
415
+ export function recordEvidence(state, receipt) {
416
+ const stamped = { ...receipt, timestamp: receipt.timestamp ?? now() };
417
+ const receiptErrors = validateReceipt(stamped);
418
+ if (receiptErrors.length) throw new Error(`Invalid evidence receipt: ${receiptErrors.join('; ')}`);
419
+ const existingIdx = state.evidenceReceipts.findIndex((r) => r.kind === stamped.kind);
420
+ const order = { claimed: 0, observed: 1, verified: 2 };
421
+ let next = [...state.evidenceReceipts];
422
+ if (existingIdx >= 0) {
423
+ const existing = next[existingIdx];
424
+ const incomingNegative = stamped.quality === 'failed' || stamped.quality === 'missing';
425
+ const existingNegative = existing.quality === 'failed' || existing.quality === 'missing';
426
+ let keepExisting;
427
+ if (incomingNegative) keepExisting = false;
428
+ else if (existingNegative) keepExisting = false;
429
+ else keepExisting = (order[existing.quality] ?? 0) >= (order[stamped.quality] ?? 0);
430
+ next[existingIdx] = keepExisting ? existing : stamped;
431
+ } else {
432
+ next = [...next, stamped].sort((a, b) => a.kind.localeCompare(b.kind));
433
+ }
434
+ return bump(state, { evidenceReceipts: next });
435
+ }
436
+
437
+ export function addWorkflowBlocker(state, blocker) {
438
+ if (state.status === 'COMPLETE') throw new Error('cannot add a blocker to a COMPLETE workflow');
439
+ if (state.status !== 'ACTIVE' && state.status !== 'READY' && state.status !== 'BLOCKED') throw new Error(`addBlocker requires ACTIVE, READY, or BLOCKED status; got ${state.status}`);
440
+ if (state.blockers.some((b) => b.id === blocker.id)) return state;
441
+ const stamped = { ...blocker, blockedAt: blocker.blockedAt ?? now() };
442
+ const blockerErrors = validateBlocker(stamped);
443
+ if (blockerErrors.length) throw new Error(`Invalid blocker: ${blockerErrors.join('; ')}`);
444
+ const blockers = [...state.blockers, stamped].sort((a, b) => a.id.localeCompare(b.id));
445
+ return bump(state, { status: 'BLOCKED', blockers });
446
+ }
447
+
448
+ export function removeWorkflowBlocker(state, blockerId) {
449
+ if (state.status === 'COMPLETE') throw new Error('cannot remove a blocker from a COMPLETE workflow');
450
+ const next = state.blockers.filter((b) => b.id !== blockerId);
451
+ if (next.length === state.blockers.length) return state;
452
+ const status = state.status === 'BLOCKED' && next.length === 0 ? 'READY' : state.status;
453
+ if (status === 'BLOCKED') return bump(state, { blockers: next });
454
+ return bump({ ...state, status: state.status === 'BLOCKED' && next.length > 0 ? 'BLOCKED' : state.status }, { blockers: next, status: next.length === 0 && state.status === 'BLOCKED' ? 'READY' : state.status });
455
+ }
456
+
457
+ export function interruptWorkflow(state, reason) {
458
+ if (state.status === 'COMPLETE') throw new Error('cannot interrupt a COMPLETE workflow');
459
+ if (state.status === 'INTERRUPTED') return state;
460
+ if (state.status !== 'ACTIVE' && state.status !== 'READY' && state.status !== 'BLOCKED') throw new Error(`interrupt requires ACTIVE, READY, or BLOCKED status; got ${state.status}`);
461
+ if (typeof reason !== 'string' || !reason.trim()) throw new Error('interrupt reason must be a non-empty string');
462
+ return bump(state, { status: 'INTERRUPTED', activeStage: state.status === 'ACTIVE' ? null : state.activeStage });
463
+ }
464
+
465
+ export function isStageComplete(primitiveSkill, receipts) {
466
+ const conditions = getStopConditions(primitiveSkill);
467
+ if (!conditions.length) return receipts.length > 0;
468
+ return true;
469
+ }
470
+
471
+ export function isWorkflowComplete(state) {
472
+ const allAccounted = state.selectedStages.every((s) =>
473
+ state.completedStages.some((c) => c.stage === s) || state.skippedStages.some((k) => k.stage === s),
474
+ );
475
+ if (!allAccounted) return false;
476
+ if (state.blockers.length > 0) return false;
477
+ return state.nextStage === null;
478
+ }
479
+
480
+ export function finalizeWorkflow(state) {
481
+ if (state.status === 'COMPLETE') return state;
482
+ if (state.status !== 'READY') throw new Error(`finalize requires READY status; got ${state.status}`);
483
+ if (!isWorkflowComplete(state)) throw new Error('workflow completion requires every selected stage completed or validly skipped with no blockers');
484
+ const negative = state.evidenceReceipts.filter((r) => r.quality === 'failed' || r.quality === 'missing');
485
+ if (negative.length) throw new Error(`workflow completion blocked by negative evidence: ${negative.map((r) => r.kind).join(', ')}`);
486
+ return bump({ ...state, nextStage: null }, { status: 'COMPLETE', activeStage: null });
487
+ }
488
+
489
+ export function serializeWorkflowState(state) {
490
+ const validation = validateWorkflowState(state);
491
+ if (!validation.ok) throw new Error(`Cannot serialize invalid workflow state: ${validation.errors.join('; ')}`);
492
+ return JSON.stringify(state, null, 2);
493
+ }
494
+
495
+ export function deserializeWorkflowState(input) {
496
+ let parsed = input;
497
+ if (typeof input === 'string') {
498
+ try {
499
+ parsed = JSON.parse(input);
500
+ } catch (error) {
501
+ throw new Error(`Invalid workflow checkpoint JSON: ${error.message}`);
502
+ }
503
+ }
504
+ const before = JSON.stringify(parsed);
505
+ const validation = validateWorkflowState(parsed);
506
+ if (!validation.ok) throw new Error(`Invalid workflow checkpoint: ${validation.errors.join('; ')}`);
507
+ const frozen = freezeState(JSON.parse(JSON.stringify(parsed)));
508
+ if (JSON.stringify(frozen) !== before && JSON.stringify(JSON.parse(JSON.stringify(parsed))) !== before) {
509
+ throw new Error('Invalid workflow checkpoint: non-deterministic representation');
510
+ }
511
+ return frozen;
512
+ }
513
+
514
+ export function isFreshnessSensitive(kind) {
515
+ return HIGH_SENSITIVITY_KINDS.has(kind) || MEDIUM_SENSITIVITY_KINDS.has(kind);
516
+ }
517
+
518
+ export function isHighSensitivity(kind) {
519
+ return HIGH_SENSITIVITY_KINDS.has(kind);
520
+ }
521
+
522
+ export function requiresReverification(kind) {
523
+ return HIGH_SENSITIVITY_KINDS.has(kind);
524
+ }
525
+
526
+ function workflowApplicableSkill(workflowId, primarySkill) {
527
+ return (SELECTABLE_STAGES[workflowId] ?? []).includes(primarySkill);
528
+ }
529
+
530
+ export function checkStaleCheckpoint(state, resolution) {
531
+ if (!isRecord(resolution) || !isRecord(resolution.primary) || typeof resolution.primary.skill !== 'string') {
532
+ return { stale: true, reason: 'workflow-no-longer-applicable', detail: 'resolution is missing a primary skill' };
533
+ }
534
+ if (!workflowApplicableSkill(state.workflowId, resolution.primary.skill)) {
535
+ return { stale: true, reason: 'workflow-no-longer-applicable', detail: `primary ${resolution.primary.skill} is not a stage of ${state.workflowId}` };
536
+ }
537
+ const expectedPrimary = state.activeStage ?? state.nextStage ?? SELECTABLE_STAGES[state.workflowId][0];
538
+ if (resolution.primary.skill !== expectedPrimary) {
539
+ return { stale: true, reason: 'primary-skill-shifted', detail: `expected ${expectedPrimary}, got ${resolution.primary.skill}` };
540
+ }
541
+ const required = resolution.verificationPlan?.required ?? [];
542
+ for (const check of required) {
543
+ const evidenceKind = checkToEvidence(check);
544
+ if (!evidenceKind) continue;
545
+ const receipt = state.evidenceReceipts.find((r) => r.kind === evidenceKind);
546
+ if (receipt && isHighSensitivity(evidenceKind)) {
547
+ return { stale: true, reason: 'verification-evidence-stale', detail: `required check ${check} maps to high-sensitivity evidence ${evidenceKind} which must be re-verified` };
548
+ }
549
+ }
550
+ return { stale: false };
551
+ }
552
+
553
+ function checkToEvidence(check) {
554
+ const mapping = {
555
+ 'targeted-test': 'targeted-tests-passed',
556
+ 'relevant-suite': 'relevant-suite-passed',
557
+ 'typecheck': 'typecheck-passed',
558
+ 'lint': 'lint-passed',
559
+ 'build': 'build-passed',
560
+ 'package': 'package-verified',
561
+ 'compatibility': 'compatibility-verified',
562
+ 'security': 'security-reviewed',
563
+ 'regression': 'regression-proof-added',
564
+ 'release-readiness': 'release-readiness-verified',
565
+ 'deployment-safety': 'deployment-verified',
566
+ };
567
+ return mapping[check] ?? null;
568
+ }
569
+
570
+ export function resumeFromCheckpoint(checkpoint, resolution) {
571
+ const state = deserializeWorkflowState(checkpoint);
572
+ if (state.status !== 'INTERRUPTED' && state.status !== 'BLOCKED') {
573
+ throw new Error(`resume requires INTERRUPTED or BLOCKED status; got ${state.status}`);
574
+ }
575
+ const stale = checkStaleCheckpoint(state, resolution);
576
+ if (stale.stale) {
577
+ const blocked = bump({ ...state, status: state.status }, {
578
+ status: 'BLOCKED',
579
+ blockers: state.blockers.some((b) => b.id === 'stale-checkpoint')
580
+ ? state.blockers
581
+ : [...state.blockers, { id: 'stale-checkpoint', reason: stale.reason, type: 'evidence', detail: stale.detail ?? stale.reason, blockedAt: now() }].sort((a, b) => a.id.localeCompare(b.id)),
582
+ });
583
+ return { state: blocked, replanRequired: true, reason: stale.reason, detail: stale.detail };
584
+ }
585
+ if (state.status === 'INTERRUPTED') {
586
+ return { state: bump(state, { status: 'READY' }), replanRequired: false };
587
+ }
588
+ if (state.status === 'BLOCKED' && state.blockers.length === 0) {
589
+ return { state: bump(state, { status: 'READY' }), replanRequired: false };
590
+ }
591
+ return { state, replanRequired: false };
592
+ }
593
+
594
+ export function selectableStages(workflowId) {
595
+ return [...(SELECTABLE_STAGES[workflowId] ?? [])];
596
+ }
597
+
598
+ export function skipRule(workflowId, stage) {
599
+ return (SKIP_RULES[workflowId] ?? {})[stage] ?? null;
600
+ }
601
+
602
+ export function requiredStages(workflowId) {
603
+ return [...(REQUIRED_STAGES[workflowId] ?? [])];
604
+ }
605
+
606
+ export { STALE_REASONS, FORBIDDEN_AUTHORITY_KEYS, HIGH_SENSITIVITY_KINDS, MEDIUM_SENSITIVITY_KINDS, SELECTABLE_STAGES, SKIP_RULES, uniqueSortedStages, getWorkflow };
607
+
608
+ export const workflowStateAPI = {
609
+ createWorkflowState,
610
+ startStage,
611
+ completeStage,
612
+ skipStage,
613
+ recordEvidence,
614
+ addWorkflowBlocker,
615
+ removeWorkflowBlocker,
616
+ interruptWorkflow,
617
+ isStageComplete,
618
+ isWorkflowComplete,
619
+ finalizeWorkflow,
620
+ serializeWorkflowState,
621
+ deserializeWorkflowState,
622
+ validateWorkflowState,
623
+ checkStaleCheckpoint,
624
+ resumeFromCheckpoint,
625
+ isFreshnessSensitive,
626
+ isHighSensitivity,
627
+ requiresReverification,
628
+ };