@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/sdlc.mjs CHANGED
@@ -1,8 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { access, readFile } 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';
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';
6
16
 
7
17
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
8
18
  const contractPath = resolve(packageRoot, 'contract.json');
@@ -28,10 +38,10 @@ async function verifyContract() {
28
38
  for (const stage of stages) {
29
39
  for (const asset of [stage.skill, stage.command]) if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing stage asset ${asset}`);
30
40
  }
31
- for (const asset of [contract.assets?.routes, contract.assets?.gates, contract.assets?.controlPlaneSchema]) {
41
+ 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]) {
32
42
  if (!asset || !(await exists(resolve(packageRoot, asset)))) problems.push(`missing contract asset ${asset ?? '(empty)'}`);
33
43
  }
34
- for (const asset of ['routes.json', 'gates.json', 'schemas/routes.schema.json', 'schemas/gates.schema.json', 'schemas/control-plane.schema.json']) {
44
+ 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', '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']) {
35
45
  if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing package asset ${asset}`);
36
46
  }
37
47
  try {
@@ -44,18 +54,297 @@ async function verifyContract() {
44
54
  const gateIds = gates.gates?.map((gate) => gate.id) ?? [];
45
55
  if (gateIds.join(',') !== contract.gates.join(',')) problems.push('gates.json does not match contract gates');
46
56
  } catch (error) { problems.push(`Cannot read gates.json: ${error.message}`); }
47
- for (const command of ['status', ...expectedStages]) if (!(await exists(resolve(packageRoot, `commands/${command}.md`)))) problems.push(`missing command asset ${command}`);
57
+ try {
58
+ const stages = await readJson(resolve(packageRoot, 'stages.json'));
59
+ const stageIds = stages.stages?.map((stage) => stage.id) ?? [];
60
+ if (stageIds.join(',') !== expectedStages.join(',')) problems.push('stages.json does not match contract stages');
61
+ } catch (error) { problems.push(`Cannot read stages.json: ${error.message}`); }
62
+ 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}`);
48
63
  for (const metaCommand of contract.metaCommands ?? []) if (!(await exists(resolve(packageRoot, `meta-commands/${metaCommand}.md`)))) problems.push(`missing meta-command asset ${metaCommand}`);
49
64
  return problems;
50
65
  }
51
66
 
67
+ function option(args, name) {
68
+ const index = args.indexOf(name);
69
+ if (index !== -1) return args[index + 1];
70
+ const prefix = `${name}=`;
71
+ return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
72
+ }
73
+
74
+ function triageText(report) {
75
+ const lines = [
76
+ `Triage: ${report.status}`,
77
+ `Work item: ${report.workItem?.id ?? '(unknown)'} — ${report.workItem?.title ?? '(untitled)'}`,
78
+ `Size: ${report.assessment.size}`,
79
+ `Urgency: ${report.assessment.urgency}`,
80
+ `Impact: ${report.assessment.impact}`,
81
+ `Risk: ${report.assessment.risk}`,
82
+ `Blast radius: ${report.assessment.blastRadius}`,
83
+ `Route: ${report.assessment.route ?? '(unknown)'}`,
84
+ `Transition: ${report.transition.from ?? '(unknown)'} -> ${report.transition.to ?? '(unknown)'}`,
85
+ `Labels: ${report.labels.proposed.join(', ') || '(none)'}`,
86
+ ];
87
+ if (report.missingEvidence.length) lines.push(`Missing evidence: ${report.missingEvidence.join(', ')}`);
88
+ if (report.unavailableChecks.length) lines.push(`Unavailable checks: ${report.unavailableChecks.join(', ')}`);
89
+ if (report.pendingReview.length) lines.push(`Pending review: ${report.pendingReview.join(', ')}`);
90
+ for (const error of report.errors) lines.push(`Error: ${error.message}`);
91
+ lines.push(report.applied ? 'Applied: yes' : 'Applied: no (read-only)');
92
+ return `${lines.join('\n')}\n`;
93
+ }
94
+
95
+ async function runTriage(args) {
96
+ const inputPath = option(args, '--input');
97
+ const configPath = option(args, '--config');
98
+ const labelsPath = option(args, '--labels-file');
99
+ const format = option(args, '--format') ?? 'text';
100
+ const apply = args.includes('--apply');
101
+ if (!inputPath || !['text', 'json'].includes(format) || (apply && !labelsPath)) {
102
+ console.error('Usage: lighthouse-sdlc triage --input <file> [--config <file>] [--format <text|json>] [--apply --labels-file <file>]');
103
+ process.exit(2);
104
+ }
105
+ const input = await readJson(resolve(process.cwd(), inputPath));
106
+ if (configPath) input.configuration = await readJson(resolve(process.cwd(), configPath));
107
+ const report = buildTriageReport(input);
108
+ report.applied = false;
109
+ if (apply && report.canApply) {
110
+ const labelsFile = resolve(process.cwd(), labelsPath);
111
+ const current = await readJson(labelsFile);
112
+ const expected = JSON.stringify(report.labels.current);
113
+ if (!Array.isArray(current) || JSON.stringify([...new Set(current)].sort()) !== expected) throw new Error('labels file does not match currentLabels; refusing to apply the triage projection');
114
+ await writeFile(labelsFile, `${JSON.stringify(applyTriageLabels(report), null, 2)}\n`);
115
+ report.applied = true;
116
+ }
117
+ if (format === 'json') console.log(JSON.stringify(report, null, 2));
118
+ else process.stdout.write(triageText(report));
119
+ if (report.status !== 'ready') process.exit(1);
120
+ }
121
+
122
+ function output(value, format = 'json') {
123
+ if (format === 'json') console.log(JSON.stringify(value, null, 2));
124
+ else console.log(JSON.stringify(value));
125
+ }
126
+
127
+ async function runState(args) {
128
+ const inputPath = option(args, '--input');
129
+ const format = option(args, '--format') ?? 'json';
130
+ if (!inputPath) { console.error('Usage: lighthouse-sdlc state --input <events.json> [--format <json|text>]'); process.exit(2); }
131
+ const input = await readJson(resolve(process.cwd(), inputPath));
132
+ const result = replayWorkflow(Array.isArray(input) ? input : input.events);
133
+ output(result, format);
134
+ if (!result.valid) process.exit(1);
135
+ }
136
+
137
+ async function runControlPlane(args) {
138
+ const inputPath = option(args, '--input');
139
+ const format = option(args, '--format') ?? 'json';
140
+ if (!inputPath) { console.error('Usage: lighthouse-sdlc control-plane --input <control-plane.json> [--format <json|text>]'); process.exit(2); }
141
+ const input = await readJson(resolve(process.cwd(), inputPath));
142
+ const errors = validateControlPlane(input);
143
+ const result = { schemaVersion: 1, status: errors.length ? 'invalid' : 'valid', valid: errors.length === 0, errors };
144
+ output(result, format);
145
+ if (errors.length) process.exit(1);
146
+ }
147
+
148
+ async function runStageResult(args) {
149
+ const inputPath = option(args, '--input');
150
+ const format = option(args, '--format') ?? 'json';
151
+ if (!inputPath) { console.error('Usage: lighthouse-sdlc stage-result --input <result.json> [--format <json|text>]'); process.exit(2); }
152
+ const result = await readJson(resolve(process.cwd(), inputPath));
153
+ const errors = validateStageResult(result);
154
+ output({ schemaVersion: 1, status: errors.length ? 'invalid' : 'valid', valid: errors.length === 0, errors }, format);
155
+ if (errors.length) process.exit(1);
156
+ }
157
+
158
+ async function runMemory(args) {
159
+ const inputPath = option(args, '--input');
160
+ const policyPath = option(args, '--policy');
161
+ const format = option(args, '--format') ?? 'json';
162
+ if (!inputPath) { console.error('Usage: lighthouse-sdlc memory --input <record.json> [--policy <memory-policy.json>] [--format <json|text>]'); process.exit(2); }
163
+ const record = await readJson(resolve(process.cwd(), inputPath));
164
+ const policy = policyPath ? await readJson(resolve(process.cwd(), policyPath)) : {};
165
+ const errors = validateMemoryRecord(record, policy);
166
+ output({ schemaVersion: 1, status: errors.length ? 'invalid' : 'valid', valid: errors.length === 0, errors }, format);
167
+ if (errors.length) process.exit(1);
168
+ }
169
+
170
+ async function runLaunch(args) {
171
+ const inputPath = option(args, '--input');
172
+ const controlPlanePath = option(args, '--control-plane');
173
+ const format = option(args, '--format') ?? 'json';
174
+ if (!inputPath || !controlPlanePath) { console.error('Usage: lighthouse-sdlc launch --input <request.json> --control-plane <control-plane.json> [--format <json|text>]'); process.exit(2); }
175
+ const request = await readJson(resolve(process.cwd(), inputPath));
176
+ const controlPlane = await readJson(resolve(process.cwd(), controlPlanePath));
177
+ const errors = [...validateControlPlane(controlPlane), ...validateLaunchRequest(request, controlPlane)];
178
+ output({ schemaVersion: 1, status: errors.length ? 'invalid' : 'valid', valid: errors.length === 0, executed: false, errors }, format);
179
+ if (errors.length) process.exit(1);
180
+ }
181
+
182
+ async function runDecompose(args) {
183
+ const inputPath = option(args, '--input');
184
+ const outputPath = option(args, '--output');
185
+ const format = option(args, '--format') ?? 'json';
186
+ const apply = args.includes('--apply');
187
+ if (!inputPath || (apply && !outputPath)) { console.error('Usage: lighthouse-sdlc decompose --input <file> [--format <json|text>] [--apply --output <file>]'); process.exit(2); }
188
+ const plan = buildDecompositionPlan(await readJson(resolve(process.cwd(), inputPath)));
189
+ if (apply && plan.canApply) await writeFile(resolve(process.cwd(), outputPath), `${JSON.stringify(applyDecompositionPlan(plan), null, 2)}\n`);
190
+ output({ ...plan, applied: Boolean(apply && plan.canApply) }, format);
191
+ if (!plan.valid) process.exit(1);
192
+ }
193
+
194
+ async function runRoute(args) {
195
+ const routeId = option(args, '--route');
196
+ const format = option(args, '--format') ?? 'json';
197
+ if (!routeId) { console.error('Usage: lighthouse-sdlc route --route <name> [--available <comma-separated-prerequisites>] [--format <json|text>]'); process.exit(2); }
198
+ const routes = await readJson(resolve(packageRoot, 'routes.json'));
199
+ const route = routes.routes?.find((candidate) => candidate.id === routeId);
200
+ if (!route) { output({ schemaVersion: 1, status: 'invalid', valid: false, errors: [{ code: 'route.unknown', message: `unknown route ${routeId}` }] }, format); process.exit(1); }
201
+ const available = (option(args, '--available') ?? '').split(',').map((item) => item.trim()).filter(Boolean);
202
+ const plan = buildRoutePlan(route, { available });
203
+ output(plan, format);
204
+ if (!plan.valid) process.exit(1);
205
+ }
206
+
207
+ async function runStacked(args) {
208
+ const inputPath = option(args, '--input');
209
+ const outputPath = option(args, '--output');
210
+ const format = option(args, '--format') ?? 'json';
211
+ const apply = args.includes('--apply');
212
+ if (!inputPath || (apply && !outputPath)) { console.error('Usage: lighthouse-sdlc stacked --input <file> [--format <json|text>] [--apply --output <file>]'); process.exit(2); }
213
+ const plan = buildStackedPlan(await readJson(resolve(process.cwd(), inputPath)));
214
+ if (apply && plan.canApply) await writeFile(resolve(process.cwd(), outputPath), `${JSON.stringify({ ...plan, applied: true }, null, 2)}\n`);
215
+ output({ ...plan, applied: Boolean(apply && plan.canApply) }, format);
216
+ if (!plan.valid) process.exit(1);
217
+ }
218
+
219
+ async function runGithubTriage(args) {
220
+ const repo = option(args, '--repo');
221
+ const issueNumber = option(args, '--issue');
222
+ const assessmentPath = option(args, '--assessment');
223
+ const format = option(args, '--format') ?? 'json';
224
+ const apply = args.includes('--apply');
225
+ 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); }
226
+ const assessmentInput = await readJson(resolve(process.cwd(), assessmentPath));
227
+ const issueMetadata = await readGithubIssue(repo, issueNumber);
228
+ const report = buildTriageReport(mergeGithubTriage(issueMetadata, assessmentInput.assessment, assessmentInput.configuration));
229
+ report.applied = false;
230
+ if (apply && report.canApply) {
231
+ const reread = await readGithubIssue(repo, issueNumber);
232
+ 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');
233
+ const applied = await replaceGithubLabels(repo, issueNumber, applyTriageLabels(report), { dryRun: false });
234
+ report.applied = applied.applied;
235
+ }
236
+ output(report, format);
237
+ if (report.status !== 'ready') process.exit(1);
238
+ }
239
+
240
+ async function runGithubDecompose(args) {
241
+ const repo = option(args, '--repo');
242
+ const issueNumber = option(args, '--issue');
243
+ const inputPath = option(args, '--input');
244
+ const format = option(args, '--format') ?? 'json';
245
+ const apply = args.includes('--apply');
246
+ 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); }
247
+ const parent = await readGithubIssue(repo, issueNumber);
248
+ const input = await readJson(resolve(process.cwd(), inputPath));
249
+ const plan = buildDecompositionPlan({ ...input, parent: { ...input.parent, id: parent.workItem.id, number: Number(issueNumber), title: parent.workItem.title } });
250
+ const result = plan.canApply ? await createGithubSubIssues(repo, issueNumber, plan, { dryRun: !apply }) : { applied: false, operations: [] };
251
+ output({ ...plan, github: result }, format);
252
+ if (!plan.valid) process.exit(1);
253
+ }
254
+
255
+ async function optionalJson(path) {
256
+ if (!path || !(await exists(path))) return null;
257
+ return readJson(path);
258
+ }
259
+
260
+ async function writeRecord(path, plan, verified = true) {
261
+ const recordPath = resolve(process.cwd(), path);
262
+ await mkdir(dirname(recordPath), { recursive: true });
263
+ await writeFile(recordPath, `${JSON.stringify({ ...plan.record, observedHash: plan.targetHash, verified }, null, 2)}\n`);
264
+ }
265
+
266
+ async function runReconcileLabels(args) {
267
+ const declarationPath = option(args, '--input');
268
+ const existingPath = option(args, '--existing');
269
+ const recordPath = option(args, '--record') ?? '.lighthouse/reconciliation/labels.json';
270
+ const format = option(args, '--format') ?? 'json';
271
+ const apply = args.includes('--apply');
272
+ 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); }
273
+ const declaration = await readJson(resolve(process.cwd(), declarationPath));
274
+ const existing = await readJson(resolve(process.cwd(), existingPath));
275
+ const plan = buildLabelReconciliationPlan(declaration, existing, await optionalJson(resolve(process.cwd(), recordPath)));
276
+ if (apply && plan.canApply) {
277
+ const current = await readJson(resolve(process.cwd(), existingPath));
278
+ const reread = buildLabelReconciliationPlan(declaration, current);
279
+ if (reread.observedHash !== plan.observedHash) throw new Error('Label reconciliation changed since the plan was created; refusing to apply.');
280
+ const updated = applyLabelReconciliationPlan(reread, current);
281
+ await writeFile(resolve(process.cwd(), existingPath), `${JSON.stringify(updated, null, 2)}\n`);
282
+ await writeRecord(recordPath, reread);
283
+ plan.applied = true;
284
+ plan.record = { ...reread.record, observedHash: reread.targetHash, verified: true };
285
+ } else plan.applied = false;
286
+ output(plan, format);
287
+ if (!plan.valid || !plan.canApply) process.exit(1);
288
+ }
289
+
290
+ async function runGithubLabels(args) {
291
+ const repo = option(args, '--repo');
292
+ const declarationPath = option(args, '--input');
293
+ const recordPath = option(args, '--record') ?? '.lighthouse/reconciliation/labels.json';
294
+ const format = option(args, '--format') ?? 'json';
295
+ const apply = args.includes('--apply');
296
+ 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); }
297
+ const declaration = await readJson(resolve(process.cwd(), declarationPath));
298
+ const labels = await readGithubLabels(repo);
299
+ const plan = buildLabelReconciliationPlan(declaration, labels, await optionalJson(resolve(process.cwd(), recordPath)));
300
+ if (apply && plan.canApply) {
301
+ const current = await readGithubLabels(repo);
302
+ if (buildLabelReconciliationPlan(declaration, current).observedHash !== plan.observedHash) throw new Error('GitHub labels changed since the plan was created; refusing to apply.');
303
+ for (const operation of plan.operations) await createGithubLabel(repo, operation, { dryRun: false });
304
+ const finalLabels = await readGithubLabels(repo);
305
+ const appliedPlan = buildLabelReconciliationPlan(declaration, finalLabels);
306
+ if (!appliedPlan.canApply || appliedPlan.operations.length > 0) throw new Error('GitHub label reconciliation did not reach the declared state.');
307
+ await writeRecord(recordPath, appliedPlan);
308
+ plan.applied = true;
309
+ plan.record = { ...appliedPlan.record, observedHash: appliedPlan.targetHash, verified: true };
310
+ } else plan.applied = false;
311
+ output(plan, format);
312
+ if (!plan.valid || !plan.canApply) process.exit(1);
313
+ }
314
+
52
315
  const command = process.argv[2] ?? 'verify';
53
- if (!['contract', 'verify'].includes(command)) {
54
- console.error('Usage: lighthouse-sdlc <contract|verify>');
316
+ if (!['contract', 'verify', 'triage', 'state', 'stage-result', 'control-plane', 'memory', 'launch', 'decompose', 'stacked', 'route', 'github-triage', 'github-decompose', 'reconcile-labels', 'github-labels'].includes(command)) {
317
+ 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>');
55
318
  process.exit(2);
56
319
  }
57
320
  if (command === 'contract') {
58
321
  console.log(await readFile(contractPath, 'utf8'));
322
+ } else if (command === 'triage') {
323
+ await runTriage(process.argv.slice(3));
324
+ } else if (command === 'state') {
325
+ await runState(process.argv.slice(3));
326
+ } else if (command === 'control-plane') {
327
+ await runControlPlane(process.argv.slice(3));
328
+ } else if (command === 'stage-result') {
329
+ await runStageResult(process.argv.slice(3));
330
+ } else if (command === 'memory') {
331
+ await runMemory(process.argv.slice(3));
332
+ } else if (command === 'launch') {
333
+ await runLaunch(process.argv.slice(3));
334
+ } else if (command === 'decompose') {
335
+ await runDecompose(process.argv.slice(3));
336
+ } else if (command === 'route') {
337
+ await runRoute(process.argv.slice(3));
338
+ } else if (command === 'stacked') {
339
+ await runStacked(process.argv.slice(3));
340
+ } else if (command === 'github-triage') {
341
+ await runGithubTriage(process.argv.slice(3));
342
+ } else if (command === 'github-decompose') {
343
+ await runGithubDecompose(process.argv.slice(3));
344
+ } else if (command === 'reconcile-labels') {
345
+ await runReconcileLabels(process.argv.slice(3));
346
+ } else if (command === 'github-labels') {
347
+ await runGithubLabels(process.argv.slice(3));
59
348
  } else {
60
349
  const problems = await verifyContract();
61
350
  if (problems.length) {
@@ -65,4 +354,4 @@ if (command === 'contract') {
65
354
  console.log('Lighthouse SDLC package contract is valid.');
66
355
  }
67
356
 
68
- export { contractPath, verifyContract };
357
+ export { contractPath, runTriage, verifyContract, runState, runStageResult, runControlPlane, runMemory, runLaunch, runDecompose, runRoute, runStacked, runGithubTriage, runGithubDecompose, runReconcileLabels, runGithubLabels };
@@ -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
+ }