@juangcarmona/lighthouse-sdlc 0.3.0 → 0.5.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/README.md +17 -1
- package/capabilities.json +50 -0
- package/commands/capabilities.md +13 -0
- package/commands/control-plane.md +3 -0
- package/commands/decompose.md +3 -0
- package/commands/github-decompose.md +3 -0
- package/commands/github-labels.md +3 -0
- package/commands/github-triage.md +3 -0
- package/commands/launch.md +3 -0
- package/commands/memory.md +3 -0
- package/commands/reconcile-labels.md +3 -0
- package/commands/route.md +3 -0
- package/commands/stacked.md +3 -0
- package/commands/stage-result.md +3 -0
- package/commands/state.md +3 -0
- package/contract.json +10 -3
- package/package.json +21 -2
- package/schemas/capabilities.schema.json +27 -0
- package/schemas/capability-catalog.schema.json +88 -0
- package/schemas/control-plane.schema.json +18 -3
- package/schemas/decomposition.schema.json +13 -0
- package/schemas/sdlc-contract.schema.json +7 -2
- package/schemas/stage-result.schema.json +22 -0
- package/schemas/stages.schema.json +27 -0
- package/schemas/workflow-state.schema.json +22 -0
- package/skills/implement/SKILL.md +2 -2
- package/skills/integrate/SKILL.md +2 -2
- package/skills/propose/SKILL.md +2 -2
- package/skills/refine/SKILL.md +2 -2
- package/skills/review/SKILL.md +2 -2
- package/skills/triage/SKILL.md +2 -2
- package/src/capabilities.mjs +84 -0
- package/src/contracts.mjs +39 -0
- package/src/control-plane.mjs +51 -0
- package/src/decompose.mjs +53 -0
- package/src/github.mjs +67 -0
- package/src/memory.mjs +22 -0
- package/src/reconcile.mjs +169 -0
- package/src/routes.mjs +22 -0
- package/src/sdlc.mjs +243 -7
- package/src/stacked.mjs +50 -0
- package/src/triage.mjs +2 -2
- package/src/workflow.mjs +66 -0
- package/stages.json +12 -0
package/src/sdlc.mjs
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
4
|
import { dirname, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { buildDecompositionPlan, applyDecompositionPlan } from './decompose.mjs';
|
|
7
|
+
import { validateControlPlane, validateLaunchRequest } from './control-plane.mjs';
|
|
8
|
+
import { createGithubLabel, createGithubSubIssues, mergeGithubTriage, readGithubIssue, readGithubLabels, replaceGithubLabels } from './github.mjs';
|
|
9
|
+
import { applyLabelReconciliationPlan, buildLabelReconciliationPlan } from './reconcile.mjs';
|
|
10
|
+
import { buildRoutePlan } from './routes.mjs';
|
|
11
|
+
import { buildStackedPlan } from './stacked.mjs';
|
|
6
12
|
import { applyTriageLabels, buildTriageReport } from './triage.mjs';
|
|
13
|
+
import { validateMemoryRecord } from './memory.mjs';
|
|
14
|
+
import { replayWorkflow } from './workflow.mjs';
|
|
15
|
+
import { validateStageResult } from './contracts.mjs';
|
|
16
|
+
import { readCapabilityCatalog, validateCapabilityCatalog } from './capabilities.mjs';
|
|
7
17
|
|
|
8
18
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
19
|
const contractPath = resolve(packageRoot, 'contract.json');
|
|
@@ -29,10 +39,10 @@ async function verifyContract() {
|
|
|
29
39
|
for (const stage of stages) {
|
|
30
40
|
for (const asset of [stage.skill, stage.command]) if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing stage asset ${asset}`);
|
|
31
41
|
}
|
|
32
|
-
for (const asset of [contract.assets?.routes, contract.assets?.gates, contract.assets?.controlPlaneSchema, contract.assets?.triageSchema]) {
|
|
42
|
+
for (const asset of [contract.assets?.routes, contract.assets?.gates, contract.assets?.stages, contract.assets?.controlPlaneSchema, contract.assets?.triageSchema, contract.assets?.stageResultSchema, contract.assets?.stateSchema, contract.assets?.decompositionSchema, contract.assets?.capabilitiesSchema, contract.assets?.capabilityCatalog, contract.assets?.capabilityCatalogSchema]) {
|
|
33
43
|
if (!asset || !(await exists(resolve(packageRoot, asset)))) problems.push(`missing contract asset ${asset ?? '(empty)'}`);
|
|
34
44
|
}
|
|
35
|
-
for (const asset of ['routes.json', 'gates.json', 'schemas/routes.schema.json', 'schemas/gates.schema.json', 'schemas/control-plane.schema.json', 'schemas/triage.schema.json', 'src/triage.mjs']) {
|
|
45
|
+
for (const asset of ['routes.json', 'gates.json', 'stages.json', 'schemas/routes.schema.json', 'schemas/gates.schema.json', 'schemas/stages.schema.json', 'schemas/control-plane.schema.json', 'schemas/triage.schema.json', 'schemas/stage-result.schema.json', 'schemas/workflow-state.schema.json', 'schemas/decomposition.schema.json', 'schemas/capabilities.schema.json', 'schemas/capability-catalog.schema.json', 'src/capabilities.mjs', 'src/contracts.mjs', 'src/control-plane.mjs', 'src/decompose.mjs', 'src/github.mjs', 'src/memory.mjs', 'src/reconcile.mjs', 'src/routes.mjs', 'src/stacked.mjs', 'src/triage.mjs', 'src/workflow.mjs']) {
|
|
36
46
|
if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing package asset ${asset}`);
|
|
37
47
|
}
|
|
38
48
|
try {
|
|
@@ -45,7 +55,16 @@ async function verifyContract() {
|
|
|
45
55
|
const gateIds = gates.gates?.map((gate) => gate.id) ?? [];
|
|
46
56
|
if (gateIds.join(',') !== contract.gates.join(',')) problems.push('gates.json does not match contract gates');
|
|
47
57
|
} catch (error) { problems.push(`Cannot read gates.json: ${error.message}`); }
|
|
48
|
-
|
|
58
|
+
try {
|
|
59
|
+
const stages = await readJson(resolve(packageRoot, 'stages.json'));
|
|
60
|
+
const stageIds = stages.stages?.map((stage) => stage.id) ?? [];
|
|
61
|
+
if (stageIds.join(',') !== expectedStages.join(',')) problems.push('stages.json does not match contract stages');
|
|
62
|
+
} catch (error) { problems.push(`Cannot read stages.json: ${error.message}`); }
|
|
63
|
+
try {
|
|
64
|
+
const catalog = await readCapabilityCatalog();
|
|
65
|
+
problems.push(...validateCapabilityCatalog(catalog).map((error) => `${error.code}: ${error.message}`));
|
|
66
|
+
} catch (error) { problems.push(`Cannot read capabilities.json: ${error.message}`); }
|
|
67
|
+
for (const command of ['status', ...expectedStages, ...(contract.utilities ?? []).filter((command) => command !== 'status')]) if (!(await exists(resolve(packageRoot, `commands/${command}.md`)))) problems.push(`missing command asset ${command}`);
|
|
49
68
|
for (const metaCommand of contract.metaCommands ?? []) if (!(await exists(resolve(packageRoot, `meta-commands/${metaCommand}.md`)))) problems.push(`missing meta-command asset ${metaCommand}`);
|
|
50
69
|
return problems;
|
|
51
70
|
}
|
|
@@ -105,15 +124,232 @@ async function runTriage(args) {
|
|
|
105
124
|
if (report.status !== 'ready') process.exit(1);
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
function output(value, format = 'json') {
|
|
128
|
+
if (format === 'json') console.log(JSON.stringify(value, null, 2));
|
|
129
|
+
else console.log(JSON.stringify(value));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function runState(args) {
|
|
133
|
+
const inputPath = option(args, '--input');
|
|
134
|
+
const format = option(args, '--format') ?? 'json';
|
|
135
|
+
if (!inputPath) { console.error('Usage: lighthouse-sdlc state --input <events.json> [--format <json|text>]'); process.exit(2); }
|
|
136
|
+
const input = await readJson(resolve(process.cwd(), inputPath));
|
|
137
|
+
const result = replayWorkflow(Array.isArray(input) ? input : input.events);
|
|
138
|
+
output(result, format);
|
|
139
|
+
if (!result.valid) process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function runControlPlane(args) {
|
|
143
|
+
const inputPath = option(args, '--input');
|
|
144
|
+
const format = option(args, '--format') ?? 'json';
|
|
145
|
+
if (!inputPath) { console.error('Usage: lighthouse-sdlc control-plane --input <control-plane.json> [--format <json|text>]'); process.exit(2); }
|
|
146
|
+
const input = await readJson(resolve(process.cwd(), inputPath));
|
|
147
|
+
const errors = validateControlPlane(input);
|
|
148
|
+
const result = { schemaVersion: 1, status: errors.length ? 'invalid' : 'valid', valid: errors.length === 0, errors };
|
|
149
|
+
output(result, format);
|
|
150
|
+
if (errors.length) process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function runStageResult(args) {
|
|
154
|
+
const inputPath = option(args, '--input');
|
|
155
|
+
const format = option(args, '--format') ?? 'json';
|
|
156
|
+
if (!inputPath) { console.error('Usage: lighthouse-sdlc stage-result --input <result.json> [--format <json|text>]'); process.exit(2); }
|
|
157
|
+
const result = await readJson(resolve(process.cwd(), inputPath));
|
|
158
|
+
const errors = validateStageResult(result);
|
|
159
|
+
output({ schemaVersion: 1, status: errors.length ? 'invalid' : 'valid', valid: errors.length === 0, errors }, format);
|
|
160
|
+
if (errors.length) process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function runMemory(args) {
|
|
164
|
+
const inputPath = option(args, '--input');
|
|
165
|
+
const policyPath = option(args, '--policy');
|
|
166
|
+
const format = option(args, '--format') ?? 'json';
|
|
167
|
+
if (!inputPath) { console.error('Usage: lighthouse-sdlc memory --input <record.json> [--policy <memory-policy.json>] [--format <json|text>]'); process.exit(2); }
|
|
168
|
+
const record = await readJson(resolve(process.cwd(), inputPath));
|
|
169
|
+
const policy = policyPath ? await readJson(resolve(process.cwd(), policyPath)) : {};
|
|
170
|
+
const errors = validateMemoryRecord(record, policy);
|
|
171
|
+
output({ schemaVersion: 1, status: errors.length ? 'invalid' : 'valid', valid: errors.length === 0, errors }, format);
|
|
172
|
+
if (errors.length) process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function runLaunch(args) {
|
|
176
|
+
const inputPath = option(args, '--input');
|
|
177
|
+
const controlPlanePath = option(args, '--control-plane');
|
|
178
|
+
const format = option(args, '--format') ?? 'json';
|
|
179
|
+
if (!inputPath || !controlPlanePath) { console.error('Usage: lighthouse-sdlc launch --input <request.json> --control-plane <control-plane.json> [--format <json|text>]'); process.exit(2); }
|
|
180
|
+
const request = await readJson(resolve(process.cwd(), inputPath));
|
|
181
|
+
const controlPlane = await readJson(resolve(process.cwd(), controlPlanePath));
|
|
182
|
+
const errors = [...validateControlPlane(controlPlane), ...validateLaunchRequest(request, controlPlane)];
|
|
183
|
+
output({ schemaVersion: 1, status: errors.length ? 'invalid' : 'valid', valid: errors.length === 0, executed: false, errors }, format);
|
|
184
|
+
if (errors.length) process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function runDecompose(args) {
|
|
188
|
+
const inputPath = option(args, '--input');
|
|
189
|
+
const outputPath = option(args, '--output');
|
|
190
|
+
const format = option(args, '--format') ?? 'json';
|
|
191
|
+
const apply = args.includes('--apply');
|
|
192
|
+
if (!inputPath || (apply && !outputPath)) { console.error('Usage: lighthouse-sdlc decompose --input <file> [--format <json|text>] [--apply --output <file>]'); process.exit(2); }
|
|
193
|
+
const plan = buildDecompositionPlan(await readJson(resolve(process.cwd(), inputPath)));
|
|
194
|
+
if (apply && plan.canApply) await writeFile(resolve(process.cwd(), outputPath), `${JSON.stringify(applyDecompositionPlan(plan), null, 2)}\n`);
|
|
195
|
+
output({ ...plan, applied: Boolean(apply && plan.canApply) }, format);
|
|
196
|
+
if (!plan.valid) process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function runRoute(args) {
|
|
200
|
+
const routeId = option(args, '--route');
|
|
201
|
+
const format = option(args, '--format') ?? 'json';
|
|
202
|
+
if (!routeId) { console.error('Usage: lighthouse-sdlc route --route <name> [--available <comma-separated-prerequisites>] [--format <json|text>]'); process.exit(2); }
|
|
203
|
+
const routes = await readJson(resolve(packageRoot, 'routes.json'));
|
|
204
|
+
const route = routes.routes?.find((candidate) => candidate.id === routeId);
|
|
205
|
+
if (!route) { output({ schemaVersion: 1, status: 'invalid', valid: false, errors: [{ code: 'route.unknown', message: `unknown route ${routeId}` }] }, format); process.exit(1); }
|
|
206
|
+
const available = (option(args, '--available') ?? '').split(',').map((item) => item.trim()).filter(Boolean);
|
|
207
|
+
const plan = buildRoutePlan(route, { available });
|
|
208
|
+
output(plan, format);
|
|
209
|
+
if (!plan.valid) process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function runStacked(args) {
|
|
213
|
+
const inputPath = option(args, '--input');
|
|
214
|
+
const outputPath = option(args, '--output');
|
|
215
|
+
const format = option(args, '--format') ?? 'json';
|
|
216
|
+
const apply = args.includes('--apply');
|
|
217
|
+
if (!inputPath || (apply && !outputPath)) { console.error('Usage: lighthouse-sdlc stacked --input <file> [--format <json|text>] [--apply --output <file>]'); process.exit(2); }
|
|
218
|
+
const plan = buildStackedPlan(await readJson(resolve(process.cwd(), inputPath)));
|
|
219
|
+
if (apply && plan.canApply) await writeFile(resolve(process.cwd(), outputPath), `${JSON.stringify({ ...plan, applied: true }, null, 2)}\n`);
|
|
220
|
+
output({ ...plan, applied: Boolean(apply && plan.canApply) }, format);
|
|
221
|
+
if (!plan.valid) process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function runGithubTriage(args) {
|
|
225
|
+
const repo = option(args, '--repo');
|
|
226
|
+
const issueNumber = option(args, '--issue');
|
|
227
|
+
const assessmentPath = option(args, '--assessment');
|
|
228
|
+
const format = option(args, '--format') ?? 'json';
|
|
229
|
+
const apply = args.includes('--apply');
|
|
230
|
+
if (!repo || !issueNumber || !assessmentPath) { console.error('Usage: lighthouse-sdlc github-triage --repo <owner/repo> --issue <number> --assessment <file> [--apply] [--format <json|text>]'); process.exit(2); }
|
|
231
|
+
const assessmentInput = await readJson(resolve(process.cwd(), assessmentPath));
|
|
232
|
+
const issueMetadata = await readGithubIssue(repo, issueNumber);
|
|
233
|
+
const report = buildTriageReport(mergeGithubTriage(issueMetadata, assessmentInput.assessment, assessmentInput.configuration));
|
|
234
|
+
report.applied = false;
|
|
235
|
+
if (apply && report.canApply) {
|
|
236
|
+
const reread = await readGithubIssue(repo, issueNumber);
|
|
237
|
+
if (JSON.stringify(reread.currentLabels) !== JSON.stringify(report.labels.current)) throw new Error('GitHub labels changed since the triage read; refusing to apply the projection');
|
|
238
|
+
const applied = await replaceGithubLabels(repo, issueNumber, applyTriageLabels(report), { dryRun: false });
|
|
239
|
+
report.applied = applied.applied;
|
|
240
|
+
}
|
|
241
|
+
output(report, format);
|
|
242
|
+
if (report.status !== 'ready') process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function runGithubDecompose(args) {
|
|
246
|
+
const repo = option(args, '--repo');
|
|
247
|
+
const issueNumber = option(args, '--issue');
|
|
248
|
+
const inputPath = option(args, '--input');
|
|
249
|
+
const format = option(args, '--format') ?? 'json';
|
|
250
|
+
const apply = args.includes('--apply');
|
|
251
|
+
if (!repo || !issueNumber || !inputPath) { console.error('Usage: lighthouse-sdlc github-decompose --repo <owner/repo> --issue <number> --input <file> [--apply] [--format <json|text>]'); process.exit(2); }
|
|
252
|
+
const parent = await readGithubIssue(repo, issueNumber);
|
|
253
|
+
const input = await readJson(resolve(process.cwd(), inputPath));
|
|
254
|
+
const plan = buildDecompositionPlan({ ...input, parent: { ...input.parent, id: parent.workItem.id, number: Number(issueNumber), title: parent.workItem.title } });
|
|
255
|
+
const result = plan.canApply ? await createGithubSubIssues(repo, issueNumber, plan, { dryRun: !apply }) : { applied: false, operations: [] };
|
|
256
|
+
output({ ...plan, github: result }, format);
|
|
257
|
+
if (!plan.valid) process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function optionalJson(path) {
|
|
261
|
+
if (!path || !(await exists(path))) return null;
|
|
262
|
+
return readJson(path);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function writeRecord(path, plan, verified = true) {
|
|
266
|
+
const recordPath = resolve(process.cwd(), path);
|
|
267
|
+
await mkdir(dirname(recordPath), { recursive: true });
|
|
268
|
+
await writeFile(recordPath, `${JSON.stringify({ ...plan.record, observedHash: plan.targetHash, verified }, null, 2)}\n`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function runReconcileLabels(args) {
|
|
272
|
+
const declarationPath = option(args, '--input');
|
|
273
|
+
const existingPath = option(args, '--existing');
|
|
274
|
+
const recordPath = option(args, '--record') ?? '.lighthouse/reconciliation/labels.json';
|
|
275
|
+
const format = option(args, '--format') ?? 'json';
|
|
276
|
+
const apply = args.includes('--apply');
|
|
277
|
+
if (!declarationPath || !existingPath) { console.error('Usage: lighthouse-sdlc reconcile-labels --input <capabilities.json> --existing <labels.json> [--record <path>] [--apply] [--format <json|text>]'); process.exit(2); }
|
|
278
|
+
const declaration = await readJson(resolve(process.cwd(), declarationPath));
|
|
279
|
+
const existing = await readJson(resolve(process.cwd(), existingPath));
|
|
280
|
+
const plan = buildLabelReconciliationPlan(declaration, existing, await optionalJson(resolve(process.cwd(), recordPath)));
|
|
281
|
+
if (apply && plan.canApply) {
|
|
282
|
+
const current = await readJson(resolve(process.cwd(), existingPath));
|
|
283
|
+
const reread = buildLabelReconciliationPlan(declaration, current);
|
|
284
|
+
if (reread.observedHash !== plan.observedHash) throw new Error('Label reconciliation changed since the plan was created; refusing to apply.');
|
|
285
|
+
const updated = applyLabelReconciliationPlan(reread, current);
|
|
286
|
+
await writeFile(resolve(process.cwd(), existingPath), `${JSON.stringify(updated, null, 2)}\n`);
|
|
287
|
+
await writeRecord(recordPath, reread);
|
|
288
|
+
plan.applied = true;
|
|
289
|
+
plan.record = { ...reread.record, observedHash: reread.targetHash, verified: true };
|
|
290
|
+
} else plan.applied = false;
|
|
291
|
+
output(plan, format);
|
|
292
|
+
if (!plan.valid || !plan.canApply) process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function runGithubLabels(args) {
|
|
296
|
+
const repo = option(args, '--repo');
|
|
297
|
+
const declarationPath = option(args, '--input');
|
|
298
|
+
const recordPath = option(args, '--record') ?? '.lighthouse/reconciliation/labels.json';
|
|
299
|
+
const format = option(args, '--format') ?? 'json';
|
|
300
|
+
const apply = args.includes('--apply');
|
|
301
|
+
if (!repo || !declarationPath) { console.error('Usage: lighthouse-sdlc github-labels --repo <owner/repo> --input <capabilities.json> [--record <path>] [--apply] [--format <json|text>]'); process.exit(2); }
|
|
302
|
+
const declaration = await readJson(resolve(process.cwd(), declarationPath));
|
|
303
|
+
const labels = await readGithubLabels(repo);
|
|
304
|
+
const plan = buildLabelReconciliationPlan(declaration, labels, await optionalJson(resolve(process.cwd(), recordPath)));
|
|
305
|
+
if (apply && plan.canApply) {
|
|
306
|
+
const current = await readGithubLabels(repo);
|
|
307
|
+
if (buildLabelReconciliationPlan(declaration, current).observedHash !== plan.observedHash) throw new Error('GitHub labels changed since the plan was created; refusing to apply.');
|
|
308
|
+
for (const operation of plan.operations) await createGithubLabel(repo, operation, { dryRun: false });
|
|
309
|
+
const finalLabels = await readGithubLabels(repo);
|
|
310
|
+
const appliedPlan = buildLabelReconciliationPlan(declaration, finalLabels);
|
|
311
|
+
if (!appliedPlan.canApply || appliedPlan.operations.length > 0) throw new Error('GitHub label reconciliation did not reach the declared state.');
|
|
312
|
+
await writeRecord(recordPath, appliedPlan);
|
|
313
|
+
plan.applied = true;
|
|
314
|
+
plan.record = { ...appliedPlan.record, observedHash: appliedPlan.targetHash, verified: true };
|
|
315
|
+
} else plan.applied = false;
|
|
316
|
+
output(plan, format);
|
|
317
|
+
if (!plan.valid || !plan.canApply) process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
|
|
108
320
|
const command = process.argv[2] ?? 'verify';
|
|
109
|
-
if (!['contract', 'verify', 'triage'].includes(command)) {
|
|
110
|
-
console.error('Usage: lighthouse-sdlc <contract|verify|triage>');
|
|
321
|
+
if (!['contract', 'verify', 'triage', 'state', 'stage-result', 'control-plane', 'memory', 'launch', 'decompose', 'stacked', 'route', 'github-triage', 'github-decompose', 'reconcile-labels', 'github-labels'].includes(command)) {
|
|
322
|
+
console.error('Usage: lighthouse-sdlc <contract|verify|triage|state|stage-result|control-plane|memory|launch|decompose|stacked|route|github-triage|github-decompose|reconcile-labels|github-labels>');
|
|
111
323
|
process.exit(2);
|
|
112
324
|
}
|
|
113
325
|
if (command === 'contract') {
|
|
114
326
|
console.log(await readFile(contractPath, 'utf8'));
|
|
115
327
|
} else if (command === 'triage') {
|
|
116
328
|
await runTriage(process.argv.slice(3));
|
|
329
|
+
} else if (command === 'state') {
|
|
330
|
+
await runState(process.argv.slice(3));
|
|
331
|
+
} else if (command === 'control-plane') {
|
|
332
|
+
await runControlPlane(process.argv.slice(3));
|
|
333
|
+
} else if (command === 'stage-result') {
|
|
334
|
+
await runStageResult(process.argv.slice(3));
|
|
335
|
+
} else if (command === 'memory') {
|
|
336
|
+
await runMemory(process.argv.slice(3));
|
|
337
|
+
} else if (command === 'launch') {
|
|
338
|
+
await runLaunch(process.argv.slice(3));
|
|
339
|
+
} else if (command === 'decompose') {
|
|
340
|
+
await runDecompose(process.argv.slice(3));
|
|
341
|
+
} else if (command === 'route') {
|
|
342
|
+
await runRoute(process.argv.slice(3));
|
|
343
|
+
} else if (command === 'stacked') {
|
|
344
|
+
await runStacked(process.argv.slice(3));
|
|
345
|
+
} else if (command === 'github-triage') {
|
|
346
|
+
await runGithubTriage(process.argv.slice(3));
|
|
347
|
+
} else if (command === 'github-decompose') {
|
|
348
|
+
await runGithubDecompose(process.argv.slice(3));
|
|
349
|
+
} else if (command === 'reconcile-labels') {
|
|
350
|
+
await runReconcileLabels(process.argv.slice(3));
|
|
351
|
+
} else if (command === 'github-labels') {
|
|
352
|
+
await runGithubLabels(process.argv.slice(3));
|
|
117
353
|
} else {
|
|
118
354
|
const problems = await verifyContract();
|
|
119
355
|
if (problems.length) {
|
|
@@ -123,4 +359,4 @@ if (command === 'contract') {
|
|
|
123
359
|
console.log('Lighthouse SDLC package contract is valid.');
|
|
124
360
|
}
|
|
125
361
|
|
|
126
|
-
export { contractPath, runTriage, verifyContract };
|
|
362
|
+
export { contractPath, runTriage, verifyContract, runState, runStageResult, runControlPlane, runMemory, runLaunch, runDecompose, runRoute, runStacked, runGithubTriage, runGithubDecompose, runReconcileLabels, runGithubLabels };
|
package/src/stacked.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function issue(code, message, field) {
|
|
6
|
+
return field ? { code, message, field } : { code, message };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function slug(value) {
|
|
10
|
+
return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function buildStackedPlan(input) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
if (!isRecord(input) || !isRecord(input.parent) || !Array.isArray(input.slices)) return { schemaVersion: 1, status: 'invalid', valid: false, canApply: false, errors: [issue('stacked.invalid', 'stacked input needs parent and slices')] };
|
|
16
|
+
const parentRef = input.parent.number ?? input.parent.id;
|
|
17
|
+
if (parentRef === undefined || parentRef === null || String(parentRef).trim() === '') errors.push(issue('stacked.parent', 'parent needs an id or number', 'parent'));
|
|
18
|
+
const seenBranches = new Set();
|
|
19
|
+
const slices = input.slices.map((slice, index) => {
|
|
20
|
+
if (!isRecord(slice) || typeof slice.id !== 'string' || !slice.id.trim() || typeof slice.title !== 'string' || !slice.title.trim()) { errors.push(issue('stacked.slice', `slice ${index} needs id and title`, `slices[${index}]`)); return null; }
|
|
21
|
+
const branchPrefix = String(parentRef).match(/[1-9]\d*/u)?.[0];
|
|
22
|
+
const branch = slice.branch ?? (branchPrefix ? `feat/${branchPrefix}-${slug(slice.title)}` : null);
|
|
23
|
+
if (!branch) { errors.push(issue('stacked.branch', `slice ${slice.id} needs an explicit branch when the parent has no numeric issue id`, `slices[${index}].branch`)); return null; }
|
|
24
|
+
if (seenBranches.has(branch)) errors.push(issue('stacked.branch-duplicate', `branch ${branch} appears more than once`, `slices[${index}].branch`));
|
|
25
|
+
seenBranches.add(branch);
|
|
26
|
+
return { id: slice.id, title: slice.title, branch, base: slice.base ?? null, dependencies: [...(slice.dependencies ?? [])].sort(), acceptance: [...(slice.acceptance ?? [])] };
|
|
27
|
+
}).filter(Boolean);
|
|
28
|
+
const byId = new Map(slices.map((slice) => [slice.id, slice]));
|
|
29
|
+
const order = [];
|
|
30
|
+
const visiting = new Set();
|
|
31
|
+
const visited = new Set();
|
|
32
|
+
function visit(id) {
|
|
33
|
+
if (visited.has(id)) return;
|
|
34
|
+
if (visiting.has(id)) { errors.push(issue('stacked.cycle', `dependency cycle includes ${id}`, 'slices')); return; }
|
|
35
|
+
visiting.add(id);
|
|
36
|
+
for (const dependency of byId.get(id)?.dependencies ?? []) {
|
|
37
|
+
if (!byId.has(dependency)) errors.push(issue('stacked.unknown-dependency', `slice ${id} depends on unknown slice ${dependency}`, 'slices'));
|
|
38
|
+
else visit(dependency);
|
|
39
|
+
}
|
|
40
|
+
visiting.delete(id);
|
|
41
|
+
visited.add(id);
|
|
42
|
+
order.push(id);
|
|
43
|
+
}
|
|
44
|
+
for (const slice of slices) visit(slice.id);
|
|
45
|
+
const operations = order.map((id, index) => {
|
|
46
|
+
const slice = byId.get(id);
|
|
47
|
+
return { type: 'open-pull-request', sliceId: id, branch: slice.branch, base: slice.base ?? (index === 0 ? input.parent.base ?? 'main' : slices.find((candidate) => candidate.id === order[index - 1])?.branch) };
|
|
48
|
+
});
|
|
49
|
+
return { schemaVersion: 1, status: errors.length ? 'invalid' : 'ready', valid: errors.length === 0, canApply: errors.length === 0, parent: input.parent, slices, order, operations, errors };
|
|
50
|
+
}
|
package/src/triage.mjs
CHANGED
|
@@ -54,7 +54,7 @@ function addIssue(issues, code, message, field) {
|
|
|
54
54
|
issues.push(issue(code, message, field));
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function validateConfiguration(configuration) {
|
|
57
|
+
export function validateConfiguration(configuration) {
|
|
58
58
|
const errors = [];
|
|
59
59
|
const labels = Array.isArray(configuration?.labels) ? configuration.labels : null;
|
|
60
60
|
const byName = new Map();
|
|
@@ -281,4 +281,4 @@ export function applyTriageLabels(report) {
|
|
|
281
281
|
return [...report.labels.proposed];
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
export { DIMENSIONS, ROUTES
|
|
284
|
+
export { DIMENSIONS, ROUTES };
|
package/src/workflow.mjs
ADDED
|
@@ -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
|
+
}
|