@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
6
|
+
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function issue(code, message, field) {
|
|
12
|
+
return field ? { code, message, field } : { code, message };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const capabilityAdapters = Object.freeze({
|
|
16
|
+
'labels/github': 'github-labels',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function capabilityAdapter(capability) {
|
|
20
|
+
return capabilityAdapters[`${capability?.id ?? ''}/${capability?.provider ?? ''}`] ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function initialAdoptionStatus(capability) {
|
|
24
|
+
return capability?.selection === 'opt-in' ? 'available' : 'pending-adoption';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readCapabilityCatalog(path = resolve(packageRoot, 'capabilities.json')) {
|
|
28
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validateCapabilityCatalog(catalog) {
|
|
32
|
+
const errors = [];
|
|
33
|
+
if (!isRecord(catalog)) return [issue('catalog.invalid', 'capability catalog must be an object')];
|
|
34
|
+
if (catalog.schemaVersion !== 1) errors.push(issue('catalog.schema-version', 'catalog schemaVersion must be 1', 'schemaVersion'));
|
|
35
|
+
if (catalog.package?.name !== '@juangcarmona/lighthouse-sdlc') errors.push(issue('catalog.package', 'catalog package name is incorrect', 'package.name'));
|
|
36
|
+
if (typeof catalog.package?.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(catalog.package.version)) errors.push(issue('catalog.package-version', 'catalog package version must be semver-like', 'package.version'));
|
|
37
|
+
if (!Array.isArray(catalog.capabilities) || catalog.capabilities.length === 0) return [...errors, issue('catalog.capabilities', 'catalog capabilities must be a non-empty array', 'capabilities')];
|
|
38
|
+
const ids = new Set();
|
|
39
|
+
for (const capability of catalog.capabilities) {
|
|
40
|
+
if (!isRecord(capability) || typeof capability.id !== 'string' || !capability.id.trim()) {
|
|
41
|
+
errors.push(issue('catalog.capability', 'every catalog capability needs an id', 'capabilities'));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (ids.has(capability.id)) errors.push(issue('catalog.duplicate', `catalog capability ${capability.id} appears more than once`, 'capabilities'));
|
|
45
|
+
ids.add(capability.id);
|
|
46
|
+
if (typeof capability.provider !== 'string' || !capability.provider) errors.push(issue('catalog.provider', `catalog capability ${capability.id} needs a provider`, 'capabilities'));
|
|
47
|
+
if (typeof capability.version !== 'string' || !capability.version) errors.push(issue('catalog.version', `catalog capability ${capability.id} needs a version`, 'capabilities'));
|
|
48
|
+
if (typeof capability.adapter !== 'string' || !capability.adapter) errors.push(issue('catalog.adapter', `catalog capability ${capability.id} needs an adapter`, 'capabilities'));
|
|
49
|
+
else if (capability.adapter !== capabilityAdapter(capability)) errors.push(issue('catalog.adapter-unsupported', `catalog capability ${capability.id} does not have a registered ${capability.adapter} adapter`, 'capabilities'));
|
|
50
|
+
if (!isRecord(capability.configuration)) errors.push(issue('catalog.configuration', `catalog capability ${capability.id} needs a configuration object`, 'capabilities'));
|
|
51
|
+
if (capability.adapter === 'github-labels' && (!Array.isArray(capability.configuration?.labels) || !Array.isArray(capability.configuration?.transitions))) {
|
|
52
|
+
errors.push(issue('catalog.configuration', `catalog capability ${capability.id} needs labels and transitions for the github-labels adapter`, 'capabilities'));
|
|
53
|
+
}
|
|
54
|
+
if (!isRecord(capability.operations) || !Array.isArray(capability.operations.allowed) || !Array.isArray(capability.operations.blocked)) errors.push(issue('catalog.operations', `catalog capability ${capability.id} needs operation policy`, 'capabilities'));
|
|
55
|
+
else {
|
|
56
|
+
const allowed = new Set(capability.operations.allowed);
|
|
57
|
+
const blocked = new Set(capability.operations.blocked);
|
|
58
|
+
const operationKinds = new Set(['create', 'preserve', 'modify', 'rename', 'remove', 'ambiguous', 'unsupported']);
|
|
59
|
+
for (const kind of [...allowed, ...blocked]) if (!operationKinds.has(kind)) errors.push(issue('catalog.operation-kind', `catalog capability ${capability.id} uses unknown operation ${kind}`, 'capabilities'));
|
|
60
|
+
for (const kind of allowed) if (blocked.has(kind)) errors.push(issue('catalog.operation-overlap', `catalog capability ${capability.id} marks ${kind} as both allowed and blocked`, 'capabilities'));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function catalogCapability(catalog, id) {
|
|
67
|
+
return catalog?.capabilities?.find((capability) => capability.id === id) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function declarationFromCatalog(catalog, id = 'labels') {
|
|
71
|
+
const capability = catalogCapability(catalog, id);
|
|
72
|
+
if (!capability) throw new Error(`Capability ${id} is not declared by the installed SDLC package.`);
|
|
73
|
+
return {
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
capabilities: [{
|
|
76
|
+
id: capability.id,
|
|
77
|
+
provider: capability.provider,
|
|
78
|
+
version: capability.version,
|
|
79
|
+
adapter: capability.adapter,
|
|
80
|
+
catalogVersion: catalog.package.version,
|
|
81
|
+
configuration: capability.configuration,
|
|
82
|
+
}],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const STAGES = Object.freeze(['triage', 'refine', 'propose', 'implement', 'integrate', 'review']);
|
|
2
|
+
export const GATES = Object.freeze(['triaged', 'ready', 'planned', 'done']);
|
|
3
|
+
export const EVIDENCE_STATUSES = Object.freeze(['passed', 'failed', 'unavailable', 'unconfigured', 'pending-review', 'not-applicable']);
|
|
4
|
+
export const STAGE_STATUSES = Object.freeze(['ready', 'blocked', 'failed', 'passed']);
|
|
5
|
+
|
|
6
|
+
const stageSet = new Set(STAGES);
|
|
7
|
+
const gateSet = new Set(GATES);
|
|
8
|
+
const evidenceStatusSet = new Set(EVIDENCE_STATUSES);
|
|
9
|
+
const stageStatusSet = new Set(STAGE_STATUSES);
|
|
10
|
+
|
|
11
|
+
function issue(code, message, field) {
|
|
12
|
+
return field ? { code, message, field } : { code, message };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateStageResult(value) {
|
|
20
|
+
const errors = [];
|
|
21
|
+
if (!isRecord(value)) return [issue('stage-result.invalid', 'stage result must be an object')];
|
|
22
|
+
if (value.schemaVersion !== 1) errors.push(issue('stage-result.schema-version', 'schemaVersion must be 1', 'schemaVersion'));
|
|
23
|
+
for (const field of ['runId', 'nextAction']) if (typeof value[field] !== 'string' || !value[field].trim()) errors.push(issue(`stage-result.${field}`, `${field} must be a non-empty string`, field));
|
|
24
|
+
if (!stageSet.has(value.stage)) errors.push(issue('stage-result.stage', `stage must be one of ${STAGES.join(', ')}`, 'stage'));
|
|
25
|
+
if (!stageStatusSet.has(value.status)) errors.push(issue('stage-result.status', `status must be one of ${STAGE_STATUSES.join(', ')}`, 'status'));
|
|
26
|
+
if (value.gate !== null && !gateSet.has(value.gate)) errors.push(issue('stage-result.gate', `gate must be one of ${GATES.join(', ')} or null`, 'gate'));
|
|
27
|
+
if (!Array.isArray(value.evidence)) errors.push(issue('stage-result.evidence', 'evidence must be an array', 'evidence'));
|
|
28
|
+
else for (const [index, evidence] of value.evidence.entries()) {
|
|
29
|
+
if (!isRecord(evidence) || typeof evidence.id !== 'string' || !evidence.id.trim() || !evidenceStatusSet.has(evidence.status)) errors.push(issue('stage-result.evidence-item', `evidence[${index}] needs an id and a supported status`, `evidence[${index}]`));
|
|
30
|
+
}
|
|
31
|
+
for (const field of ['missingEvidence', 'unavailableChecks', 'pendingReview']) if (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== 'string' || !item.trim())) errors.push(issue(`stage-result.${field}`, `${field} must be an array of names`, field));
|
|
32
|
+
if (value.input !== undefined && !isRecord(value.input)) errors.push(issue('stage-result.input', 'input must be an object when present', 'input'));
|
|
33
|
+
if (value.output !== undefined && !isRecord(value.output)) errors.push(issue('stage-result.output', 'output must be an object when present', 'output'));
|
|
34
|
+
return errors;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isValidStageResult(value) {
|
|
38
|
+
return validateStageResult(value).length === 0;
|
|
39
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { MEMORY_SCOPES } from './memory.mjs';
|
|
2
|
+
import { WORKFLOW_STATES } from './workflow.mjs';
|
|
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 validateControlPlane(value) {
|
|
13
|
+
const errors = [];
|
|
14
|
+
if (!isRecord(value)) return [issue('control-plane.invalid', 'control plane must be an object')];
|
|
15
|
+
if (value.schemaVersion !== 1) errors.push(issue('control-plane.schema-version', 'schemaVersion must be 1', 'schemaVersion'));
|
|
16
|
+
if (!isRecord(value.package) || typeof value.package.name !== 'string' || typeof value.package.version !== 'string') errors.push(issue('control-plane.package', 'package needs name and version', 'package'));
|
|
17
|
+
for (const field of ['routes', 'gates']) if (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== 'string' || !item.trim())) errors.push(issue(`control-plane.${field}`, `${field} must be an array of names`, field));
|
|
18
|
+
if (!isRecord(value.state) || typeof value.state.provider !== 'string' || !value.state.provider.trim()) errors.push(issue('control-plane.state', 'state.provider is required', 'state'));
|
|
19
|
+
if (value.state?.states !== undefined && (!Array.isArray(value.state.states) || value.state.states.some((state) => !WORKFLOW_STATES.includes(state)))) errors.push(issue('control-plane.state-states', 'state.states must contain known workflow states', 'state.states'));
|
|
20
|
+
if (!isRecord(value.memory) || typeof value.memory.provider !== 'string' || !value.memory.provider.trim()) errors.push(issue('control-plane.memory', 'memory.provider is required', 'memory'));
|
|
21
|
+
if (!Array.isArray(value.memory?.scopes) || value.memory.scopes.length === 0 || value.memory.scopes.some((scope) => !MEMORY_SCOPES.includes(scope))) errors.push(issue('control-plane.memory-scopes', `memory.scopes must contain values from ${MEMORY_SCOPES.join(', ')}`, 'memory.scopes'));
|
|
22
|
+
if (value.memory?.scopes?.includes('shared') && value.memory.shared !== true) errors.push(issue('control-plane.shared-memory', 'shared memory requires memory.shared=true', 'memory.shared'));
|
|
23
|
+
if (!isRecord(value.roles)) errors.push(issue('control-plane.roles', 'roles must be an object', 'roles'));
|
|
24
|
+
else for (const [name, role] of Object.entries(value.roles)) {
|
|
25
|
+
if (typeof role === 'string' && role.trim()) continue;
|
|
26
|
+
if (!isRecord(role)) errors.push(issue('control-plane.role', `role ${name} must be an object or provider reference`, `roles.${name}`));
|
|
27
|
+
else {
|
|
28
|
+
for (const field of ['inputSchema', 'outputSchema']) if (typeof role[field] !== 'string' || !role[field].trim()) errors.push(issue('control-plane.role-schema', `role ${name} needs ${field}`, `roles.${name}.${field}`));
|
|
29
|
+
if (!Array.isArray(role.tools)) errors.push(issue('control-plane.role-tools', `role ${name} needs a tools array`, `roles.${name}.tools`));
|
|
30
|
+
if (!Array.isArray(role.writeScope)) errors.push(issue('control-plane.role-write-scope', `role ${name} needs a writeScope array`, `roles.${name}.writeScope`));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!isRecord(value.adapters)) errors.push(issue('control-plane.adapters', 'adapters must be an object', 'adapters'));
|
|
34
|
+
return errors;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateLaunchRequest(request, controlPlane) {
|
|
38
|
+
const errors = [];
|
|
39
|
+
if (!isRecord(request)) return [issue('launch.invalid', 'launch request must be an object')];
|
|
40
|
+
for (const field of ['runId', 'role', 'outputSchema']) if (typeof request[field] !== 'string' || !request[field].trim()) errors.push(issue(`launch.${field}`, `${field} must be a non-empty string`, field));
|
|
41
|
+
const role = controlPlane?.roles?.[request.role];
|
|
42
|
+
if (!role) errors.push(issue('launch.role', `role ${request.role} is not declared`, 'role'));
|
|
43
|
+
if (role && request.outputSchema !== role.outputSchema) errors.push(issue('launch.output-schema', `outputSchema does not match role ${request.role}`, 'outputSchema'));
|
|
44
|
+
if (!Array.isArray(request.tools)) errors.push(issue('launch.tools', 'tools must be an array', 'tools'));
|
|
45
|
+
else if (role) for (const tool of request.tools) if (!role.tools.includes(tool)) errors.push(issue('launch.tool', `tool ${tool} is not allowed for role ${request.role}`, 'tools'));
|
|
46
|
+
if (!Array.isArray(request.writeScope)) errors.push(issue('launch.write-scope', 'writeScope must be an array', 'writeScope'));
|
|
47
|
+
else if (role) for (const path of request.writeScope) if (!role.writeScope.includes(path)) errors.push(issue('launch.write-path', `write path ${path} is not allowed for role ${request.role}`, 'writeScope'));
|
|
48
|
+
if (!isRecord(request.limits)) errors.push(issue('launch.limits', 'limits must be an object', 'limits'));
|
|
49
|
+
if (!Array.isArray(request.stopConditions)) errors.push(issue('launch.stop-conditions', 'stopConditions must be an array', 'stopConditions'));
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
@@ -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,169 @@
|
|
|
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', operation: 'ambiguous', desired: label, candidates: equivalents });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
changes.push({ name: label.name, status: 'missing', operation: 'create', desired: label });
|
|
118
|
+
operations.push({ type: 'create-label', kind: 'create', 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', operation: 'preserve', blockedOperations: customized ? ['modify'] : [], existing: current, desired: label });
|
|
123
|
+
}
|
|
124
|
+
for (const label of existing.labels) if (!desiredNames.has(label.name)) changes.push({ name: label.name, status: 'preserved', operation: 'preserve', 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
|
+
target,
|
|
144
|
+
drift,
|
|
145
|
+
operationPolicy: capability?.operations ?? { allowed: ['create', 'preserve'], blocked: ['modify', 'rename', 'remove', 'ambiguous'] },
|
|
146
|
+
blockedOperations: changes.flatMap((change) => change.blockedOperations ?? []),
|
|
147
|
+
changes,
|
|
148
|
+
operations,
|
|
149
|
+
errors,
|
|
150
|
+
record: {
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
capability: capability ? { id: capability.id, provider: capability.provider, version: capability.version } : null,
|
|
153
|
+
desiredHash: digest(desired),
|
|
154
|
+
observedHash: targetHash,
|
|
155
|
+
targetHash,
|
|
156
|
+
operations: operations.map(({ type, kind, name, color: labelColor, description }) => ({ type, kind, name, color: labelColor, description })),
|
|
157
|
+
blockedOperations: changes.flatMap((change) => change.blockedOperations ?? []),
|
|
158
|
+
verified: false,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function applyLabelReconciliationPlan(plan, existingLabels) {
|
|
164
|
+
if (!plan?.canApply) throw new Error('Label reconciliation is not ready to apply. Resolve its errors first.');
|
|
165
|
+
const existing = normalizeExisting(existingLabels);
|
|
166
|
+
if (existing.errors.length) throw new Error(existing.errors.map((error) => error.message).join('\n'));
|
|
167
|
+
const names = new Set(existing.labels.map((label) => label.name));
|
|
168
|
+
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));
|
|
169
|
+
}
|
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
|
+
}
|