@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
@@ -0,0 +1,53 @@
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 orderSlices(slices) {
10
+ const byId = new Map(slices.map((slice) => [slice.id, slice]));
11
+ const visiting = new Set();
12
+ const visited = new Set();
13
+ const ordered = [];
14
+ const errors = [];
15
+ function visit(id) {
16
+ if (visited.has(id)) return;
17
+ if (visiting.has(id)) { errors.push(issue('decompose.cycle', `dependency cycle includes ${id}`, 'slices')); return; }
18
+ visiting.add(id);
19
+ for (const dependency of byId.get(id)?.dependencies ?? []) if (byId.has(dependency)) visit(dependency);
20
+ visiting.delete(id);
21
+ visited.add(id);
22
+ ordered.push(id);
23
+ }
24
+ for (const slice of slices) visit(slice.id);
25
+ return { ordered, errors };
26
+ }
27
+
28
+ export function buildDecompositionPlan(input) {
29
+ const errors = [];
30
+ if (!isRecord(input)) return { schemaVersion: 1, status: 'invalid', valid: false, canApply: false, errors: [issue('decompose.invalid', 'decomposition input must be an object')] };
31
+ if (input.schemaVersion !== 1) errors.push(issue('decompose.schema-version', 'schemaVersion must be 1', 'schemaVersion'));
32
+ if (!isRecord(input.parent) || typeof input.parent.id !== 'string' || !input.parent.id.trim() || typeof input.parent.title !== 'string' || !input.parent.title.trim()) errors.push(issue('decompose.parent', 'parent needs id and title', 'parent'));
33
+ if (!Array.isArray(input.slices) || input.slices.length === 0) errors.push(issue('decompose.slices', 'slices must be a non-empty array', 'slices'));
34
+ const ids = new Set();
35
+ const slices = Array.isArray(input.slices) ? input.slices.map((slice, index) => {
36
+ if (!isRecord(slice) || typeof slice.id !== 'string' || !slice.id.trim() || typeof slice.title !== 'string' || !slice.title.trim()) { errors.push(issue('decompose.slice', `slice ${index} needs id and title`, `slices[${index}]`)); return null; }
37
+ if (ids.has(slice.id)) errors.push(issue('decompose.duplicate', `slice ${slice.id} appears more than once`, `slices[${index}].id`));
38
+ ids.add(slice.id);
39
+ if (!Array.isArray(slice.dependencies)) errors.push(issue('decompose.dependencies', `slice ${slice.id} needs a dependencies array`, `slices[${index}].dependencies`));
40
+ if (!Array.isArray(slice.acceptance) || slice.acceptance.length === 0 || slice.acceptance.some((item) => typeof item !== 'string' || !item.trim())) errors.push(issue('decompose.acceptance', `slice ${slice.id} needs independent acceptance evidence`, `slices[${index}].acceptance`));
41
+ return { id: slice.id, title: slice.title, acceptance: [...(slice.acceptance ?? [])], dependencies: [...(slice.dependencies ?? [])].sort(), scope: [...(slice.scope ?? [])].sort(), labels: [...(slice.labels ?? [])].sort() };
42
+ }).filter(Boolean) : [];
43
+ for (const slice of slices) for (const dependency of slice.dependencies) if (!ids.has(dependency)) errors.push(issue('decompose.unknown-dependency', `slice ${slice.id} depends on unknown slice ${dependency}`, 'slices'));
44
+ const order = orderSlices(slices);
45
+ errors.push(...order.errors);
46
+ const status = errors.length ? 'invalid' : 'ready';
47
+ return { schemaVersion: 1, status, valid: errors.length === 0, canApply: status === 'ready', parent: input.parent ?? null, slices, order: order.ordered, operations: status === 'ready' ? order.ordered.map((id) => ({ type: 'create-child', parentId: input.parent?.id, sliceId: id })) : [], errors };
48
+ }
49
+
50
+ export function applyDecompositionPlan(plan) {
51
+ if (!plan?.canApply) throw new Error('Decomposition plan is not ready to apply. Resolve its errors first.');
52
+ return { ...plan, applied: true };
53
+ }
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
+ }