showdar-skills 0.5.0 → 0.7.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,67 @@ 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.7.0]
8
+
9
+ ### Added
10
+
11
+ - Deterministic workflow trace projection (`src/workflow-trace.js`): pure
12
+ state-diff observation over workflow transitions with 10 closed semantic
13
+ event types, no timestamps, no authority content, and no automatic
14
+ persistence.
15
+ - Workflow benchmark scenario schema and loader
16
+ (`benchmark/schema/workflow-scenario.schema.json`,
17
+ `benchmark/lib/workflow-scenario-loader.js`).
18
+ - Deterministic workflow benchmark corpus
19
+ (`benchmark/scenarios/workflows/`, 16 scenarios): exact-match M1–M10
20
+ invariants covering selection, skip policy, verification preservation,
21
+ stale-resume blocking, authority invariance, and completion.
22
+ - `npm run eval:workflows` driver
23
+ (`scripts/workflow-observability-eval.mjs`).
24
+
25
+ ### Changed
26
+
27
+ - `npm run eval` now runs retrieval evaluation (`eval:retrieval`) followed
28
+ by workflow evaluation (`eval:workflows`); release evaluation blocks on
29
+ exact workflow invariants.
30
+ - `npm run check` remains test/validate/package correctness only.
31
+
32
+ ### Safety
33
+
34
+ - Traces contain normalized semantic data only (IDs, enums, receipt
35
+ summaries, statuses): no authority state, raw prompts, logs, telemetry,
36
+ or network reporting.
37
+
38
+ ## [0.6.0]
39
+
40
+ ### Added
41
+
42
+ - Portable workflow execution state (`src/workflow-state.js`): versioned
43
+ JSON checkpoint schema (schemaVersion 1) with stage selection, evidence
44
+ receipts, structured skip, interruption, and resume. State never persists
45
+ authority; resume always re-resolves through Phase 6G. Caller/harness
46
+ owns persistence; no filesystem store, backend, or telemetry.
47
+ - Workflow checkpoint documentation in all four workflow skills
48
+ (feature, bugfix, release, incident): serialization,
49
+ interruption/resume, stale-checkpoint blocking, and stage vs workflow
50
+ completion semantics.
51
+ - Workflow-state policy validation in `src/validate.js`: catalog coverage,
52
+ no authority fields in checkpoints, no harness or storage coupling.
53
+
54
+ ### Changed
55
+
56
+ - Four workflow skills now describe adaptive state semantics instead of
57
+ ephemeral-only tracking.
58
+ - Workflow completion explicitly distinguishes stage completion (primitive
59
+ evidence and stop conditions) from workflow completion (all selected
60
+ stages completed or validly skipped, no blockers, required verification
61
+ satisfied).
62
+
63
+ ### Safety
64
+
65
+ - Checkpoint state never persists authority-derived fields.
66
+ - Caller owns checkpoint persistence; Showdar chooses no storage.
67
+ - Stale checkpoints block with `replanRequired` rather than continuing.
8
68
 
9
69
  ## [0.5.0]
10
70
 
package/MIGRATION.md CHANGED
@@ -1,3 +1,42 @@
1
+ # Migrating to 0.7.0
2
+
3
+ 0.7.0 adds deterministic workflow trace projection
4
+ (`src/workflow-trace.js`) and a workflow benchmark corpus over the
5
+ unchanged 0.6 runtime. No user action is required.
6
+
7
+ - Existing 0.6 installs and configs remain valid: no workflow-state
8
+ schema migration (still schemaVersion 1), no config migration, no
9
+ adapter migration, no checkpoint migration.
10
+ - Runtime workflow semantics are unchanged; traces observe released
11
+ behavior only and never affect execution, routing, or authority.
12
+ - The new `src/workflow-trace.js` module ships in the package; benchmark
13
+ scenarios, schema, loader, and eval driver are development/release
14
+ tooling and do not ship.
15
+ - No telemetry, network reporting, automatic storage, or tracking is
16
+ introduced.
17
+
18
+ # Migrating to 0.6.0
19
+
20
+ 0.6.0 adds portable workflow execution state (`src/workflow-state.js`,
21
+ schemaVersion 1) over the unchanged 15 primitives, 4 workflows, and Phase 6G
22
+ authority.
23
+
24
+ - Existing 0.5 installs and configs remain valid; no adapter or config
25
+ migration is required.
26
+ - The four workflows now support explicit portable execution state:
27
+ stage selection and progression, evidence-backed skipping, stage vs
28
+ workflow completion, interruption, and resume.
29
+ - Checkpoints are plain JSON at schemaVersion 1. Persistence is owned by
30
+ the caller or harness; Showdar 0.6 defines no state directory, backend,
31
+ session registry, or telemetry.
32
+ - Resume always re-resolves current context through Phase 6G. Stale
33
+ checkpoints return `BLOCKED` with `replanRequired` instead of silently
34
+ continuing.
35
+ - Checkpoints never persist authority. No migration of prior route or
36
+ authority state exists or is needed.
37
+ - No config change is required. Callers that never persist a checkpoint
38
+ keep the previous ephemeral-only behavior.
39
+
1
40
  # Migrating to 0.5.0
