@juangcarmona/lighthouse-sdlc 0.3.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.
package/src/github.mjs ADDED
@@ -0,0 +1,67 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ function issue(code, message, field) {
7
+ return field ? { code, message, field } : { code, message };
8
+ }
9
+
10
+ export async function ghJson(args, { command = 'gh', cwd } = {}) {
11
+ const { stdout } = await execFileAsync(command, ['api', ...args], { cwd, maxBuffer: 10 * 1024 * 1024 });
12
+ return JSON.parse(stdout);
13
+ }
14
+
15
+ export async function readGithubIssue(repo, number, options = {}) {
16
+ if (!repo || !number) throw new Error('repo and issue number are required');
17
+ const issueRecord = await ghJson([`repos/${repo}/issues/${number}`], options);
18
+ return {
19
+ schemaVersion: 1,
20
+ workItem: { id: `GH-${issueRecord.number}`, title: issueRecord.title, type: issueRecord.pull_request ? 'pull-request' : 'issue', url: issueRecord.html_url, state: issueRecord.state },
21
+ currentLabels: (issueRecord.labels ?? []).map((label) => typeof label === 'string' ? label : label.name).filter(Boolean).sort(),
22
+ links: { url: issueRecord.html_url, parent: issueRecord.parent ?? null, subIssues: issueRecord.sub_issues ?? [] },
23
+ dependencies: issueRecord.dependencies ?? [],
24
+ source: { provider: 'github', repository: repo, number: issueRecord.number },
25
+ };
26
+ }
27
+
28
+ export async function readGithubLabels(repo, options = {}) {
29
+ if (!repo) throw new Error('repo is required');
30
+ const labels = await ghJson([`repos/${repo}/labels?per_page=100`], options);
31
+ return Array.isArray(labels) ? labels.map((label) => ({ name: label.name, color: label.color, description: label.description ?? null })).sort((left, right) => left.name.localeCompare(right.name)) : [];
32
+ }
33
+
34
+ export async function createGithubLabel(repo, label, options = {}) {
35
+ if (!label?.name) throw new Error('label name is required');
36
+ if (options.dryRun !== false) return { applied: false, label };
37
+ const created = await ghJson(['--method', 'POST', `repos/${repo}/labels`, '-f', `name=${label.name}`, '-f', `color=${String(label.color ?? '8B949E').replace(/^#/u, '')}`, '-f', `description=${label.description ?? ''}`], options);
38
+ return { applied: true, label: { name: created.name, color: created.color, description: created.description ?? null } };
39
+ }
40
+
41
+ export function mergeGithubTriage(issueRecord, assessment, configuration) {
42
+ if (!issueRecord || typeof issueRecord !== 'object') return { errors: [issue('github.issue', 'issue metadata is required')] };
43
+ if (!assessment || typeof assessment !== 'object') return { errors: [issue('github.assessment', 'structured assessment is required')] };
44
+ return { ...issueRecord, assessment, configuration };
45
+ }
46
+
47
+ export async function replaceGithubLabels(repo, number, labels, options = {}) {
48
+ if (!Array.isArray(labels)) throw new Error('labels must be an array');
49
+ if (options.dryRun !== false) return { applied: false, labels: [...labels] };
50
+ const { stdout } = await execFileAsync(options.command ?? 'gh', ['api', '--method', 'PUT', `repos/${repo}/issues/${number}/labels`, ...labels.flatMap((label) => ['-f', `labels[]=${label}`])], { cwd: options.cwd, maxBuffer: 10 * 1024 * 1024 });
51
+ return { applied: true, labels: JSON.parse(stdout).map((label) => label.name).sort() };
52
+ }
53
+
54
+ export async function createGithubSubIssues(repo, parentNumber, plan, options = {}) {
55
+ if (!plan?.canApply) throw new Error('Decomposition plan is not ready to apply. Resolve its errors first.');
56
+ const operations = plan.order.map((id) => {
57
+ const slice = plan.slices.find((candidate) => candidate.id === id);
58
+ return { type: 'create-child-issue', parent: Number(parentNumber), sliceId: id, title: slice.title, body: `Parent issue: #${parentNumber}\n\nAcceptance evidence:\n${slice.acceptance.map((item) => `- ${item}`).join('\n')}` };
59
+ });
60
+ if (options.dryRun !== false) return { applied: false, operations };
61
+ const created = [];
62
+ for (const operation of operations) {
63
+ const issueRecord = await ghJson(['--method', 'POST', `repos/${repo}/issues`, '-f', `title=${operation.title}`, '-f', `body=${operation.body}`], options);
64
+ created.push({ ...operation, number: issueRecord.number, url: issueRecord.html_url });
65
+ }
66
+ return { applied: true, operations: created };
67
+ }
package/src/memory.mjs ADDED
@@ -0,0 +1,22 @@
1
+ export const MEMORY_SCOPES = Object.freeze(['run', 'project', 'shared']);
2
+ const scopeSet = new Set(MEMORY_SCOPES);
3
+
4
+ function isRecord(value) {
5
+ return value && typeof value === 'object' && !Array.isArray(value);
6
+ }
7
+
8
+ function issue(code, message, field) {
9
+ return field ? { code, message, field } : { code, message };
10
+ }
11
+
12
+ export function validateMemoryRecord(record, policy = {}) {
13
+ const errors = [];
14
+ if (!isRecord(record)) return [issue('memory.invalid', 'memory record must be an object')];
15
+ for (const field of ['id', 'owner', 'source', 'confidence', 'retention', 'sensitivity']) if (typeof record[field] !== 'string' || !record[field].trim()) errors.push(issue(`memory.${field}`, `${field} must be a non-empty string`, field));
16
+ if (!scopeSet.has(record.scope)) errors.push(issue('memory.scope', `scope must be one of ${MEMORY_SCOPES.join(', ')}`, 'scope'));
17
+ if (record.scope === 'run' && (typeof record.runId !== 'string' || !record.runId.trim())) errors.push(issue('memory.run-id', 'run-scoped memory needs runId', 'runId'));
18
+ if (record.scope === 'project' && (typeof record.projectId !== 'string' || !record.projectId.trim())) errors.push(issue('memory.project-id', 'project-scoped memory needs projectId', 'projectId'));
19
+ if (record.scope === 'shared' && policy.shared !== true) errors.push(issue('memory.shared-disabled', 'shared memory is not enabled by the control-plane policy', 'scope'));
20
+ if (!isRecord(record.value)) errors.push(issue('memory.value', 'value must be an object', 'value'));
21
+ return errors;
22
+ }
@@ -0,0 +1,165 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { validateConfiguration, suggestedLabelColors } from './triage.mjs';
3
+
4
+ const CAPABILITY_ID = 'labels';
5
+ const SUPPORTED_PROVIDER = 'github';
6
+
7
+ function issue(code, message, field) {
8
+ return field ? { code, message, field } : { code, message };
9
+ }
10
+
11
+ function isRecord(value) {
12
+ return value && typeof value === 'object' && !Array.isArray(value);
13
+ }
14
+
15
+ function stable(value) {
16
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
17
+ if (!isRecord(value)) return JSON.stringify(value);
18
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`;
19
+ }
20
+
21
+ function digest(value) {
22
+ return createHash('sha256').update(stable(value)).digest('hex');
23
+ }
24
+
25
+ function color(value) {
26
+ return typeof value === 'string' ? value.replace(/^#/u, '').toUpperCase() : null;
27
+ }
28
+
29
+ function comparableName(value) {
30
+ return String(value).toLowerCase().replace(/[^a-z0-9]/gu, '');
31
+ }
32
+
33
+ function normalizeExisting(labels) {
34
+ const errors = [];
35
+ if (!Array.isArray(labels)) return { labels: [], errors: [issue('labels.existing', 'existing labels must be an array', 'existing')] };
36
+ const seen = new Set();
37
+ const normalized = [];
38
+ for (const [index, item] of labels.entries()) {
39
+ const value = typeof item === 'string' ? { name: item } : item;
40
+ if (!isRecord(value) || typeof value.name !== 'string' || !value.name.trim()) {
41
+ errors.push(issue('labels.existing-item', `existing label ${index} needs a name`, `existing[${index}]`));
42
+ continue;
43
+ }
44
+ const name = value.name.trim();
45
+ if (seen.has(name)) errors.push(issue('labels.existing-duplicate', `existing label ${name} appears more than once`, 'existing'));
46
+ seen.add(name);
47
+ normalized.push({ name, color: color(value.color), description: value.description ?? null });
48
+ }
49
+ normalized.sort((left, right) => left.name.localeCompare(right.name));
50
+ return { labels: normalized, errors };
51
+ }
52
+
53
+ function capabilityConfig(capability) {
54
+ return capability?.configuration && isRecord(capability.configuration) ? capability.configuration : capability;
55
+ }
56
+
57
+ export function validateCapabilityDeclaration(declaration) {
58
+ const errors = [];
59
+ if (!isRecord(declaration)) return [issue('capabilities.invalid', 'capability declaration must be an object')];
60
+ if (declaration.schemaVersion !== 1) errors.push(issue('capabilities.schema-version', 'schemaVersion must be 1', 'schemaVersion'));
61
+ if (!Array.isArray(declaration.capabilities) || declaration.capabilities.length === 0) {
62
+ errors.push(issue('capabilities.list', 'capabilities must be a non-empty array', 'capabilities'));
63
+ return errors;
64
+ }
65
+ const selected = declaration.capabilities.filter((capability) => capability?.id === CAPABILITY_ID);
66
+ if (selected.length !== 1) errors.push(issue('capabilities.labels-selection', 'exactly one labels capability must be selected', 'capabilities'));
67
+ for (const [index, capability] of declaration.capabilities.entries()) {
68
+ if (!isRecord(capability) || typeof capability.id !== 'string' || !capability.id.trim()) {
69
+ errors.push(issue('capability.invalid', `capability ${index} needs an id`, `capabilities[${index}]`));
70
+ continue;
71
+ }
72
+ if (typeof capability.provider !== 'string' || !capability.provider.trim()) errors.push(issue('capability.provider', `capability ${capability.id} needs a provider`, `capabilities[${index}].provider`));
73
+ if (typeof capability.version !== 'string' || !capability.version.trim()) errors.push(issue('capability.version', `capability ${capability.id} needs a version`, `capabilities[${index}].version`));
74
+ }
75
+ const labels = selected[0];
76
+ if (labels) errors.push(...validateConfiguration(capabilityConfig(labels)).errors);
77
+ return errors;
78
+ }
79
+
80
+ export function buildLabelReconciliationPlan(declaration, existingLabels, previousRecord = null) {
81
+ const errors = validateCapabilityDeclaration(declaration);
82
+ const capability = declaration?.capabilities?.find((item) => item?.id === CAPABILITY_ID);
83
+ const existing = normalizeExisting(existingLabels);
84
+ errors.push(...existing.errors);
85
+ if (capability && capability.provider !== SUPPORTED_PROVIDER) errors.push(issue('capability.provider-unsupported', `labels provider ${capability.provider} is not supported by this adapter`, 'capabilities'));
86
+ const configuration = capabilityConfig(capability);
87
+ const desired = (Array.isArray(configuration?.labels) ? configuration.labels : []).filter((label) => isRecord(label) && typeof label.name === 'string' && label.name.trim()).map((label) => ({
88
+ name: label.name.trim(),
89
+ color: color(label.color ?? suggestedLabelColors[label.name]),
90
+ description: label.description ?? null,
91
+ role: label.role,
92
+ dimension: label.dimension,
93
+ value: label.value,
94
+ })).sort((left, right) => left.name.localeCompare(right.name));
95
+ const existingByName = new Map(existing.labels.map((label) => [label.name, label]));
96
+ const existingByComparableName = new Map();
97
+ for (const label of existing.labels) {
98
+ const key = comparableName(label.name);
99
+ existingByComparableName.set(key, [...(existingByComparableName.get(key) ?? []), label]);
100
+ }
101
+ const desiredNames = new Set();
102
+ const changes = [];
103
+ const operations = [];
104
+ for (const label of desired) {
105
+ if (desiredNames.has(label.name)) {
106
+ errors.push(issue('labels.desired-duplicate', `desired label ${label.name} appears more than once`, 'capabilities'));
107
+ continue;
108
+ }
109
+ desiredNames.add(label.name);
110
+ const current = existingByName.get(label.name);
111
+ if (!current) {
112
+ const equivalents = existingByComparableName.get(comparableName(label.name)) ?? [];
113
+ if (equivalents.length) {
114
+ changes.push({ name: label.name, status: 'conflict', desired: label, candidates: equivalents });
115
+ continue;
116
+ }
117
+ changes.push({ name: label.name, status: 'missing', desired: label });
118
+ operations.push({ type: 'create-label', name: label.name, color: label.color ?? '8B949E', description: label.description ?? '' });
119
+ continue;
120
+ }
121
+ const customized = (label.color !== null && current.color !== label.color) || (label.description !== null && current.description !== label.description);
122
+ changes.push({ name: label.name, status: customized ? 'customized' : 'unchanged', existing: current, desired: label });
123
+ }
124
+ for (const label of existing.labels) if (!desiredNames.has(label.name)) changes.push({ name: label.name, status: 'preserved', existing: label });
125
+ changes.sort((left, right) => left.name.localeCompare(right.name));
126
+ const target = [...existing.labels, ...operations.map((operation) => ({ name: operation.name, color: operation.color, description: operation.description }))].sort((left, right) => left.name.localeCompare(right.name));
127
+ const observedHash = digest(existing.labels);
128
+ const targetHash = digest(target);
129
+ const drift = previousRecord && previousRecord.targetHash !== observedHash
130
+ ? { status: 'drift', expectedHash: previousRecord.targetHash, observedHash }
131
+ : { status: 'none', expectedHash: previousRecord?.targetHash ?? null, observedHash };
132
+ const conflicts = changes.filter((change) => change.status === 'conflict');
133
+ const status = errors.length ? 'invalid' : conflicts.length ? 'blocked' : 'ready';
134
+ return {
135
+ schemaVersion: 1,
136
+ status,
137
+ valid: errors.length === 0,
138
+ canApply: status === 'ready',
139
+ capability: capability ? { id: capability.id, provider: capability.provider, version: capability.version } : null,
140
+ desiredHash: digest(desired),
141
+ observedHash,
142
+ targetHash,
143
+ drift,
144
+ changes,
145
+ operations,
146
+ errors,
147
+ record: {
148
+ schemaVersion: 1,
149
+ capability: capability ? { id: capability.id, provider: capability.provider, version: capability.version } : null,
150
+ desiredHash: digest(desired),
151
+ observedHash: targetHash,
152
+ targetHash,
153
+ operations: operations.map(({ type, name, color: labelColor, description }) => ({ type, name, color: labelColor, description })),
154
+ verified: false,
155
+ },
156
+ };
157
+ }
158
+
159
+ export function applyLabelReconciliationPlan(plan, existingLabels) {
160
+ if (!plan?.canApply) throw new Error('Label reconciliation is not ready to apply. Resolve its errors first.');
161
+ const existing = normalizeExisting(existingLabels);
162
+ if (existing.errors.length) throw new Error(existing.errors.map((error) => error.message).join('\n'));
163
+ const names = new Set(existing.labels.map((label) => label.name));
164
+ return [...existing.labels, ...plan.operations.filter((operation) => !names.has(operation.name)).map((operation) => ({ name: operation.name, color: operation.color, description: operation.description }))].sort((left, right) => left.name.localeCompare(right.name));
165
+ }
package/src/routes.mjs ADDED
@@ -0,0 +1,22 @@
1
+ import { GATES, STAGES } from './contracts.mjs';
2
+
3
+ function issue(code, message, field) {
4
+ return field ? { code, message, field } : { code, message };
5
+ }
6
+
7
+ export function validateRouteDescriptor(route, contract = {}) {
8
+ const errors = [];
9
+ if (!route || typeof route !== 'object') return [issue('route.invalid', 'route must be an object')];
10
+ if (typeof route.id !== 'string' || !route.id.trim()) errors.push(issue('route.id', 'route id must be a non-empty string', 'id'));
11
+ if (!Array.isArray(route.stages) || route.stages.length === 0 || route.stages.some((stage) => !STAGES.includes(stage))) errors.push(issue('route.stages', `stages must use ${STAGES.join(', ')}`, 'stages'));
12
+ if (!Array.isArray(route.humanGates) || route.humanGates.some((gate) => !GATES.includes(gate))) errors.push(issue('route.gates', `humanGates must use ${GATES.join(', ')}`, 'humanGates'));
13
+ if (contract.stages && route.stages?.some((stage, index) => stage !== contract.stages[index])) errors.push(issue('route.order', 'route stages must preserve the package stage order', 'stages'));
14
+ return errors;
15
+ }
16
+
17
+ export function buildRoutePlan(route, { available = [], decisions = {} } = {}) {
18
+ const errors = validateRouteDescriptor(route);
19
+ const prerequisites = (route.requires ?? []).map((requirement) => ({ id: requirement, status: available.includes(requirement) ? 'passed' : 'pending-review' }));
20
+ const status = errors.length ? 'invalid' : prerequisites.some((item) => item.status !== 'passed') ? 'blocked' : 'ready';
21
+ return { schemaVersion: 1, status, valid: errors.length === 0, canRun: status === 'ready', route: route.id, stages: [...(route.stages ?? [])], humanGates: [...(route.humanGates ?? [])], requires: prerequisites, decisions, errors };
22
+ }
package/src/sdlc.mjs CHANGED
@@ -1,9 +1,18 @@
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';
7
16
 
8
17
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
9
18
  const contractPath = resolve(packageRoot, 'contract.json');
@@ -29,10 +38,10 @@ async function verifyContract() {
29
38
  for (const stage of stages) {
30
39
  for (const asset of [stage.skill, stage.command]) if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing stage asset ${asset}`);
