@juangcarmona/lighthouse-sdlc 0.2.0 → 0.4.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.
Files changed (42) hide show
  1. package/README.md +13 -1
  2. package/commands/control-plane.md +3 -0
  3. package/commands/decompose.md +3 -0
  4. package/commands/github-decompose.md +3 -0
  5. package/commands/github-labels.md +3 -0
  6. package/commands/github-triage.md +3 -0
  7. package/commands/launch.md +3 -0
  8. package/commands/memory.md +3 -0
  9. package/commands/reconcile-labels.md +3 -0
  10. package/commands/route.md +3 -0
  11. package/commands/stacked.md +3 -0
  12. package/commands/stage-result.md +3 -0
  13. package/commands/state.md +3 -0
  14. package/commands/triage.md +6 -0
  15. package/contract.json +9 -3
  16. package/package.json +19 -2
  17. package/schemas/capabilities.schema.json +27 -0
  18. package/schemas/control-plane.schema.json +18 -3
  19. package/schemas/decomposition.schema.json +13 -0
  20. package/schemas/sdlc-contract.schema.json +8 -2
  21. package/schemas/stage-result.schema.json +22 -0
  22. package/schemas/stages.schema.json +27 -0
  23. package/schemas/triage.schema.json +62 -0
  24. package/schemas/workflow-state.schema.json +22 -0
  25. package/skills/implement/SKILL.md +2 -2
  26. package/skills/integrate/SKILL.md +2 -2
  27. package/skills/propose/SKILL.md +2 -2
  28. package/skills/refine/SKILL.md +2 -2
  29. package/skills/review/SKILL.md +2 -2
  30. package/skills/triage/SKILL.md +6 -5
  31. package/src/contracts.mjs +39 -0
  32. package/src/control-plane.mjs +51 -0
  33. package/src/decompose.mjs +53 -0
  34. package/src/github.mjs +67 -0
  35. package/src/memory.mjs +22 -0
  36. package/src/reconcile.mjs +165 -0
  37. package/src/routes.mjs +22 -0
  38. package/src/sdlc.mjs +296 -7
  39. package/src/stacked.mjs +50 -0
  40. package/src/triage.mjs +284 -0
  41. package/src/workflow.mjs +66 -0
  42. package/stages.json +12 -0