2
41
 
3
42
  0.5.0 adds a thin native adapter layer over the unchanged portable core.
package/README.md CHANGED
@@ -385,6 +385,97 @@ 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
+
446
+ ### Workflow traces (observability, benchmark-only)
447
+
448
+ Workflow traces are pure projections of before/after workflow states
449
+ (`src/workflow-trace.js`): ordered events such as `stage-entered`,
450
+ `stage-completed`, `stage-skipped`, `workflow-blocked`, `workflow-resumed`,
451
+ and `workflow-completed`. Traces carry stage IDs, skip reasons, receipt
452
+ summaries, and statuses only — no timestamps, no prompts, no secrets, no
453
+ authority content. Nothing persists them automatically; the benchmark
454
+ corpus (`npm run eval:workflows`) uses them to verify selection, skip,
455
+ resume, and completion behavior deterministically.
456
+
457
+ A workflow trace does not mutate workflow state, affect routing or
458
+ authority, persist automatically, or send telemetry. The 10 event
459
+ categories are `workflow-created`, `stages-selected`, `stage-entered`,
460
+ `evidence-recorded`, `stage-completed`, `stage-skipped`,
461
+ `workflow-blocked`, `workflow-interrupted`, `workflow-resumed`, and
462
+ `workflow-completed`.
463
+
464
+ ### Evaluation
465
+
466
+ ```bash
467
+ npm run eval:retrieval # retrieval evaluation (unchanged behavior)
468
+ npm run eval:workflows # deterministic workflow semantic benchmark
469
+ npm run eval # both, sequentially (release-blocking)
470
+ ```
471
+
472
+ Workflow evaluation asserts exact M1–M10 invariants (selection accuracy,
473
+ invalid-skip rejection, verification preservation, stale-resume blocking,
474
+ authority invariance, completion, trace equality, revision monotonicity,
475
+ checkpoint round-trip, skip-evidence backing) with no fuzzy score
476
+ thresholds. `npm run check` (test/validate/pack) does not run the
477
+ benchmark; the release pipeline runs `npm run eval`, gating both suites.
478
+
388
479
  ## A typical software workflow
389
480
 
390
481
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "showdar-skills",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Production-grade software engineering lifecycle skills for coding agents.",
5
5
  "type": "module",
6
6
  "bin": { "showdar": "./bin/showdar.js" },
@@ -8,7 +8,9 @@
8
8
  "test": "node --test",
9
9
  "validate": "node bin/showdar.js validate",
10
10
  "sync:runtime": "node scripts/sync-runtime.mjs",
11
- "eval": "node scripts/retrieval-eval.mjs",
11
+ "eval": "npm run eval:retrieval && npm run eval:workflows",
12
+ "eval:retrieval": "node scripts/retrieval-eval.mjs",
13
+ "eval:workflows": "node scripts/workflow-observability-eval.mjs",
12
14
  "check": "npm test && npm run validate && npm pack --dry-run",
13
15
  "smoke": "node scripts/package-smoke.mjs",
14
16
  "release:check": "node scripts/check-release-version.mjs"
