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 +61 -1
- package/MIGRATION.md +39 -0
- package/README.md +91 -0
- package/package.json +4 -2
- package/skills/showdar-bugfix/SKILL.md +6 -2
- package/skills/showdar-feature/SKILL.md +6 -2
- package/skills/showdar-incident/SKILL.md +6 -2
- package/skills/showdar-release/SKILL.md +6 -2
- package/src/validate.js +108 -0
- package/src/workflow-state.js +628 -0
- package/src/workflow-trace.js +183 -0
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
|
-
## [
|
|
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.
|
|
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": "
|
|
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
|
|
57
|
-
-
|
|
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
|
|
60
|
-
-
|
|
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
|
|
61
|
-
-
|
|
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
|
|
58
|
-
-
|
|
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
|
+
};
|