@namewta/speculo 2.0.4 → 2.0.5
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/package.json +1 -1
- package/template/canonical/canonical-specdev-goal-plan.md +2145 -1003
- package/template/canonical/canonical-specdev-grill-with-docs.md +1948 -506
- package/template/canonical/canonical-specdev-spec.md +2046 -604
- package/template/canonical/canonical-specdev-tickets.md +1978 -518
- package/template/canonical/canonical-specdev-wayfinder.md +2393 -951
- package/template/references/coordination/coordination-contract.md +7 -5
- package/template/references/coordination/result-and-recovery.md +1 -1
- package/template/references/coordination/session-files.md +2 -2
- package/template/references/coordination/task-brief.md +4 -4
- package/template/references/retrospective/references/friction-taxonomy.md +1 -1
- package/template/references/writing-for-agents/references/project-skill-maintenance.md +29 -0
- package/template/references/writing-for-agents/writing-contract.md +2 -0
- package/template/workflows/specdev/SKILL.md +1 -1
- package/template/workflows/specdev/common/README.md +1 -1
- package/template/workflows/specdev/common/rules/artifact-contract.md +1 -1
- package/template/workflows/specdev/common/rules/change-completion.md +6 -6
- package/template/workflows/specdev/common/rules/domain-runtime.md +2 -2
- package/template/workflows/specdev/common/rules/evidence-and-verification.md +17 -34
- package/template/workflows/specdev/common/rules/execution-policy.md +24 -16
- package/template/workflows/specdev/common/rules/operating-governance.md +1 -1
- package/template/workflows/specdev/common/rules/parent-implementation-orchestration.md +4 -2
- package/template/workflows/specdev/common/rules/path-ownership.md +9 -7
- package/template/workflows/specdev/common/rules/workflow-routing.md +1 -1
- package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +6 -2
- package/template/workflows/specdev/common/schemas/execution-context.schema.json +67 -0
- package/template/workflows/specdev/common/schemas/execution-record.schema.json +154 -0
- package/template/workflows/specdev/common/schemas/goal-plan.schema.json +15 -55
- package/template/workflows/specdev/common/schemas/implementation-plan.schema.json +15 -55
- package/template/workflows/specdev/common/schemas/retro-approval.schema.json +44 -0
- package/template/workflows/specdev/common/schemas/retro-archive.schema.json +99 -0
- package/template/workflows/specdev/common/schemas/retro-execution.schema.json +112 -0
- package/template/workflows/specdev/common/schemas/retro-upgrade.schema.json +163 -0
- package/template/workflows/specdev/common/schemas/retrospective.schema.json +119 -14
- package/template/workflows/specdev/common/skills/code-review/references/review-evidence.md +2 -2
- package/template/workflows/specdev/common/skills/code-review/references/source-discovery.md +2 -0
- package/template/workflows/specdev/common/skills/code-review/references/working-tree-review.md +1 -1
- package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +15 -34
- package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +27 -56
- package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +10 -54
- package/template/workflows/specdev/common/tools/README.md +10 -0
- package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +4 -4
- package/template/workflows/specdev/common/tools/execution-context.mjs +104 -0
- package/template/workflows/specdev/common/tools/plan-contract.mjs +3 -1
- package/template/workflows/specdev/common/tools/review-evidence.mjs +2 -2
- package/template/workflows/specdev/common/tools/tests/engineering.test.mjs +2 -2
- package/template/workflows/specdev/common/tools/tests/review-fixture.mjs +4 -4
- package/template/workflows/specdev/common/tools/ticket-control.mjs +17 -12
- package/template/workflows/specdev/common/tools/validate-retro.mjs +331 -0
- package/template/workflows/specdev/common/tools/validate-specdev.mjs +135 -105
- package/template/workflows/specdev/specdev-a-archive-and-consolidate/SKILL.md +2 -1
- package/template/workflows/specdev/specdev-a-archive-and-consolidate/references/operation-contract.md +2 -0
- package/template/workflows/specdev/specdev-a-archive-and-consolidate/references/retro-archive.md +27 -0
- package/template/workflows/specdev/specdev-c-code-review/SKILL.md +1 -1
- package/template/workflows/specdev/specdev-i-implement/SKILL.md +3 -3
- package/template/workflows/specdev/specdev-i-implement/assets/evidence-template.md +37 -29
- package/template/workflows/specdev/specdev-i-implement/references/execution-preflight.md +16 -16
- package/template/workflows/specdev/specdev-i-implement/references/implementation-procedure.md +21 -37
- package/template/workflows/specdev/specdev-i-implement/references/merge-conflict-protocol.md +8 -18
- package/template/workflows/specdev/specdev-p-goal-plan/SKILL.md +3 -3
- package/template/workflows/specdev/specdev-p-goal-plan/assets/goal-plan-template.md +23 -26
- package/template/workflows/specdev/specdev-p-goal-plan/references/completion-control.md +11 -14
- package/template/workflows/specdev/specdev-p-goal-plan/references/lead-orchestration.md +12 -33
- package/template/workflows/specdev/specdev-p-goal-plan/references/map-control.md +1 -1
- package/template/workflows/specdev/specdev-p-goal-plan/references/multi-change-plan.md +11 -16
- package/template/workflows/specdev/specdev-p-goal-plan/references/multi-conflict-and-drift.md +2 -2
- package/template/workflows/specdev/specdev-p-goal-plan/references/multi-execution-loop.md +6 -6
- package/template/workflows/specdev/specdev-p-goal-plan/references/orchestration-protocol.md +13 -44
- package/template/workflows/specdev/specdev-p-goal-plan/references/planning-modes.md +18 -25
- package/template/workflows/specdev/specdev-p-goal-plan/references/single-change-plan.md +16 -16
- package/template/workflows/specdev/specdev-r-retro/SKILL.md +15 -11
- package/template/workflows/specdev/specdev-r-retro/assets/retro-template.md +26 -14
- package/template/workflows/specdev/specdev-r-retro/assets/upgrade-plan-template.md +41 -0
- package/template/workflows/specdev/specdev-r-retro/references/development-retrospective.md +42 -0
- package/template/workflows/specdev/specdev-r-retro/references/records.md +47 -0
- package/template/workflows/specdev/specdev-r-retro/references/skill-upgrade.md +13 -0
- package/template/workflows/specdev/specdev-t-tickets/SKILL.md +2 -0
- package/template/workflows/specdev/specdev-t-tickets/assets/ticket-template.md +1 -1
- package/template/workflows/specdev/specdev-t-tickets/assets/tickets-map-template.md +6 -6
- package/template/workflows/specdev/specdev-t-tickets/references/planning-procedure.md +2 -2
- package/template/workflows/specdev/specdev-t-tickets/references/ticket-readiness.md +3 -3
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Read-only checks. Approval records document authority; they cannot grant it. */
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import {
|
|
8
|
+
assertChangeName, markdownBodyLines, parseFrontmatter, readUtf8ArtifactSync,
|
|
9
|
+
resolveChangeSync, resolveWorkContextSync, safeWorkPathSync, validateSkill,
|
|
10
|
+
} from '../../../../lib/speculo-runtime.mjs';
|
|
11
|
+
import { checkSchema } from './delivery-artifacts.mjs';
|
|
12
|
+
|
|
13
|
+
const schemaRoot = fileURLToPath(new URL('../schemas/', import.meta.url));
|
|
14
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
15
|
+
const fail = message => { throw new Error(message); };
|
|
16
|
+
const requireThat = (condition, message) => { if (!condition) fail(message); };
|
|
17
|
+
const portable = path => path.split(sep).join('/');
|
|
18
|
+
const unique = (values, label) => requireThat(new Set(values).size === values.length, `${label}: duplicate entry`);
|
|
19
|
+
const file = (root, path) => safeWorkPathSync(root, path, { mustExist: true, type: 'file' });
|
|
20
|
+
const digestFile = path => hash(readFileSync(path));
|
|
21
|
+
|
|
22
|
+
function schema(value, name) {
|
|
23
|
+
const errors = [];
|
|
24
|
+
checkSchema(value, JSON.parse(readFileSync(join(schemaRoot, `${name}.schema.json`), 'utf8')), name, errors);
|
|
25
|
+
requireThat(!errors.length, errors.join('\n'));
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function json(root, path, name) {
|
|
29
|
+
return schema(JSON.parse(readUtf8ArtifactSync(file(root, path))), name);
|
|
30
|
+
}
|
|
31
|
+
function headings(body, names) {
|
|
32
|
+
const lines = markdownBodyLines(body, 'retro record');
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
const found = lines.filter(line => line.text === `## ${name}`);
|
|
35
|
+
requireThat(found.length === 1, `expected one ${name} section`);
|
|
36
|
+
const end = lines.find(line => line.line > found[0].line && /^#{1,2} /.test(line.text))?.line;
|
|
37
|
+
requireThat(body.split(/\r?\n/).slice(found[0].line, end ? end - 1 : undefined).join('\n').trim(), `empty ${name} section`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function object(context, id, kind) {
|
|
41
|
+
assertChangeName(id);
|
|
42
|
+
const active = safeWorkPathSync(context.specdevRoot, `retro/${id}`, { type: 'directory' });
|
|
43
|
+
const archived = safeWorkPathSync(context.specdevRoot, `retro/archive/${id}`, { type: 'directory' });
|
|
44
|
+
const present = [active, archived].filter(existsSync);
|
|
45
|
+
requireThat(present.length === 1, `${id}: missing or duplicate retro identity`);
|
|
46
|
+
const root = present[0], filename = kind === 'report' ? 'RETRO.md' : 'PLAN.md';
|
|
47
|
+
requireThat(!existsSync(join(root, kind === 'report' ? 'PLAN.md' : 'RETRO.md')), `${id}: conflicting object kinds`);
|
|
48
|
+
const path = file(root, filename), { fields: meta, body } = parseFrontmatter(readUtf8ArtifactSync(path));
|
|
49
|
+
schema(meta, kind === 'report' ? 'retrospective' : 'retro-upgrade');
|
|
50
|
+
requireThat(meta.id === id, `${id}: record identity mismatch`);
|
|
51
|
+
headings(body, kind === 'report' ? ['Scope', 'Evidence', 'Findings', 'Acceptance', 'Routes'] : ['Scope', 'Decisions', 'Changes', 'Validation', 'Archive']);
|
|
52
|
+
return { id, root, path, meta, body, archived: root === archived, sha256: digestFile(path) };
|
|
53
|
+
}
|
|
54
|
+
function snapshot(root, entry, prefix) {
|
|
55
|
+
requireThat(entry.snapshot.startsWith(`${prefix}/`), `${entry.snapshot}: snapshot must be under ${prefix}/`);
|
|
56
|
+
const path = file(root, entry.snapshot);
|
|
57
|
+
requireThat(digestFile(path) === entry.sha256, `${entry.snapshot}: snapshot digest drift`);
|
|
58
|
+
requireThat(readUtf8ArtifactSync(path).includes(entry.quote), `${entry.snapshot}: quote is not verbatim in snapshot`);
|
|
59
|
+
}
|
|
60
|
+
function report(context, id, warnings) {
|
|
61
|
+
const record = object(context, id, 'report'), { meta, root } = record;
|
|
62
|
+
unique(meta.sources.map(s => s.id), 'sources');
|
|
63
|
+
unique(meta.findings.map(f => f.id), 'findings');
|
|
64
|
+
requireThat(meta.sources.length || meta.outcome === 'insufficient-evidence', `${id}: evidence is required`);
|
|
65
|
+
requireThat((meta.outcome === 'findings') === (meta.findings.length > 0), `${id}: outcome/findings mismatch`);
|
|
66
|
+
const sourceIds = new Set(meta.sources.map(s => s.id));
|
|
67
|
+
for (const source of meta.sources) {
|
|
68
|
+
snapshot(root, source, 'sources');
|
|
69
|
+
const visibleQuote = record.body.replace(/^> ?/gm, '');
|
|
70
|
+
requireThat(visibleQuote.includes(source.quote) && record.body.includes(source.id), `${source.id}: body must display the original quote and source ID`);
|
|
71
|
+
if (source.change !== null) requireThat(record.body.includes(source.change), `${source.id}: body must identify its source change`);
|
|
72
|
+
requireThat(!/[\x00-\x1f]/.test(source.locator), `${source.id}: unsafe source locator`);
|
|
73
|
+
if (source.kind === 'change') {
|
|
74
|
+
requireThat(source.change !== null, `${source.id}: missing source change`);
|
|
75
|
+
// A snapshot preserves historical bytes; current originals may legitimately evolve.
|
|
76
|
+
const location = resolveChangeSync(context, source.change, { includeArchived: true });
|
|
77
|
+
const original = safeWorkPathSync(location.root, source.locator.split('#')[0], { type: 'file' });
|
|
78
|
+
if (!existsSync(original)) warnings.push(`${source.id}: original unavailable; only captured evidence was checked`);
|
|
79
|
+
} else if (source.kind === 'project') {
|
|
80
|
+
const original = safeWorkPathSync(context.projectRoot, source.locator.split('#')[0], { type: 'file' });
|
|
81
|
+
if (!existsSync(original)) warnings.push(`${source.id}: original unavailable; recheck current project facts before adoption`);
|
|
82
|
+
} else {
|
|
83
|
+
requireThat(source.locator.startsWith('session:'), `${source.id}: expected actual session locator`);
|
|
84
|
+
warnings.push(`${source.id}: session provenance requires observation; snapshot integrity alone is not proof`);
|
|
85
|
+
}
|
|
86
|
+
if (source.change !== null) assertChangeName(source.change);
|
|
87
|
+
}
|
|
88
|
+
for (const finding of meta.findings) {
|
|
89
|
+
requireThat(finding.evidence.every(id => sourceIds.has(id)), `${finding.id}: unknown evidence`);
|
|
90
|
+
headings(record.body, [finding.id]);
|
|
91
|
+
}
|
|
92
|
+
return record;
|
|
93
|
+
}
|
|
94
|
+
function builderPaths(value) {
|
|
95
|
+
requireThat(value?.schema_version === 1 && value.generator === 'engineering-standards-builder' && Array.isArray(value.skills), 'invalid Builder ownership manifest');
|
|
96
|
+
requireThat(Object.keys(value).every(k => ['schema_version', 'generator', 'skills'].includes(k)), 'unknown Builder ownership fields');
|
|
97
|
+
unique(value.skills.map(s => s.path), 'Builder paths');
|
|
98
|
+
requireThat(value.skills.filter(s => s.role === 'router').length === 1 && value.skills.some(s => s.role === 'router' && s.name === 'engineering-standards'), 'invalid Builder router');
|
|
99
|
+
for (const skill of value.skills) requireThat(Object.keys(skill).every(k => ['name', 'path', 'role'].includes(k)) && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skill.name) && skill.path === `.agents/skills/${skill.name}` && ['router', 'domain'].includes(skill.role), 'invalid Builder owned path');
|
|
100
|
+
return new Set(value.skills.map(s => s.path));
|
|
101
|
+
}
|
|
102
|
+
function owners(context, record) {
|
|
103
|
+
const projected = new Set();
|
|
104
|
+
const projection = safeWorkPathSync(context.stateRoot, 'projections.json', { type: 'file' });
|
|
105
|
+
if (existsSync(projection)) {
|
|
106
|
+
const value = JSON.parse(readUtf8ArtifactSync(projection));
|
|
107
|
+
const businessId = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
108
|
+
requireThat(value?.schema_version === 3 && typeof value.package_version === 'string' && Array.isArray(value.files) && Array.isArray(value.targets) && value.targets.every(h => ['agents', 'claude'].includes(h)) && Array.isArray(value.asset_ids) && value.asset_ids.every(id => typeof id === 'string' && businessId.test(id)) && (value.targets.length || !value.asset_ids.length), 'invalid Speculo projection manifest');
|
|
109
|
+
unique(value.targets, 'projection hosts'); unique(value.asset_ids, 'projection assets');
|
|
110
|
+
unique(value.files.map(f => f?.path), 'projection paths');
|
|
111
|
+
for (const entry of value.files) {
|
|
112
|
+
requireThat(entry && typeof entry.path === 'string' && /^\.(?:agents|claude)\/skills\/[a-z0-9-]+\/(?:SKILL\.md|agents\/openai\.yaml)$/.test(entry.path) && typeof entry.asset_id === 'string' && businessId.test(entry.asset_id) && typeof entry.sha256 === 'string' && /^[a-f0-9]{64}$/.test(entry.sha256), 'invalid projection record');
|
|
113
|
+
projected.add(entry.path.split('/').slice(0, 3).join('/'));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const manifest = '.agents/skills/engineering-standards/generated-skill-set.json';
|
|
117
|
+
const original = safeWorkPathSync(context.projectRoot, manifest, { type: 'file' });
|
|
118
|
+
const before = existsSync(original) ? builderPaths(JSON.parse(readUtf8ArtifactSync(original))) : new Set();
|
|
119
|
+
const candidate = record.meta.changes.find(c => c.path === manifest);
|
|
120
|
+
const after = candidate ? builderPaths(JSON.parse(readUtf8ArtifactSync(file(record.root, candidate.candidate)))) : before;
|
|
121
|
+
for (const path of before) requireThat(after.has(path), 'Skill deletion/renaming needs a separate explicit maintenance plan');
|
|
122
|
+
return { projected, before, after };
|
|
123
|
+
}
|
|
124
|
+
function candidateLinks(context, record, change, candidates) {
|
|
125
|
+
if (!change.path.endsWith('.md')) return;
|
|
126
|
+
const text = readUtf8ArtifactSync(file(record.root, change.candidate));
|
|
127
|
+
for (const match of text.matchAll(/\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g)) {
|
|
128
|
+
const link = match[1].replace(/^<|>$/g, '');
|
|
129
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(link)) continue;
|
|
130
|
+
const part = decodeURIComponent(link.split('#')[0]);
|
|
131
|
+
requireThat(part && !part.startsWith('/') && !part.includes('\\'), `${change.path}: invalid local link ${link}`);
|
|
132
|
+
const target = portable(relative(context.projectRoot, resolve(context.projectRoot, dirname(change.path), part)));
|
|
133
|
+
safeWorkPathSync(context.projectRoot, target);
|
|
134
|
+
requireThat(candidates.has(target) || existsSync(safeWorkPathSync(context.projectRoot, target)), `${change.path}: broken local link ${link}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function readExecution(record, targets) {
|
|
138
|
+
const receipt = json(record.root, 'execution.json', 'retro-execution');
|
|
139
|
+
requireThat(receipt.plan_sha256 === record.sha256 && receipt.approval_sha256 === record.approvalHash, 'execution is not bound to approved plan');
|
|
140
|
+
unique(receipt.results.map(r => r.path), 'execution targets');
|
|
141
|
+
requireThat(receipt.results.every(r => targets.has(r.path)), 'execution target set mismatch');
|
|
142
|
+
return receipt;
|
|
143
|
+
}
|
|
144
|
+
function passedResult(record, result, change) {
|
|
145
|
+
requireThat(result?.result === 'passed' && result.sha256 === change.after_sha256, `${change.path}: unverified upgrade`);
|
|
146
|
+
requireThat(result.evidence.startsWith('evidence/'), `${change.path}: evidence must be under evidence/`);
|
|
147
|
+
requireThat(digestFile(file(record.root, result.evidence)) === result.evidence_sha256, `${change.path}: verification evidence drift`);
|
|
148
|
+
requireThat(result.command === null ? result.exit === null : result.exit === 0, `${change.path}: verification command failed`);
|
|
149
|
+
}
|
|
150
|
+
function upgrade(context, id, warnings, { live = true, execution = false, approved = false } = {}) {
|
|
151
|
+
const record = object(context, id, 'upgrade'), { meta, root } = record;
|
|
152
|
+
unique(meta.inputs.map(i => i.report), 'input reports');
|
|
153
|
+
unique(meta.changes.map(c => c.path), 'change targets');
|
|
154
|
+
unique(meta.decisions.map(d => `${d.report}/${d.finding}`), 'decisions');
|
|
155
|
+
const reports = new Map();
|
|
156
|
+
for (const input of meta.inputs) {
|
|
157
|
+
const source = report(context, input.report, warnings);
|
|
158
|
+
requireThat(source.meta.status === 'completed', `${input.report}: incomplete report`);
|
|
159
|
+
requireThat(source.sha256 === input.sha256, `${input.report}: input report digest drift`);
|
|
160
|
+
requireThat(execution || approved || !source.archived, `${input.report}: new plans use unarchived reports`);
|
|
161
|
+
reports.set(input.report, source);
|
|
162
|
+
}
|
|
163
|
+
const targets = new Set(meta.changes.map(c => c.path));
|
|
164
|
+
for (const decision of meta.decisions) {
|
|
165
|
+
requireThat(reports.get(decision.report)?.meta.findings.some(f => f.id === decision.finding), `${decision.report}/${decision.finding}: unknown finding`);
|
|
166
|
+
requireThat(decision.disposition === 'adopt' ? decision.targets.length > 0 && decision.targets.every(p => targets.has(p)) : decision.targets.length === 0, `${decision.finding}: invalid disposition targets`);
|
|
167
|
+
}
|
|
168
|
+
for (const source of reports.values()) for (const finding of source.meta.findings) {
|
|
169
|
+
requireThat(meta.decisions.some(d => d.report === source.id && d.finding === finding.id), `${source.id}/${finding.id}: missing disposition`);
|
|
170
|
+
}
|
|
171
|
+
for (const id of meta.archive_reports) {
|
|
172
|
+
requireThat(reports.has(id), `${id}: archive report not in frozen inputs`);
|
|
173
|
+
requireThat(!meta.decisions.some(d => d.report === id && d.disposition === 'defer'), `${id}: deferred findings cannot be archived`);
|
|
174
|
+
}
|
|
175
|
+
if (approved || execution) {
|
|
176
|
+
const approval = json(root, 'approval.json', 'retro-approval');
|
|
177
|
+
snapshot(root, approval, 'approval');
|
|
178
|
+
requireThat(approval.plan_sha256 === record.sha256, 'approved plan digest drift');
|
|
179
|
+
record.approval = approval;
|
|
180
|
+
record.approvalHash = digestFile(file(root, 'approval.json'));
|
|
181
|
+
}
|
|
182
|
+
const previous = approved && existsSync(join(root, 'execution.json')) ? readExecution(record, targets) : null;
|
|
183
|
+
const ownership = live ? owners(context, record) : null;
|
|
184
|
+
const candidates = new Map(meta.changes.map(c => [c.path, c]));
|
|
185
|
+
for (const change of meta.changes) {
|
|
186
|
+
requireThat(meta.decisions.some(d => d.disposition === 'adopt' && d.targets.includes(change.path)), `${change.path}: target has no adopted finding`);
|
|
187
|
+
requireThat(/^\.agents\/skills\/[a-z0-9]+(?:-[a-z0-9]+)*\/.+/.test(change.path), `${change.path}: target must be a project Skill file`);
|
|
188
|
+
const target = live ? safeWorkPathSync(context.projectRoot, change.path, { type: 'file' }) : null;
|
|
189
|
+
requireThat(change.candidate.startsWith('candidates/'), `${change.path}: candidate must be under candidates/`);
|
|
190
|
+
const candidate = file(root, change.candidate);
|
|
191
|
+
requireThat(digestFile(candidate) === change.after_sha256, `${change.path}: candidate digest drift`);
|
|
192
|
+
requireThat((change.action === 'create') === (change.before_sha256 === null), `${change.path}: create/before mismatch`);
|
|
193
|
+
if (change.action === 'retain') requireThat(change.before_sha256 === change.after_sha256, `${change.path}: retain must not change bytes`);
|
|
194
|
+
if (change.action === 'update') requireThat(change.before_sha256 !== change.after_sha256, `${change.path}: unchanged target should be retain`);
|
|
195
|
+
if (live) {
|
|
196
|
+
const skill = change.path.split('/').slice(0, 3).join('/');
|
|
197
|
+
requireThat(!ownership.projected.has(skill), `${skill}: managed Speculo projection; edit its source through its owner`);
|
|
198
|
+
const owned = ownership.before.has(skill) || ownership.after.has(skill);
|
|
199
|
+
requireThat(owned === (change.owner === 'engineering-standards-builder'), `${skill}: ownership mismatch`);
|
|
200
|
+
const current = existsSync(target) ? digestFile(target) : null;
|
|
201
|
+
if (approved && current === change.after_sha256 && current !== change.before_sha256) {
|
|
202
|
+
requireThat(previous, `${change.path}: landed target requires original execution evidence before resuming`);
|
|
203
|
+
passedResult(record, previous.results.find(r => r.path === change.path), change);
|
|
204
|
+
} else requireThat(current === (execution ? change.after_sha256 : change.before_sha256), `${change.path}: target digest drift`);
|
|
205
|
+
candidateLinks(context, record, change, candidates);
|
|
206
|
+
}
|
|
207
|
+
if (basename(change.path) === 'SKILL.md') {
|
|
208
|
+
// Existing host-specific extensions remain owned by the project; never strip them to pass a check.
|
|
209
|
+
const prior = live && existsSync(target) ? parseFrontmatter(readFileSync(target)).fields : {};
|
|
210
|
+
const fields = parseFrontmatter(readFileSync(candidate)).fields;
|
|
211
|
+
const errors = validateSkill(readFileSync(candidate), dirname(change.path)).filter(error => {
|
|
212
|
+
if (!error.startsWith('unsupported top-level field: ')) return true;
|
|
213
|
+
const key = error.slice('unsupported top-level field: '.length);
|
|
214
|
+
return live && JSON.stringify(prior[key]) !== JSON.stringify(fields[key]);
|
|
215
|
+
});
|
|
216
|
+
requireThat(!errors.length, `${change.path}: ${errors.join('; ')}`);
|
|
217
|
+
}
|
|
218
|
+
const skillRoot = change.path.split('/').slice(0, 3).join('/');
|
|
219
|
+
if (live) requireThat(candidates.has(`${skillRoot}/SKILL.md`) || existsSync(safeWorkPathSync(context.projectRoot, `${skillRoot}/SKILL.md`, { type: 'file' })), `${skillRoot}: missing Skill entry`);
|
|
220
|
+
}
|
|
221
|
+
if (execution) {
|
|
222
|
+
const receipt = readExecution(record, targets);
|
|
223
|
+
requireThat(receipt.status === 'verified', 'upgrade execution is not verified');
|
|
224
|
+
requireThat(receipt.results.length === targets.size, 'execution target set mismatch');
|
|
225
|
+
for (const result of receipt.results) {
|
|
226
|
+
passedResult(record, result, candidates.get(result.path));
|
|
227
|
+
}
|
|
228
|
+
record.execution = receipt;
|
|
229
|
+
}
|
|
230
|
+
return record;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Snapshot without following symlinks. The sole mutable move receipt is excluded. */
|
|
234
|
+
export function retroInventory(root) {
|
|
235
|
+
const entries = [];
|
|
236
|
+
function walk(directory, prefix = '') {
|
|
237
|
+
for (const name of readdirSync(directory).sort()) {
|
|
238
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
239
|
+
if (path === 'archive.json') continue;
|
|
240
|
+
const full = join(directory, name), stat = lstatSync(full), entry = { path, mode: stat.mode & 0o7777 };
|
|
241
|
+
if (stat.isSymbolicLink()) entries.push({ ...entry, type: 'symlink', target: readlinkSync(full) });
|
|
242
|
+
else if (stat.isDirectory()) { entries.push({ ...entry, type: 'directory' }); walk(full, path); }
|
|
243
|
+
else { requireThat(stat.isFile(), `${path}: unsupported archive entry`); entries.push({ ...entry, type: 'file', sha256: digestFile(full) }); }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
walk(root);
|
|
247
|
+
return entries;
|
|
248
|
+
}
|
|
249
|
+
function archive(context, record, plan, sealed) {
|
|
250
|
+
requireThat(record.archived, `${record.id}: not in retro/archive`);
|
|
251
|
+
const receipt = json(record.root, 'archive.json', 'retro-archive');
|
|
252
|
+
requireThat(receipt.id === record.id && receipt.owner === record.meta.owner && receipt.upgrade === plan.id, 'archive identity/owner mismatch');
|
|
253
|
+
requireThat(receipt.source === `retro/${record.id}` && receipt.target === `retro/archive/${record.id}`, 'archive source/target mismatch');
|
|
254
|
+
requireThat(receipt.plan_sha256 === plan.sha256 && receipt.approval_sha256 === plan.approvalHash, 'archive approval mismatch');
|
|
255
|
+
if (sealed) requireThat(receipt.result === 'verified', 'archive has not been sealed');
|
|
256
|
+
unique(receipt.entries.map(e => e.path), 'archive inventory');
|
|
257
|
+
const actual = new Map(retroInventory(record.root).map(e => [e.path, e]));
|
|
258
|
+
requireThat(actual.size === receipt.entries.length, 'archive inventory coverage mismatch');
|
|
259
|
+
for (const entry of receipt.entries) {
|
|
260
|
+
const observed = actual.get(entry.path);
|
|
261
|
+
requireThat(observed && entry.type === observed.type && entry.mode === observed.mode && entry.sha256 === observed.sha256 && entry.target === observed.target, `${entry.path}: archive integrity drift`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
export function listRetroReports(input) {
|
|
265
|
+
const context = resolveWorkContextSync(input), root = safeWorkPathSync(context.specdevRoot, 'retro', { type: 'directory' });
|
|
266
|
+
const result = { reports: [], issues: [] };
|
|
267
|
+
if (!existsSync(root)) return result;
|
|
268
|
+
for (const name of readdirSync(root).filter(name => name !== 'archive').sort()) {
|
|
269
|
+
try {
|
|
270
|
+
const path = safeWorkPathSync(root, name, { mustExist: true, type: 'directory' });
|
|
271
|
+
if (!existsSync(join(path, 'RETRO.md'))) continue;
|
|
272
|
+
object(context, name, 'report');
|
|
273
|
+
result.reports.push(name);
|
|
274
|
+
} catch (error) { result.issues.push({ id: name, error: String(error.message ?? error) }); }
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
export function validateRetro(input) {
|
|
279
|
+
const errors = [], warnings = [];
|
|
280
|
+
try {
|
|
281
|
+
const context = resolveWorkContextSync(input), stage = input.stage ?? 'record';
|
|
282
|
+
requireThat(['report', 'upgrade'].includes(input.kind), 'expected report or upgrade kind');
|
|
283
|
+
requireThat(['record', 'apply', 'verified', 'archive-ready', 'archived'].includes(stage), 'invalid retro stage');
|
|
284
|
+
requireThat(!input.sealed || stage === 'archived', '--sealed requires archived stage');
|
|
285
|
+
requireThat(input.kind === 'upgrade' || !['apply', 'verified'].includes(stage), 'apply/verified stages require upgrade');
|
|
286
|
+
const historical = stage === 'archived';
|
|
287
|
+
const record = input.kind === 'report' ? report(context, input.id, warnings) : upgrade(context, input.id, warnings, { live: !historical, approved: stage === 'apply', execution: ['verified', 'archive-ready', 'archived'].includes(stage) });
|
|
288
|
+
requireThat(historical === record.archived, historical ? 'object has not moved' : 'archived objects are read-only; use archived validation');
|
|
289
|
+
if (stage === 'archive-ready' || historical) {
|
|
290
|
+
const plan = input.kind === 'upgrade' ? record : upgrade(context, input.upgrade, warnings, { live: !historical, execution: true });
|
|
291
|
+
if (input.kind === 'report') requireThat(plan.meta.archive_reports.includes(input.id), `${input.id}: not approved for archive`);
|
|
292
|
+
if (input.kind === 'upgrade') for (const id of plan.meta.archive_reports) {
|
|
293
|
+
const source = report(context, id, warnings);
|
|
294
|
+
requireThat(source.archived, `${id}: report must be sealed before archiving its upgrade`);
|
|
295
|
+
archive(context, source, plan, true);
|
|
296
|
+
}
|
|
297
|
+
if (historical) archive(context, record, plan, Boolean(input.sealed));
|
|
298
|
+
}
|
|
299
|
+
} catch (error) { errors.push(String(error.message ?? error)); }
|
|
300
|
+
return { errors, warnings: [...new Set(warnings)], caveat: 'Read-only integrity checks do not authenticate approval, provenance, semantic quality or host invocation.' };
|
|
301
|
+
}
|
|
302
|
+
export function retroCLI(argv) {
|
|
303
|
+
const options = {}, names = { '--repo': 'projectRoot', '--method-root': 'methodRoot', '--state-root': 'stateRoot', '--stage': 'stage', '--upgrade-plan': 'upgrade' };
|
|
304
|
+
if (argv.includes('--help')) {
|
|
305
|
+
console.log('Usage: node validate-retro.mjs (--list | --report <id> | --upgrade <id>) --repo <project> [--method-root <methods> --state-root <data-parent>] [--stage record|apply|verified|archive-ready|archived] [--upgrade-plan <id>] [--sealed]\nDefault record checks are read-only. Direct use requires all three roots. Example: --report 2026-10-15-ci --repo . --method-root ./template --state-root ./data');
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
let mode;
|
|
310
|
+
for (let i = 0; i < argv.length; i++) {
|
|
311
|
+
const arg = argv[i];
|
|
312
|
+
if (['--list', '--report', '--upgrade'].includes(arg)) {
|
|
313
|
+
requireThat(!mode, 'choose exactly one mode'); mode = arg;
|
|
314
|
+
if (arg !== '--list') { options.kind = arg.slice(2); options.id = argv[++i]; requireThat(options.id && !options.id.startsWith('--'), 'missing object ID'); }
|
|
315
|
+
} else if (arg === '--sealed') { requireThat(!options.sealed, 'duplicate --sealed'); options.sealed = true; }
|
|
316
|
+
else {
|
|
317
|
+
const key = names[arg]; requireThat(key && !(key in options), `invalid or duplicate option: ${arg}`);
|
|
318
|
+
const value = argv[++i]; requireThat(value && !value.startsWith('--'), `missing value: ${arg}`); options[key] = value;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
requireThat(mode, 'choose --list, --report or --upgrade');
|
|
322
|
+
if (mode === '--list') {
|
|
323
|
+
requireThat(!options.stage && !options.upgrade && !options.sealed, 'list does not accept validation stage options');
|
|
324
|
+
const result = listRetroReports(options);
|
|
325
|
+
console.log(JSON.stringify(result, null, 2)); return result.issues.length ? 1 : 0;
|
|
326
|
+
}
|
|
327
|
+
requireThat(!options.upgrade || options.kind === 'report' && ['archive-ready', 'archived'].includes(options.stage), '--upgrade-plan is for report archive validation');
|
|
328
|
+
const result = validateRetro(options); console.log(JSON.stringify(result, null, 2)); return result.errors.length ? 1 : 0;
|
|
329
|
+
} catch (error) { console.error(String(error.message ?? error)); return 2; }
|
|
330
|
+
}
|
|
331
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = retroCLI(process.argv.slice(2));
|