@@ -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,112 @@ 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
+ for (const error of (await validateWorkflowScenarioCoverage(packageRoot, policy)).errors) errors.push(error);
270
+ return { ok: errors.length === 0, errors };
271
+ }
272
+
273
+ export async function validateWorkflowScenarioCoverage(packageRoot, policy = null) {
274
+ const errors = [];
275
+ const primitives = new Set(SKILLS.map((skill) => skill.id));
276
+ const workflows = new Set(WORKFLOW_SKILLS.map((w) => w.id));
277
+ let trace;
278
+ try {
279
+ trace = await import('./workflow-trace.js');
280
+ } catch (error) {
281
+ return { ok: false, errors: [`workflow-trace unavailable: ${error.message}`] };
282
+ }
283
+ const eventTypes = new Set(trace.WORKFLOW_EVENT_TYPES);
284
+ let files;
285
+ try {
286
+ files = await readdir(path.join(packageRoot, 'benchmark', 'scenarios', 'workflows'));
287
+ } catch {
288
+ return { ok: false, errors: ['workflow-scenarios: benchmark/scenarios/workflows missing'] };
289
+ }
290
+ const scenarioFiles = files.filter((f) => f.endsWith('.json')).sort();
291
+ if (!scenarioFiles.length) return { ok: false, errors: ['workflow-scenarios: no scenario files'] };
292
+ const seenIds = new Set();
293
+ const coverage = new Map();
294
+ for (const file of scenarioFiles) {
295
+ let scenario;
296
+ try {
297
+ scenario = JSON.parse(await readFile(path.join(packageRoot, 'benchmark', 'scenarios', 'workflows', file), 'utf8'));
298
+ } catch (error) {
299
+ errors.push(`workflow-scenarios: ${file} is not valid JSON (${error.message})`);
300
+ continue;
301
+ }
302
+ if (seenIds.has(scenario.id)) errors.push(`workflow-scenarios: duplicate id ${scenario.id}`);
303
+ seenIds.add(scenario.id);
304
+ if (!workflows.has(scenario.workflow)) {
305
+ errors.push(`workflow-scenarios: ${file} references unknown workflow ${scenario.workflow}`);
306
+ continue;
307
+ }
308
+ coverage.set(scenario.workflow, (coverage.get(scenario.workflow) ?? 0) + 1);
309
+ const catalogStages = new Set((policy?.selectableStages?.(scenario.workflow)) ?? []);
310
+ for (const stage of scenario.selection?.selectedStages ?? []) {
311
+ if (!primitives.has(stage)) errors.push(`workflow-scenarios: ${file} selects unknown stage ${stage}`);
312
+ else if (catalogStages.size && !catalogStages.has(stage)) errors.push(`workflow-scenarios: ${file} selects non-catalog stage ${stage}`);
313
+ }
314
+ for (const step of scenario.steps ?? []) {
315
+ if (step.stage && !primitives.has(step.stage)) errors.push(`workflow-scenarios: ${file} step references unknown stage ${step.stage}`);
316
+ }
317
+ for (const entry of scenario.expected?.trace ?? []) {
318
+ if (!eventTypes.has(entry.type)) errors.push(`workflow-scenarios: ${file} expects unknown event type ${entry.type}`);
319
+ if (entry.stage !== null && entry.stage !== undefined && !primitives.has(entry.stage)) {
320
+ errors.push(`workflow-scenarios: ${file} expects unknown event stage ${entry.stage}`);
321
+ }
322
+ }
323
+ }
324
+ for (const workflow of WORKFLOW_SKILLS) {
325
+ if (!coverage.get(workflow.id)) errors.push(`workflow-scenarios: ${workflow.id} has no scenario coverage`);
326
+ }
327
+ return { ok: errors.length === 0, errors };
328
+ }
329
+
224
330
  export async function validateRepository(packageRoot) {
225
331
  const errors = [];
226
332
  const warnings = [];
@@ -254,6 +360,8 @@ export async function validateRepository(packageRoot) {
254
360
  if (new Set(workflow.stages).size !== workflow.stages.length) errors.push(`${workflow.id}: candidate stages contain duplicates`);
255
361
  }
256
362
 
363
+ for (const error of (await validateWorkflowStatePolicy(packageRoot)).errors) errors.push(error);
364
+
257
365
  let canonicalRuntime;
258
366
  try {
259
367
  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
+ };
@@ -0,0 +1,183 @@
1
+ import {
2
+ validateWorkflowState,
3
+ isWorkflowComplete,
4
+ FORBIDDEN_AUTHORITY_KEYS,
5
+ } from './workflow-state.js';
6
+
7
+ export const WORKFLOW_EVENT_TYPES = Object.freeze([
8
+ 'workflow-created',
9
+ 'stages-selected',
10
+ 'stage-entered',
11
+ 'evidence-recorded',
12
+ 'stage-completed',
13
+ 'stage-skipped',
14
+ 'workflow-blocked',
15
+ 'workflow-interrupted',
16
+ 'workflow-resumed',
17
+ 'workflow-completed',
18
+ ]);
19
+
20
+ const EVENT_TYPE_SET = new Set(WORKFLOW_EVENT_TYPES);
21
+ const EVENT_KEYS = new Set(['seq', 'type', 'workflowId', 'revision', 'stage', 'detail']);
22
+
23
+ function sortedKinds(receipts) {
24
+ return [...receipts].map((r) => r.kind).sort();
25
+ }
26
+
27
+ function sortedQualities(receipts) {
28
+ return [...receipts].map((r) => r.quality).sort();
29
+ }
30
+
31
+ function freezeEvent(event) {
32
+ return Object.freeze({ ...event, detail: Object.freeze({ ...event.detail }) });
33
+ }
34
+
35
+ function makeEvent(seq, type, state, stage, detail) {
36
+ return freezeEvent({
37
+ seq,
38
+ type,
39
+ workflowId: state.workflowId,
40
+ revision: state.revision,
41
+ stage: stage ?? null,
42
+ detail: detail ?? {},
43
+ });
44
+ }
45
+
46
+ export function assertNoAuthority(event) {
47
+ const serialized = JSON.stringify(event).toLowerCase().replace(/[\s_-]+/g, '');
48
+ for (const forbidden of FORBIDDEN_AUTHORITY_KEYS) {
49
+ if (serialized.includes(forbidden)) {
50
+ throw new Error(`workflow trace event must not contain authority-derived content (found ${forbidden})`);
51
+ }
52
+ }
53
+ return true;
54
+ }
55
+
56
+ export function validateWorkflowEvent(event) {
57
+ const errors = [];
58
+ if (event === null || typeof event !== 'object' || Array.isArray(event)) return { ok: false, errors: ['workflow event must be an object'] };
59
+ for (const key of Object.keys(event)) {
60
+ if (!EVENT_KEYS.has(key)) errors.push(`event contains unknown key: ${key}`);
61
+ }
62
+ if (!Number.isInteger(event.seq) || event.seq < 0) errors.push('event seq must be a non-negative integer');
63
+ if (!EVENT_TYPE_SET.has(event.type)) errors.push(`event type must be one of: ${WORKFLOW_EVENT_TYPES.join(', ')}`);
64
+ if (typeof event.workflowId !== 'string' || !event.workflowId) errors.push('event workflowId must be a non-empty string');
65
+ if (!Number.isInteger(event.revision) || event.revision < 0) errors.push('event revision must be a non-negative integer');
66
+ if (event.stage !== null && typeof event.stage !== 'string') errors.push('event stage must be a string or null');
67
+ if (event.detail === null || typeof event.detail !== 'object' || Array.isArray(event.detail)) errors.push('event detail must be an object');
68
+ if (!errors.length) {
69
+ try {
70
+ assertNoAuthority(event);
71
+ } catch (error) {
72
+ errors.push(error.message);
73
+ }
74
+ }
75
+ return { ok: errors.length === 0, errors };
76
+ }
77
+
78
+ export function eventKey(event) {
79
+ return JSON.stringify([event.type, event.workflowId, event.stage, event.detail]);
80
+ }
81
+
82
+ export function normalizeTrace(events) {
83
+ return events.map(eventKey);
84
+ }
85
+
86
+ function requireValidState(state, label) {
87
+ const validation = validateWorkflowState(state);
88
+ if (!validation.ok) throw new Error(`Invalid ${label} workflow state: ${validation.errors.join('; ')}`);
89
+ }
90
+
91
+ export function projectWorkflowEvents(prevState, nextState, input = {}) {
92
+ if (prevState !== null) requireValidState(prevState, 'prev');
93
+ requireValidState(nextState, 'next');
94
+ const op = input.op ?? null;
95
+ if (typeof op !== 'string' || !op) throw new Error('projectWorkflowEvents requires input.op');
96
+ const events = [];
97
+ const emit = (type, stage, detail) => {
98
+ const event = makeEvent(events.length, type, nextState, stage, detail);
99
+ const validation = validateWorkflowEvent(event);
100
+ if (!validation.ok) throw new Error(`Projected invalid event: ${validation.errors.join('; ')}`);
101
+ events.push(event);
102
+ };
103
+ switch (op) {
104
+ case 'create': {
105
+ emit('workflow-created', null, {
106
+ selectedStages: [...nextState.selectedStages],
107
+ candidateStages: [...nextState.candidateStages],
108
+ });
109
+ const sameOrder = nextState.selectedStages.length === nextState.candidateStages.length
110
+ && nextState.selectedStages.every((s, i) => s === nextState.candidateStages[i]);
111
+ if (!sameOrder) emit('stages-selected', null, { selectedStages: [...nextState.selectedStages] });
112
+ break;
113
+ }
114
+ case 'start':
115
+ emit('stage-entered', input.stage ?? nextState.activeStage, {});
116
+ break;
117
+ case 'record': {
118
+ const receipts = input.receipts ?? nextState.evidenceReceipts.slice(-1);
119
+ emit('evidence-recorded', null, { receiptKinds: sortedKinds(receipts), receiptQualities: sortedQualities(receipts) });
120
+ break;
121
+ }
122
+ case 'complete': {
123
+ const entry = nextState.completedStages[nextState.completedStages.length - 1];
124
+ if (!entry) throw new Error('complete projection requires a completed stage entry');
125
+ emit('stage-completed', entry.stage, {
126
+ receiptKinds: sortedKinds(entry.evidenceReceipts),
127
+ receiptQualities: sortedQualities(entry.evidenceReceipts),
128
+ });
129
+ if (nextState.nextStage === null && isWorkflowComplete(nextState)) {
130
+ emit('workflow-completed', null, {
131
+ completedStages: nextState.completedStages.map((c) => c.stage),
132
+ skippedStages: nextState.skippedStages.map((s) => s.stage),
133
+ });
134
+ }
135
+ break;
136
+ }
137
+ case 'skip': {
138
+ const entry = nextState.skippedStages[nextState.skippedStages.length - 1];
139
+ if (!entry) throw new Error('skip projection requires a skipped stage entry');
140
+ emit('stage-skipped', entry.stage, { reason: entry.reason, policy: entry.policy, evidence: [...entry.evidence] });
141
+ break;
142
+ }
143
+ case 'block':
144
+ emit('workflow-blocked', null, {
145
+ blockerIds: [...nextState.blockers].map((b) => b.id).sort(),
146
+ blockerTypes: [...nextState.blockers].map((b) => b.type).sort(),
147
+ });
148
+ break;
149
+ case 'unblock':
150
+ break;
151
+ case 'interrupt':
152
+ emit('workflow-interrupted', null, {});
153
+ break;
154
+ case 'resume': {
155
+ const outcome = input.resolutionOutcome ?? (nextState.status === 'BLOCKED' ? 'blocked' : 'ready');
156
+ emit('workflow-resumed', null, { outcome, staleReason: input.staleReason ?? null });
157
+ if (outcome === 'blocked') {
158
+ emit('workflow-blocked', null, {
159
+ blockerIds: [...nextState.blockers].map((b) => b.id).sort(),
160
+ blockerTypes: [...nextState.blockers].map((b) => b.type).sort(),
161
+ });
162
+ }
163
+ break;
164
+ }
165
+ case 'finalize':
166
+ emit('workflow-completed', null, {
167
+ completedStages: nextState.completedStages.map((c) => c.stage),
168
+ skippedStages: nextState.skippedStages.map((s) => s.stage),
169
+ });
170
+ break;
171
+ default:
172
+ throw new Error(`Unknown workflow trace op: ${op}`);
173
+ }
174
+ return Object.freeze(events);
175
+ }
176
+
177
+ export const workflowTraceAPI = {
178
+ projectWorkflowEvents,
179
+ eventKey,
180
+ normalizeTrace,
181
+ assertNoAuthority,
182
+ validateWorkflowEvent,
183
+ };