@namewta/speculo 2.0.4 → 2.0.6
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 +2272 -1069
- package/template/canonical/canonical-specdev-grill-with-docs.md +2164 -661
- package/template/canonical/canonical-specdev-spec.md +2163 -660
- package/template/canonical/canonical-specdev-tickets.md +2095 -574
- package/template/canonical/canonical-specdev-wayfinder.md +2339 -836
- package/template/references/coordination/coordination-contract.md +8 -6
- package/template/references/coordination/modes/cli.md +1 -1
- package/template/references/coordination/modes/herdr.md +2 -2
- package/template/references/coordination/modes/orca.md +3 -3
- package/template/references/coordination/report-template.md +18 -0
- package/template/references/coordination/result-and-recovery.md +4 -4
- package/template/references/coordination/session-files.md +49 -56
- package/template/references/coordination/task-brief.md +7 -7
- package/template/references/coordination/task-template.md +25 -0
- 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/path-reference-contract.md +1 -1
- package/template/workflows/specdev/common/rules/workflow-routing.md +1 -1
- package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +7 -3
- 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/schemas/session-locator.schema.json +15 -0
- package/template/workflows/specdev/common/skills/code-review/references/review-evidence.md +3 -3
- 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 +16 -0
- package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +5 -5
- 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-handoff.mjs +108 -0
- 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 +39 -31
- 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
|
@@ -5,6 +5,7 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { parseFrontmatter, validateChange, pathsOverlap } from './validate-specdev.mjs';
|
|
7
7
|
import { CHANGE_ID, digest, findGraphCycle, resolveProjectSource, validateGoalMap, resolveToolContext, validateExecutionPolicy, canonicalToolPath } from './plan-contract.mjs';
|
|
8
|
+
import { readExecutionContext, ticketPolicy } from './execution-context.mjs';
|
|
8
9
|
import * as runtime from '../../../../lib/speculo-runtime.mjs';
|
|
9
10
|
|
|
10
11
|
const terminal = new Set(['done','cancelled']);
|
|
@@ -43,7 +44,7 @@ function contractHash(node) {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
return digest(JSON.stringify({ meta, body: node.artifact.body, spec: node.specDigest, goal: node.goalDigest, dependencies: node.dependencies, serializations: node.serializations, sources }));
|
|
47
|
+
return digest(JSON.stringify({ meta, body: node.artifact.body, spec: node.specDigest, goal: node.goalDigest, scheduling:node.policy, workspace:readExecutionContext(dirname(dirname(node.artifact.path))), dependencies: node.dependencies, serializations: node.serializations, sources }));
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context: suppliedContext, previous = null }) {
|
|
@@ -68,9 +69,9 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
|
|
|
68
69
|
if (!Array.isArray(members) || !members.length || (parent && members.length < 2) || new Set(members).size !== members.length || members.some(id => !CHANGE_ID.test(id))) return failure(['invalid/duplicate member change IDs']);
|
|
69
70
|
const planFile = join(root,parent ? 'implementation-plan.md' : 'goal-plan.md');
|
|
70
71
|
const plan = existsSync(planFile) ? readArtifact(planFile) : null;
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const scopeContext = readExecutionContext(root);
|
|
73
|
+
const policy = ticketPolicy(plan?.meta,scopeContext);
|
|
74
|
+
const limit = policy.max_parallel_tickets;
|
|
74
75
|
const configPath = join(context.specdevRoot, 'config.json');
|
|
75
76
|
const config = existsSync(configPath) ? JSON.parse(readFileSync(runtime.safeWorkPathSync(context.specdevRoot, 'config.json', {mustExist:true,type:'file'}), 'utf8')) : null;
|
|
76
77
|
if (plan) errors.push(...validateExecutionPolicy(plan, config));
|
|
@@ -81,6 +82,9 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
|
|
|
81
82
|
if (!existsSync(memberRoot) || lstatSync(memberRoot).isSymbolicLink() || !lstatSync(memberRoot).isDirectory()) { errors.push(`member must be a real change directory: ${member}`); continue; }
|
|
82
83
|
memberRoots.set(member,memberRoot);
|
|
83
84
|
const facts = runtime.readChangeFactsSync(context, member);
|
|
85
|
+
const childPlanPath = join(memberRoot,'goal-plan.md');
|
|
86
|
+
const childPlan = parent && existsSync(childPlanPath) ? readArtifact(childPlanPath) : null;
|
|
87
|
+
const memberPolicy = ticketPolicy(plan?.meta,childPlan?.meta,readExecutionContext(memberRoot));
|
|
84
88
|
if (facts.location.root !== memberRoot) { errors.push(`member is not an active change: ${member}`); continue; }
|
|
85
89
|
const specFile = join(memberRoot,'spec.md');
|
|
86
90
|
const spec = readArtifact(specFile);
|
|
@@ -93,7 +97,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
|
|
|
93
97
|
const id = parent ? `${member}::${artifact.meta.id}` : artifact.meta.id;
|
|
94
98
|
if (nodes.has(id)) { errors.push(`duplicate Ticket ${id}`); continue; }
|
|
95
99
|
if (!Array.isArray(artifact.meta.blocked_by) || artifact.meta.blocked_by.some(value => !/^T-\d{2,}$/.test(value))) { errors.push(`invalid dependencies: ${id}`); continue; }
|
|
96
|
-
nodes.set(id, { id, member, repoRoot, artifact, specDigest, facts, dependencies: artifact.meta.blocked_by.map(value => parent ? `${member}::${value}` : value), reasons: [], validationErrors: [] });
|
|
100
|
+
nodes.set(id, { id, member, repoRoot, policy:memberPolicy, artifact, specDigest, facts, dependencies: artifact.meta.blocked_by.map(value => parent ? `${member}::${value}` : value), reasons: [], validationErrors: [] });
|
|
97
101
|
if (spec.meta.ready_for_tickets !== true) nodes.get(id).reasons.push('source Spec is not Ready');
|
|
98
102
|
}
|
|
99
103
|
}
|
|
@@ -146,7 +150,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
|
|
|
146
150
|
}
|
|
147
151
|
for (const node of nodes.values()) {
|
|
148
152
|
node.reasons.push(...node.validationErrors);
|
|
149
|
-
if (!terminal.has(node.artifact.meta.status) && (
|
|
153
|
+
if (!terminal.has(node.artifact.meta.status) && (plan && plan.meta.ready_for_execution !== true)) node.reasons.push('Goal planning gate is not ready for execution');
|
|
150
154
|
if (node.artifact.meta.plan_contract_version !== 1) node.reasons.push('Ticket requires current plan_contract_version: 1');
|
|
151
155
|
}
|
|
152
156
|
const goalDigest = stableGoalContract(plan,entry);
|
|
@@ -157,7 +161,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
|
|
|
157
161
|
const contractDigests = Object.fromEntries([...nodes].map(([id,node]) => [id,contractHash(node)]));
|
|
158
162
|
const invalidated = new Set();
|
|
159
163
|
if (previous !== null) {
|
|
160
|
-
if (previous.schema_version !==
|
|
164
|
+
if (previous.schema_version !== 2 || !previous.contract_digests || typeof previous.contract_digests !== 'object' || Array.isArray(previous.contract_digests) || previous.scope !== basename(root)) errors.push('previous checkpoint schema/scope does not match this Goal');
|
|
161
165
|
else {
|
|
162
166
|
for (const [id,value] of Object.entries(contractDigests)) if (previous.contract_digests[id] !== value) invalidated.add(id);
|
|
163
167
|
for (const id of Object.keys(previous.contract_digests)) if (!nodes.has(id)) warnings.push(`previous node removed; check coverage before accepting replan: ${id}`);
|
|
@@ -183,7 +187,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
|
|
|
183
187
|
if (!prerequisite || prerequisite.artifact.meta.status !== 'done' || prerequisite.reasons.length) node.reasons.push(`dependency not successfully satisfied: ${dependency}`);
|
|
184
188
|
}
|
|
185
189
|
for (const other of active) {
|
|
186
|
-
const issue =
|
|
190
|
+
const issue = conflicts(node,other,serializations);
|
|
187
191
|
if (issue) node.reasons.push(`in-flight owner ${other.artifact.meta.owner ?? 'unknown'} (${other.id}): ${issue}`);
|
|
188
192
|
}
|
|
189
193
|
if (!node.reasons.length) candidates.push(node);
|
|
@@ -206,15 +210,16 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
|
|
|
206
210
|
for (const node of candidates.sort((a,b) => a.id.localeCompare(b.id))) {
|
|
207
211
|
if (node.reasons.length) continue;
|
|
208
212
|
const conflict = frontier.map(id => conflicts(node,nodes.get(id),serializations)).find(Boolean);
|
|
209
|
-
|
|
213
|
+
const memberActive = active.filter(other=>other.member === node.member).length + frontier.filter(id=>nodes.get(id).member === node.member).length;
|
|
214
|
+
if (conflict || limit !== null && frontier.length >= Math.max(0,limit-active.length) || node.policy.max_parallel_tickets !== null && memberActive >= node.policy.max_parallel_tickets) { deferred.push({id:node.id, reason:conflict ?? 'Ticket scheduling concurrency limit'});continue; }
|
|
210
215
|
frontier.push(node.id);
|
|
211
216
|
}
|
|
212
217
|
const blocked = [...nodes.values()].filter(node => node.reasons.length).map(node => ({id:node.id,reasons:[...new Set(node.reasons)]}));
|
|
213
218
|
const nodeErrors = [...nodes.values()].flatMap(node => node.validationErrors.map(message => ({id:node.id,message})));
|
|
214
219
|
const result = {
|
|
215
|
-
schema_version:
|
|
220
|
+
schema_version:2, scope:basename(root), mode:'read-only-analysis',
|
|
216
221
|
authorization_checked:false, transaction_gateway_checked:false,
|
|
217
|
-
|
|
222
|
+
ticket_execution:policy.ticket_execution, max_parallel_tickets:limit,
|
|
218
223
|
input_digest:digest(JSON.stringify({entry:entry.meta,contracts:contractDigests})),
|
|
219
224
|
goal_contract_digest:goalDigest, contract_digests:contractDigests, frontier:errors.length ? [] : frontier,
|
|
220
225
|
deferred, in_flight:active.map(node => ({id:node.id,owner:node.artifact.meta.owner})),
|
|
@@ -226,7 +231,7 @@ export function analyzeMap({ mapPath, repoRoot, methodRoot, stateRoot, context:
|
|
|
226
231
|
return result;
|
|
227
232
|
}
|
|
228
233
|
function failure(errors) {
|
|
229
|
-
return {schema_version:
|
|
234
|
+
return {schema_version:2,mode:'read-only-analysis',frontier:[],errors,validation_errors:[],eligible_for_final_verification:false};
|
|
230
235
|
}
|
|
231
236
|
function main(args) {
|
|
232
237
|
const options = {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Read-only handoff checks. Never discovers, opens or authenticates external transcripts. */
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { posix, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
markdownBodyLines, parseFrontmatter, readChangeFactsSync, readUtf8ArtifactSync,
|
|
8
|
+
resolveWorkContextSync, safeWorkPathSync,
|
|
9
|
+
} from '../../../../lib/speculo-runtime.mjs';
|
|
10
|
+
import { checkSchema } from './delivery-artifacts.mjs';
|
|
11
|
+
|
|
12
|
+
const schema = JSON.parse(readFileSync(new URL('../schemas/session-locator.schema.json', import.meta.url), 'utf8'));
|
|
13
|
+
const requireThat = (condition, message) => { if (!condition) throw new Error(message); };
|
|
14
|
+
const real = value => typeof value === 'string' && value.trim() === value && value.length > 0 && !/^(?:unknown|unassigned|pending|todo|tbd|\.\.\.)$/i.test(value) && !/[<>\x00-\x1f]|\{[^}]*\}/.test(value);
|
|
15
|
+
const readFile = (root, path) => readUtf8ArtifactSync(safeWorkPathSync(root, path, {mustExist:true, type:'file'}));
|
|
16
|
+
const readJson = path => JSON.parse(readUtf8ArtifactSync(path));
|
|
17
|
+
|
|
18
|
+
function utc(value) {
|
|
19
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) return false;
|
|
20
|
+
const time = Date.parse(value);
|
|
21
|
+
return Number.isFinite(time) && new Date(time).toISOString() === value.replace(/(?:\.(\d{1,3}))?Z$/, (_, fraction = '') => `.${fraction.padEnd(3, '0')}Z`);
|
|
22
|
+
}
|
|
23
|
+
function identity(session, task) {
|
|
24
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z(?:-(\d{2,}))?$/.exec(session ?? '');
|
|
25
|
+
requireThat(match && utc(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`) && (!match[7] || Number(match[7]) > 0 && String(Number(match[7])).padStart(2,'0') === match[7]), 'invalid orchestration session');
|
|
26
|
+
const number = /^(\d{3,})-[a-z0-9]+(?:-[a-z0-9]+)*$/.exec(task ?? '')?.[1];
|
|
27
|
+
requireThat(number && Number.isSafeInteger(Number(number)) && Number(number) > 0 && String(Number(number)).padStart(3,'0') === number, 'invalid task sequence/topic');
|
|
28
|
+
}
|
|
29
|
+
function headings(text, names) {
|
|
30
|
+
const lines = markdownBodyLines(text);
|
|
31
|
+
for (const name of names) {
|
|
32
|
+
const matches = lines.filter(line => line.text === `## ${name}`);
|
|
33
|
+
requireThat(matches.length === 1, `expected one ${name} section`);
|
|
34
|
+
const end = lines.find(line => line.line > matches[0].line && /^#{1,2} /.test(line.text))?.line ?? Infinity;
|
|
35
|
+
requireThat(lines.some(line => line.line > matches[0].line && line.line < end && line.text.trim() && !/^#+\s/.test(line.text)), `empty ${name} section`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Only a log path is stored; correspondence to this task is checked before writing. */
|
|
40
|
+
export function validateSessionLocator(value) {
|
|
41
|
+
const errors = [];
|
|
42
|
+
checkSchema(value, schema, 'session-locator', errors);
|
|
43
|
+
if (errors.length) return errors;
|
|
44
|
+
const path = value.path;
|
|
45
|
+
if (!real(path) || /(?:^|[\\/])(?:\.\.\.|unknown|todo|tbd)(?:[\\/]|$)/i.test(path)) errors.push('path: placeholder or empty locator');
|
|
46
|
+
// Accept source paths on either platform; do not access the external file here.
|
|
47
|
+
if (!(posix.isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) || /^\\\\[^\\/]+[\\/][^\\/]+[\\/]/.test(path))) errors.push('path must be an absolute file path');
|
|
48
|
+
return errors;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateHandoff(input) {
|
|
52
|
+
const errors = [], warnings = [];
|
|
53
|
+
try {
|
|
54
|
+
requireThat(['dispatch','return'].includes(input.stage), 'expected dispatch or return stage');
|
|
55
|
+
identity(input.session,input.task);
|
|
56
|
+
const context = resolveWorkContextSync(input);
|
|
57
|
+
const facts = readChangeFactsSync(context,input.change);
|
|
58
|
+
requireThat(facts.location.kind === 'active', 'archived handoffs are frozen; use the original archive integrity checks');
|
|
59
|
+
if (input.stage === 'dispatch') requireThat(facts.outcome === 'active', 'dispatch requires an active change');
|
|
60
|
+
const round = safeWorkPathSync(facts.location.root,`sessions/${input.session}`,{mustExist:true,type:'directory'});
|
|
61
|
+
const prefix = input.task.split('-')[0];
|
|
62
|
+
requireThat(readdirSync(round).filter(name => name.split('-')[0] === prefix).length === 1, 'task sequence is missing or occupied more than once');
|
|
63
|
+
const root = safeWorkPathSync(round,input.task,{mustExist:true,type:'directory'});
|
|
64
|
+
const {fields:meta,body} = parseFrontmatter(readFile(root,'TASK.md'));
|
|
65
|
+
requireThat(meta.schema_version === 1, 'TASK requires schema_version 1; retain legacy handoffs under their original contract');
|
|
66
|
+
requireThat(Object.keys(meta).every(key => ['schema_version','session','task','kind','change','ticket'].includes(key)), 'TASK: unknown metadata field');
|
|
67
|
+
requireThat(meta.session === input.session && meta.task === input.task, 'TASK identity mismatch');
|
|
68
|
+
requireThat(['implementation','review','research','test-observation'].includes(meta.kind), 'TASK: invalid kind');
|
|
69
|
+
if (meta.change !== undefined) requireThat(meta.change === input.change, 'TASK change mismatch');
|
|
70
|
+
if (meta.ticket !== undefined) requireThat(real(meta.ticket), 'TASK: invalid ticket');
|
|
71
|
+
headings(body,['Goal','Inputs','Workspace and Scope','Acceptance','Return']);
|
|
72
|
+
const report = safeWorkPathSync(root,'REPORT.md',{type:'file'});
|
|
73
|
+
if (input.stage === 'dispatch') requireThat(!existsSync(report), 'already delivered; create a new task instead of redispatch');
|
|
74
|
+
else {
|
|
75
|
+
const text = readFile(root,'REPORT.md'), lines = markdownBodyLines(text);
|
|
76
|
+
const headerEnd = lines.find(line => /^## /.test(line.text))?.line ?? Infinity;
|
|
77
|
+
const fields = {};
|
|
78
|
+
for (const line of lines.filter(line => line.line < headerEnd)) {
|
|
79
|
+
const match = /^(Session|Task|Status):\s*(.*?)\s*$/.exec(line.text);
|
|
80
|
+
if (!match) continue;
|
|
81
|
+
requireThat(!Object.hasOwn(fields,match[1]), `REPORT: duplicate ${match[1]}`); fields[match[1]]=match[2];
|
|
82
|
+
}
|
|
83
|
+
requireThat(fields.Session === input.session && fields.Task === input.task, 'REPORT identity mismatch');
|
|
84
|
+
requireThat(['completed','blocked','failed','cancelled'].includes(fields.Status), 'REPORT: invalid terminal status');
|
|
85
|
+
headings(text,['聚焦交接','产物与验证','问题与下一步']);
|
|
86
|
+
}
|
|
87
|
+
const path = safeWorkPathSync(root,'session-locator.json',{type:'file'});
|
|
88
|
+
if (existsSync(path)) errors.push(...validateSessionLocator(readJson(path)));
|
|
89
|
+
} catch (error) { errors.push(String(error.message ?? error)); }
|
|
90
|
+
return {errors,warnings,caveat:'Read-only structure checks do not authenticate tool identity, transcript content, authorization or completion; external histories are never opened.'};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function handoffCLI(argv) {
|
|
94
|
+
const names = {'--repo':'projectRoot','--method-root':'methodRoot','--state-root':'stateRoot','--change':'change','--session':'session','--task':'task','--stage':'stage'}, input = {};
|
|
95
|
+
if (argv.length === 1 && argv[0] === '--help') {
|
|
96
|
+
console.log('Usage: node validate-handoff.mjs --repo <project> [--method-root <methods> --state-root <data-parent>] --change <id> --session <round> --task <id> --stage dispatch|return\nDirect use requires all three roots. Read-only; no log discovery or external transcript access.'); return 0;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
for (let i=0;i<argv.length;i++) {
|
|
100
|
+
const key = Object.hasOwn(names,argv[i]) ? names[argv[i]] : undefined, value = argv[++i];
|
|
101
|
+
requireThat(key && !Object.hasOwn(input,key) && value && !value.startsWith('--'), 'unknown, duplicate or incomplete option'); input[key] = value;
|
|
102
|
+
}
|
|
103
|
+
requireThat(['projectRoot','change','session','task','stage'].every(key => input[key]), 'missing required arguments');
|
|
104
|
+
requireThat(['dispatch','return'].includes(input.stage), 'invalid stage');
|
|
105
|
+
} catch (error) { console.error(String(error.message ?? error)); return 2; }
|
|
106
|
+
const result = validateHandoff(input); console.log(JSON.stringify(result,null,2)); return result.errors.length ? 1 : 0;
|
|
107
|
+
}
|
|
108
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = handoffCLI(process.argv.slice(2));
|
|
@@ -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));
|