package/src/triage.mjs ADDED
@@ -0,0 +1,284 @@
1
+ const DIMENSIONS = Object.freeze({
2
+ size: ['S', 'M', 'L', 'XL'],
3
+ urgency: ['normal', 'asap', 'now'],
4
+ impact: ['low', 'medium', 'high'],
5
+ risk: ['low', 'medium', 'high'],
6
+ 'blast-radius': ['low', 'medium', 'high'],
7
+ });
8
+
9
+ const CONFIDENCE = new Set(['low', 'medium', 'high']);
10
+ const ROLES = new Set(['state', 'command', 'classification']);
11
+ const ROUTES = new Set(['supervised', 'plan-only', 'unattended', 'hotfix', 'stacked']);
12
+ const DEPENDENCY_STATES = new Set(['resolved', 'open', 'unknown']);
13
+ const EVIDENCE_STATES = new Set(['provided', 'unavailable', 'pending-review']);
14
+ const HEX = /^[0-9a-f]{6}$/iu;
15
+ const EVIDENCE_FIELDS = ['size', 'urgency', 'impact', 'risk', 'blast-radius'];
16
+
17
+ export const suggestedLabelColors = Object.freeze({
18
+ 'size-s': 'BFE3FF',
19
+ 'size-m': '80C2FF',
20
+ 'size-l': '4DA3FF',
21
+ 'size-xl': '1F6FEB',
22
+ 'urgency-normal': '8B949E',
23
+ 'urgency-asap': 'D29922',
24
+ 'urgency-now': 'CF222E',
25
+ 'impact-low': 'E9D5FF',
26
+ 'impact-medium': 'A371F7',
27
+ 'impact-high': '6E40C9',
28
+ 'risk-low': 'FFEBE9',
29
+ 'risk-medium': 'F0883E',
30
+ 'risk-high': 'CF222E',
31
+ 'blast-radius-low': 'C7F0E8',
32
+ 'blast-radius-medium': '4FB3A4',
33
+ 'blast-radius-high': '0F766E',
34
+ });
35
+
36
+ function isRecord(value) {
37
+ return value && typeof value === 'object' && !Array.isArray(value);
38
+ }
39
+
40
+ function normalizedValue(dimension, value) {
41
+ if (dimension === 'size') return String(value ?? '').toUpperCase();
42
+ return String(value ?? '').toLowerCase();
43
+ }
44
+
45
+ function labelFor(dimension, value) {
46
+ return `${dimension}-${normalizedValue(dimension, value).toLowerCase()}`;
47
+ }
48
+
49
+ function issue(code, message, field) {
50
+ return field ? { code, field, message } : { code, message };
51
+ }
52
+
53
+ function addIssue(issues, code, message, field) {
54
+ issues.push(issue(code, message, field));
55
+ }
56
+
57
+ export function validateConfiguration(configuration) {
58
+ const errors = [];
59
+ const labels = Array.isArray(configuration?.labels) ? configuration.labels : null;
60
+ const byName = new Map();
61
+ const classifications = new Map();
62
+ const stateLabels = new Set();
63
+ const commandLabels = new Set();
64
+
65
+ if (!labels) addIssue(errors, 'configuration.labels', 'configuration.labels must be an array', 'configuration.labels');
66
+ for (const descriptor of labels ?? []) {
67
+ if (!isRecord(descriptor) || typeof descriptor.name !== 'string' || !descriptor.name.trim()) {
68
+ addIssue(errors, 'label.invalid', 'every label descriptor needs a non-empty name', 'configuration.labels');
69
+ continue;
70
+ }
71
+ const name = descriptor.name.trim();
72
+ if (byName.has(name)) addIssue(errors, 'label.duplicate', `label ${name} is declared more than once`, 'configuration.labels');
73
+ byName.set(name, descriptor);
74
+ if (!ROLES.has(descriptor.role)) {
75
+ addIssue(errors, 'label.role', `label ${name} must use role state, command or classification`, 'configuration.labels');
76
+ continue;
77
+ }
78
+ if (descriptor.role === 'state') stateLabels.add(name);
79
+ if (descriptor.role === 'command') commandLabels.add(name);
80
+ if (descriptor.role !== 'classification') continue;
81
+ const dimension = String(descriptor.dimension ?? '').toLowerCase();
82
+ const value = normalizedValue(dimension, descriptor.value);
83
+ if (!Object.hasOwn(DIMENSIONS, dimension) || !DIMENSIONS[dimension].includes(value)) {
84
+ addIssue(errors, 'label.classification', `label ${name} has an invalid classification dimension or value`, 'configuration.labels');
85
+ continue;
86
+ }
87
+ if (labelFor(dimension, value) !== name) {
88
+ addIssue(errors, 'label.syntax', `classification label ${name} must use the ${labelFor(dimension, value)} syntax`, 'configuration.labels');
89
+ }
90
+ const key = `${dimension}:${value}`;
91
+ if (classifications.has(key)) addIssue(errors, 'label.classification-duplicate', `classification ${key} is declared more than once`, 'configuration.labels');
92
+ classifications.set(key, name);
93
+ if (descriptor.color !== undefined && (typeof descriptor.color !== 'string' || !HEX.test(descriptor.color))) {
94
+ addIssue(errors, 'label.color', `classification label ${name} color must be six hexadecimal characters`, 'configuration.labels');
95
+ }
96
+ }
97
+
98
+ const transitions = Array.isArray(configuration?.transitions) ? configuration.transitions : [];
99
+ const transitionSet = new Set();
100
+ for (const transition of transitions) {
101
+ if (!isRecord(transition) || typeof transition.from !== 'string' || typeof transition.to !== 'string') {
102
+ addIssue(errors, 'transition.invalid', 'every transition needs from and to labels', 'configuration.transitions');
103
+ continue;
104
+ }
105
+ const key = `${transition.from}->${transition.to}`;
106
+ if (transitionSet.has(key)) addIssue(errors, 'transition.duplicate', `transition ${key} is declared more than once`, 'configuration.transitions');
107
+ transitionSet.add(key);
108
+ }
109
+
110
+ return { errors, byName, classifications, stateLabels, commandLabels, transitionSet };
111
+ }
112
+
113
+ function validateWorkItem(workItem, errors) {
114
+ if (!isRecord(workItem)) {
115
+ addIssue(errors, 'work-item.invalid', 'workItem must be an object', 'workItem');
116
+ return;
117
+ }
118
+ for (const field of ['id', 'title']) {
119
+ if (typeof workItem[field] !== 'string' || !workItem[field].trim()) addIssue(errors, `work-item.${field}`, `workItem.${field} must be a non-empty string`, `workItem.${field}`);
120
+ }
121
+ if (workItem.type !== undefined && (typeof workItem.type !== 'string' || !workItem.type.trim())) addIssue(errors, 'work-item.type', 'workItem.type must be a non-empty string when provided', 'workItem.type');
122
+ }
123
+
124
+ function validateAssessment(assessment, errors) {
125
+ if (!isRecord(assessment)) {
126
+ addIssue(errors, 'assessment.invalid', 'assessment must be an object', 'assessment');
127
+ return;
128
+ }
129
+ for (const dimension of Object.keys(DIMENSIONS)) {
130
+ const value = normalizedValue(dimension, assessment[dimension === 'blast-radius' ? 'blastRadius' : dimension]);
131
+ if (!DIMENSIONS[dimension].includes(value) && !(dimension === 'blast-radius' && value === 'unknown')) {
132
+ addIssue(errors, `assessment.${dimension}`, `${dimension} has an unsupported value`, `assessment.${dimension}`);
133
+ }
134
+ }
135
+ if (!ROUTES.has(assessment.route)) addIssue(errors, 'assessment.route', `route must be one of ${[...ROUTES].join(', ')}`, 'assessment.route');
136
+ if (typeof assessment.nextState !== 'string' || !assessment.nextState.trim()) addIssue(errors, 'assessment.next-state', 'nextState must be a non-empty string', 'assessment.nextState');
137
+ if (typeof assessment.owner !== 'string' || !assessment.owner.trim()) addIssue(errors, 'assessment.owner', 'owner must be a non-empty string', 'assessment.owner');
138
+ if (!isRecord(assessment.confidence)) {
139
+ addIssue(errors, 'assessment.confidence', 'confidence must be an object with one value per triage dimension', 'assessment.confidence');
140
+ } else {
141
+ for (const dimension of EVIDENCE_FIELDS) if (!CONFIDENCE.has(assessment.confidence[dimension])) addIssue(errors, `assessment.confidence.${dimension}`, `${dimension} confidence must be low, medium or high`, `assessment.confidence.${dimension}`);
142
+ }
143
+ if (!Array.isArray(assessment.dependencies)) {
144
+ addIssue(errors, 'assessment.dependencies', 'dependencies must be an array', 'assessment.dependencies');
145
+ } else {
146
+ for (const dependency of assessment.dependencies) {
147
+ if (!isRecord(dependency) || typeof dependency.id !== 'string' || !DEPENDENCY_STATES.has(dependency.status)) addIssue(errors, 'assessment.dependency', 'each dependency needs an id and a resolved, open or unknown status', 'assessment.dependencies');
148
+ }
149
+ }
150
+ if (!Array.isArray(assessment.evidence)) {
151
+ addIssue(errors, 'assessment.evidence', 'evidence must be an array', 'assessment.evidence');
152
+ } else {
153
+ const fields = new Set();
154
+ for (const evidence of assessment.evidence) {
155
+ const status = evidence?.status ?? 'provided';
156
+ const valid = isRecord(evidence) && EVIDENCE_FIELDS.includes(evidence.field) && EVIDENCE_STATES.has(status);
157
+ if (!valid) {
158
+ addIssue(errors, 'assessment.evidence-item', 'each evidence item needs a known field and a provided, unavailable or pending-review status', 'assessment.evidence');
159
+ continue;
160
+ }
161
+ if (fields.has(evidence.field)) addIssue(errors, 'assessment.evidence-duplicate', `${evidence.field} has more than one evidence status`, 'assessment.evidence');
162
+ fields.add(evidence.field);
163
+ if (status === 'provided' && (typeof evidence.source !== 'string' || !evidence.source.trim())) addIssue(errors, 'assessment.evidence-source', 'provided evidence needs a non-empty source', 'assessment.evidence');
164
+ if (status !== 'provided' && (typeof evidence.reason !== 'string' || !evidence.reason.trim())) addIssue(errors, 'assessment.evidence-reason', `${status} evidence needs a non-empty reason`, 'assessment.evidence');
165
+ }
166
+ }
167
+ }
168
+
169
+ function currentState(labels, configuration) {
170
+ const states = labels.filter((label) => configuration.stateLabels.has(label));
171
+ return { states, value: states.length === 1 ? states[0] : null };
172
+ }
173
+
174
+ function classificationState(labels, configuration) {
175
+ const byDimension = new Map();
176
+ for (const label of labels) {
177
+ const descriptor = configuration.byName.get(label);
178
+ if (!descriptor || descriptor.role !== 'classification') continue;
179
+ const list = byDimension.get(descriptor.dimension) ?? [];
180
+ list.push(label);
181
+ byDimension.set(descriptor.dimension, list);
182
+ }
183
+ return byDimension;
184
+ }
185
+
186
+ function proposedLabels(currentLabels, assessment, configuration) {
187
+ const result = new Set(currentLabels.filter((label) => {
188
+ const descriptor = configuration.byName.get(label);
189
+ return !descriptor || (descriptor.role !== 'state' && descriptor.role !== 'classification' && descriptor.role !== 'command');
190
+ }));
191
+ for (const label of configuration.stateLabels) if (currentLabels.includes(label)) result.add(label);
192
+ for (const label of configuration.commandLabels) if (currentLabels.includes(label)) result.delete(label);
193
+ const state = currentState(currentLabels, configuration).value;
194
+ if (state) result.delete(state);
195
+ if (assessment.nextState) result.add(assessment.nextState);
196
+ for (const dimension of Object.keys(DIMENSIONS)) {
197
+ const key = dimension === 'blast-radius' ? 'blastRadius' : dimension;
198
+ const value = normalizedValue(dimension, assessment[key]);
199
+ for (const label of configuration.classifications.values()) {
200
+ const descriptor = configuration.byName.get(label);
201
+ if (descriptor?.dimension === dimension) result.delete(label);
202
+ }
203
+ if (value !== 'UNKNOWN') {
204
+ const label = configuration.classifications.get(`${dimension}:${value}`);
205
+ if (label) result.add(label);
206
+ }
207
+ }
208
+ return [...result].sort((a, b) => a.localeCompare(b));
209
+ }
210
+
211
+ export function buildTriageReport(input) {
212
+ const errors = [];
213
+ if (!isRecord(input)) return { schemaVersion: 1, status: 'invalid', valid: false, canApply: false, errors: [issue('input.invalid', 'triage input must be an object')], warnings: [], missingEvidence: [], unavailableChecks: [], pendingReview: [], labels: { current: [], proposed: [] } };
214
+ if (input.schemaVersion !== 1) addIssue(errors, 'input.schema-version', 'schemaVersion must be 1', 'schemaVersion');
215
+ const configuration = validateConfiguration(input.configuration);
216
+ errors.push(...configuration.errors);
217
+ const currentLabels = Array.isArray(input.currentLabels) ? [...new Set(input.currentLabels.filter((label) => typeof label === 'string'))].sort() : [];
218
+ if (!Array.isArray(input.currentLabels)) addIssue(errors, 'labels.invalid', 'currentLabels must be an array', 'currentLabels');
219
+ validateWorkItem(input.workItem, errors);
220
+ validateAssessment(input.assessment, errors);
221
+
222
+ const state = currentState(currentLabels, configuration);
223
+ if (state.states.length > 1) addIssue(errors, 'state.ambiguous', `work item has multiple workflow state labels: ${state.states.join(', ')}`, 'currentLabels');
224
+ if (state.states.length === 0) addIssue(errors, 'state.missing', 'work item needs one configured workflow state label', 'currentLabels');
225
+ const assessment = isRecord(input.assessment) ? input.assessment : {};
226
+ if (state.value && typeof assessment.nextState === 'string' && configuration.stateLabels.has(assessment.nextState)) {
227
+ if (state.value !== assessment.nextState && !configuration.transitionSet.has(`${state.value}->${assessment.nextState}`)) addIssue(errors, 'transition.illegal', `transition ${state.value}->${assessment.nextState} is not configured`, 'assessment.nextState');
228
+ } else if (typeof assessment.nextState === 'string' && assessment.nextState && !configuration.stateLabels.has(assessment.nextState)) {
229
+ addIssue(errors, 'transition.unconfigured', `next state ${assessment.nextState} is not configured`, 'assessment.nextState');
230
+ }
231
+ const currentClassifications = classificationState(currentLabels, configuration);
232
+ for (const [dimension, values] of currentClassifications) if (values.length > 1) addIssue(errors, 'classification.ambiguous', `work item has multiple ${dimension} classification labels: ${values.join(', ')}`, 'currentLabels');
233
+ for (const dimension of Object.keys(DIMENSIONS)) {
234
+ const value = normalizedValue(dimension, assessment[dimension === 'blast-radius' ? 'blastRadius' : dimension]);
235
+ if (value !== 'UNKNOWN' && configuration.classifications.has(`${dimension}:${value}`) === false) addIssue(errors, 'classification.unconfigured', `${labelFor(dimension, value)} is not configured`, `assessment.${dimension}`);
236
+ }
237
+
238
+ const evidence = Array.isArray(assessment.evidence) ? assessment.evidence : [];
239
+ const evidenceFields = new Set(evidence.filter(isRecord).map((item) => item.field));
240
+ const missingEvidence = EVIDENCE_FIELDS.filter((field) => !evidenceFields.has(field));
241
+ const unavailableChecks = evidence.filter((item) => isRecord(item) && item.status === 'unavailable').map((item) => item.field);
242
+ const pendingReview = evidence.filter((item) => isRecord(item) && item.status === 'pending-review').map((item) => item.field);
243
+ const warnings = [
244
+ ...missingEvidence.map((field) => issue('evidence.missing', `${field} has no evidence record`, field)),
245
+ ...unavailableChecks.map((field) => issue('evidence.unavailable', `${field} could not be checked`, field)),
246
+ ...pendingReview.map((field) => issue('evidence.pending-review', `${field} is waiting for judgement`, field)),
247
+ ];
248
+ const proposed = proposedLabels(currentLabels, assessment, configuration);
249
+ const status = errors.length ? 'invalid' : warnings.length ? 'blocked' : 'ready';
250
+ return {
251
+ schemaVersion: 1,
252
+ status,
253
+ valid: errors.length === 0,
254
+ canApply: status === 'ready',
255
+ workItem: input.workItem ?? null,
256
+ assessment: {
257
+ size: normalizedValue('size', assessment.size),
258
+ urgency: normalizedValue('urgency', assessment.urgency),
259
+ impact: normalizedValue('impact', assessment.impact),
260
+ risk: normalizedValue('risk', assessment.risk),
261
+ blastRadius: normalizedValue('blast-radius', assessment.blastRadius),
262
+ dependencies: assessment.dependencies ?? [],
263
+ route: assessment.route ?? null,
264
+ owner: assessment.owner ?? null,
265
+ confidence: assessment.confidence ?? {},
266
+ evidence,
267
+ },
268
+ transition: { from: state.value, to: assessment.nextState ?? null },
269
+ labels: { current: currentLabels, proposed },
270
+ missingEvidence,
271
+ unavailableChecks,
272
+ pendingReview,
273
+ suggestedColors: suggestedLabelColors,
274
+ errors,
275
+ warnings,
276
+ };
277
+ }
278
+
279
+ export function applyTriageLabels(report) {
280
+ if (!report?.canApply) throw new Error('Triage report is not ready to apply. Resolve its errors or outstanding evidence state first.');
281
+ return [...report.labels.proposed];
282
+ }
283
+
284
+ export { DIMENSIONS, ROUTES };
@@ -0,0 +1,66 @@
1
+ export const WORKFLOW_STATES = Object.freeze(['ready', 'running', 'waiting-human', 'blocked', 'succeeded', 'failed', 'cancelled']);
2
+
3
+ const transitions = Object.freeze({
4
+ ready: ['running', 'cancelled'],
5
+ running: ['waiting-human', 'blocked', 'succeeded', 'failed', 'cancelled'],
6
+ 'waiting-human': ['running', 'blocked', 'cancelled'],
7
+ blocked: ['ready', 'cancelled'],
8
+ failed: ['ready', 'cancelled'],
9
+ succeeded: [],
10
+ cancelled: [],
11
+ });
12
+ const stateSet = new Set(WORKFLOW_STATES);
13
+
14
+ function isRecord(value) {
15
+ return value && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+
18
+ function problem(code, message, field) {
19
+ return field ? { code, message, field } : { code, message };
20
+ }
21
+
22
+ export function allowedTransitions(state) {
23
+ return [...(transitions[state] ?? [])];
24
+ }
25
+
26
+ export function validateStateEvent(event, previousState = null) {
27
+ const errors = [];
28
+ if (!isRecord(event)) return [problem('state-event.invalid', 'state event must be an object')];
29
+ if (event.schemaVersion !== 1) errors.push(problem('state-event.schema-version', 'schemaVersion must be 1', 'schemaVersion'));
30
+ for (const field of ['eventId', 'runId', 'route', 'stage', 'actor', 'timestamp', 'reason']) if (typeof event[field] !== 'string' || !event[field].trim()) errors.push(problem(`state-event.${field}`, `${field} must be a non-empty string`, field));
31
+ if (!stateSet.has(event.to)) errors.push(problem('state-event.to', `to must be one of ${WORKFLOW_STATES.join(', ')}`, 'to'));
32
+ if (event.from !== null && !stateSet.has(event.from)) errors.push(problem('state-event.from', `from must be one of ${WORKFLOW_STATES.join(', ')} or null`, 'from'));
33
+ if (previousState !== null && event.from !== previousState) errors.push(problem('state-event.previous', `event from ${event.from} does not match current state ${previousState}`, 'from'));
34
+ if (event.from !== null && !allowedTransitions(event.from).includes(event.to)) errors.push(problem('state-event.transition', `transition ${event.from}->${event.to} is not allowed`, 'to'));
35
+ if (event.from === null && event.to !== 'ready') errors.push(problem('state-event.initial', 'the first event must enter ready', 'to'));
36
+ if (!Array.isArray(event.evidence)) errors.push(problem('state-event.evidence', 'evidence must be an array', 'evidence'));
37
+ return errors;
38
+ }
39
+
40
+ export function replayWorkflow(events) {
41
+ if (!Array.isArray(events)) return { valid: false, errors: [problem('state.events', 'events must be an array')], runs: {} };
42
+ const runs = new Map();
43
+ const errors = [];
44
+ const eventIds = new Set();
45
+ for (const [index, event] of events.entries()) {
46
+ const previous = runs.get(event?.runId)?.state ?? null;
47
+ const eventErrors = validateStateEvent(event, previous);
48
+ if (eventErrors.length) {
49
+ errors.push(...eventErrors.map((error) => ({ ...error, index })));
50
+ continue;
51
+ }
52
+ const current = runs.get(event.runId);
53
+ if (eventIds.has(event.eventId)) {
54
+ errors.push(problem('state-event.duplicate', `event ${event.eventId} appears more than once`, `events[${index}]`));
55
+ continue;
56
+ }
57
+ eventIds.add(event.eventId);
58
+ runs.set(event.runId, { state: event.to, route: event.route, stage: event.stage, events: [...(current?.events ?? []), event] });
59
+ }
60
+ const result = Object.fromEntries([...runs.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([runId, run]) => [runId, { ...run, events: run.events.map((event) => ({ ...event })) }]));
61
+ return { valid: errors.length === 0, errors, runs: result };
62
+ }
63
+
64
+ export function createStateEvent({ eventId, runId, route, stage, from, to, actor, timestamp, reason, evidence = [], parentRunId }) {
65
+ return { schemaVersion: 1, eventId, runId, ...(parentRunId ? { parentRunId } : {}), route, stage, from, to, actor, timestamp, reason, evidence };
66
+ }
package/stages.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "./schemas/stages.schema.json",
3
+ "version": 1,
4
+ "stages": [
5
+ { "id": "triage", "gate": "triaged", "input": "work-item", "output": "triage-report", "mutation": "explicit" },
6
+ { "id": "refine", "gate": "ready", "input": "triage-report", "output": "ready-change", "mutation": "none" },
7
+ { "id": "propose", "gate": "planned", "input": "ready-change", "output": "change-artifacts", "mutation": "explicit" },
8
+ { "id": "implement", "gate": "done", "input": "change-artifacts", "output": "implementation-evidence", "mutation": "workspace" },
9
+ { "id": "integrate", "gate": "done", "input": "implementation-evidence", "output": "integration-evidence", "mutation": "explicit" },
10
+ { "id": "review", "gate": null, "input": "integration-evidence", "output": "review-report", "mutation": "explicit" }
11
+ ]
12
+ }