31
40
  }
32
- for (const asset of [contract.assets?.routes, contract.assets?.gates, contract.assets?.controlPlaneSchema, contract.assets?.triageSchema]) {
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]) {
33
42
  if (!asset || !(await exists(resolve(packageRoot, asset)))) problems.push(`missing contract asset ${asset ?? '(empty)'}`);
34
43
  }
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']) {
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']) {
36
45
  if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing package asset ${asset}`);
37
46
  }
38
47
  try {
@@ -45,7 +54,12 @@ async function verifyContract() {
45
54
  const gateIds = gates.gates?.map((gate) => gate.id) ?? [];
46
55
  if (gateIds.join(',') !== contract.gates.join(',')) problems.push('gates.json does not match contract gates');
47
56
  } catch (error) { problems.push(`Cannot read gates.json: ${error.message}`); }
48
- 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}`);
49
63
  for (const metaCommand of contract.metaCommands ?? []) if (!(await exists(resolve(packageRoot, `meta-commands/${metaCommand}.md`)))) problems.push(`missing meta-command asset ${metaCommand}`);
50
64
  return problems;
51
65
  }
@@ -105,15 +119,232 @@ async function runTriage(args) {
105
119
  if (report.status !== 'ready') process.exit(1);
106
120
  }
107
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
+
108
315
  const command = process.argv[2] ?? 'verify';
109
- if (!['contract', 'verify', 'triage'].includes(command)) {
110
- console.error('Usage: lighthouse-sdlc <contract|verify|triage>');
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>');
111
318
  process.exit(2);
112
319
  }
113
320
  if (command === 'contract') {
114
321
  console.log(await readFile(contractPath, 'utf8'));
115
322
  } else if (command === 'triage') {
116
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));
117
348
  } else {
118
349
  const problems = await verifyContract();
119
350
  if (problems.length) {
@@ -123,4 +354,4 @@ if (command === 'contract') {
123
354
  console.log('Lighthouse SDLC package contract is valid.');
124
355
  }
125
356
 
126
- export { contractPath, runTriage, 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
+ }
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, validateConfiguration };
284
+ export { DIMENSIONS, ROUTES };