ultra-builder-pro-cli 0.14.0 → 0.15.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/.ultra-template/docs/research/README.md +11 -7
- package/.ultra-template/specs/architecture.md +36 -36
- package/.ultra-template/specs/discovery.md +46 -33
- package/.ultra-template/specs/product.md +38 -29
- package/.ultra-template/specs/research-distillate.md +48 -0
- package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
- package/CHANGELOG.md +44 -1
- package/README.md +34 -19
- package/adapters/_shared/codex-assets.cjs +6 -2
- package/adapters/_shared/file-ops.cjs +6 -2
- package/adapters/claude.js +3 -2
- package/adapters/codex.js +1 -0
- package/adapters/kimi.js +3 -2
- package/adapters/opencode.js +3 -2
- package/agents/review-coordinator.md +9 -6
- package/docs/AGENT-CONTEXT.md +15 -8
- package/docs/ARCHITECTURE.md +33 -12
- package/docs/PLAN.zh-CN.md +3 -2
- package/docs/ROADMAP.md +8 -4
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
- package/docs/WORKFLOW-LIFECYCLE.md +266 -0
- package/hooks/README.md +6 -1
- package/hooks/health_check.py +12 -3
- package/hooks/subagent_tracker.py +52 -77
- package/mcp-server/hook-event.cjs +34 -0
- package/mcp-server/lib/archive-journal.cjs +17 -5
- package/mcp-server/lib/baseline-workflow.cjs +158 -18
- package/mcp-server/lib/change-workflow.cjs +550 -85
- package/mcp-server/lib/context-spine.cjs +242 -24
- package/mcp-server/lib/doctor.cjs +33 -3
- package/mcp-server/lib/init-project.cjs +210 -16
- package/mcp-server/lib/plan-store.cjs +4 -1
- package/mcp-server/lib/pricing.cjs +7 -10
- package/mcp-server/lib/project-breadcrumb.cjs +6 -0
- package/mcp-server/lib/projector.cjs +57 -3
- package/mcp-server/lib/spec-learning.cjs +79 -2
- package/mcp-server/lib/state-db.cjs +145 -8
- package/mcp-server/lib/state-ops.cjs +160 -9
- package/mcp-server/lib/workflow-state.cjs +2292 -0
- package/mcp-server/server.cjs +161 -15
- package/orchestrator/daemon.cjs +1 -2
- package/orchestrator/dispatch-rules.cjs +5 -31
- package/orchestrator/planner/plan-builder.cjs +21 -12
- package/package.json +13 -3
- package/skills/ultra-change/SKILL.md +53 -68
- package/skills/ultra-change/references/change-contract.md +36 -0
- package/skills/ultra-deliver/SKILL.md +69 -79
- package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
- package/skills/ultra-dev/SKILL.md +54 -59
- package/skills/ultra-doctor/SKILL.md +40 -71
- package/skills/ultra-init/SKILL.md +96 -103
- package/skills/ultra-init/references/brownfield-adoption.md +2 -2
- package/skills/ultra-plan/SKILL.md +79 -85
- package/skills/ultra-plan/references/semantic-preflight.md +18 -0
- package/skills/ultra-research/SKILL.md +87 -60
- package/skills/ultra-research/references/00-problem-validation.md +36 -0
- package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
- package/skills/ultra-research/references/02-market-assessment.md +30 -0
- package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
- package/skills/ultra-research/references/04-product-strategy.md +32 -0
- package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
- package/skills/ultra-research/references/10-user-personas.md +29 -0
- package/skills/ultra-research/references/11-user-scenarios.md +28 -0
- package/skills/ultra-research/references/20-user-stories.md +30 -0
- package/skills/ultra-research/references/21-features-scope.md +29 -0
- package/skills/ultra-research/references/22-success-metrics.md +28 -0
- package/skills/ultra-research/references/30-architecture-context.md +30 -0
- package/skills/ultra-research/references/31-solution-strategy.md +28 -0
- package/skills/ultra-research/references/32-building-blocks.md +28 -0
- package/skills/ultra-research/references/40-deployment.md +29 -0
- package/skills/ultra-research/references/41-quality-risks.md +28 -0
- package/skills/ultra-research/references/99-synthesis.md +35 -0
- package/skills/ultra-review/SKILL.md +57 -73
- package/skills/ultra-review/references/review-modes.md +27 -0
- package/skills/ultra-review/references/unified-schema.md +25 -0
- package/skills/ultra-review/scripts/review_wait.py +40 -3
- package/skills/ultra-status/SKILL.md +41 -45
- package/skills/ultra-test/SKILL.md +48 -55
- package/skills/ultra-verify/SKILL.md +1 -1
- package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
- package/spec/cli-protocol.md +7 -2
- package/spec/mcp-tools.yaml +384 -63
- package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
- package/spec/schemas/delivery-report.v1.schema.json +111 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +82 -1
- package/spec/schemas/tasks.v4.5.schema.json +8 -1
- package/spec/schemas/test-report.v1.schema.json +194 -0
- package/ultra-tools/cli.cjs +1 -1
- package/ultra-tools/commands/migrate.cjs +3 -3
- package/ultra-tools/commands/status.cjs +213 -12
- package/.ultra-template/delivery-report.json +0 -9
- package/.ultra-template/test-report.json +0 -8
- package/adapters/_shared/tests/file-ops.test.cjs +0 -54
- package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
- package/adapters/_shared/tests/handbook.test.cjs +0 -101
- package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
- package/adapters/_shared/tests/provenance.test.cjs +0 -133
- package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
- package/adapters/_shared/tests/runtime-assets.test.cjs +0 -149
- package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
- package/adapters/_shared/tests/validate.test.cjs +0 -30
- package/adapters/tests/claude.test.cjs +0 -138
- package/adapters/tests/codex-hook.test.cjs +0 -142
- package/adapters/tests/codex.test.cjs +0 -430
- package/adapters/tests/kimi-hook.test.cjs +0 -149
- package/adapters/tests/kimi.test.cjs +0 -259
- package/adapters/tests/opencode.test.cjs +0 -357
- package/mcp-server/lib/archive-journal.test.cjs +0 -35
- package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
- package/mcp-server/lib/change-workflow.test.cjs +0 -971
- package/mcp-server/lib/context-spine.test.cjs +0 -93
- package/mcp-server/lib/doctor.test.cjs +0 -148
- package/mcp-server/lib/plan-store.test.cjs +0 -132
- package/mcp-server/lib/prd-parser.test.cjs +0 -82
- package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
- package/mcp-server/lib/runtime-state.test.cjs +0 -92
- package/mcp-server/lib/spec-learning.test.cjs +0 -67
- package/mcp-server/lib/state-db.test.cjs +0 -314
- package/mcp-server/lib/task-expander.test.cjs +0 -169
- package/mcp-server/lib/topo.test.cjs +0 -158
- package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
- package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
- package/mcp-server/tests/concurrency-update.test.cjs +0 -96
- package/mcp-server/tests/concurrency.test.cjs +0 -112
- package/mcp-server/tests/db-ops.test.cjs +0 -121
- package/mcp-server/tests/init-project.test.cjs +0 -393
- package/mcp-server/tests/migrate.test.cjs +0 -232
- package/mcp-server/tests/projector.test.cjs +0 -162
- package/mcp-server/tests/server-contract.test.cjs +0 -1467
- package/mcp-server/tests/staleness.test.cjs +0 -190
- package/mcp-server/tests/state-db.test.cjs +0 -106
- package/mcp-server/tests/state-ops.test.cjs +0 -449
- package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
- package/mcp-server/tests/telemetry.test.cjs +0 -219
- package/orchestrator/planner/plan-builder.test.cjs +0 -165
- package/orchestrator/tests/auto-merge.test.cjs +0 -216
- package/orchestrator/tests/daemon.test.cjs +0 -311
- package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
- package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
- package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
- package/orchestrator/tests/recovery.test.cjs +0 -190
- package/orchestrator/tests/session-runner.test.cjs +0 -314
- package/orchestrator/tests/skill-miner.test.cjs +0 -147
- package/orchestrator/tests/worktree-manager.test.cjs +0 -148
- package/skills/ultra-research/references/architecture.md +0 -38
- package/skills/ultra-research/references/problem-product.md +0 -39
- package/skills/ultra-research/references/quality-delivery.md +0 -34
- package/skills/ultra-research/references/synthesis.md +0 -38
- package/skills/ultra-research/references/users-scope.md +0 -40
- package/ultra-tools/legacy-memory.test.cjs +0 -80
- package/ultra-tools/status-command.test.cjs +0 -181
- package/ultra-tools/system-command.test.cjs +0 -309
|
@@ -0,0 +1,2292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const Ajv = require('ajv/dist/2020');
|
|
8
|
+
const addFormats = require('ajv-formats');
|
|
9
|
+
|
|
10
|
+
const ops = require('./state-ops.cjs');
|
|
11
|
+
const testReportSchema = require('../../spec/schemas/test-report.v1.schema.json');
|
|
12
|
+
const deliveryReportSchema = require('../../spec/schemas/delivery-report.v1.schema.json');
|
|
13
|
+
|
|
14
|
+
const DEFINITION_VERSION = '1.1';
|
|
15
|
+
const RUN_KINDS = new Set(['init', 'research', 'plan', 'change', 'dev', 'test', 'review', 'deliver']);
|
|
16
|
+
const RUN_STATUSES = new Set(['active', 'blocked', 'ready', 'completed', 'cancelled']);
|
|
17
|
+
const STEP_STATUSES = new Set(['pending', 'in_progress', 'completed', 'skipped', 'blocked']);
|
|
18
|
+
|
|
19
|
+
const reportAjv = new Ajv({ allErrors: true, strict: false });
|
|
20
|
+
addFormats(reportAjv);
|
|
21
|
+
const REPORT_VALIDATORS = Object.freeze({
|
|
22
|
+
test: reportAjv.compile(testReportSchema),
|
|
23
|
+
delivery: reportAjv.compile(deliveryReportSchema),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function step(id, title, options = {}) {
|
|
27
|
+
return Object.freeze({ id, title, ...options });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const WORKFLOW_DEFINITIONS = Object.freeze({
|
|
31
|
+
init: Object.freeze([
|
|
32
|
+
step('inspect-authority', 'Inspect repository and existing Ultra authority'),
|
|
33
|
+
step('classify-repository', 'Classify greenfield, brownfield, or migrated state'),
|
|
34
|
+
step('scaffold-authority', 'Create or resume the authoritative state and scaffold'),
|
|
35
|
+
step('establish-baseline', 'Record evidence and establish the project baseline', { evidence_required: true }),
|
|
36
|
+
step('verify-initialization', 'Verify authority, projections, and the next route', { evidence_required: true }),
|
|
37
|
+
]),
|
|
38
|
+
research: Object.freeze([
|
|
39
|
+
step('00-problem-validation', 'Validate the problem and demand evidence', { evidence_required: true, output_required: true }),
|
|
40
|
+
step('01-opportunity-discovery', 'Map outcomes and opportunity space', { evidence_required: true, output_required: true }),
|
|
41
|
+
step('02-market-assessment', 'Assess the relevant market and constraints', { evidence_required: true, output_required: true }),
|
|
42
|
+
step('03-competitive-landscape', 'Assess direct, adjacent, and substitute alternatives', { evidence_required: true, output_required: true }),
|
|
43
|
+
step('04-product-strategy', 'Define product strategy and explicit tradeoffs', { evidence_required: true, output_required: true }),
|
|
44
|
+
step('05-assumptions-validation', 'Identify assumptions and validation evidence', { evidence_required: true, output_required: true }),
|
|
45
|
+
step('10-user-personas', 'Describe evidence-backed actors and jobs', { evidence_required: true, output_required: true }),
|
|
46
|
+
step('11-user-scenarios', 'Trace current and desired user scenarios', { evidence_required: true, output_required: true }),
|
|
47
|
+
step('20-user-stories', 'Define behavior and acceptance contracts', { evidence_required: true, output_required: true }),
|
|
48
|
+
step('21-features-scope', 'Define scope, exclusions, and dependencies', { evidence_required: true, output_required: true }),
|
|
49
|
+
step('22-success-metrics', 'Define measurable outcomes and anti-metrics', { evidence_required: true, output_required: true }),
|
|
50
|
+
step('30-architecture-context', 'Record quality goals, constraints, and system context', { evidence_required: true, output_required: true }),
|
|
51
|
+
step('31-solution-strategy', 'Select solution strategy from accepted constraints', { evidence_required: true, output_required: true }),
|
|
52
|
+
step('32-building-blocks', 'Define building blocks and runtime paths', { evidence_required: true, output_required: true }),
|
|
53
|
+
step('40-deployment', 'Define environments, delivery, rollback, and operations', { evidence_required: true, output_required: true }),
|
|
54
|
+
step('41-quality-risks', 'Define quality scenarios, risks, and recovery', { evidence_required: true, output_required: true }),
|
|
55
|
+
step('99-synthesis', 'Validate traceability and compile planning context', { evidence_required: true, output_required: true }),
|
|
56
|
+
]),
|
|
57
|
+
plan: Object.freeze([
|
|
58
|
+
step('validate-baseline', 'Validate baseline and research coverage', { evidence_required: true }),
|
|
59
|
+
step('select-posture', 'Record the owner-selected planning posture', { evidence_required: true }),
|
|
60
|
+
step('analyze-requirements', 'Trace requirements and acceptance'),
|
|
61
|
+
step('analyze-codebase', 'Inspect current codebase patterns and boundaries', { evidence_required: true }),
|
|
62
|
+
step('design-slices', 'Design walking skeleton and vertical slices'),
|
|
63
|
+
step('validate-dependencies', 'Validate ownership, dependencies, and parallel waves', { evidence_required: true }),
|
|
64
|
+
step('approve-plan', 'Obtain explicit plan approval', { evidence_required: true }),
|
|
65
|
+
step('persist-task-contracts', 'Persist tasks and complete execution contracts', { evidence_required: true }),
|
|
66
|
+
step('verify-plan', 'Read back and verify the authoritative plan', {
|
|
67
|
+
evidence_required: true, output_required: true,
|
|
68
|
+
}),
|
|
69
|
+
]),
|
|
70
|
+
change: Object.freeze([
|
|
71
|
+
step('bind-baseline', 'Bind current baseline and repository revision', { evidence_required: true }),
|
|
72
|
+
step('classify-change', 'Classify quick, standard, major, or incident'),
|
|
73
|
+
step('record-intent', 'Record intent, scope, acceptance, and documentation impact', { evidence_required: true }),
|
|
74
|
+
step('plan-change', 'Create or validate change tasks'),
|
|
75
|
+
step('compile-context', 'Compile bounded role context', { evidence_required: true, output_required: true }),
|
|
76
|
+
step('verify-readiness', 'Verify one executable next action', { evidence_required: true }),
|
|
77
|
+
]),
|
|
78
|
+
dev: Object.freeze([
|
|
79
|
+
step('bind-task', 'Bind the authoritative task and dependencies', { evidence_required: true }),
|
|
80
|
+
step('compile-context', 'Compile fresh implementation context', { evidence_required: true, output_required: true }),
|
|
81
|
+
step('establish-feedback-loop', 'Establish the failing or characterization signal', { evidence_required: true }),
|
|
82
|
+
step('implement-slice', 'Implement the complete live-path slice'),
|
|
83
|
+
step('verify-slice', 'Run focused, adjacent, build, and public-seam checks', { evidence_required: true }),
|
|
84
|
+
step('review-slice', 'Run independent specification and engineering review', { evidence_required: true }),
|
|
85
|
+
step('record-completion', 'Record completion evidence and final task state', { evidence_required: true }),
|
|
86
|
+
]),
|
|
87
|
+
test: Object.freeze([
|
|
88
|
+
step('bind-scope', 'Bind completed tasks and current revision', { evidence_required: true }),
|
|
89
|
+
step('compile-context', 'Compile independent checking context', { evidence_required: true, output_required: true }),
|
|
90
|
+
step('map-acceptance', 'Map acceptance claims to executable checks'),
|
|
91
|
+
step('execute-checks', 'Execute focused, regression, static, build, and recovery checks', { evidence_required: true }),
|
|
92
|
+
step('verify-public-seam', 'Verify the declared public seam', { evidence_required: true }),
|
|
93
|
+
step('write-report', 'Write the current test evidence report', { evidence_required: true, output_required: true }),
|
|
94
|
+
step('verify-test-gate', 'Verify report freshness and route the next action', { evidence_required: true }),
|
|
95
|
+
]),
|
|
96
|
+
review: Object.freeze([
|
|
97
|
+
step('bind-diff', 'Bind one current diff and acceptance scope', { evidence_required: true }),
|
|
98
|
+
step('compile-context', 'Compile independent review context', { evidence_required: true, output_required: true }),
|
|
99
|
+
step('review-specification', 'Run specification-fidelity review', { evidence_required: true, output_required: true }),
|
|
100
|
+
step('review-engineering', 'Run engineering-standards review', { evidence_required: true, output_required: true }),
|
|
101
|
+
step('coordinate-findings', 'Coordinate findings without collapsing axes', { evidence_required: true, output_required: true }),
|
|
102
|
+
step('verify-review-gate', 'Verify both verdict axes and current revision', { evidence_required: true }),
|
|
103
|
+
]),
|
|
104
|
+
deliver: Object.freeze([
|
|
105
|
+
step('bind-evidence', 'Bind current test, review, task, and revision evidence', { evidence_required: true }),
|
|
106
|
+
step('reconcile-specifications', 'Resolve learning and reconcile specifications', { evidence_required: true }),
|
|
107
|
+
step('verify-candidate', 'Verify the release candidate and recovery path', { evidence_required: true, output_required: true }),
|
|
108
|
+
step('converge-authority', 'Converge baseline or change authority', { evidence_required: true }),
|
|
109
|
+
step('archive-change', 'Archive the converged change when applicable', { evidence_required: true }),
|
|
110
|
+
step('release-if-authorized', 'Release only when explicitly authorized'),
|
|
111
|
+
step('verify-delivery', 'Verify local and remote delivery evidence', { evidence_required: true, output_required: true }),
|
|
112
|
+
]),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const RESEARCH_MODES = Object.freeze({
|
|
116
|
+
full: WORKFLOW_DEFINITIONS.research.map((item) => item.id),
|
|
117
|
+
adoption: WORKFLOW_DEFINITIONS.research.map((item) => item.id),
|
|
118
|
+
product: WORKFLOW_DEFINITIONS.research
|
|
119
|
+
.filter((item) => Number.parseInt(item.id, 10) < 30 || item.id === '99-synthesis')
|
|
120
|
+
.map((item) => item.id),
|
|
121
|
+
feature: ['10-user-personas', '11-user-scenarios', '20-user-stories', '21-features-scope', '22-success-metrics', '99-synthesis'],
|
|
122
|
+
architecture: ['30-architecture-context', '31-solution-strategy', '32-building-blocks', '40-deployment', '41-quality-risks', '99-synthesis'],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const RESEARCH_SEMANTIC_CONTRACTS = Object.freeze({
|
|
126
|
+
'00-problem-validation': ['problem', ['actor', 'current_workaround', 'consequence', 'evidence_status']],
|
|
127
|
+
'01-opportunity-discovery': ['opportunity', ['actor', 'desired_outcome', 'evidence_status']],
|
|
128
|
+
'02-market-assessment': ['market_constraint', ['constraint', 'decision_impact', 'freshness']],
|
|
129
|
+
'03-competitive-landscape': ['alternative', ['category', 'switching_constraint', 'strategy_implication']],
|
|
130
|
+
'04-product-strategy': ['strategy_decision', ['tradeoff', 'rationale']],
|
|
131
|
+
'05-assumptions-validation': ['assumption', [
|
|
132
|
+
'category', 'consequence', 'validation_signal', 'success_rule', 'failure_rule', 'ambiguous_rule',
|
|
133
|
+
]],
|
|
134
|
+
'10-user-personas': ['actor', ['job', 'current_workflow', 'goal', 'constraint']],
|
|
135
|
+
'11-user-scenarios': ['scenario', [
|
|
136
|
+
'actor_id', 'trigger', 'preconditions', 'flow', 'success', 'failure', 'recovery',
|
|
137
|
+
]],
|
|
138
|
+
'20-user-stories': ['requirement', [
|
|
139
|
+
'preconditions', 'action', 'observable_result', 'error_recovery', 'verification',
|
|
140
|
+
]],
|
|
141
|
+
'21-features-scope': ['capability', ['requirement_ids', 'scope_status', 'rationale']],
|
|
142
|
+
'22-success-metrics': ['metric', ['definition', 'source', 'window', 'owner', 'decision_use']],
|
|
143
|
+
'30-architecture-context': ['architecture_context', [
|
|
144
|
+
'boundary', 'inputs_outputs', 'trust_authority', 'consumers',
|
|
145
|
+
]],
|
|
146
|
+
'31-solution-strategy': ['architecture_decision', [
|
|
147
|
+
'drivers', 'direction', 'consequences', 'compatibility', 'recovery',
|
|
148
|
+
]],
|
|
149
|
+
'32-building-blocks': ['runtime_path', [
|
|
150
|
+
'entry_point', 'state_side_effects', 'observable_result', 'failure_recovery', 'consumers',
|
|
151
|
+
]],
|
|
152
|
+
'40-deployment': ['deployment', [
|
|
153
|
+
'environment', 'entry_point', 'config_migration', 'observation', 'rollback_recovery',
|
|
154
|
+
]],
|
|
155
|
+
'41-quality-risks': ['risk', [
|
|
156
|
+
'trigger_condition', 'expected_response', 'measurement', 'mitigation', 'recovery', 'owner',
|
|
157
|
+
]],
|
|
158
|
+
'99-synthesis': ['synthesis_trace', [
|
|
159
|
+
'problem_id', 'scenario_id', 'requirement_ids', 'architecture_path_ids', 'verification_refs',
|
|
160
|
+
]],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const SEMANTIC_STATUSES = new Set(['observed', 'verified', 'decided', 'accepted', 'unknown', 'not_applicable']);
|
|
164
|
+
const SEMANTIC_ID = /^[a-zA-Z0-9_-]+$/;
|
|
165
|
+
|
|
166
|
+
class WorkflowStateError extends Error {
|
|
167
|
+
constructor(code, message, details) {
|
|
168
|
+
super(message);
|
|
169
|
+
this.name = 'WorkflowStateError';
|
|
170
|
+
this.code = code;
|
|
171
|
+
if (details !== undefined) this.details = details;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function assertReportSchema(report, kind) {
|
|
176
|
+
const validate = REPORT_VALIDATORS[kind];
|
|
177
|
+
if (!validate(report)) {
|
|
178
|
+
throw new WorkflowStateError(
|
|
179
|
+
'WORKFLOW_REPORT_INVALID',
|
|
180
|
+
`${kind} report does not satisfy its published schema: ${reportAjv.errorsText(validate.errors)}`,
|
|
181
|
+
validate.errors,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function nowIso() {
|
|
187
|
+
return new Date().toISOString();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseJson(value, field, fallback) {
|
|
191
|
+
if (value == null || value === '') return fallback;
|
|
192
|
+
try { return JSON.parse(value); }
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw new WorkflowStateError('STATE_CORRUPT', `invalid ${field}: ${error.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function nonEmpty(value, field) {
|
|
199
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
200
|
+
if (!text) throw new WorkflowStateError('VALIDATION_ERROR', `${field} must be a non-empty string`);
|
|
201
|
+
return text;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function safeProjectFile(rootDir, candidate, field) {
|
|
205
|
+
const relative = nonEmpty(candidate, field);
|
|
206
|
+
if (path.isAbsolute(relative)) {
|
|
207
|
+
throw new WorkflowStateError('VALIDATION_ERROR', `${field} must be project-relative`);
|
|
208
|
+
}
|
|
209
|
+
const root = path.resolve(rootDir);
|
|
210
|
+
const file = path.resolve(root, relative);
|
|
211
|
+
if (file !== root && !file.startsWith(`${root}${path.sep}`)) {
|
|
212
|
+
throw new WorkflowStateError('VALIDATION_ERROR', `${field} escapes project root`);
|
|
213
|
+
}
|
|
214
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
215
|
+
throw new WorkflowStateError('WORKFLOW_OUTPUT_MISSING', `workflow output does not exist: ${relative}`);
|
|
216
|
+
}
|
|
217
|
+
return { relative, file };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeObjects(value, field) {
|
|
221
|
+
if (value === undefined) return [];
|
|
222
|
+
if (!Array.isArray(value) || value.some((item) => !item || typeof item !== 'object' || Array.isArray(item))) {
|
|
223
|
+
throw new WorkflowStateError('VALIDATION_ERROR', `${field} must be an array of objects`);
|
|
224
|
+
}
|
|
225
|
+
return JSON.parse(JSON.stringify(value));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeEvidence(value) {
|
|
229
|
+
const evidence = normalizeObjects(value, 'evidence');
|
|
230
|
+
return evidence.map((item, index) => ({
|
|
231
|
+
kind: nonEmpty(item.kind, `evidence[${index}].kind`),
|
|
232
|
+
ref: nonEmpty(item.ref, `evidence[${index}].ref`),
|
|
233
|
+
summary: nonEmpty(item.summary, `evidence[${index}].summary`),
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeOutputs(value, rootDir) {
|
|
238
|
+
const outputs = normalizeObjects(value, 'outputs');
|
|
239
|
+
return outputs.map((item, index) => {
|
|
240
|
+
const resolved = safeProjectFile(rootDir, item.path, `outputs[${index}].path`);
|
|
241
|
+
return {
|
|
242
|
+
path: resolved.relative,
|
|
243
|
+
kind: nonEmpty(item.kind, `outputs[${index}].kind`),
|
|
244
|
+
digest: crypto.createHash('sha256').update(fs.readFileSync(resolved.file)).digest('hex'),
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function markdownSlug(value) {
|
|
250
|
+
return String(value).trim().toLowerCase()
|
|
251
|
+
.replace(/<[^>]+>/g, '')
|
|
252
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
253
|
+
.replace(/\s+/g, '-')
|
|
254
|
+
.replace(/-+/g, '-');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function sourceRefFile(rootDir, sourceRef, field) {
|
|
258
|
+
const ref = nonEmpty(sourceRef, field);
|
|
259
|
+
const separator = ref.lastIndexOf('#');
|
|
260
|
+
if (separator <= 0 || separator === ref.length - 1) {
|
|
261
|
+
throw new WorkflowStateError('WORKFLOW_SEMANTIC_SOURCE_INVALID', `${field} must use path#anchor`);
|
|
262
|
+
}
|
|
263
|
+
const relative = ref.slice(0, separator);
|
|
264
|
+
const anchor = ref.slice(separator + 1).trim().toLowerCase();
|
|
265
|
+
const resolved = safeProjectFile(rootDir, relative, field);
|
|
266
|
+
const content = fs.readFileSync(resolved.file, 'utf8');
|
|
267
|
+
const anchors = new Set();
|
|
268
|
+
for (const match of content.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) anchors.add(markdownSlug(match[1]));
|
|
269
|
+
for (const match of content.matchAll(/<a\s+(?:name|id)=["']([^"']+)["'][^>]*>/gi)) {
|
|
270
|
+
anchors.add(match[1].trim().toLowerCase());
|
|
271
|
+
}
|
|
272
|
+
if (!anchors.has(anchor)) {
|
|
273
|
+
throw new WorkflowStateError(
|
|
274
|
+
'WORKFLOW_SEMANTIC_SOURCE_INVALID', `${field} anchor does not exist: ${ref}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
ref, relative, anchor,
|
|
279
|
+
digest: crypto.createHash('sha256').update(content).digest('hex'),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function semanticValuePresent(value) {
|
|
284
|
+
if (typeof value === 'string') return value.trim().length > 0;
|
|
285
|
+
if (Array.isArray(value)) return value.length > 0 && value.every(semanticValuePresent);
|
|
286
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
287
|
+
if (typeof value === 'boolean') return true;
|
|
288
|
+
return Boolean(value && typeof value === 'object' && Object.keys(value).length > 0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function normalizeSemanticRecords(value, stepId, rootDir) {
|
|
292
|
+
const records = normalizeObjects(value, 'semantic_records');
|
|
293
|
+
const contract = RESEARCH_SEMANTIC_CONTRACTS[stepId];
|
|
294
|
+
if (!contract) return records;
|
|
295
|
+
if (records.length === 0) {
|
|
296
|
+
throw new WorkflowStateError(
|
|
297
|
+
'WORKFLOW_SEMANTIC_RECORDS_REQUIRED', `${stepId} requires at least one typed semantic record`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const [expectedKind, requiredAttributes] = contract;
|
|
301
|
+
const ids = new Set();
|
|
302
|
+
return records.map((record, index) => {
|
|
303
|
+
const label = `semantic_records[${index}]`;
|
|
304
|
+
const id = nonEmpty(record.id, `${label}.id`);
|
|
305
|
+
if (!SEMANTIC_ID.test(id) || ids.has(id)) {
|
|
306
|
+
throw new WorkflowStateError('WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.id is invalid or duplicated`);
|
|
307
|
+
}
|
|
308
|
+
ids.add(id);
|
|
309
|
+
const kind = nonEmpty(record.kind, `${label}.kind`);
|
|
310
|
+
if (kind !== expectedKind) {
|
|
311
|
+
throw new WorkflowStateError(
|
|
312
|
+
'WORKFLOW_SEMANTIC_RECORD_INVALID', `${stepId} requires semantic kind ${expectedKind}, got ${kind}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const status = nonEmpty(record.status, `${label}.status`);
|
|
316
|
+
if (!SEMANTIC_STATUSES.has(status)) {
|
|
317
|
+
throw new WorkflowStateError('WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.status is invalid`);
|
|
318
|
+
}
|
|
319
|
+
const attributes = record.attributes;
|
|
320
|
+
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
|
|
321
|
+
throw new WorkflowStateError('WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.attributes must be an object`);
|
|
322
|
+
}
|
|
323
|
+
const required = status === 'not_applicable' ? ['rationale'] : requiredAttributes;
|
|
324
|
+
const missing = required.filter((field) => !semanticValuePresent(attributes[field]));
|
|
325
|
+
if (missing.length > 0) {
|
|
326
|
+
throw new WorkflowStateError(
|
|
327
|
+
'WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.attributes is missing: ${missing.join(', ')}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const evidenceRefs = record.evidence_refs;
|
|
331
|
+
if (!Array.isArray(evidenceRefs) || evidenceRefs.length === 0
|
|
332
|
+
|| evidenceRefs.some((ref) => typeof ref !== 'string' || !ref.trim())) {
|
|
333
|
+
throw new WorkflowStateError(
|
|
334
|
+
'WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.evidence_refs must contain non-empty references`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const links = record.links === undefined ? [] : record.links;
|
|
338
|
+
if (!Array.isArray(links) || links.some((link) => (
|
|
339
|
+
!link || typeof link !== 'object' || Array.isArray(link)
|
|
340
|
+
|| !String(link.relation || '').trim() || !String(link.target || '').trim()
|
|
341
|
+
))) {
|
|
342
|
+
throw new WorkflowStateError('WORKFLOW_SEMANTIC_RECORD_INVALID', `${label}.links is invalid`);
|
|
343
|
+
}
|
|
344
|
+
const source = sourceRefFile(rootDir, record.source_ref, `${label}.source_ref`);
|
|
345
|
+
return {
|
|
346
|
+
id, kind, status,
|
|
347
|
+
summary: nonEmpty(record.summary, `${label}.summary`),
|
|
348
|
+
source_ref: source.ref,
|
|
349
|
+
source_digest: source.digest,
|
|
350
|
+
evidence_refs: [...new Set(evidenceRefs.map((ref) => ref.trim()))],
|
|
351
|
+
attributes: JSON.parse(JSON.stringify(attributes)),
|
|
352
|
+
links: links.map((link) => ({
|
|
353
|
+
relation: String(link.relation).trim(), target: String(link.target).trim(),
|
|
354
|
+
})),
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function validateSynthesisTrace(db, runRow, semanticRecords) {
|
|
360
|
+
if (runRow.kind !== 'research' || !['full', 'adoption'].includes(runRow.mode)) return;
|
|
361
|
+
const rows = db.prepare(
|
|
362
|
+
'SELECT semantic_records_json FROM workflow_steps WHERE run_id = ? AND step_id != ?',
|
|
363
|
+
).all(runRow.id, '99-synthesis');
|
|
364
|
+
const known = new Set(rows.flatMap((row) => (
|
|
365
|
+
parseJson(row.semantic_records_json, 'workflow_steps.semantic_records_json', [])
|
|
366
|
+
)).map((record) => record.id));
|
|
367
|
+
const trace = semanticRecords.find((record) => record.kind === 'synthesis_trace');
|
|
368
|
+
const refs = [
|
|
369
|
+
trace?.attributes?.problem_id,
|
|
370
|
+
trace?.attributes?.scenario_id,
|
|
371
|
+
...(trace?.attributes?.requirement_ids || []),
|
|
372
|
+
...(trace?.attributes?.architecture_path_ids || []),
|
|
373
|
+
];
|
|
374
|
+
const missing = refs.filter((ref) => !known.has(ref));
|
|
375
|
+
if (missing.length > 0) {
|
|
376
|
+
throw new WorkflowStateError(
|
|
377
|
+
'WORKFLOW_SYNTHESIS_TRACE_INVALID', `99-synthesis references missing semantic records: ${missing.join(', ')}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function validateReleaseAuthorizationDecision(decisions) {
|
|
383
|
+
if (!Array.isArray(decisions) || decisions.length !== 1) {
|
|
384
|
+
throw new WorkflowStateError(
|
|
385
|
+
'WORKFLOW_RELEASE_AUTHORIZATION_INVALID',
|
|
386
|
+
'release-if-authorized requires exactly one release_authorization decision',
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const decision = decisions[0];
|
|
390
|
+
if (decision?.kind !== 'release_authorization'
|
|
391
|
+
|| typeof decision.authorized !== 'boolean'
|
|
392
|
+
|| !String(decision.reason || '').trim()) {
|
|
393
|
+
throw new WorkflowStateError(
|
|
394
|
+
'WORKFLOW_RELEASE_AUTHORIZATION_INVALID',
|
|
395
|
+
'release authorization requires kind, authorized, and reason',
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (decision.authorized === true
|
|
399
|
+
&& (!String(decision.approved_by || '').trim() || !String(decision.scope || '').trim())) {
|
|
400
|
+
throw new WorkflowStateError(
|
|
401
|
+
'WORKFLOW_RELEASE_AUTHORIZATION_INVALID',
|
|
402
|
+
'an authorized release requires approved_by and an explicit release scope',
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return decision;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function validateResearchStepReport(run, stepId, outputs, rootDir) {
|
|
409
|
+
const expected = path.join(
|
|
410
|
+
'.ultra', 'docs', 'research', run.id, `${stepId}.md`,
|
|
411
|
+
);
|
|
412
|
+
const report = outputs.find((output) => path.normalize(output.path) === path.normalize(expected));
|
|
413
|
+
if (!report) {
|
|
414
|
+
throw new WorkflowStateError(
|
|
415
|
+
'WORKFLOW_RESEARCH_REPORT_REQUIRED',
|
|
416
|
+
`${stepId} requires its immutable research report at ${expected}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
const resolved = safeProjectFile(rootDir, report.path, `${stepId}.research_report`);
|
|
420
|
+
const content = fs.readFileSync(resolved.file, 'utf8');
|
|
421
|
+
const requiredSections = ['Evidence', 'Specification updates', 'Decisions and unknowns'];
|
|
422
|
+
const missing = requiredSections.filter(
|
|
423
|
+
(heading) => !new RegExp(`^##\\s+${heading}\\s*$`, 'im').test(content),
|
|
424
|
+
);
|
|
425
|
+
if (!/^#\s+\S/m.test(content) || missing.length > 0) {
|
|
426
|
+
throw new WorkflowStateError(
|
|
427
|
+
'WORKFLOW_RESEARCH_REPORT_INVALID',
|
|
428
|
+
`${report.path} is missing a title or required sections: ${missing.join(', ')}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (stepId === '99-synthesis' && ['full', 'adoption'].includes(run.mode)) {
|
|
432
|
+
const recorded = new Set(outputs.map((output) => path.normalize(output.path)));
|
|
433
|
+
const required = [
|
|
434
|
+
'.ultra/specs/discovery.md',
|
|
435
|
+
'.ultra/specs/product.md',
|
|
436
|
+
'.ultra/specs/architecture.md',
|
|
437
|
+
'.ultra/specs/research-distillate.md',
|
|
438
|
+
].map((item) => path.normalize(item));
|
|
439
|
+
const missingArtifacts = required.filter((item) => !recorded.has(item));
|
|
440
|
+
if (missingArtifacts.length > 0) {
|
|
441
|
+
throw new WorkflowStateError(
|
|
442
|
+
'WORKFLOW_RESEARCH_SYNTHESIS_INCOMPLETE',
|
|
443
|
+
`99-synthesis must bind the current baseline specifications and distillate: ${missingArtifacts.join(', ')}`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function rowToStep(row) {
|
|
450
|
+
if (!row) return null;
|
|
451
|
+
const value = {
|
|
452
|
+
...row,
|
|
453
|
+
required: Boolean(row.required),
|
|
454
|
+
evidence: parseJson(row.evidence_json, 'workflow_steps.evidence_json', []),
|
|
455
|
+
outputs: parseJson(row.outputs_json, 'workflow_steps.outputs_json', []),
|
|
456
|
+
decisions: parseJson(row.decisions_json, 'workflow_steps.decisions_json', []),
|
|
457
|
+
semantic_records: parseJson(row.semantic_records_json, 'workflow_steps.semantic_records_json', []),
|
|
458
|
+
blockers: parseJson(row.blockers_json, 'workflow_steps.blockers_json', []),
|
|
459
|
+
};
|
|
460
|
+
for (const field of [
|
|
461
|
+
'evidence_json', 'outputs_json', 'decisions_json', 'semantic_records_json', 'blockers_json',
|
|
462
|
+
]) {
|
|
463
|
+
delete value[field];
|
|
464
|
+
}
|
|
465
|
+
return value;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function decorateRun(db, row, rootDir) {
|
|
469
|
+
if (!row) return null;
|
|
470
|
+
const steps = db.prepare(
|
|
471
|
+
'SELECT * FROM workflow_steps WHERE run_id = ? ORDER BY position ASC',
|
|
472
|
+
).all(row.id).map(rowToStep);
|
|
473
|
+
const nextStep = steps.find((item) => item.required && !['completed', 'skipped'].includes(item.status)) || null;
|
|
474
|
+
const value = {
|
|
475
|
+
...row,
|
|
476
|
+
metadata: parseJson(row.metadata_json, 'workflow_runs.metadata_json', {}),
|
|
477
|
+
blockers: parseJson(row.blockers_json, 'workflow_runs.blockers_json', []),
|
|
478
|
+
approval: parseJson(row.approval_json, 'workflow_runs.approval_json', null),
|
|
479
|
+
summary: parseJson(row.summary_json, 'workflow_runs.summary_json', {}),
|
|
480
|
+
steps,
|
|
481
|
+
next_step: nextStep,
|
|
482
|
+
artifact_health: inspectRunHealth(row, steps, rootDir),
|
|
483
|
+
};
|
|
484
|
+
for (const field of ['metadata_json', 'blockers_json', 'approval_json', 'summary_json']) {
|
|
485
|
+
delete value[field];
|
|
486
|
+
}
|
|
487
|
+
return value;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function inspectRunHealth(run, steps, rootDir) {
|
|
491
|
+
const blockers = [...inspectOutputHealth(steps, rootDir).blockers];
|
|
492
|
+
const definition = WORKFLOW_DEFINITIONS[run.kind] || [];
|
|
493
|
+
const expectedIds = new Set(definition.map((item) => item.id));
|
|
494
|
+
const actualIds = new Set(steps.map((item) => item.step_id));
|
|
495
|
+
if (run.definition_version !== DEFINITION_VERSION) {
|
|
496
|
+
blockers.push(`WORKFLOW_DEFINITION_VERSION_STALE:${run.definition_version || 'missing'}`);
|
|
497
|
+
}
|
|
498
|
+
definition.forEach((item, position) => {
|
|
499
|
+
const actual = steps.find((candidate) => candidate.step_id === item.id);
|
|
500
|
+
if (!actual) blockers.push(`WORKFLOW_STEP_MISSING:${item.id}`);
|
|
501
|
+
else if (actual.position !== position) blockers.push(`WORKFLOW_STEP_POSITION_INVALID:${item.id}`);
|
|
502
|
+
});
|
|
503
|
+
for (const item of steps) {
|
|
504
|
+
if (!expectedIds.has(item.step_id)) blockers.push(`WORKFLOW_STEP_UNKNOWN:${item.step_id}`);
|
|
505
|
+
if (!item.required && item.status !== 'skipped') {
|
|
506
|
+
blockers.push(`WORKFLOW_OPTIONAL_STEP_STATE_INVALID:${item.step_id}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (run.status === 'completed') {
|
|
510
|
+
for (const item of steps.filter((candidate) => candidate.required)) {
|
|
511
|
+
if (item.status !== 'completed') blockers.push(`WORKFLOW_COMPLETED_STEP_INVALID:${item.step_id}`);
|
|
512
|
+
}
|
|
513
|
+
if (run.current_step) blockers.push('WORKFLOW_COMPLETED_CURRENT_STEP_PRESENT');
|
|
514
|
+
}
|
|
515
|
+
if (run.kind === 'research') {
|
|
516
|
+
for (const item of steps.filter((candidate) => candidate.required && candidate.status === 'completed')) {
|
|
517
|
+
if (!Array.isArray(item.semantic_records) || item.semantic_records.length === 0) {
|
|
518
|
+
blockers.push(`WORKFLOW_SEMANTIC_RECORDS_MISSING:${item.step_id}`);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
for (const record of item.semantic_records) {
|
|
522
|
+
try {
|
|
523
|
+
const source = sourceRefFile(
|
|
524
|
+
rootDir, record.source_ref,
|
|
525
|
+
`workflow ${run.id} semantic record ${record.id || 'unknown'}`,
|
|
526
|
+
);
|
|
527
|
+
if (source.digest !== record.source_digest) {
|
|
528
|
+
blockers.push(`WORKFLOW_SEMANTIC_SOURCE_STALE:${item.step_id}:${record.id || 'unknown'}`);
|
|
529
|
+
}
|
|
530
|
+
} catch (_error) {
|
|
531
|
+
blockers.push(`WORKFLOW_SEMANTIC_SOURCE_INVALID:${item.step_id}:${record.id || 'unknown'}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (run.status === 'ready') {
|
|
537
|
+
const incomplete = steps.filter(
|
|
538
|
+
(item) => item.required && item.status !== 'completed',
|
|
539
|
+
);
|
|
540
|
+
if (incomplete.length > 0) {
|
|
541
|
+
blockers.push(...incomplete.map((item) => `WORKFLOW_READY_STEP_INCOMPLETE:${item.step_id}`));
|
|
542
|
+
}
|
|
543
|
+
if (run.current_step) blockers.push('WORKFLOW_READY_CURRENT_STEP_PRESENT');
|
|
544
|
+
}
|
|
545
|
+
if (['active', 'blocked'].includes(run.status)) {
|
|
546
|
+
const firstOpen = steps.find(
|
|
547
|
+
(item) => item.required && !['completed', 'skipped'].includes(item.status),
|
|
548
|
+
);
|
|
549
|
+
if (!firstOpen) blockers.push('WORKFLOW_ACTIVE_WITHOUT_OPEN_STEP');
|
|
550
|
+
else if (run.current_step !== firstOpen.step_id) {
|
|
551
|
+
blockers.push(`WORKFLOW_CURRENT_STEP_INVALID:${run.current_step || 'missing'}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (actualIds.size !== steps.length) blockers.push('WORKFLOW_DUPLICATE_STEP_ID');
|
|
555
|
+
return {
|
|
556
|
+
status: blockers.length === 0 ? 'pass' : 'fail',
|
|
557
|
+
blockers: [...new Set(blockers)].sort(),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function inspectOutputHealth(steps, rootDir) {
|
|
562
|
+
const blockers = [];
|
|
563
|
+
const latestByPath = new Map();
|
|
564
|
+
for (const workflowStep of steps) {
|
|
565
|
+
for (const output of workflowStep.outputs) latestByPath.set(output.path, output);
|
|
566
|
+
}
|
|
567
|
+
for (const output of latestByPath.values()) {
|
|
568
|
+
try {
|
|
569
|
+
const resolved = safeProjectFile(rootDir, output.path, 'output.path');
|
|
570
|
+
const current = crypto.createHash('sha256').update(fs.readFileSync(resolved.file)).digest('hex');
|
|
571
|
+
if (current !== output.digest) blockers.push(`WORKFLOW_OUTPUT_STALE:${output.path}`);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
blockers.push(error.code === 'WORKFLOW_OUTPUT_MISSING'
|
|
574
|
+
? `WORKFLOW_OUTPUT_MISSING:${output.path}`
|
|
575
|
+
: `WORKFLOW_OUTPUT_INVALID:${output.path}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return { status: blockers.length === 0 ? 'pass' : 'fail', blockers };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function readWorkflow(db, id, { rootDir = process.cwd() } = {}) {
|
|
582
|
+
const row = db.prepare('SELECT * FROM workflow_runs WHERE id = ?').get(id);
|
|
583
|
+
return decorateRun(db, row, rootDir);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function listWorkflows(db, filter = {}, { rootDir = process.cwd() } = {}) {
|
|
587
|
+
const rows = db.prepare(
|
|
588
|
+
`SELECT * FROM workflow_runs
|
|
589
|
+
WHERE (? IS NULL OR kind = ?) AND (? IS NULL OR status = ?)
|
|
590
|
+
AND (? IS NULL OR baseline_id = ?) AND (? IS NULL OR change_id = ?)
|
|
591
|
+
AND (? IS NULL OR task_id = ?)
|
|
592
|
+
ORDER BY updated_at DESC, rowid DESC LIMIT ?`,
|
|
593
|
+
).all(
|
|
594
|
+
filter.kind || null, filter.kind || null,
|
|
595
|
+
filter.status || null, filter.status || null,
|
|
596
|
+
filter.baseline_id || null, filter.baseline_id || null,
|
|
597
|
+
filter.change_id || null, filter.change_id || null,
|
|
598
|
+
filter.task_id || null, filter.task_id || null,
|
|
599
|
+
Math.min(Math.max(filter.limit || 100, 1), 500),
|
|
600
|
+
);
|
|
601
|
+
return rows.map((row) => decorateRun(db, row, rootDir));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function selectedResearchSteps(mode, selectedSteps) {
|
|
605
|
+
if (mode === 'custom') {
|
|
606
|
+
if (!Array.isArray(selectedSteps) || selectedSteps.length === 0) {
|
|
607
|
+
throw new WorkflowStateError('VALIDATION_ERROR', 'custom research requires selected_steps');
|
|
608
|
+
}
|
|
609
|
+
const known = new Set(WORKFLOW_DEFINITIONS.research.map((item) => item.id));
|
|
610
|
+
const invalid = selectedSteps.find((item) => !known.has(item));
|
|
611
|
+
if (invalid) throw new WorkflowStateError('VALIDATION_ERROR', `unknown research step: ${invalid}`);
|
|
612
|
+
return [...new Set([...selectedSteps, '99-synthesis'])];
|
|
613
|
+
}
|
|
614
|
+
const selected = RESEARCH_MODES[mode];
|
|
615
|
+
if (!selected) throw new WorkflowStateError('VALIDATION_ERROR', `unsupported research mode: ${mode}`);
|
|
616
|
+
return selected;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function assertReference(db, table, id, field) {
|
|
620
|
+
if (!id) return;
|
|
621
|
+
if (!db.prepare(`SELECT 1 FROM ${table} WHERE id = ?`).get(id)) {
|
|
622
|
+
throw new WorkflowStateError('VALIDATION_ERROR', `${field} does not exist: ${id}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function assertWorkflowAuthority(db, input, mode) {
|
|
627
|
+
const requireField = (field) => {
|
|
628
|
+
if (!input[field]) {
|
|
629
|
+
throw new WorkflowStateError(
|
|
630
|
+
'WORKFLOW_AUTHORITY_REQUIRED', `${input.kind} workflow requires ${field}`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
if (input.kind !== 'research' && (input.mode !== undefined || input.selected_steps !== undefined)) {
|
|
635
|
+
throw new WorkflowStateError(
|
|
636
|
+
'VALIDATION_ERROR', 'mode and selected_steps are valid only for research workflows',
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
if (input.kind === 'init') requireField('baseline_id');
|
|
640
|
+
if (input.kind === 'plan') {
|
|
641
|
+
requireField('change_id');
|
|
642
|
+
}
|
|
643
|
+
if (input.kind === 'dev') {
|
|
644
|
+
requireField('change_id');
|
|
645
|
+
requireField('task_id');
|
|
646
|
+
}
|
|
647
|
+
if (['change', 'test', 'review', 'deliver'].includes(input.kind)) requireField('change_id');
|
|
648
|
+
if (input.kind === 'research') {
|
|
649
|
+
if (!input.baseline_id && !input.change_id) {
|
|
650
|
+
throw new WorkflowStateError(
|
|
651
|
+
'WORKFLOW_AUTHORITY_REQUIRED', 'research workflow requires baseline_id or change_id',
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (['product', 'feature', 'architecture', 'custom'].includes(mode)) {
|
|
655
|
+
requireField('change_id');
|
|
656
|
+
if (!String(input.metadata?.selection_reason || '').trim()) {
|
|
657
|
+
throw new WorkflowStateError(
|
|
658
|
+
'WORKFLOW_SELECTION_REASON_REQUIRED',
|
|
659
|
+
`${mode} research requires an explicit owner-selected scope reason`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (input.baseline_id && !input.change_id) {
|
|
664
|
+
const baseline = db.prepare('SELECT mode FROM baselines WHERE id = ?').get(input.baseline_id);
|
|
665
|
+
const expectedMode = baseline?.mode === 'brownfield' ? 'adoption' : 'full';
|
|
666
|
+
if (mode !== expectedMode) {
|
|
667
|
+
throw new WorkflowStateError(
|
|
668
|
+
'WORKFLOW_RESEARCH_MODE_MISMATCH',
|
|
669
|
+
`${baseline?.mode || 'greenfield'} baseline research requires ${expectedMode} mode`,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function assertPlanWorkflowAuthority(db, input) {
|
|
677
|
+
const change = db.prepare(
|
|
678
|
+
`SELECT id, kind, status, baseline_bypass_json, contract_json,
|
|
679
|
+
research_disposition_json FROM changes WHERE id = ?`,
|
|
680
|
+
).get(input.change_id);
|
|
681
|
+
if (!change) return;
|
|
682
|
+
if (!['active', 'blocked'].includes(change.status)) {
|
|
683
|
+
throw new WorkflowStateError(
|
|
684
|
+
'WORKFLOW_CHANGE_NOT_MUTABLE', `plan change ${change.id} is ${change.status}`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
const linked = db.prepare(
|
|
688
|
+
`SELECT id, baseline_id FROM workflow_runs
|
|
689
|
+
WHERE kind = 'change' AND change_id = ?
|
|
690
|
+
ORDER BY started_at DESC, rowid DESC LIMIT 1`,
|
|
691
|
+
).get(change.id);
|
|
692
|
+
if (!linked) {
|
|
693
|
+
throw new WorkflowStateError(
|
|
694
|
+
'WORKFLOW_CHANGE_RUN_REQUIRED',
|
|
695
|
+
`change ${change.id} has no durable change workflow; run ultra-doctor repair before planning`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const expectedBaseline = linked.baseline_id || null;
|
|
699
|
+
const suppliedBaseline = input.baseline_id || null;
|
|
700
|
+
if (suppliedBaseline !== expectedBaseline) {
|
|
701
|
+
throw new WorkflowStateError(
|
|
702
|
+
'WORKFLOW_BASELINE_MISMATCH',
|
|
703
|
+
`plan baseline ${suppliedBaseline || '(none)'} does not match change workflow ${linked.id} baseline ${expectedBaseline || '(none)'}`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (expectedBaseline === null) {
|
|
707
|
+
const bypass = parseJson(change.baseline_bypass_json, 'changes.baseline_bypass_json', null);
|
|
708
|
+
if (change.kind !== 'incident' || bypass?.mode !== 'incident_break_glass'
|
|
709
|
+
|| !String(bypass.reason || '').trim() || !String(bypass.approved_by || '').trim()) {
|
|
710
|
+
throw new WorkflowStateError(
|
|
711
|
+
'WORKFLOW_BASELINE_REQUIRED',
|
|
712
|
+
`ordinary plan ${change.id} requires the baseline bound by its change workflow`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const contract = parseJson(change.contract_json || '{}', 'changes.contract_json', {});
|
|
717
|
+
const blockingDecisions = (contract.unresolved_decisions || []).filter((item) => item?.blocking === true);
|
|
718
|
+
if (blockingDecisions.length > 0) {
|
|
719
|
+
throw new WorkflowStateError(
|
|
720
|
+
'WORKFLOW_CHANGE_DECISION_BLOCKING',
|
|
721
|
+
`change ${change.id} has unresolved blocking decisions: ${blockingDecisions.map((item) => item.id).join(', ')}`,
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
const disposition = parseJson(
|
|
725
|
+
change.research_disposition_json || '{}', 'changes.research_disposition_json', {},
|
|
726
|
+
);
|
|
727
|
+
if (['bounded', 'required'].includes(disposition.status)) {
|
|
728
|
+
const row = db.prepare(
|
|
729
|
+
`SELECT id FROM workflow_runs
|
|
730
|
+
WHERE kind = 'research' AND change_id = ? AND mode = ? AND status = 'completed'
|
|
731
|
+
ORDER BY completed_at DESC, rowid DESC LIMIT 1`,
|
|
732
|
+
).get(change.id, disposition.mode);
|
|
733
|
+
const research = row ? readWorkflow(db, row.id, { rootDir: input.root_dir || process.cwd() }) : null;
|
|
734
|
+
const expectedSteps = new Set(selectedResearchSteps(
|
|
735
|
+
disposition.mode, disposition.selected_steps,
|
|
736
|
+
));
|
|
737
|
+
const actualSteps = new Set(
|
|
738
|
+
(research?.steps || []).filter((item) => item.required).map((item) => item.step_id),
|
|
739
|
+
);
|
|
740
|
+
const selectionMatches = expectedSteps.size === actualSteps.size
|
|
741
|
+
&& [...expectedSteps].every((item) => actualSteps.has(item));
|
|
742
|
+
if (!research || research.artifact_health.status !== 'pass' || !selectionMatches) {
|
|
743
|
+
throw new WorkflowStateError(
|
|
744
|
+
'WORKFLOW_CHANGE_RESEARCH_INCOMPLETE',
|
|
745
|
+
`change ${change.id} requires completed current ${disposition.mode} research before planning`,
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const CHANGE_BOUND_STAGE_KINDS = new Set([
|
|
752
|
+
'research', 'plan', 'dev', 'test', 'review', 'deliver',
|
|
753
|
+
]);
|
|
754
|
+
|
|
755
|
+
function bindChangeWorkflowAuthority(db, input) {
|
|
756
|
+
if (!input.change_id || !CHANGE_BOUND_STAGE_KINDS.has(input.kind)) return input;
|
|
757
|
+
const change = db.prepare(
|
|
758
|
+
'SELECT id, kind, status, baseline_bypass_json FROM changes WHERE id = ?',
|
|
759
|
+
).get(input.change_id);
|
|
760
|
+
if (!change) {
|
|
761
|
+
throw new WorkflowStateError(
|
|
762
|
+
'WORKFLOW_CHANGE_NOT_FOUND', `workflow change does not exist: ${input.change_id}`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
const allowedStatuses = input.kind === 'deliver'
|
|
766
|
+
? new Set(['active', 'blocked', 'ready'])
|
|
767
|
+
: new Set(['active', 'blocked']);
|
|
768
|
+
if (!allowedStatuses.has(change.status)) {
|
|
769
|
+
throw new WorkflowStateError(
|
|
770
|
+
'WORKFLOW_CHANGE_NOT_MUTABLE',
|
|
771
|
+
`${input.kind} workflow cannot start while change ${change.id} is ${change.status}`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
const linked = db.prepare(
|
|
775
|
+
`SELECT id, baseline_id FROM workflow_runs
|
|
776
|
+
WHERE kind = 'change' AND change_id = ?
|
|
777
|
+
ORDER BY started_at DESC, rowid DESC LIMIT 1`,
|
|
778
|
+
).get(change.id);
|
|
779
|
+
if (!linked) {
|
|
780
|
+
throw new WorkflowStateError(
|
|
781
|
+
'WORKFLOW_CHANGE_RUN_REQUIRED',
|
|
782
|
+
`change ${change.id} has no durable change workflow; run ultra-doctor repair before ${input.kind}`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
const expectedBaseline = linked.baseline_id || null;
|
|
786
|
+
if (input.baseline_id !== undefined && (input.baseline_id || null) !== expectedBaseline) {
|
|
787
|
+
throw new WorkflowStateError(
|
|
788
|
+
'WORKFLOW_BASELINE_MISMATCH',
|
|
789
|
+
`${input.kind} baseline ${input.baseline_id || '(none)'} does not match change workflow ${linked.id} baseline ${expectedBaseline || '(none)'}`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
if (expectedBaseline) {
|
|
793
|
+
const baseline = db.prepare('SELECT mode, status FROM baselines WHERE id = ?').get(expectedBaseline);
|
|
794
|
+
if (!baseline || baseline.status !== 'ready' || baseline.mode === 'migrated') {
|
|
795
|
+
throw new WorkflowStateError(
|
|
796
|
+
'WORKFLOW_BASELINE_NOT_READY',
|
|
797
|
+
`${input.kind} workflow requires its bound baseline ${expectedBaseline} to remain approved and ready`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
} else {
|
|
801
|
+
const bypass = parseJson(change.baseline_bypass_json, 'changes.baseline_bypass_json', null);
|
|
802
|
+
if (change.kind !== 'incident' || bypass?.mode !== 'incident_break_glass'
|
|
803
|
+
|| !String(bypass.reason || '').trim() || !String(bypass.approved_by || '').trim()) {
|
|
804
|
+
throw new WorkflowStateError(
|
|
805
|
+
'WORKFLOW_BASELINE_REQUIRED',
|
|
806
|
+
`${input.kind} workflow requires a bound ready baseline or approved incident break-glass authority`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return { ...input, baseline_id: expectedBaseline };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function insertWorkflowInTx(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
814
|
+
if (!RUN_KINDS.has(input.kind)) {
|
|
815
|
+
throw new WorkflowStateError('VALIDATION_ERROR', `unsupported workflow kind: ${input.kind}`);
|
|
816
|
+
}
|
|
817
|
+
input = bindChangeWorkflowAuthority(db, input);
|
|
818
|
+
const definition = WORKFLOW_DEFINITIONS[input.kind];
|
|
819
|
+
const mode = input.kind === 'research' ? (input.mode || 'full') : null;
|
|
820
|
+
assertWorkflowAuthority(db, input, mode);
|
|
821
|
+
const id = input.id || `wf-${input.kind}-${crypto.randomUUID().slice(0, 12)}`;
|
|
822
|
+
const subject = nonEmpty(input.subject, 'subject');
|
|
823
|
+
if (db.prepare('SELECT 1 FROM workflow_runs WHERE id = ?').get(id)) {
|
|
824
|
+
throw new WorkflowStateError('DUPLICATE_WORKFLOW_ID', `workflow ${id} already exists`);
|
|
825
|
+
}
|
|
826
|
+
assertReference(db, 'baselines', input.baseline_id, 'baseline_id');
|
|
827
|
+
assertReference(db, 'changes', input.change_id, 'change_id');
|
|
828
|
+
assertReference(db, 'tasks', input.task_id, 'task_id');
|
|
829
|
+
if (input.kind === 'plan') assertPlanWorkflowAuthority(db, { ...input, root_dir: rootDir });
|
|
830
|
+
if (input.kind === 'dev') {
|
|
831
|
+
if (!input.task_id) {
|
|
832
|
+
throw new WorkflowStateError('VALIDATION_ERROR', 'dev workflow requires task_id');
|
|
833
|
+
}
|
|
834
|
+
const task = ops.readTask(db, input.task_id);
|
|
835
|
+
ops.assertTaskExecutionContract(task);
|
|
836
|
+
if (!task.change_id) {
|
|
837
|
+
throw new WorkflowStateError(
|
|
838
|
+
'WORKFLOW_AUTHORITY_REQUIRED', `dev task ${task.id} is not owned by a change`,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
if (!['pending', 'in_progress', 'blocked'].includes(task.status) || task.stale) {
|
|
842
|
+
throw new WorkflowStateError(
|
|
843
|
+
'WORKFLOW_TASK_NOT_EXECUTABLE',
|
|
844
|
+
`task ${task.id} is not executable from status ${task.status}${task.stale ? ' (stale)' : ''}`,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
const incompleteDependencies = (task.deps || []).filter((id) => {
|
|
848
|
+
const dependency = ops.readTask(db, id);
|
|
849
|
+
return !dependency || !['completed', 'expanded'].includes(dependency.status);
|
|
850
|
+
});
|
|
851
|
+
if (incompleteDependencies.length > 0) {
|
|
852
|
+
throw new WorkflowStateError(
|
|
853
|
+
'TASK_DEPENDENCIES_INCOMPLETE',
|
|
854
|
+
`task ${task.id} has incomplete dependencies: ${incompleteDependencies.join(', ')}`,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
if (input.change_id && task.change_id !== input.change_id) {
|
|
858
|
+
throw new WorkflowStateError(
|
|
859
|
+
'TASK_CHANGE_OWNERSHIP_MISMATCH',
|
|
860
|
+
`task ${task.id} belongs to ${task.change_id || '(none)'}, not ${input.change_id}`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
assertCurrentPlan(db, task.change_id, rootDir);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const active = db.prepare(
|
|
867
|
+
`SELECT id FROM workflow_runs
|
|
868
|
+
WHERE kind = ? AND status IN ('active', 'blocked', 'ready')
|
|
869
|
+
AND baseline_id IS ? AND change_id IS ? AND task_id IS ? LIMIT 1`,
|
|
870
|
+
).get(input.kind, input.baseline_id || null, input.change_id || null, input.task_id || null);
|
|
871
|
+
if (active) {
|
|
872
|
+
throw new WorkflowStateError('WORKFLOW_IN_PROGRESS', `workflow ${active.id} is already active`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const selected = new Set(input.kind === 'research'
|
|
876
|
+
? selectedResearchSteps(mode, input.selected_steps)
|
|
877
|
+
: definition.map((item) => item.id));
|
|
878
|
+
let metadata = input.kind === 'research'
|
|
879
|
+
? { ...(input.metadata || {}), selected_steps: [...selected] }
|
|
880
|
+
: (input.metadata || {});
|
|
881
|
+
if (input.kind === 'plan') {
|
|
882
|
+
const profile = db.prepare('SELECT kind FROM changes WHERE id = ?').get(input.change_id)?.kind;
|
|
883
|
+
metadata = { ...metadata, profile };
|
|
884
|
+
}
|
|
885
|
+
const ts = nowIso();
|
|
886
|
+
const currentStep = definition.find((item) => selected.has(item.id))?.id || null;
|
|
887
|
+
db.prepare(
|
|
888
|
+
`INSERT INTO workflow_runs
|
|
889
|
+
(id, kind, mode, subject, definition_version, status, current_step, baseline_id,
|
|
890
|
+
change_id, task_id, metadata_json, blockers_json, summary_json, started_at, updated_at)
|
|
891
|
+
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, '[]', '{}', ?, ?)`,
|
|
892
|
+
).run(
|
|
893
|
+
id, input.kind, mode, subject, DEFINITION_VERSION, currentStep,
|
|
894
|
+
input.baseline_id || null, input.change_id || null, input.task_id || null,
|
|
895
|
+
JSON.stringify(metadata), ts, ts,
|
|
896
|
+
);
|
|
897
|
+
const insertStep = db.prepare(
|
|
898
|
+
`INSERT INTO workflow_steps
|
|
899
|
+
(run_id, step_id, position, title, required, status, skip_reason, updated_at)
|
|
900
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
901
|
+
);
|
|
902
|
+
definition.forEach((item, position) => {
|
|
903
|
+
const required = selected.has(item.id);
|
|
904
|
+
insertStep.run(
|
|
905
|
+
id, item.id, position, item.title, required ? 1 : 0,
|
|
906
|
+
required ? 'pending' : 'skipped', required ? null : `Excluded by ${mode} mode.`, ts,
|
|
907
|
+
);
|
|
908
|
+
});
|
|
909
|
+
ops.appendEventInTx(db, {
|
|
910
|
+
type: 'workflow_started', task_id: input.task_id, change_id: input.change_id,
|
|
911
|
+
payload: { workflow_id: id, kind: input.kind, mode, baseline_id: input.baseline_id || null },
|
|
912
|
+
});
|
|
913
|
+
return id;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function startWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
917
|
+
const id = ops.tx(db, () => insertWorkflowInTx(db, input, { rootDir }));
|
|
918
|
+
return readWorkflow(db, id, { rootDir });
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function allowedStepTransition(from, to, required) {
|
|
922
|
+
if (from === to) return true;
|
|
923
|
+
if (from === 'pending') return ['in_progress', 'completed', 'blocked'].includes(to)
|
|
924
|
+
|| (!required && to === 'skipped');
|
|
925
|
+
if (from === 'in_progress') return ['completed', 'blocked'].includes(to);
|
|
926
|
+
if (from === 'blocked') return ['in_progress', 'completed'].includes(to);
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function recordWorkflowStep(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
931
|
+
const id = nonEmpty(input.id, 'id');
|
|
932
|
+
const stepId = nonEmpty(input.step_id, 'step_id');
|
|
933
|
+
if (!STEP_STATUSES.has(input.status) || input.status === 'pending') {
|
|
934
|
+
throw new WorkflowStateError('VALIDATION_ERROR', `unsupported step status: ${input.status}`);
|
|
935
|
+
}
|
|
936
|
+
return ops.tx(db, () => {
|
|
937
|
+
const runRow = db.prepare('SELECT * FROM workflow_runs WHERE id = ?').get(id);
|
|
938
|
+
if (!runRow) throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${id} not found`);
|
|
939
|
+
if (['completed', 'cancelled'].includes(runRow.status)) {
|
|
940
|
+
throw new WorkflowStateError('WORKFLOW_NOT_MUTABLE', `workflow ${id} is ${runRow.status}`);
|
|
941
|
+
}
|
|
942
|
+
const stepRow = db.prepare('SELECT * FROM workflow_steps WHERE run_id = ? AND step_id = ?').get(id, stepId);
|
|
943
|
+
if (!stepRow) throw new WorkflowStateError('WORKFLOW_STEP_NOT_FOUND', `workflow step ${stepId} not found`);
|
|
944
|
+
if (!allowedStepTransition(stepRow.status, input.status, Boolean(stepRow.required))) {
|
|
945
|
+
throw new WorkflowStateError(
|
|
946
|
+
'ILLEGAL_WORKFLOW_STEP_TRANSITION',
|
|
947
|
+
`cannot transition ${id}/${stepId} from ${stepRow.status} to ${input.status}`,
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
const firstOpen = db.prepare(
|
|
951
|
+
`SELECT step_id FROM workflow_steps
|
|
952
|
+
WHERE run_id = ? AND required = 1 AND status NOT IN ('completed', 'skipped')
|
|
953
|
+
ORDER BY position ASC LIMIT 1`,
|
|
954
|
+
).get(id);
|
|
955
|
+
if (firstOpen && firstOpen.step_id !== stepId) {
|
|
956
|
+
throw new WorkflowStateError(
|
|
957
|
+
'WORKFLOW_STEP_OUT_OF_ORDER',
|
|
958
|
+
`complete ${firstOpen.step_id} before ${stepId}`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const definition = WORKFLOW_DEFINITIONS[runRow.kind].find((item) => item.id === stepId);
|
|
963
|
+
const evidence = input.evidence === undefined
|
|
964
|
+
? parseJson(stepRow.evidence_json, 'evidence_json', [])
|
|
965
|
+
: normalizeEvidence(input.evidence);
|
|
966
|
+
const outputs = input.outputs === undefined
|
|
967
|
+
? parseJson(stepRow.outputs_json, 'outputs_json', [])
|
|
968
|
+
: normalizeOutputs(input.outputs, rootDir);
|
|
969
|
+
const decisions = input.decisions === undefined
|
|
970
|
+
? parseJson(stepRow.decisions_json, 'decisions_json', [])
|
|
971
|
+
: normalizeObjects(input.decisions, 'decisions');
|
|
972
|
+
const semanticRecords = input.semantic_records === undefined
|
|
973
|
+
? parseJson(stepRow.semantic_records_json, 'semantic_records_json', [])
|
|
974
|
+
: (runRow.kind === 'research'
|
|
975
|
+
? normalizeSemanticRecords(input.semantic_records, stepId, rootDir)
|
|
976
|
+
: normalizeObjects(input.semantic_records, 'semantic_records'));
|
|
977
|
+
const blockers = input.blockers === undefined ? [] : input.blockers;
|
|
978
|
+
if (!Array.isArray(blockers) || blockers.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
979
|
+
throw new WorkflowStateError('VALIDATION_ERROR', 'blockers must be an array of non-empty strings');
|
|
980
|
+
}
|
|
981
|
+
if (input.status === 'completed' && definition.evidence_required && evidence.length === 0) {
|
|
982
|
+
throw new WorkflowStateError('WORKFLOW_EVIDENCE_REQUIRED', `${stepId} requires evidence`);
|
|
983
|
+
}
|
|
984
|
+
if (input.status === 'completed' && definition.output_required && outputs.length === 0) {
|
|
985
|
+
throw new WorkflowStateError('WORKFLOW_OUTPUT_REQUIRED', `${stepId} requires a durable output`);
|
|
986
|
+
}
|
|
987
|
+
if (input.status === 'completed' && runRow.kind === 'research') {
|
|
988
|
+
validateResearchStepReport(runRow, stepId, outputs, rootDir);
|
|
989
|
+
if (semanticRecords.length === 0) {
|
|
990
|
+
throw new WorkflowStateError(
|
|
991
|
+
'WORKFLOW_SEMANTIC_RECORDS_REQUIRED', `${stepId} requires at least one typed semantic record`,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
if (stepId === '99-synthesis') validateSynthesisTrace(db, runRow, semanticRecords);
|
|
995
|
+
}
|
|
996
|
+
if (input.status === 'completed' && contextStepForKind(runRow.kind) === stepId) {
|
|
997
|
+
validateContextManifestOutput(db, runRow, outputs, rootDir, { current: true });
|
|
998
|
+
}
|
|
999
|
+
if (input.status === 'blocked' && blockers.length === 0) {
|
|
1000
|
+
throw new WorkflowStateError('WORKFLOW_BLOCKER_REQUIRED', `${stepId} requires a blocker code`);
|
|
1001
|
+
}
|
|
1002
|
+
if (input.status === 'completed' && runRow.kind === 'test' && stepId === 'bind-scope') {
|
|
1003
|
+
assertDevelopmentComplete(db, runRow.change_id, rootDir);
|
|
1004
|
+
}
|
|
1005
|
+
if (input.status === 'completed' && runRow.kind === 'dev' && stepId === 'review-slice') {
|
|
1006
|
+
assertApprovedReview(db, runRow.change_id, runRow.task_id, rootDir);
|
|
1007
|
+
}
|
|
1008
|
+
if (input.status === 'completed' && runRow.kind === 'deliver' && stepId === 'bind-evidence') {
|
|
1009
|
+
validateDeliveryPrerequisites(db, { change_id: runRow.change_id }, rootDir);
|
|
1010
|
+
}
|
|
1011
|
+
if (input.status === 'completed' && runRow.kind === 'deliver'
|
|
1012
|
+
&& stepId === 'release-if-authorized') {
|
|
1013
|
+
validateReleaseAuthorizationDecision(decisions);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const ts = nowIso();
|
|
1017
|
+
const startedAt = stepRow.started_at || ts;
|
|
1018
|
+
const completedAt = input.status === 'completed' ? ts : null;
|
|
1019
|
+
db.prepare(
|
|
1020
|
+
`UPDATE workflow_steps SET status = ?, evidence_json = ?, outputs_json = ?,
|
|
1021
|
+
decisions_json = ?, semantic_records_json = ?, blockers_json = ?,
|
|
1022
|
+
started_at = ?, completed_at = ?, updated_at = ?
|
|
1023
|
+
WHERE run_id = ? AND step_id = ?`,
|
|
1024
|
+
).run(
|
|
1025
|
+
input.status, JSON.stringify(evidence), JSON.stringify(outputs), JSON.stringify(decisions),
|
|
1026
|
+
JSON.stringify(semanticRecords), JSON.stringify(blockers),
|
|
1027
|
+
startedAt, completedAt, ts, id, stepId,
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
const next = db.prepare(
|
|
1031
|
+
`SELECT step_id FROM workflow_steps
|
|
1032
|
+
WHERE run_id = ? AND required = 1 AND status NOT IN ('completed', 'skipped')
|
|
1033
|
+
ORDER BY position ASC LIMIT 1`,
|
|
1034
|
+
).get(id);
|
|
1035
|
+
let runStatus = 'active';
|
|
1036
|
+
let runBlockers = [];
|
|
1037
|
+
if (input.status === 'blocked') {
|
|
1038
|
+
runStatus = 'blocked';
|
|
1039
|
+
runBlockers = blockers.map((item) => item.trim());
|
|
1040
|
+
} else if (!next) {
|
|
1041
|
+
runStatus = 'ready';
|
|
1042
|
+
}
|
|
1043
|
+
db.prepare(
|
|
1044
|
+
`UPDATE workflow_runs SET status = ?, current_step = ?, blockers_json = ?, updated_at = ?
|
|
1045
|
+
WHERE id = ?`,
|
|
1046
|
+
).run(runStatus, next?.step_id || null, JSON.stringify(runBlockers), ts, id);
|
|
1047
|
+
ops.appendEventInTx(db, {
|
|
1048
|
+
type: input.status === 'blocked' ? 'workflow_blocked' : 'workflow_step_updated',
|
|
1049
|
+
task_id: runRow.task_id, change_id: runRow.change_id,
|
|
1050
|
+
payload: { workflow_id: id, kind: runRow.kind, step_id: stepId, status: input.status },
|
|
1051
|
+
});
|
|
1052
|
+
return readWorkflow(db, id, { rootDir });
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function currentCheckout(rootDir) {
|
|
1057
|
+
const baselines = require('./baseline-workflow.cjs');
|
|
1058
|
+
return baselines.gitWorktreeSnapshot(rootDir, ['.']);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function assertIsoTimestamp(value, field) {
|
|
1062
|
+
if (typeof value !== 'string' || !value.trim() || Number.isNaN(Date.parse(value))) {
|
|
1063
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${field} must be an ISO-8601 timestamp`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function readOutputJson(run, stepId, rootDir, schema) {
|
|
1068
|
+
const workflowStep = run.steps.find((item) => item.step_id === stepId);
|
|
1069
|
+
const candidates = workflowStep?.outputs || [];
|
|
1070
|
+
for (const output of candidates) {
|
|
1071
|
+
const resolved = safeProjectFile(rootDir, output.path, `${stepId}.output`);
|
|
1072
|
+
let value;
|
|
1073
|
+
try { value = JSON.parse(fs.readFileSync(resolved.file, 'utf8')); }
|
|
1074
|
+
catch (error) {
|
|
1075
|
+
throw new WorkflowStateError(
|
|
1076
|
+
'WORKFLOW_REPORT_INVALID', `${output.path} is not valid JSON: ${error.message}`,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && value.$schema === schema) {
|
|
1080
|
+
return { value, output };
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
throw new WorkflowStateError(
|
|
1084
|
+
'WORKFLOW_REPORT_INVALID', `${stepId} requires a ${schema} output`,
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function contextStepForKind(kind) {
|
|
1089
|
+
return {
|
|
1090
|
+
change: 'compile-context',
|
|
1091
|
+
dev: 'compile-context',
|
|
1092
|
+
test: 'compile-context',
|
|
1093
|
+
review: 'compile-context',
|
|
1094
|
+
deliver: 'verify-candidate',
|
|
1095
|
+
}[kind] || null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function contextExpectation(kind) {
|
|
1099
|
+
return {
|
|
1100
|
+
change: { roles: ['plan', 'implement'], gates: ['planning', 'implementation'] },
|
|
1101
|
+
dev: { roles: ['implement'], gates: ['implementation'] },
|
|
1102
|
+
test: { roles: ['check'], gates: ['verification'] },
|
|
1103
|
+
review: { roles: ['review'], gates: ['review'] },
|
|
1104
|
+
deliver: { roles: ['check'], gates: ['convergence'] },
|
|
1105
|
+
}[kind] || null;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function validateContextManifestOutput(db, run, outputs, rootDir, { current = true } = {}) {
|
|
1109
|
+
const expectation = contextExpectation(run.kind);
|
|
1110
|
+
if (!expectation) return null;
|
|
1111
|
+
let candidate = null;
|
|
1112
|
+
let manifest = null;
|
|
1113
|
+
for (const output of outputs || []) {
|
|
1114
|
+
const resolved = safeProjectFile(rootDir, output.path, 'context output');
|
|
1115
|
+
let value;
|
|
1116
|
+
try { value = JSON.parse(fs.readFileSync(resolved.file, 'utf8')); }
|
|
1117
|
+
catch { continue; }
|
|
1118
|
+
if (value?.schema_version === '2.0' && value?.snapshot_id) {
|
|
1119
|
+
candidate = output;
|
|
1120
|
+
manifest = value;
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (!candidate) {
|
|
1125
|
+
throw new WorkflowStateError(
|
|
1126
|
+
'WORKFLOW_CONTEXT_OUTPUT_REQUIRED', `${run.kind} requires a Context Manifest v2 output`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
if (manifest.change?.id !== run.change_id
|
|
1130
|
+
|| !expectation.roles.includes(manifest.role)
|
|
1131
|
+
|| !expectation.gates.includes(manifest.gate)
|
|
1132
|
+
|| manifest.readiness?.status !== 'ready') {
|
|
1133
|
+
throw new WorkflowStateError(
|
|
1134
|
+
'WORKFLOW_CONTEXT_MISMATCH', `${run.kind} context is not bound to the expected change, role, gate, or readiness`,
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
if (run.task_id && manifest.resume?.task_id !== run.task_id) {
|
|
1138
|
+
throw new WorkflowStateError(
|
|
1139
|
+
'WORKFLOW_CONTEXT_MISMATCH', `${run.kind} context is not bound to task ${run.task_id}`,
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
const stored = db.prepare(
|
|
1143
|
+
`SELECT manifest_path, manifest_hash, role, gate, readiness, change_id, task_id
|
|
1144
|
+
FROM context_snapshots WHERE id = ?`,
|
|
1145
|
+
).get(manifest.snapshot_id);
|
|
1146
|
+
if (!stored || stored.manifest_path !== candidate.path || stored.manifest_hash !== candidate.digest
|
|
1147
|
+
|| stored.change_id !== run.change_id || stored.role !== manifest.role
|
|
1148
|
+
|| stored.gate !== manifest.gate || stored.readiness !== 'ready'
|
|
1149
|
+
|| (run.task_id && stored.task_id !== run.task_id)) {
|
|
1150
|
+
throw new WorkflowStateError(
|
|
1151
|
+
'WORKFLOW_CONTEXT_AUTHORITY_MISMATCH', 'context output does not match its authoritative snapshot row',
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
if (current) {
|
|
1155
|
+
const checkout = currentCheckout(rootDir);
|
|
1156
|
+
if ((checkout.head && manifest.git?.head !== checkout.head)
|
|
1157
|
+
|| (checkout.digest && manifest.git?.worktree_digest !== checkout.digest)) {
|
|
1158
|
+
throw new WorkflowStateError(
|
|
1159
|
+
'WORKFLOW_CONTEXT_STALE', `${run.kind} context does not match the current checkout`,
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return { manifest, output: candidate };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function recordedContext(db, run, rootDir, options) {
|
|
1167
|
+
const stepId = contextStepForKind(run.kind);
|
|
1168
|
+
const workflowStep = run.steps.find((item) => item.step_id === stepId);
|
|
1169
|
+
return validateContextManifestOutput(db, run, workflowStep?.outputs || [], rootDir, options);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function expectedTaskIds(db, run) {
|
|
1173
|
+
if (run.task_id) return [run.task_id];
|
|
1174
|
+
return db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY id')
|
|
1175
|
+
.all(run.change_id).map((row) => row.id);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function changeTasks(db, changeId) {
|
|
1179
|
+
return db.prepare('SELECT id FROM tasks WHERE change_id = ? ORDER BY id')
|
|
1180
|
+
.all(changeId).map((row) => ops.readTask(db, row.id));
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function taskPlanDigest(task) {
|
|
1184
|
+
const contract = {
|
|
1185
|
+
id: task.id,
|
|
1186
|
+
change_id: task.change_id,
|
|
1187
|
+
parent_id: task.parent_id,
|
|
1188
|
+
title: task.title,
|
|
1189
|
+
type: task.type,
|
|
1190
|
+
priority: task.priority,
|
|
1191
|
+
complexity: task.complexity,
|
|
1192
|
+
estimated_days: task.estimated_days,
|
|
1193
|
+
deps: task.deps || [],
|
|
1194
|
+
files_modified: task.files_modified || [],
|
|
1195
|
+
tag: task.tag,
|
|
1196
|
+
trace_to: task.trace_to,
|
|
1197
|
+
outcome: task.outcome,
|
|
1198
|
+
slice_kind: task.slice_kind,
|
|
1199
|
+
public_seam: task.public_seam,
|
|
1200
|
+
verification_command: task.verification_command,
|
|
1201
|
+
acceptance: task.acceptance || [],
|
|
1202
|
+
context_refs: task.context_refs || [],
|
|
1203
|
+
docs_impact: task.docs_impact || {},
|
|
1204
|
+
ownership: task.ownership || {},
|
|
1205
|
+
};
|
|
1206
|
+
return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function latestCompletedWorkflow(db, { kind, changeId, taskId = null }, rootDir) {
|
|
1210
|
+
const row = db.prepare(
|
|
1211
|
+
`SELECT id FROM workflow_runs
|
|
1212
|
+
WHERE kind = ? AND change_id = ? AND task_id IS ? AND status = 'completed'
|
|
1213
|
+
ORDER BY completed_at DESC, rowid DESC LIMIT 1`,
|
|
1214
|
+
).get(kind, changeId, taskId);
|
|
1215
|
+
return row ? readWorkflow(db, row.id, { rootDir }) : null;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function assertCurrentPlan(db, changeId, rootDir) {
|
|
1219
|
+
const plan = latestCompletedWorkflow(
|
|
1220
|
+
db, { kind: 'plan', changeId, taskId: null }, rootDir,
|
|
1221
|
+
);
|
|
1222
|
+
if (!plan || plan.artifact_health.status !== 'pass') {
|
|
1223
|
+
throw new WorkflowStateError(
|
|
1224
|
+
'WORKFLOW_PLAN_NOT_COMPLETED', `change ${changeId} requires a completed current plan workflow`,
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
const tasks = changeTasks(db, changeId);
|
|
1228
|
+
const ids = tasks.map((task) => task.id);
|
|
1229
|
+
if (JSON.stringify(ids) !== JSON.stringify([...(plan.summary.task_ids || [])].sort())) {
|
|
1230
|
+
throw new WorkflowStateError(
|
|
1231
|
+
'WORKFLOW_PLAN_TASK_SET_STALE', `change ${changeId} task set changed after planning`,
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
const approvedDigests = plan.summary.task_contract_digests;
|
|
1235
|
+
if (!approvedDigests || typeof approvedDigests !== 'object' || Array.isArray(approvedDigests)) {
|
|
1236
|
+
throw new WorkflowStateError(
|
|
1237
|
+
'WORKFLOW_PLAN_TASK_CONTRACT_STALE', `change ${changeId} plan has no task-contract provenance`,
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
for (const task of tasks) {
|
|
1241
|
+
if (approvedDigests[task.id] !== taskPlanDigest(task)) {
|
|
1242
|
+
throw new WorkflowStateError(
|
|
1243
|
+
'WORKFLOW_PLAN_TASK_CONTRACT_STALE', `task ${task.id} changed after plan approval`,
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return { plan, tasks, taskIds: ids };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function assertGateCheckoutCurrent(run, rootDir, label) {
|
|
1251
|
+
if (!run || run.artifact_health.status !== 'pass') {
|
|
1252
|
+
throw new WorkflowStateError(
|
|
1253
|
+
'WORKFLOW_GATE_MISSING', `${label} workflow is missing or has stale outputs`,
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
const checkout = currentCheckout(rootDir);
|
|
1257
|
+
if (checkout.head && run.summary.git_commit !== checkout.head) {
|
|
1258
|
+
throw new WorkflowStateError('WORKFLOW_GATE_STALE', `${label} workflow does not match current HEAD`);
|
|
1259
|
+
}
|
|
1260
|
+
if (checkout.digest && run.summary.worktree_digest !== checkout.digest) {
|
|
1261
|
+
throw new WorkflowStateError('WORKFLOW_GATE_STALE', `${label} workflow does not match current worktree`);
|
|
1262
|
+
}
|
|
1263
|
+
return run;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function gitCommitIsAncestor(rootDir, ancestor, descendant) {
|
|
1267
|
+
if (!ancestor || !descendant) return true;
|
|
1268
|
+
const result = spawnSync('git', ['merge-base', '--is-ancestor', ancestor, descendant], {
|
|
1269
|
+
cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'ignore', 'ignore'],
|
|
1270
|
+
});
|
|
1271
|
+
return result.status === 0;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function assertCompletedDevelopmentEvidence(run, task, rootDir) {
|
|
1275
|
+
if (!run || run.artifact_health.status !== 'pass') {
|
|
1276
|
+
throw new WorkflowStateError(
|
|
1277
|
+
'WORKFLOW_GATE_MISSING', `dev:${task.id} workflow is missing or has stale outputs`,
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
if (run.summary.task_id !== task.id
|
|
1281
|
+
|| (run.summary.public_seam !== undefined && run.summary.public_seam !== task.public_seam)
|
|
1282
|
+
|| (run.summary.verification_command !== undefined
|
|
1283
|
+
&& run.summary.verification_command !== task.verification_command)) {
|
|
1284
|
+
throw new WorkflowStateError(
|
|
1285
|
+
'WORKFLOW_GATE_STALE', `dev:${task.id} workflow no longer matches the task contract`,
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
const checkout = currentCheckout(rootDir);
|
|
1289
|
+
if (!gitCommitIsAncestor(rootDir, run.summary.git_commit, checkout.head)) {
|
|
1290
|
+
throw new WorkflowStateError(
|
|
1291
|
+
'WORKFLOW_GATE_STALE', `dev:${task.id} commit is not an ancestor of current HEAD`,
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
if (task.completion_commit
|
|
1295
|
+
&& !gitCommitIsAncestor(rootDir, task.completion_commit, checkout.head)) {
|
|
1296
|
+
throw new WorkflowStateError(
|
|
1297
|
+
'WORKFLOW_GATE_STALE', `task ${task.id} completion commit is not an ancestor of current HEAD`,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
return run;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function assertDevelopmentComplete(db, changeId, rootDir) {
|
|
1304
|
+
const tasks = changeTasks(db, changeId);
|
|
1305
|
+
if (tasks.length === 0) {
|
|
1306
|
+
throw new WorkflowStateError('WORKFLOW_TASKS_REQUIRED', `change ${changeId} has no tasks`);
|
|
1307
|
+
}
|
|
1308
|
+
for (const task of tasks) {
|
|
1309
|
+
if (!['completed', 'expanded'].includes(task.status)) {
|
|
1310
|
+
throw new WorkflowStateError(
|
|
1311
|
+
'WORKFLOW_TASK_NOT_COMPLETED', `task ${task.id} is ${task.status}`,
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
if (task.status === 'expanded') continue;
|
|
1315
|
+
const dev = latestCompletedWorkflow(
|
|
1316
|
+
db, { kind: 'dev', changeId, taskId: task.id }, rootDir,
|
|
1317
|
+
);
|
|
1318
|
+
assertCompletedDevelopmentEvidence(dev, task, rootDir);
|
|
1319
|
+
}
|
|
1320
|
+
return tasks;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function assertApprovedReview(db, changeId, taskId, rootDir, expectedMode = taskId ? 'task' : 'change') {
|
|
1324
|
+
const review = latestCompletedWorkflow(
|
|
1325
|
+
db, { kind: 'review', changeId, taskId }, rootDir,
|
|
1326
|
+
);
|
|
1327
|
+
assertGateCheckoutCurrent(review, rootDir, `review:${taskId || changeId}`);
|
|
1328
|
+
if (review.summary.verdict !== 'APPROVE'
|
|
1329
|
+
|| review.summary.axes?.spec_fidelity?.verdict !== 'PASS'
|
|
1330
|
+
|| review.summary.axes?.engineering_standards?.verdict !== 'PASS') {
|
|
1331
|
+
throw new WorkflowStateError(
|
|
1332
|
+
'WORKFLOW_REVIEW_NOT_APPROVED', `review for ${taskId || changeId} is not approved on both axes`,
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
if (review.summary.mode !== expectedMode) {
|
|
1336
|
+
throw new WorkflowStateError(
|
|
1337
|
+
'WORKFLOW_REVIEW_MODE_MISMATCH',
|
|
1338
|
+
`review for ${taskId || changeId} uses ${review.summary.mode || 'missing'} mode; expected ${expectedMode}`,
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
return review;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function validateDevCompletion(db, run, rootDir) {
|
|
1345
|
+
const task = ops.readTask(db, run.task_id);
|
|
1346
|
+
if (!task || task.status !== 'completed') {
|
|
1347
|
+
throw new WorkflowStateError(
|
|
1348
|
+
'WORKFLOW_TASK_NOT_COMPLETED', `task ${run.task_id} must be completed before dev workflow convergence`,
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
const context = recordedContext(db, run, rootDir, { current: false });
|
|
1352
|
+
if (context.manifest.execution_contract?.public_seam !== task.public_seam
|
|
1353
|
+
|| context.manifest.execution_contract?.verification_command !== task.verification_command) {
|
|
1354
|
+
throw new WorkflowStateError(
|
|
1355
|
+
'WORKFLOW_CONTEXT_NOT_READY', `task ${task.id} has no current executable context contract`,
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
const sessions = db.prepare(
|
|
1359
|
+
"SELECT sid FROM sessions WHERE task_id = ? AND status = 'running'",
|
|
1360
|
+
).all(task.id);
|
|
1361
|
+
if (sessions.length > 0) {
|
|
1362
|
+
throw new WorkflowStateError(
|
|
1363
|
+
'WORKFLOW_SESSION_ACTIVE', `task ${task.id} still has active sessions: ${sessions.map((row) => row.sid).join(', ')}`,
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
const review = assertApprovedReview(db, run.change_id, task.id, rootDir);
|
|
1367
|
+
const checkout = currentCheckout(rootDir);
|
|
1368
|
+
return {
|
|
1369
|
+
change_id: run.change_id,
|
|
1370
|
+
task_id: task.id,
|
|
1371
|
+
outcome: task.outcome,
|
|
1372
|
+
public_seam: task.public_seam,
|
|
1373
|
+
verification_command: task.verification_command,
|
|
1374
|
+
context_path: context.output.path,
|
|
1375
|
+
context_digest: context.output.digest,
|
|
1376
|
+
git_commit: checkout.head,
|
|
1377
|
+
worktree_digest: checkout.digest,
|
|
1378
|
+
review_workflow_id: review.id,
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function validateChangeCompletion(db, run, rootDir) {
|
|
1383
|
+
const { plan, taskIds } = assertCurrentPlan(db, run.change_id, rootDir);
|
|
1384
|
+
return { plan_workflow_id: plan.id, task_ids: taskIds };
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function validateDeliveryPrerequisites(db, run, rootDir) {
|
|
1388
|
+
const tasks = assertDevelopmentComplete(db, run.change_id, rootDir);
|
|
1389
|
+
const taskIds = tasks.map((task) => task.id).sort();
|
|
1390
|
+
const test = latestCompletedWorkflow(
|
|
1391
|
+
db, { kind: 'test', changeId: run.change_id, taskId: null }, rootDir,
|
|
1392
|
+
);
|
|
1393
|
+
assertGateCheckoutCurrent(test, rootDir, `test:${run.change_id}`);
|
|
1394
|
+
if (test.summary.passed !== true) {
|
|
1395
|
+
throw new WorkflowStateError('WORKFLOW_TEST_NOT_PASSED', `test gate for ${run.change_id} did not pass`);
|
|
1396
|
+
}
|
|
1397
|
+
if (JSON.stringify([...(test.summary.task_ids || [])].sort()) !== JSON.stringify(taskIds)) {
|
|
1398
|
+
throw new WorkflowStateError(
|
|
1399
|
+
'WORKFLOW_GATE_STALE', `test gate for ${run.change_id} does not cover the current task set`,
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
const review = assertApprovedReview(db, run.change_id, null, rootDir);
|
|
1403
|
+
if (JSON.stringify([...(review.summary.task_ids || [])].sort()) !== JSON.stringify(taskIds)) {
|
|
1404
|
+
throw new WorkflowStateError(
|
|
1405
|
+
'WORKFLOW_GATE_STALE', `review gate for ${run.change_id} does not cover the current task set`,
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
task_ids: taskIds,
|
|
1410
|
+
test_workflow_id: test.id,
|
|
1411
|
+
review_workflow_id: review.id,
|
|
1412
|
+
test_summary: test.summary,
|
|
1413
|
+
review_summary: review.summary,
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function assertBoundTaskSet(db, run, report) {
|
|
1418
|
+
if (!Array.isArray(report.task_ids)
|
|
1419
|
+
|| report.task_ids.some((id) => typeof id !== 'string' || !id.trim())) {
|
|
1420
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'report.task_ids must be an array of task ids');
|
|
1421
|
+
}
|
|
1422
|
+
const actual = [...report.task_ids].sort();
|
|
1423
|
+
const expected = expectedTaskIds(db, run);
|
|
1424
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
1425
|
+
throw new WorkflowStateError(
|
|
1426
|
+
'WORKFLOW_REPORT_TASK_SET_STALE',
|
|
1427
|
+
`report task set does not match ${run.change_id}: expected ${expected.join(', ') || '(none)'}`,
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function assertCurrentCheckout(report, rootDir) {
|
|
1433
|
+
const checkout = currentCheckout(rootDir);
|
|
1434
|
+
if (checkout.head && report.git_commit !== checkout.head && report.head !== checkout.head) {
|
|
1435
|
+
throw new WorkflowStateError(
|
|
1436
|
+
'WORKFLOW_REPORT_REVISION_STALE', `report revision does not match current HEAD ${checkout.head}`,
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
if (checkout.digest && report.worktree_digest !== checkout.digest) {
|
|
1440
|
+
throw new WorkflowStateError(
|
|
1441
|
+
'WORKFLOW_REPORT_WORKTREE_STALE', 'report worktree digest does not match the current checkout',
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
return checkout;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function validateTestCompletion(db, run, rootDir) {
|
|
1448
|
+
assertDevelopmentComplete(db, run.change_id, rootDir);
|
|
1449
|
+
const context = recordedContext(db, run, rootDir, { current: true });
|
|
1450
|
+
const { value: report, output } = readOutputJson(
|
|
1451
|
+
run, 'write-report', rootDir, 'ultra-test-report-v1',
|
|
1452
|
+
);
|
|
1453
|
+
assertReportSchema(report, 'test');
|
|
1454
|
+
if (report.change_id !== run.change_id) {
|
|
1455
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_CHANGE_MISMATCH', 'test report change_id is not bound to the workflow');
|
|
1456
|
+
}
|
|
1457
|
+
assertBoundTaskSet(db, run, report);
|
|
1458
|
+
const change = db.prepare('SELECT kind FROM changes WHERE id = ?').get(run.change_id);
|
|
1459
|
+
const requiresRegressionSignal = change?.kind === 'incident'
|
|
1460
|
+
|| changeTasks(db, run.change_id).some((task) => task.type === 'bugfix');
|
|
1461
|
+
if (requiresRegressionSignal) {
|
|
1462
|
+
const signal = report.regression_signal;
|
|
1463
|
+
if (!signal || signal.observed_red !== true || signal.observed_green !== true
|
|
1464
|
+
|| signal.deterministic !== true) {
|
|
1465
|
+
throw new WorkflowStateError(
|
|
1466
|
+
'WORKFLOW_REGRESSION_SIGNAL_MISSING',
|
|
1467
|
+
'incident and bugfix testing requires one deterministic observed red-to-green regression signal',
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
const checkout = assertCurrentCheckout(report, rootDir);
|
|
1472
|
+
assertIsoTimestamp(report.timestamp, 'test report timestamp');
|
|
1473
|
+
if (!Number.isInteger(report.run_count) || report.run_count < 1) {
|
|
1474
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'test report run_count must be a positive integer');
|
|
1475
|
+
}
|
|
1476
|
+
for (const field of ['acceptance', 'commands', 'public_seams', 'failures', 'recovery', 'blocking_issues']) {
|
|
1477
|
+
if (!Array.isArray(report[field])) {
|
|
1478
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `test report ${field} must be an array`);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (!String(report.context_digest || '').trim()) {
|
|
1482
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'test report context_digest is required');
|
|
1483
|
+
}
|
|
1484
|
+
if (report.context_digest !== context.output.digest) {
|
|
1485
|
+
throw new WorkflowStateError(
|
|
1486
|
+
'WORKFLOW_REPORT_CONTEXT_MISMATCH', 'test report context_digest does not match the checking context',
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
if (report.commands.length === 0 || report.public_seams.length === 0) {
|
|
1490
|
+
throw new WorkflowStateError(
|
|
1491
|
+
'WORKFLOW_REPORT_INVALID', 'test report requires commands and public_seams evidence',
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
const claimedPass = report.acceptance.length > 0
|
|
1495
|
+
&& report.acceptance.every((item) => item.status === 'pass')
|
|
1496
|
+
&& report.commands.every((item) => item.status === 'pass' && item.exit_code === 0)
|
|
1497
|
+
&& report.public_seams.every((item) => item?.status === 'pass')
|
|
1498
|
+
&& report.failures.every((item) => item.status === 'resolved')
|
|
1499
|
+
&& report.recovery.every((item) => item.status === 'pass')
|
|
1500
|
+
&& Object.values(report.verification_dimensions || {}).every(
|
|
1501
|
+
(item) => ['pass', 'not_applicable'].includes(item?.status),
|
|
1502
|
+
)
|
|
1503
|
+
&& report.blocking_issues.length === 0;
|
|
1504
|
+
if (typeof report.passed !== 'boolean' || report.passed !== claimedPass) {
|
|
1505
|
+
throw new WorkflowStateError(
|
|
1506
|
+
'WORKFLOW_REPORT_VERDICT_CONFLICT', 'test report passed conflicts with command, seam, or blocker evidence',
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
return {
|
|
1510
|
+
change_id: run.change_id,
|
|
1511
|
+
task_ids: report.task_ids,
|
|
1512
|
+
passed: report.passed,
|
|
1513
|
+
git_commit: checkout.head,
|
|
1514
|
+
worktree_digest: checkout.digest,
|
|
1515
|
+
report_path: output.path,
|
|
1516
|
+
report_digest: output.digest,
|
|
1517
|
+
context_path: context.output.path,
|
|
1518
|
+
context_digest: context.output.digest,
|
|
1519
|
+
regression_signal: report.regression_signal,
|
|
1520
|
+
verification_dimensions: report.verification_dimensions,
|
|
1521
|
+
blocking_issues: report.blocking_issues,
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const REVIEW_AXES = new Set(['spec_fidelity', 'engineering_standards']);
|
|
1526
|
+
const REVIEW_SEVERITIES = new Set(['P0', 'P1', 'P2', 'P3']);
|
|
1527
|
+
const REVIEW_FINDING_FIELDS = [
|
|
1528
|
+
'id', 'axis', 'severity', 'category', 'title', 'file', 'line',
|
|
1529
|
+
'trigger', 'impact', 'evidence', 'suggestion',
|
|
1530
|
+
];
|
|
1531
|
+
|
|
1532
|
+
function validateReviewFinding(finding, axis, seenIds, label) {
|
|
1533
|
+
if (!finding || typeof finding !== 'object' || Array.isArray(finding)) {
|
|
1534
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding must be an object`);
|
|
1535
|
+
}
|
|
1536
|
+
for (const field of REVIEW_FINDING_FIELDS) {
|
|
1537
|
+
if (field === 'line') {
|
|
1538
|
+
if (!Number.isInteger(finding.line) || finding.line < 1) {
|
|
1539
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding.line must be positive`);
|
|
1540
|
+
}
|
|
1541
|
+
} else if (!String(finding[field] || '').trim()) {
|
|
1542
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding.${field} is required`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
if (finding.axis !== axis || !REVIEW_AXES.has(finding.axis)) {
|
|
1546
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding axis does not match ${axis}`);
|
|
1547
|
+
}
|
|
1548
|
+
if (!REVIEW_SEVERITIES.has(finding.severity)) {
|
|
1549
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding severity is invalid`);
|
|
1550
|
+
}
|
|
1551
|
+
if (finding.line_end !== undefined
|
|
1552
|
+
&& (!Number.isInteger(finding.line_end) || finding.line_end < finding.line)) {
|
|
1553
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${label} finding.line_end is invalid`);
|
|
1554
|
+
}
|
|
1555
|
+
if (seenIds.has(finding.id)) {
|
|
1556
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `duplicate review finding id: ${finding.id}`);
|
|
1557
|
+
}
|
|
1558
|
+
seenIds.add(finding.id);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function readReviewSpecialists(run, rootDir) {
|
|
1562
|
+
const axes = [
|
|
1563
|
+
['review-specification', 'spec_fidelity'],
|
|
1564
|
+
['review-engineering', 'engineering_standards'],
|
|
1565
|
+
];
|
|
1566
|
+
const checkout = currentCheckout(rootDir);
|
|
1567
|
+
const expectedHead = checkout.head || 'workspace';
|
|
1568
|
+
const seenIds = new Set();
|
|
1569
|
+
const artifacts = [];
|
|
1570
|
+
for (const [stepId, expectedAxis] of axes) {
|
|
1571
|
+
const outputs = run.steps.find((item) => item.step_id === stepId)?.outputs || [];
|
|
1572
|
+
if (outputs.length === 0) {
|
|
1573
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${stepId} has no specialist artifact`);
|
|
1574
|
+
}
|
|
1575
|
+
for (const output of outputs) {
|
|
1576
|
+
const resolved = safeProjectFile(rootDir, output.path, `${stepId}.output`);
|
|
1577
|
+
let artifact;
|
|
1578
|
+
try { artifact = JSON.parse(fs.readFileSync(resolved.file, 'utf8')); }
|
|
1579
|
+
catch (error) {
|
|
1580
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `${output.path} is not valid JSON: ${error.message}`);
|
|
1581
|
+
}
|
|
1582
|
+
if (artifact?.$schema !== 'ultra-review-findings-v2'
|
|
1583
|
+
|| artifact.axis !== expectedAxis || artifact.status !== 'complete'
|
|
1584
|
+
|| !String(artifact.agent || '').trim() || !String(artifact.session || '').trim()
|
|
1585
|
+
|| !String(artifact.timestamp || '').trim() || Number.isNaN(Date.parse(artifact.timestamp))
|
|
1586
|
+
|| !artifact.scope || typeof artifact.scope !== 'object'
|
|
1587
|
+
|| artifact.scope.head !== expectedHead || !String(artifact.scope.range || '').trim()
|
|
1588
|
+
|| !Array.isArray(artifact.scope.files_analyzed)
|
|
1589
|
+
|| artifact.scope.files_analyzed.some((file) => !String(file || '').trim())
|
|
1590
|
+
|| typeof artifact.scope.diff_only !== 'boolean'
|
|
1591
|
+
|| !Array.isArray(artifact.findings)
|
|
1592
|
+
|| !Array.isArray(artifact.positive_observations)
|
|
1593
|
+
|| !Array.isArray(artifact.limitations)) {
|
|
1594
|
+
throw new WorkflowStateError(
|
|
1595
|
+
'WORKFLOW_REPORT_INVALID', `${output.path} is not a complete ${expectedAxis} artifact`,
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
for (const finding of artifact.findings) {
|
|
1599
|
+
validateReviewFinding(finding, expectedAxis, seenIds, output.path);
|
|
1600
|
+
}
|
|
1601
|
+
artifacts.push({ path: output.path, digest: output.digest, ...artifact });
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return { artifacts, findings: artifacts.flatMap((artifact) => artifact.findings) };
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function assertCoordinatedReviewEvidence(report, specialists) {
|
|
1608
|
+
const artifactPathsByAxis = Object.fromEntries([...REVIEW_AXES].map((axis) => [
|
|
1609
|
+
axis,
|
|
1610
|
+
specialists.artifacts.filter((artifact) => artifact.axis === axis).map((artifact) => artifact.path).sort(),
|
|
1611
|
+
]));
|
|
1612
|
+
for (const axis of REVIEW_AXES) {
|
|
1613
|
+
const refs = [...(report.axes?.[axis]?.evidence_refs || [])].sort();
|
|
1614
|
+
if (JSON.stringify(refs) !== JSON.stringify(artifactPathsByAxis[axis])) {
|
|
1615
|
+
throw new WorkflowStateError(
|
|
1616
|
+
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH',
|
|
1617
|
+
`review summary ${axis} evidence_refs do not match specialist outputs`,
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
const sessions = new Set(specialists.artifacts.map((artifact) => artifact.session));
|
|
1622
|
+
if (sessions.size !== 1 || !sessions.has(report.session)) {
|
|
1623
|
+
throw new WorkflowStateError(
|
|
1624
|
+
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH', 'review summary session does not match specialist artifacts',
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
const completed = new Set(report.workers?.completed || []);
|
|
1628
|
+
if (specialists.artifacts.some((artifact) => !completed.has(artifact.agent))
|
|
1629
|
+
|| (report.workers?.failed || []).length > 0) {
|
|
1630
|
+
throw new WorkflowStateError(
|
|
1631
|
+
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH', 'review worker completion does not match specialist artifacts',
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
if ((report.workers?.skipped || []).length > 0 && (report.limitations || []).length === 0) {
|
|
1635
|
+
throw new WorkflowStateError(
|
|
1636
|
+
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH', 'skipped review workers require a recorded limitation',
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
const selection = Array.isArray(report.worker_selection) ? report.worker_selection : [];
|
|
1640
|
+
const seenWorkers = new Set();
|
|
1641
|
+
const selected = new Set();
|
|
1642
|
+
const skipped = new Set();
|
|
1643
|
+
for (const item of selection) {
|
|
1644
|
+
const worker = String(item?.worker || '').trim();
|
|
1645
|
+
const status = item?.status;
|
|
1646
|
+
if (!worker || !['selected', 'skipped'].includes(status)
|
|
1647
|
+
|| !String(item?.rationale || '').trim() || seenWorkers.has(worker)) {
|
|
1648
|
+
throw new WorkflowStateError(
|
|
1649
|
+
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH', 'review worker selection provenance is invalid',
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
seenWorkers.add(worker);
|
|
1653
|
+
(status === 'selected' ? selected : skipped).add(worker);
|
|
1654
|
+
}
|
|
1655
|
+
const expectedSelected = new Set([
|
|
1656
|
+
...(report.workers?.completed || []), ...(report.workers?.failed || []),
|
|
1657
|
+
]);
|
|
1658
|
+
const expectedSkipped = new Set(report.workers?.skipped || []);
|
|
1659
|
+
const sameSet = (left, right) => left.size === right.size && [...left].every((item) => right.has(item));
|
|
1660
|
+
if (selection.length === 0 || !sameSet(selected, expectedSelected)
|
|
1661
|
+
|| !sameSet(skipped, expectedSkipped)
|
|
1662
|
+
|| !selected.has('review-spec') || !completed.has('review-spec')) {
|
|
1663
|
+
throw new WorkflowStateError(
|
|
1664
|
+
'WORKFLOW_REVIEW_EVIDENCE_MISMATCH',
|
|
1665
|
+
'review worker selection must match completed, failed, and skipped worker state',
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const specialistById = new Map(specialists.findings.map((finding) => [finding.id, finding]));
|
|
1670
|
+
const summaryById = new Map((report.findings || []).map((finding) => [finding.id, finding]));
|
|
1671
|
+
if (specialistById.size !== summaryById.size
|
|
1672
|
+
|| [...specialistById.keys()].some((id) => !summaryById.has(id))) {
|
|
1673
|
+
throw new WorkflowStateError(
|
|
1674
|
+
'WORKFLOW_REVIEW_FINDINGS_MISMATCH', 'review summary must preserve every specialist finding',
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
const identityFields = [...REVIEW_FINDING_FIELDS, 'line_end'];
|
|
1678
|
+
for (const [id, source] of specialistById) {
|
|
1679
|
+
const coordinated = summaryById.get(id);
|
|
1680
|
+
for (const field of identityFields) {
|
|
1681
|
+
if ((coordinated?.[field] ?? null) !== (source[field] ?? null)) {
|
|
1682
|
+
throw new WorkflowStateError(
|
|
1683
|
+
'WORKFLOW_REVIEW_FINDINGS_MISMATCH',
|
|
1684
|
+
`review summary changed ${id}.${field} from its specialist artifact`,
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
const axes = Object.fromEntries([...REVIEW_AXES].map((axis) => {
|
|
1691
|
+
const hasBlocking = specialists.findings.some(
|
|
1692
|
+
(finding) => finding.axis === axis && ['P0', 'P1'].includes(finding.severity),
|
|
1693
|
+
);
|
|
1694
|
+
return [axis, {
|
|
1695
|
+
verdict: hasBlocking ? 'FAIL' : 'PASS',
|
|
1696
|
+
evidence_refs: artifactPathsByAxis[axis],
|
|
1697
|
+
}];
|
|
1698
|
+
}));
|
|
1699
|
+
return axes;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function validateReviewCompletion(db, run, rootDir) {
|
|
1703
|
+
const context = recordedContext(db, run, rootDir, { current: true });
|
|
1704
|
+
const specialists = readReviewSpecialists(run, rootDir);
|
|
1705
|
+
const { value: report, output } = readOutputJson(
|
|
1706
|
+
run, 'coordinate-findings', rootDir, 'ultra-review-summary-v2',
|
|
1707
|
+
);
|
|
1708
|
+
if (report.change_id !== run.change_id) {
|
|
1709
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_CHANGE_MISMATCH', 'review summary change_id is not bound to the workflow');
|
|
1710
|
+
}
|
|
1711
|
+
if (!['task', 'change', 'plan'].includes(report.mode)
|
|
1712
|
+
|| (run.task_id && report.mode !== 'task')
|
|
1713
|
+
|| (!run.task_id && report.mode === 'task')) {
|
|
1714
|
+
throw new WorkflowStateError(
|
|
1715
|
+
'WORKFLOW_REVIEW_MODE_MISMATCH', 'review summary mode does not match its workflow scope',
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
assertBoundTaskSet(db, run, report);
|
|
1719
|
+
const checkout = assertCurrentCheckout(report, rootDir);
|
|
1720
|
+
if (report.status !== 'complete' || !report.axes || typeof report.axes !== 'object') {
|
|
1721
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'review summary must be complete and contain both axes');
|
|
1722
|
+
}
|
|
1723
|
+
const spec = report.axes.spec_fidelity;
|
|
1724
|
+
const engineering = report.axes.engineering_standards;
|
|
1725
|
+
for (const [name, axis] of [['spec_fidelity', spec], ['engineering_standards', engineering]]) {
|
|
1726
|
+
if (!axis || !['PASS', 'FAIL', 'INCOMPLETE'].includes(axis.verdict)
|
|
1727
|
+
|| !Array.isArray(axis.evidence_refs) || axis.evidence_refs.length === 0) {
|
|
1728
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', `review summary ${name} axis is incomplete`);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
if (!Array.isArray(report.findings)) {
|
|
1732
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'review summary findings must be an array');
|
|
1733
|
+
}
|
|
1734
|
+
if (!report.workers || typeof report.workers !== 'object'
|
|
1735
|
+
|| ['completed', 'failed', 'skipped'].some((field) => !Array.isArray(report.workers[field]))
|
|
1736
|
+
|| !Array.isArray(report.positive_observations) || !Array.isArray(report.limitations)) {
|
|
1737
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'review summary worker and observation fields are incomplete');
|
|
1738
|
+
}
|
|
1739
|
+
const summaryFindingIds = new Set();
|
|
1740
|
+
for (const finding of report.findings) {
|
|
1741
|
+
validateReviewFinding(finding, finding?.axis, summaryFindingIds, 'review summary');
|
|
1742
|
+
}
|
|
1743
|
+
if (report.context_digest !== context.output.digest) {
|
|
1744
|
+
throw new WorkflowStateError(
|
|
1745
|
+
'WORKFLOW_REPORT_CONTEXT_MISMATCH', 'review summary context_digest does not match the review context',
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
const derivedAxes = assertCoordinatedReviewEvidence(report, specialists);
|
|
1749
|
+
for (const axis of REVIEW_AXES) {
|
|
1750
|
+
if (report.axes[axis].verdict !== derivedAxes[axis].verdict) {
|
|
1751
|
+
throw new WorkflowStateError(
|
|
1752
|
+
'WORKFLOW_REPORT_VERDICT_CONFLICT',
|
|
1753
|
+
`review ${axis} verdict conflicts with specialist findings; expected ${derivedAxes[axis].verdict}`,
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
const expectedVerdict = Object.values(derivedAxes).some((axis) => axis.verdict === 'FAIL')
|
|
1758
|
+
? 'REQUEST_CHANGES' : 'APPROVE';
|
|
1759
|
+
if (report.verdict !== expectedVerdict) {
|
|
1760
|
+
throw new WorkflowStateError(
|
|
1761
|
+
'WORKFLOW_REPORT_VERDICT_CONFLICT',
|
|
1762
|
+
`review verdict ${report.verdict} conflicts with evidence; expected ${expectedVerdict}`,
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
return {
|
|
1766
|
+
change_id: run.change_id,
|
|
1767
|
+
task_ids: report.task_ids,
|
|
1768
|
+
mode: report.mode,
|
|
1769
|
+
worker_selection: report.worker_selection,
|
|
1770
|
+
verdict: report.verdict,
|
|
1771
|
+
axes: derivedAxes,
|
|
1772
|
+
findings: specialists.findings,
|
|
1773
|
+
git_commit: checkout.head,
|
|
1774
|
+
worktree_digest: checkout.digest,
|
|
1775
|
+
report_path: output.path,
|
|
1776
|
+
report_digest: output.digest,
|
|
1777
|
+
context_path: context.output.path,
|
|
1778
|
+
context_digest: context.output.digest,
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function validateDeliveryCompletion(db, run, rootDir) {
|
|
1783
|
+
const gates = validateDeliveryPrerequisites(db, run, rootDir);
|
|
1784
|
+
const context = recordedContext(db, run, rootDir, { current: true });
|
|
1785
|
+
const change = db.prepare('SELECT status FROM changes WHERE id = ?').get(run.change_id);
|
|
1786
|
+
if (change?.status !== 'archived') {
|
|
1787
|
+
throw new WorkflowStateError(
|
|
1788
|
+
'WORKFLOW_CHANGE_NOT_ARCHIVED', `change ${run.change_id} must be archived before delivery completion`,
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
const { value: report, output } = readOutputJson(
|
|
1792
|
+
run, 'verify-delivery', rootDir, 'ultra-delivery-report-v1',
|
|
1793
|
+
);
|
|
1794
|
+
assertReportSchema(report, 'delivery');
|
|
1795
|
+
if (report.change_id !== run.change_id || report.archive_status !== 'archived') {
|
|
1796
|
+
throw new WorkflowStateError(
|
|
1797
|
+
'WORKFLOW_REPORT_CHANGE_MISMATCH', 'delivery report is not bound to the archived change',
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
const baseline = run.baseline_id
|
|
1801
|
+
? db.prepare('SELECT id, status FROM baselines WHERE id = ?').get(run.baseline_id)
|
|
1802
|
+
: db.prepare("SELECT id, status FROM baselines WHERE status != 'superseded' ORDER BY updated_at DESC LIMIT 1").get();
|
|
1803
|
+
if (!baseline || report.baseline_id !== baseline.id || report.baseline_status !== baseline.status) {
|
|
1804
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_BASELINE_MISMATCH', 'delivery report baseline state is stale');
|
|
1805
|
+
}
|
|
1806
|
+
const checkout = assertCurrentCheckout(report, rootDir);
|
|
1807
|
+
assertIsoTimestamp(report.timestamp, 'delivery report timestamp');
|
|
1808
|
+
if (!Array.isArray(report.checks) || report.checks.length === 0
|
|
1809
|
+
|| report.checks.some((item) => item?.status !== 'pass')) {
|
|
1810
|
+
throw new WorkflowStateError('WORKFLOW_DELIVERY_CHECK_FAILED', 'delivery checks must all pass');
|
|
1811
|
+
}
|
|
1812
|
+
if (!report.release || typeof report.release !== 'object'
|
|
1813
|
+
|| typeof report.release.authorized !== 'boolean'
|
|
1814
|
+
|| typeof report.release.performed !== 'boolean') {
|
|
1815
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'delivery report release decision is incomplete');
|
|
1816
|
+
}
|
|
1817
|
+
if (report.release.authorized !== report.release.performed) {
|
|
1818
|
+
throw new WorkflowStateError(
|
|
1819
|
+
'WORKFLOW_RELEASE_INCOMPLETE', 'an authorized release must complete; an unauthorized release must not run',
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
const releaseStep = run.steps.find((item) => item.step_id === 'release-if-authorized');
|
|
1823
|
+
const releaseDecision = validateReleaseAuthorizationDecision(releaseStep?.decisions || []);
|
|
1824
|
+
if (report.release.authorized !== releaseDecision.authorized) {
|
|
1825
|
+
throw new WorkflowStateError(
|
|
1826
|
+
'WORKFLOW_RELEASE_AUTHORITY_MISMATCH',
|
|
1827
|
+
'delivery report release claim does not match the recorded owner authorization decision',
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
if (!String(report.rollback || '').trim()) {
|
|
1831
|
+
throw new WorkflowStateError('WORKFLOW_REPORT_INVALID', 'delivery report rollback guidance is required');
|
|
1832
|
+
}
|
|
1833
|
+
if (report.context_digest !== context.output.digest) {
|
|
1834
|
+
throw new WorkflowStateError(
|
|
1835
|
+
'WORKFLOW_REPORT_CONTEXT_MISMATCH', 'delivery report context_digest does not match the convergence context',
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
return {
|
|
1839
|
+
change_id: run.change_id,
|
|
1840
|
+
archive_status: report.archive_status,
|
|
1841
|
+
baseline_id: baseline.id,
|
|
1842
|
+
baseline_status: baseline.status,
|
|
1843
|
+
git_commit: checkout.head,
|
|
1844
|
+
worktree_digest: checkout.digest,
|
|
1845
|
+
release: report.release,
|
|
1846
|
+
report_path: output.path,
|
|
1847
|
+
report_digest: output.digest,
|
|
1848
|
+
context_path: context.output.path,
|
|
1849
|
+
context_digest: context.output.digest,
|
|
1850
|
+
...gates,
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function validateStageCompletion(db, run, rootDir) {
|
|
1855
|
+
if (run.kind === 'plan') return validatePlanArtifact(db, run, rootDir);
|
|
1856
|
+
if (run.kind === 'change') return validateChangeCompletion(db, run, rootDir);
|
|
1857
|
+
if (run.kind === 'dev') return validateDevCompletion(db, run, rootDir);
|
|
1858
|
+
if (run.kind === 'test') return validateTestCompletion(db, run, rootDir);
|
|
1859
|
+
if (run.kind === 'review') return validateReviewCompletion(db, run, rootDir);
|
|
1860
|
+
if (run.kind === 'deliver') return validateDeliveryCompletion(db, run, rootDir);
|
|
1861
|
+
return {};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function validatePlanArtifact(db, run, rootDir) {
|
|
1865
|
+
const workflowStep = run.steps.find((item) => item.step_id === 'verify-plan');
|
|
1866
|
+
const outputs = workflowStep?.outputs || [];
|
|
1867
|
+
if (outputs.length !== 1) {
|
|
1868
|
+
throw new WorkflowStateError(
|
|
1869
|
+
'WORKFLOW_PLAN_ARTIFACT_INVALID',
|
|
1870
|
+
'verify-plan requires exactly one JSON execution-plan output',
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
const output = outputs[0];
|
|
1874
|
+
const resolved = safeProjectFile(rootDir, output.path, 'verify-plan.output');
|
|
1875
|
+
let actual;
|
|
1876
|
+
try { actual = JSON.parse(fs.readFileSync(resolved.file, 'utf8')); }
|
|
1877
|
+
catch (error) {
|
|
1878
|
+
throw new WorkflowStateError(
|
|
1879
|
+
'WORKFLOW_PLAN_ARTIFACT_INVALID',
|
|
1880
|
+
`${output.path} is not valid JSON: ${error.message}`,
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
const tasks = changeTasks(db, run.change_id);
|
|
1884
|
+
const planStore = require('./plan-store.cjs');
|
|
1885
|
+
const expected = planStore.buildPlan(tasks, { changeId: run.change_id });
|
|
1886
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
1887
|
+
throw new WorkflowStateError(
|
|
1888
|
+
'WORKFLOW_PLAN_ARTIFACT_STALE',
|
|
1889
|
+
'execution-plan artifact does not match the current change task graph and contracts',
|
|
1890
|
+
{
|
|
1891
|
+
change_id: run.change_id,
|
|
1892
|
+
expected_task_ids: tasks.map((task) => task.id),
|
|
1893
|
+
actual_change_id: actual?.change_id || null,
|
|
1894
|
+
},
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
return {
|
|
1898
|
+
plan_path: output.path,
|
|
1899
|
+
plan_digest: output.digest,
|
|
1900
|
+
plan_schema_version: actual.schema_version,
|
|
1901
|
+
plan_wave_count: actual.waves.length,
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function advanceLinkedChangeAfterPlan(db, run, rootDir) {
|
|
1906
|
+
if (run.kind !== 'plan' || !run.change_id) return;
|
|
1907
|
+
const linked = db.prepare(
|
|
1908
|
+
`SELECT id FROM workflow_runs
|
|
1909
|
+
WHERE kind = 'change' AND change_id = ? AND current_step = 'plan-change'
|
|
1910
|
+
AND status IN ('active', 'blocked')
|
|
1911
|
+
ORDER BY updated_at DESC, rowid DESC LIMIT 1`,
|
|
1912
|
+
).get(run.change_id);
|
|
1913
|
+
if (!linked) return;
|
|
1914
|
+
recordWorkflowStep(db, {
|
|
1915
|
+
id: linked.id, step_id: 'plan-change', status: 'completed',
|
|
1916
|
+
evidence: [{
|
|
1917
|
+
kind: 'workflow', ref: run.id,
|
|
1918
|
+
summary: `Plan workflow ${run.id} completed with authoritative task contracts.`,
|
|
1919
|
+
}],
|
|
1920
|
+
}, { rootDir });
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function completeWorkflow(db, input = {}, { rootDir = process.cwd() } = {}) {
|
|
1924
|
+
const id = nonEmpty(input.id, 'id');
|
|
1925
|
+
return ops.tx(db, () => {
|
|
1926
|
+
const run = readWorkflow(db, id, { rootDir });
|
|
1927
|
+
if (!run) throw new WorkflowStateError('WORKFLOW_NOT_FOUND', `workflow ${id} not found`);
|
|
1928
|
+
if (run.status === 'completed') return run;
|
|
1929
|
+
if (run.status !== 'ready') {
|
|
1930
|
+
throw new WorkflowStateError('WORKFLOW_NOT_READY', `workflow ${id} is ${run.status}`);
|
|
1931
|
+
}
|
|
1932
|
+
if (run.artifact_health.status !== 'pass') {
|
|
1933
|
+
throw new WorkflowStateError('WORKFLOW_OUTPUT_STALE', 'workflow outputs are missing or stale', run.artifact_health);
|
|
1934
|
+
}
|
|
1935
|
+
const approval = input.approval === undefined ? null : input.approval;
|
|
1936
|
+
if (approval !== null && (!approval || typeof approval !== 'object' || Array.isArray(approval))) {
|
|
1937
|
+
throw new WorkflowStateError('VALIDATION_ERROR', 'approval must be an object');
|
|
1938
|
+
}
|
|
1939
|
+
const suppliedSummary = input.summary === undefined ? {} : input.summary;
|
|
1940
|
+
if (!suppliedSummary || typeof suppliedSummary !== 'object' || Array.isArray(suppliedSummary)) {
|
|
1941
|
+
throw new WorkflowStateError('VALIDATION_ERROR', 'summary must be an object');
|
|
1942
|
+
}
|
|
1943
|
+
const unauthorizedSummaryFields = Object.keys(suppliedSummary);
|
|
1944
|
+
if (unauthorizedSummaryFields.length > 0) {
|
|
1945
|
+
throw new WorkflowStateError(
|
|
1946
|
+
'WORKFLOW_SUMMARY_AUTHORITY_VIOLATION',
|
|
1947
|
+
`workflow ${run.kind} summary is derived from DB-backed evidence; unsupported fields: ${unauthorizedSummaryFields.join(', ')}`,
|
|
1948
|
+
);
|
|
1949
|
+
}
|
|
1950
|
+
if (run.kind !== 'plan' && approval !== null) {
|
|
1951
|
+
throw new WorkflowStateError(
|
|
1952
|
+
'WORKFLOW_APPROVAL_AUTHORITY_VIOLATION',
|
|
1953
|
+
`workflow ${run.kind} does not accept completion approval`,
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
let summary = {};
|
|
1957
|
+
if (run.kind === 'plan') {
|
|
1958
|
+
const contract = validatePlanContract(db, run, rootDir);
|
|
1959
|
+
summary = {
|
|
1960
|
+
task_ids: contract.task_ids,
|
|
1961
|
+
task_contract_digests: contract.task_contract_digests,
|
|
1962
|
+
profile: contract.profile,
|
|
1963
|
+
coverage: contract.coverage,
|
|
1964
|
+
};
|
|
1965
|
+
validatePlanApproval(approval);
|
|
1966
|
+
}
|
|
1967
|
+
if (run.kind === 'dev' && ops.readTask(db, run.task_id)?.status !== 'completed') {
|
|
1968
|
+
throw new WorkflowStateError(
|
|
1969
|
+
'WORKFLOW_TASK_NOT_COMPLETED', `task ${run.task_id} must be completed before dev workflow convergence`,
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
summary = { ...summary, ...validateStageCompletion(db, run, rootDir) };
|
|
1973
|
+
const ts = nowIso();
|
|
1974
|
+
db.prepare(
|
|
1975
|
+
`UPDATE workflow_runs SET status = 'completed', approval_json = ?, summary_json = ?,
|
|
1976
|
+
blockers_json = '[]', current_step = NULL, completed_at = ?, updated_at = ? WHERE id = ?`,
|
|
1977
|
+
).run(approval === null ? null : JSON.stringify(approval), JSON.stringify(summary), ts, ts, id);
|
|
1978
|
+
ops.appendEventInTx(db, {
|
|
1979
|
+
type: 'workflow_completed', task_id: run.task_id, change_id: run.change_id,
|
|
1980
|
+
payload: { workflow_id: id, kind: run.kind, baseline_id: run.baseline_id },
|
|
1981
|
+
});
|
|
1982
|
+
if (run.kind === 'plan') {
|
|
1983
|
+
ops.appendEventInTx(db, {
|
|
1984
|
+
type: 'plan_approved', change_id: run.change_id,
|
|
1985
|
+
payload: {
|
|
1986
|
+
workflow_id: id,
|
|
1987
|
+
approved_by: approval.approved_by.trim(),
|
|
1988
|
+
approved_at: approval.approved_at || ts,
|
|
1989
|
+
plan_path: summary.plan_path,
|
|
1990
|
+
plan_digest: summary.plan_digest,
|
|
1991
|
+
task_ids: summary.task_ids,
|
|
1992
|
+
},
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
advanceLinkedChangeAfterPlan(db, run, rootDir);
|
|
1996
|
+
return readWorkflow(db, id, { rootDir });
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function validatePlanApproval(approval) {
|
|
2001
|
+
const approvedBy = typeof approval?.approved_by === 'string' ? approval.approved_by.trim() : '';
|
|
2002
|
+
const note = typeof approval?.approval_note === 'string' ? approval.approval_note.trim() : '';
|
|
2003
|
+
if (!approvedBy || note.length < 3) {
|
|
2004
|
+
throw new WorkflowStateError(
|
|
2005
|
+
'WORKFLOW_APPROVAL_REQUIRED',
|
|
2006
|
+
'plan completion requires approved_by and an approval_note for the accepted scope',
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
if (approval.approved_at !== undefined
|
|
2010
|
+
&& (typeof approval.approved_at !== 'string' || Number.isNaN(Date.parse(approval.approved_at)))) {
|
|
2011
|
+
throw new WorkflowStateError('VALIDATION_ERROR', 'approval.approved_at must be an ISO-8601 timestamp');
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
function completedResearchSemantics(db, changeId) {
|
|
2016
|
+
return db.prepare(
|
|
2017
|
+
`SELECT ws.semantic_records_json
|
|
2018
|
+
FROM workflow_steps ws JOIN workflow_runs wr ON wr.id = ws.run_id
|
|
2019
|
+
WHERE wr.kind = 'research' AND wr.change_id = ? AND wr.status = 'completed'
|
|
2020
|
+
AND ws.required = 1 AND ws.status = 'completed'`,
|
|
2021
|
+
).all(changeId).flatMap((row) => (
|
|
2022
|
+
parseJson(row.semantic_records_json, 'workflow_steps.semantic_records_json', [])
|
|
2023
|
+
));
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function validateTaskTrace(task, acceptedSources, rootDir) {
|
|
2027
|
+
const trace = String(task.trace_to || '').trim();
|
|
2028
|
+
if (acceptedSources.has(trace)) return;
|
|
2029
|
+
try {
|
|
2030
|
+
sourceRefFile(rootDir, trace, `task ${task.id}.trace_to`);
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
throw new WorkflowStateError(
|
|
2033
|
+
'WORKFLOW_PLAN_ORPHAN_TASK',
|
|
2034
|
+
`task ${task.id} trace_to does not resolve to an accepted requirement or source anchor: ${trace}`,
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
function validatePlanContract(db, run, rootDir = process.cwd()) {
|
|
2040
|
+
const taskIds = changeTasks(db, run.change_id).map((task) => task.id);
|
|
2041
|
+
if (taskIds.length === 0) {
|
|
2042
|
+
throw new WorkflowStateError('WORKFLOW_TASKS_REQUIRED', 'plan completion requires task_ids');
|
|
2043
|
+
}
|
|
2044
|
+
const tasks = taskIds.map((id) => {
|
|
2045
|
+
const task = ops.readTask(db, id);
|
|
2046
|
+
if (!task) throw new WorkflowStateError('TASK_NOT_FOUND', `planned task ${id} does not exist`);
|
|
2047
|
+
if (run.change_id && task.change_id !== run.change_id) {
|
|
2048
|
+
throw new WorkflowStateError(
|
|
2049
|
+
'TASK_CHANGE_OWNERSHIP_MISMATCH',
|
|
2050
|
+
`planned task ${id} is not owned by change ${run.change_id}`,
|
|
2051
|
+
);
|
|
2052
|
+
}
|
|
2053
|
+
ops.assertTaskExecutionContract(task);
|
|
2054
|
+
return task;
|
|
2055
|
+
});
|
|
2056
|
+
const changeRow = db.prepare(
|
|
2057
|
+
'SELECT kind, contract_json FROM changes WHERE id = ?',
|
|
2058
|
+
).get(run.change_id);
|
|
2059
|
+
const profile = changeRow?.kind || 'standard';
|
|
2060
|
+
if (profile === 'quick' && tasks.length !== 1) {
|
|
2061
|
+
throw new WorkflowStateError(
|
|
2062
|
+
'WORKFLOW_QUICK_PLAN_TOO_LARGE', 'quick changes require exactly one executable task',
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
const changeContract = parseJson(changeRow?.contract_json || '{}', 'changes.contract_json', {});
|
|
2066
|
+
const requiredAcceptance = (changeContract.acceptance || []).map((item) => item.id);
|
|
2067
|
+
const coveredAcceptance = new Set(tasks.flatMap((task) => (
|
|
2068
|
+
(task.acceptance || []).map((item) => item.id)
|
|
2069
|
+
)));
|
|
2070
|
+
const missingAcceptance = requiredAcceptance.filter((id) => !coveredAcceptance.has(id));
|
|
2071
|
+
if (missingAcceptance.length > 0) {
|
|
2072
|
+
throw new WorkflowStateError(
|
|
2073
|
+
'WORKFLOW_PLAN_COVERAGE_INCOMPLETE',
|
|
2074
|
+
`change acceptance has no executable task coverage: ${missingAcceptance.join(', ')}`,
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
const semantics = completedResearchSemantics(db, run.change_id);
|
|
2078
|
+
const acceptedSources = new Set([
|
|
2079
|
+
...requiredAcceptance,
|
|
2080
|
+
...semantics.map((record) => record.id),
|
|
2081
|
+
...semantics.map((record) => record.source_ref),
|
|
2082
|
+
]);
|
|
2083
|
+
for (const task of tasks) validateTaskTrace(task, acceptedSources, rootDir);
|
|
2084
|
+
const planned = new Set(taskIds);
|
|
2085
|
+
for (const task of tasks) {
|
|
2086
|
+
for (const dep of task.deps || []) {
|
|
2087
|
+
if (!ops.readTask(db, dep)) {
|
|
2088
|
+
throw new WorkflowStateError('TASK_DEPENDENCY_MISSING', `task ${task.id} depends on missing task ${dep}`);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
const visiting = new Set();
|
|
2093
|
+
const visited = new Set();
|
|
2094
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
2095
|
+
function visit(id) {
|
|
2096
|
+
if (visiting.has(id)) throw new WorkflowStateError('TASK_DEPENDENCY_CYCLE', `planned task dependency cycle includes ${id}`);
|
|
2097
|
+
if (visited.has(id)) return;
|
|
2098
|
+
visiting.add(id);
|
|
2099
|
+
for (const dep of byId.get(id)?.deps || []) if (planned.has(dep)) visit(dep);
|
|
2100
|
+
visiting.delete(id);
|
|
2101
|
+
visited.add(id);
|
|
2102
|
+
}
|
|
2103
|
+
for (const id of taskIds) visit(id);
|
|
2104
|
+
return {
|
|
2105
|
+
task_ids: taskIds,
|
|
2106
|
+
profile,
|
|
2107
|
+
coverage: {
|
|
2108
|
+
required_acceptance_ids: requiredAcceptance,
|
|
2109
|
+
covered_acceptance_ids: requiredAcceptance.filter((id) => coveredAcceptance.has(id)),
|
|
2110
|
+
orphan_task_ids: [],
|
|
2111
|
+
},
|
|
2112
|
+
task_contract_digests: Object.fromEntries(
|
|
2113
|
+
tasks.map((task) => [task.id, taskPlanDigest(task)]),
|
|
2114
|
+
),
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function activeInitializationRun(db, baselineId) {
|
|
2119
|
+
return db.prepare(
|
|
2120
|
+
`SELECT * FROM workflow_runs
|
|
2121
|
+
WHERE kind = 'init' AND baseline_id = ? AND status IN ('active', 'blocked', 'ready')
|
|
2122
|
+
ORDER BY updated_at DESC, rowid DESC LIMIT 1`,
|
|
2123
|
+
).get(baselineId);
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function blockInitializationInTx(db, baselineId, blockers = []) {
|
|
2127
|
+
const run = activeInitializationRun(db, baselineId);
|
|
2128
|
+
if (!run) return null;
|
|
2129
|
+
const normalized = [...new Set(blockers.map((item) => String(item).trim()).filter(Boolean))];
|
|
2130
|
+
const ts = nowIso();
|
|
2131
|
+
db.prepare(
|
|
2132
|
+
`UPDATE workflow_steps SET status = 'blocked', blockers_json = ?,
|
|
2133
|
+
started_at = COALESCE(started_at, ?), completed_at = NULL, updated_at = ?
|
|
2134
|
+
WHERE run_id = ? AND step_id = 'establish-baseline'`,
|
|
2135
|
+
).run(JSON.stringify(normalized), ts, ts, run.id);
|
|
2136
|
+
db.prepare(
|
|
2137
|
+
`UPDATE workflow_runs SET status = 'blocked', current_step = 'establish-baseline',
|
|
2138
|
+
blockers_json = ?, updated_at = ? WHERE id = ?`,
|
|
2139
|
+
).run(JSON.stringify(normalized), ts, run.id);
|
|
2140
|
+
ops.appendEventInTx(db, {
|
|
2141
|
+
type: 'workflow_blocked',
|
|
2142
|
+
payload: {
|
|
2143
|
+
workflow_id: run.id, kind: 'init', step_id: 'establish-baseline',
|
|
2144
|
+
status: 'blocked', blockers: normalized,
|
|
2145
|
+
},
|
|
2146
|
+
});
|
|
2147
|
+
return run.id;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function completeInitializationInTx(db, baseline) {
|
|
2151
|
+
const run = activeInitializationRun(db, baseline.id);
|
|
2152
|
+
if (!run) return null;
|
|
2153
|
+
const ts = nowIso();
|
|
2154
|
+
const steps = [
|
|
2155
|
+
{
|
|
2156
|
+
id: 'establish-baseline',
|
|
2157
|
+
evidence: [{
|
|
2158
|
+
kind: 'baseline', ref: baseline.id,
|
|
2159
|
+
summary: `Baseline ${baseline.id} converged at revision ${baseline.repository_revision}.`,
|
|
2160
|
+
}],
|
|
2161
|
+
},
|
|
2162
|
+
{
|
|
2163
|
+
id: 'verify-initialization',
|
|
2164
|
+
evidence: [{
|
|
2165
|
+
kind: 'state', ref: '.ultra/state.db',
|
|
2166
|
+
summary: 'Baseline authority, workflow state, and the post-initialization route are ready.',
|
|
2167
|
+
}],
|
|
2168
|
+
},
|
|
2169
|
+
];
|
|
2170
|
+
for (const item of steps) {
|
|
2171
|
+
db.prepare(
|
|
2172
|
+
`UPDATE workflow_steps SET status = 'completed', evidence_json = ?, blockers_json = '[]',
|
|
2173
|
+
started_at = COALESCE(started_at, ?), completed_at = ?, updated_at = ?
|
|
2174
|
+
WHERE run_id = ? AND step_id = ?`,
|
|
2175
|
+
).run(JSON.stringify(item.evidence), ts, ts, ts, run.id, item.id);
|
|
2176
|
+
ops.appendEventInTx(db, {
|
|
2177
|
+
type: 'workflow_step_updated',
|
|
2178
|
+
payload: { workflow_id: run.id, kind: 'init', step_id: item.id, status: 'completed' },
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
db.prepare(
|
|
2182
|
+
`UPDATE workflow_runs SET status = 'completed', current_step = NULL, blockers_json = '[]',
|
|
2183
|
+
approval_json = ?, summary_json = ?, completed_at = ?, updated_at = ? WHERE id = ?`,
|
|
2184
|
+
).run(
|
|
2185
|
+
JSON.stringify({ approved_by: baseline.approved_by, approved_at: baseline.converged_at }),
|
|
2186
|
+
JSON.stringify({
|
|
2187
|
+
baseline_id: baseline.id,
|
|
2188
|
+
repository_revision: baseline.repository_revision,
|
|
2189
|
+
research_run_id: baseline.research_run_id,
|
|
2190
|
+
}),
|
|
2191
|
+
ts, ts, run.id,
|
|
2192
|
+
);
|
|
2193
|
+
ops.appendEventInTx(db, {
|
|
2194
|
+
type: 'workflow_completed',
|
|
2195
|
+
payload: { workflow_id: run.id, kind: 'init', baseline_id: baseline.id },
|
|
2196
|
+
});
|
|
2197
|
+
return run.id;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function inspectWorkflowHealth(db, { rootDir = process.cwd() } = {}) {
|
|
2201
|
+
const all = listWorkflows(db, { limit: 500 }, { rootDir });
|
|
2202
|
+
const active = all
|
|
2203
|
+
.filter((run) => ['active', 'blocked', 'ready'].includes(run.status));
|
|
2204
|
+
const stale = active.filter((run) => run.artifact_health.status !== 'pass');
|
|
2205
|
+
const historicalStale = all.filter(
|
|
2206
|
+
(run) => run.status === 'completed' && run.artifact_health.status !== 'pass',
|
|
2207
|
+
);
|
|
2208
|
+
const terminalAuthority = db.prepare(
|
|
2209
|
+
`SELECT wr.id, wr.kind, wr.status, wr.change_id, c.status AS change_status
|
|
2210
|
+
FROM workflow_runs wr JOIN changes c ON c.id = wr.change_id
|
|
2211
|
+
WHERE wr.status IN ('active', 'blocked', 'ready')
|
|
2212
|
+
AND c.status IN ('archived', 'cancelled')
|
|
2213
|
+
ORDER BY wr.updated_at DESC`,
|
|
2214
|
+
).all();
|
|
2215
|
+
const untrackedChanges = db.prepare(
|
|
2216
|
+
`SELECT c.id FROM changes c
|
|
2217
|
+
WHERE c.status IN ('active', 'blocked', 'ready')
|
|
2218
|
+
AND NOT EXISTS (SELECT 1 FROM workflow_runs wr WHERE wr.kind = 'change' AND wr.change_id = c.id)
|
|
2219
|
+
ORDER BY c.updated_at DESC`,
|
|
2220
|
+
).all().map((row) => row.id);
|
|
2221
|
+
const structuralAuthorityFailure = terminalAuthority.length > 0 || untrackedChanges.length > 0;
|
|
2222
|
+
return {
|
|
2223
|
+
status: stale.length === 0 && !structuralAuthorityFailure ? 'pass' : 'fail',
|
|
2224
|
+
active: active.length,
|
|
2225
|
+
blocked: active.filter((run) => run.status === 'blocked').length,
|
|
2226
|
+
ready: active.filter((run) => run.status === 'ready').length,
|
|
2227
|
+
stale_outputs: stale.map((run) => ({ id: run.id, blockers: run.artifact_health.blockers })),
|
|
2228
|
+
historical_stale_outputs: historicalStale.map(
|
|
2229
|
+
(run) => ({ id: run.id, blockers: run.artifact_health.blockers }),
|
|
2230
|
+
),
|
|
2231
|
+
terminal_authority_runs: terminalAuthority,
|
|
2232
|
+
untracked_active_changes: untrackedChanges,
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
function recoverUntrackedChangeWorkflows(db, { rootDir = process.cwd() } = {}) {
|
|
2237
|
+
const changes = db.prepare(
|
|
2238
|
+
`SELECT id, title FROM changes c
|
|
2239
|
+
WHERE c.status IN ('active', 'blocked', 'ready')
|
|
2240
|
+
AND NOT EXISTS (
|
|
2241
|
+
SELECT 1 FROM workflow_runs wr WHERE wr.kind = 'change' AND wr.change_id = c.id
|
|
2242
|
+
)
|
|
2243
|
+
ORDER BY c.updated_at ASC, c.id ASC`,
|
|
2244
|
+
).all();
|
|
2245
|
+
const baseline = db.prepare(
|
|
2246
|
+
"SELECT id FROM baselines WHERE status != 'superseded' ORDER BY updated_at DESC, rowid DESC LIMIT 1",
|
|
2247
|
+
).get();
|
|
2248
|
+
const result = { found: changes.length, created: 0, failed: 0, items: [] };
|
|
2249
|
+
for (const change of changes) {
|
|
2250
|
+
let run = null;
|
|
2251
|
+
try {
|
|
2252
|
+
run = startWorkflow(db, {
|
|
2253
|
+
kind: 'change', baseline_id: baseline?.id || null, change_id: change.id,
|
|
2254
|
+
subject: `Recover durable workflow provenance for ${change.title}.`,
|
|
2255
|
+
metadata: { recovery: 'legacy_untracked_change' },
|
|
2256
|
+
}, { rootDir });
|
|
2257
|
+
run = recordWorkflowStep(db, {
|
|
2258
|
+
id: run.id, step_id: 'bind-baseline', status: 'blocked',
|
|
2259
|
+
blockers: ['LEGACY_CHANGE_PROVENANCE_REQUIRED'],
|
|
2260
|
+
}, { rootDir });
|
|
2261
|
+
result.created += 1;
|
|
2262
|
+
result.items.push({ change_id: change.id, workflow_id: run.id, status: run.status });
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
if (run?.id) db.prepare('DELETE FROM workflow_runs WHERE id = ?').run(run.id);
|
|
2265
|
+
result.failed += 1;
|
|
2266
|
+
result.items.push({ change_id: change.id, status: 'failed', error: error.code || error.message });
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
return result;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
module.exports = {
|
|
2273
|
+
DEFINITION_VERSION,
|
|
2274
|
+
WORKFLOW_DEFINITIONS,
|
|
2275
|
+
RESEARCH_MODES,
|
|
2276
|
+
RUN_KINDS,
|
|
2277
|
+
RUN_STATUSES,
|
|
2278
|
+
WorkflowStateError,
|
|
2279
|
+
insertWorkflowInTx,
|
|
2280
|
+
startWorkflow,
|
|
2281
|
+
readWorkflow,
|
|
2282
|
+
listWorkflows,
|
|
2283
|
+
recordWorkflowStep,
|
|
2284
|
+
completeWorkflow,
|
|
2285
|
+
blockInitializationInTx,
|
|
2286
|
+
completeInitializationInTx,
|
|
2287
|
+
inspectWorkflowHealth,
|
|
2288
|
+
recoverUntrackedChangeWorkflows,
|
|
2289
|
+
validateDeliveryPrerequisites,
|
|
2290
|
+
validatePlanContract,
|
|
2291
|
+
resolveProjectSourceRef: sourceRefFile,
|
|
2292
|
+
};
|