@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
|
@@ -14,7 +14,7 @@ function check(value, rule, at, errors) {
|
|
|
14
14
|
if (rule.type) {
|
|
15
15
|
const types = Array.isArray(rule.type) ? rule.type : [rule.type];
|
|
16
16
|
const t = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
17
|
-
if (!types.includes(t)) { errors.push(at + ": invalid type"); return; }
|
|
17
|
+
if (!types.includes(t) && !(types.includes("integer") && Number.isInteger(value))) { errors.push(at + ": invalid type"); return; }
|
|
18
18
|
}
|
|
19
19
|
if (typeof value === "string") {
|
|
20
20
|
if (rule.minLength && value.trim().length < rule.minLength) errors.push(at + ": empty");
|
|
@@ -35,7 +35,6 @@ function check(value, rule, at, errors) {
|
|
|
35
35
|
}
|
|
36
36
|
const specs = {
|
|
37
37
|
"pull-request": ["pull-request.schema.json", ["Summary","Evidence","Before / After","Merge Risk","Authorization","Receipt","Recovery"]],
|
|
38
|
-
retrospective: ["retrospective.schema.json", ["Scope","Evidence","Findings","Acceptance","Routes"]],
|
|
39
38
|
"logic-prototype": ["logic-prototype.schema.json", ["Model","Scenarios","Evidence","Decisions","Handoff"]],
|
|
40
39
|
"triage-run": ["triage-run.schema.json", ["Targets","Plan","Evidence","Authorization","Receipts","Recovery"]],
|
|
41
40
|
};
|
|
@@ -54,7 +53,6 @@ export function validateRecord(path, kind, parse, expectedChange = null) {
|
|
|
54
53
|
if (meta.status === "ready" && meta.verification !== "passed") errors.push(path + ": ready PR requires passed verification");
|
|
55
54
|
if (["draft","ready"].includes(meta.status) && !new RegExp("^https://github\\.com/" + String(meta.repo).replaceAll(".","\\.") + "/pull/[1-9]\\d*$").test(meta.url ?? "")) errors.push(path + ": missing matching PR URL");
|
|
56
55
|
}
|
|
57
|
-
if (kind === "retrospective" && (meta.sources ?? []).some(s => typeof s !== "string" || !s.trim() || /(?:[A-Za-z]:[\\/]|\b(?:token|secret)=)/i.test(s))) errors.push(path + ": unsafe source");
|
|
58
56
|
if (kind === "triage-run" && meta.status === "completed" && meta.mode !== "ci-security") {
|
|
59
57
|
if (meta.npm_target === "unknown") errors.push(path + ": completed release target cannot be unknown");
|
|
60
58
|
if (!meta.commit_sha) errors.push(path + ": completed release requires fixed commit");
|
|
@@ -83,7 +81,7 @@ export function validateRecord(path, kind, parse, expectedChange = null) {
|
|
|
83
81
|
return {errors,meta,body};
|
|
84
82
|
}
|
|
85
83
|
export function validateRecords(change, kind, parse) {
|
|
86
|
-
const
|
|
84
|
+
const root=join(change,"pull-requests");
|
|
87
85
|
const errors=[], records=[];
|
|
88
86
|
if (!existsSync(root)) return {errors,records};
|
|
89
87
|
if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory()) return {errors:[root + ": unsafe directory"],records};
|
|
@@ -93,3 +91,5 @@ export function validateRecords(change, kind, parse) {
|
|
|
93
91
|
}
|
|
94
92
|
return {errors,records};
|
|
95
93
|
}
|
|
94
|
+
|
|
95
|
+
export { check as checkSchema };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Change workspace and Ticket scheduling contracts. Inspection only; never creates worktrees or agents. */
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
5
|
+
import * as runtime from '../../../../lib/speculo-runtime.mjs';
|
|
6
|
+
import { validateIndependentReview } from './review-evidence.mjs';
|
|
7
|
+
|
|
8
|
+
export const EXECUTION_VERSION = 1;
|
|
9
|
+
export const EXECUTION_CONTEXT = 'README.md#execution-context';
|
|
10
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
const text = value => typeof value === 'string' && value.trim().length > 0;
|
|
12
|
+
const sha = value => /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(value ?? '');
|
|
13
|
+
const positive = value => Number.isInteger(value) && value > 0;
|
|
14
|
+
const git = (root, args) => execFileSync('git', ['-C', root, ...args], {encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
|
15
|
+
|
|
16
|
+
export function readExecutionContext(change) {
|
|
17
|
+
const path = runtime.safeWorkPathSync(change, 'README.md', {mustExist:true,type:'file'});
|
|
18
|
+
const source = readFileSync(path, 'utf8');
|
|
19
|
+
const headings = runtime.markdownBodyLines(source, path).filter(line => line.text === '## Execution Context');
|
|
20
|
+
if (!headings.length) return null;
|
|
21
|
+
if (headings.length !== 1) throw new Error('duplicate Execution Context');
|
|
22
|
+
const section = runtime.readArtifactSectionSync(path, 'execution-context').text;
|
|
23
|
+
const blocks = [...section.matchAll(/```json\s*\n([\s\S]*?)\n```/g)];
|
|
24
|
+
if (blocks.length !== 1) throw new Error('Execution Context requires one JSON object');
|
|
25
|
+
const value = JSON.parse(blocks[0][1]);
|
|
26
|
+
const required = ['schema_version','change','workspace_policy','workspace_ref','branch','base_sha','owner','ticket_execution'];
|
|
27
|
+
const allowed = [...required,'max_parallel_tickets','concurrent_implementation_limit','integration_attempt_limit'];
|
|
28
|
+
if (!object(value) || required.some(key => !(key in value)) || Object.keys(value).some(key => !allowed.includes(key))) throw new Error('invalid Execution Context fields');
|
|
29
|
+
if (value.schema_version !== EXECUTION_VERSION || value.change !== basename(change)) throw new Error('Execution Context version/change mismatch; explicit reconciliation required');
|
|
30
|
+
if (!['current','worktree'].includes(value.workspace_policy) || !['auto','serial','parallel'].includes(value.ticket_execution)) throw new Error('invalid workspace_policy or ticket_execution');
|
|
31
|
+
if (!text(value.branch) || !text(value.owner) || !sha(value.base_sha)) throw new Error('Execution Context requires branch, owner and immutable base_sha');
|
|
32
|
+
for (const key of ['max_parallel_tickets','concurrent_implementation_limit','integration_attempt_limit']) if (key in value && !positive(value[key])) throw new Error(`${key} must be a positive integer`);
|
|
33
|
+
if (value.workspace_policy === 'current' ? value.workspace_ref !== 'current' : !text(value.workspace_ref) || value.workspace_ref === 'current' || /[\\:\x00-\x1f]/.test(value.workspace_ref) || value.workspace_ref.split('/').some(part => !part || part === '.' || part === '..' || part === '.git')) throw new Error('workspace_ref must identify the selected portable change workspace');
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Defaults do not impose an Agent count. Parent and child Ticket caps remain separate from it. */
|
|
38
|
+
export function ticketPolicy(...sources) {
|
|
39
|
+
const values = sources.filter(Boolean);
|
|
40
|
+
const modes = values.map(value => value.ticket_execution).filter(Boolean);
|
|
41
|
+
const limits = values.map(value => value.max_parallel_tickets).filter(value => value !== undefined);
|
|
42
|
+
if (modes.some(mode => !['auto','serial','parallel'].includes(mode)) || limits.some(limit => !positive(limit))) throw new Error('invalid Ticket scheduling policy');
|
|
43
|
+
return {ticket_execution:modes.includes('serial') ? 'serial' : modes[0] ?? 'auto', max_parallel_tickets:modes.includes('serial') ? 1 : limits.length ? Math.min(...limits) : null};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveExecutionWorkspace(repoRoot, context) {
|
|
47
|
+
if (!context || !repoRoot) return repoRoot;
|
|
48
|
+
const root = realpathSync.native(repoRoot);
|
|
49
|
+
const workspace = context.workspace_ref === 'current' ? root : runtime.safeWorkPathSync(root, context.workspace_ref, {mustExist:true,type:'directory'});
|
|
50
|
+
if (relative(realpathSync.native(workspace), realpathSync.native(git(workspace,['rev-parse','--show-toplevel']))) !== '') throw new Error('selected workspace is not a real Git checkout');
|
|
51
|
+
const common = cwd => realpathSync.native(resolve(cwd, git(cwd,['rev-parse','--git-common-dir'])));
|
|
52
|
+
if (common(root) !== common(workspace)) throw new Error('selected workspace belongs to another repository');
|
|
53
|
+
if (git(workspace,['symbolic-ref','--short','HEAD']) !== context.branch) throw new Error('selected workspace branch drifted');
|
|
54
|
+
git(workspace,['cat-file','-e',`${context.base_sha}^{commit}`]);
|
|
55
|
+
git(workspace,['merge-base','--is-ancestor',context.base_sha,'HEAD']);
|
|
56
|
+
if (context.workspace_policy === 'worktree') {
|
|
57
|
+
const paths = git(root,['worktree','list','--porcelain','-z']).split('\0').filter(row=>row.startsWith('worktree ')).map(row=>realpathSync.native(row.slice(9)));
|
|
58
|
+
if (!paths.includes(realpathSync.native(workspace)) || realpathSync.native(workspace) === root) throw new Error('selected change worktree is not registered');
|
|
59
|
+
}
|
|
60
|
+
return workspace;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function evidenceText(change, locator) {
|
|
64
|
+
if (!text(locator) || !locator.includes('#')) throw new Error('verification evidence requires an explicit artifact anchor');
|
|
65
|
+
const [file, anchor, extra] = locator.split('#');
|
|
66
|
+
if (!anchor || extra !== undefined) throw new Error('invalid evidence locator');
|
|
67
|
+
const path = runtime.safeWorkPathSync(change, file, {mustExist:true,type:'file'});
|
|
68
|
+
return runtime.readArtifactSectionSync(path, anchor).text;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateExecutionRecord(change, record, context, {repoRoot=null, sourceRoot=repoRoot, checkCurrent=false} = {}) {
|
|
72
|
+
const errors = [], unit = record.ticket_id ?? record.purpose;
|
|
73
|
+
try {
|
|
74
|
+
const identity = record.ticket_id ? ['ticket_id'] : ['purpose','change'];
|
|
75
|
+
const keys = ['schema_version',...identity,'workspace_context','implementation_owners','status','checkpoint','verification','attempts'];
|
|
76
|
+
if (record.schema_version !== EXECUTION_VERSION || keys.some(key=>!(key in record)) || Object.keys(record).some(key=>!keys.includes(key))) throw new Error('legacy or invalid execution record; explicit reconciliation required');
|
|
77
|
+
if (record.ticket_id ? !/^T-\d{2,}$/.test(record.ticket_id) : record.purpose !== 'direct-spec' || record.change !== basename(change)) throw new Error('execution identity does not match this change');
|
|
78
|
+
if (!context || record.workspace_context !== EXECUTION_CONTEXT) throw new Error('execution must reference the unique change Execution Context');
|
|
79
|
+
if (!['active','review','verified','blocked','cancelled'].includes(record.status)) throw new Error('invalid execution status');
|
|
80
|
+
if (!Array.isArray(record.implementation_owners) || !record.implementation_owners.length || record.implementation_owners.some(owner=>!text(owner)) || new Set(record.implementation_owners).size !== record.implementation_owners.length) throw new Error('implementation_owners must list the actual distinct implementers');
|
|
81
|
+
if (!Number.isInteger(record.attempts) || record.attempts < 0 || record.attempts > (context.integration_attempt_limit ?? 3)) throw new Error('verification attempts exceed the effective limit');
|
|
82
|
+
if (record.checkpoint !== null && !text(record.checkpoint)) throw new Error('checkpoint must be null or a frozen review input locator');
|
|
83
|
+
const verification = record.verification;
|
|
84
|
+
if (!object(verification) || Object.keys(verification).sort().join(',') !== 'e2e,evidence,status' || !['pending','passed','failed'].includes(verification.status)) throw new Error('invalid workspace verification');
|
|
85
|
+
const e2e = verification.e2e;
|
|
86
|
+
if (!object(e2e) || Object.keys(e2e).sort().join(',') !== 'evidence,reason,required,status' || typeof e2e.required !== 'boolean' || !['pending','passed','failed','not-required'].includes(e2e.status)) throw new Error('invalid E2E disposition');
|
|
87
|
+
for (const value of [verification.evidence,e2e.evidence,e2e.reason]) if (value !== null && !text(value)) throw new Error('evidence/reason must be null or non-empty text');
|
|
88
|
+
if (!e2e.required && (e2e.status !== 'not-required' || !text(e2e.reason)) || e2e.required && e2e.status === 'not-required') throw new Error('E2E disposition needs a truthful result/reason');
|
|
89
|
+
if (record.status === 'verified') {
|
|
90
|
+
if (verification.status !== 'passed' || !['passed','not-required'].includes(e2e.status) || !record.checkpoint) throw new Error('verified execution requires passed checks, E2E and a frozen checkpoint');
|
|
91
|
+
const observed = evidenceText(change,verification.evidence);
|
|
92
|
+
if (!/^Result: passed\s*$/m.test(observed) || !/^Observed: \S/m.test(observed) || /^Command: /m.test(observed) && !/^Exit: 0\s*$/m.test(observed)) throw new Error('verification requires actual observations and successful command exits');
|
|
93
|
+
if (e2e.required) {
|
|
94
|
+
const result = evidenceText(change,e2e.evidence);
|
|
95
|
+
if (!/^Result: passed\s*$/m.test(result) || !/^Observed: \S/m.test(result) || /^Command: /m.test(result) && !/^Exit: 0\s*$/m.test(result) || !result.split('\n').some(line=>line.trim() === `Owner: ${context.owner}`)) throw new Error('required E2E needs observed passing evidence owned by the Lead');
|
|
96
|
+
}
|
|
97
|
+
const artifact = `evidence/${record.ticket_id ?? 'direct-spec'}.md`;
|
|
98
|
+
const source = readFileSync(runtime.safeWorkPathSync(change,artifact,{mustExist:true,type:'file'}),'utf8');
|
|
99
|
+
if (!source.split('\n').some(line=>line.trim() === `Input: ${record.checkpoint}`)) throw new Error('checkpoint must match the actual Independent Review input');
|
|
100
|
+
errors.push(...validateIndependentReview(change,artifact,{repoRoot,sourceRoot,requiredImplementers:record.implementation_owners,checkCurrent,requireDelivery:checkCurrent}));
|
|
101
|
+
}
|
|
102
|
+
} catch(error) { errors.push(`${unit}: ${error.message}`); }
|
|
103
|
+
return errors;
|
|
104
|
+
}
|
|
@@ -319,7 +319,9 @@ export function readSpecification(changeRoot, { allowLightweight = false, requir
|
|
|
319
319
|
export function validateExecutionPolicy(plan, config = null) {
|
|
320
320
|
const errors = [];
|
|
321
321
|
const meta = plan.meta;
|
|
322
|
-
if (!
|
|
322
|
+
if (!['auto','serial','parallel'].includes(meta.ticket_execution)) errors.push('plan must declare ticket_execution: auto, serial or parallel');
|
|
323
|
+
for (const key of ['concurrent_implementation_limit','max_parallel_tickets']) if (meta[key] !== undefined && (!Number.isInteger(meta[key]) || meta[key] < 1)) errors.push(`${key} must be a positive integer when specified`);
|
|
324
|
+
if (!Number.isInteger(meta.integration_attempt_limit) || meta.integration_attempt_limit < 1) errors.push('plan must declare a positive integration_attempt_limit');
|
|
323
325
|
if (config) {
|
|
324
326
|
if (config.schema_version !== 7 || !Number.isInteger(config.execution?.max_concurrent_implementations) || !Number.isInteger(config.execution?.max_integration_attempts)) return ['existing tool config requires schema v7 and explicit execution limits'];
|
|
325
327
|
if (meta.concurrent_implementation_limit > config.execution.max_concurrent_implementations) errors.push(`concurrent_implementation_limit ${meta.concurrent_implementation_limit} exceeds config max_concurrent_implementations ${config.execution.max_concurrent_implementations}`);
|
|
@@ -34,7 +34,7 @@ function readLocator(root, locator) {
|
|
|
34
34
|
export function reviewInputDigest(input) { return hash(JSON.stringify(input)); }
|
|
35
35
|
|
|
36
36
|
/** Sources use project-relative locators; an anchor avoids a README hashing itself. */
|
|
37
|
-
export function validateIndependentReview(root, artifact, {repoRoot=null, requirePassed=true, expectedHead=null, expectedBase=null, expectedResults=null, requiredImplementers=[], requiredSpecifications=[], checkCurrent=false, requireDelivery=false, allowSourceFiles=false} = {}) {
|
|
37
|
+
export function validateIndependentReview(root, artifact, {repoRoot=null, sourceRoot=repoRoot, requirePassed=true, expectedHead=null, expectedBase=null, expectedResults=null, requiredImplementers=[], requiredSpecifications=[], checkCurrent=false, requireDelivery=false, allowSourceFiles=false} = {}) {
|
|
38
38
|
const errors = [], label = `independent-review: ${artifact}`;
|
|
39
39
|
try {
|
|
40
40
|
const path = safeWorkPathSync(root, artifact, {mustExist:true, type:'file'});
|
|
@@ -54,7 +54,7 @@ export function validateIndependentReview(root, artifact, {repoRoot=null, requir
|
|
|
54
54
|
for (const source of input.sources) {
|
|
55
55
|
if (!['standards','specification','instructions'].includes(source.kind) || !real(source.locator) || !/^[a-f0-9]{64}$/.test(source.sha256 ?? '')) throw new Error('invalid review source');
|
|
56
56
|
sourceKinds.add(source.kind);
|
|
57
|
-
if (repoRoot && checkCurrent && hash(readLocator(
|
|
57
|
+
if (repoRoot && checkCurrent && hash(readLocator(sourceRoot,source.locator).text) !== source.sha256) throw new Error(`source drift: ${source.locator}`);
|
|
58
58
|
}
|
|
59
59
|
if (!sourceKinds.has('standards') || requirePassed && !sourceKinds.has('specification')) throw new Error('missing required review sources');
|
|
60
60
|
if (requiredSpecifications.some(locator=>!input.sources.some(source=>source.kind === 'specification' && source.locator === locator))) throw new Error('review omits the authoritative specification/acceptance source');
|
|
@@ -223,11 +223,11 @@ test('range comparison and merge-base comparison have distinct semantics', t =>
|
|
|
223
223
|
const range = resolveReviewScope({ repo: r.root, mode: 'range', base: r.c, head: side });
|
|
224
224
|
assert.deepEqual(baseline.changed_paths, ['side.txt']); assert.deepEqual(range.changed_paths, ['b.txt', 'c.txt', 'side.txt']);
|
|
225
225
|
});
|
|
226
|
-
test('fifteen Work IDs match the current registry and persisted schema versions
|
|
226
|
+
test('fifteen Work IDs match the current registry and persisted schema versions match their contracts', () => {
|
|
227
227
|
const entries = readdirSync(workflow).filter(name => /^specdev-[a-z]-/.test(name)); assert.equal(entries.length, 15);
|
|
228
228
|
const ids = entries.map(name => /speculo-id: (.+)$/m.exec(readFileSync(join(workflow, name, 'SKILL.md'), 'utf8'))?.[1]);
|
|
229
229
|
assert.deepEqual(ids.sort(), ['archive-and-consolidate','code-review','diagnose-bugs','goal-plan','grill-with-docs','implement','init-setup','learn','prototype','retro','review-architecture','spec','tickets','triage','wayfinder'].map(id => 'specdev/' + id).sort());
|
|
230
|
-
for (const [file, version] of [['config', 7], ['status', 5], ['change-status', 6], ['goal-plan',
|
|
230
|
+
for (const [file, version] of [['config', 7], ['status', 5], ['change-status', 6], ['goal-plan', 8], ['implementation-plan', 3], ['execution-context', 1], ['execution-record', 1]]) {
|
|
231
231
|
const schema = JSON.parse(readFileSync(join(workflow, 'common/schemas', `${file}.schema.json`), 'utf8'));
|
|
232
232
|
assert.equal(schema.properties.schema_version.const, version);
|
|
233
233
|
}
|
|
@@ -6,7 +6,7 @@ import {resolveReviewScope} from '../review-scope.mjs';
|
|
|
6
6
|
import {reviewInputDigest} from '../review-evidence.mjs';
|
|
7
7
|
import {readArtifactSectionSync} from '../../../../../lib/speculo-runtime.mjs';
|
|
8
8
|
const hash=text=>createHash('sha256').update(text).digest('hex');
|
|
9
|
-
export function appendReviewFixture({repo,root,artifact='evidence/direct-spec.md',base,head=null,files=['value.txt'],mode='native',reviewer='native:reviewer',implementers=['native:implementer'],spec=null,sourceLocators=null}) {
|
|
9
|
+
export function appendReviewFixture({repo,sourceRoot=repo,root,artifact='evidence/direct-spec.md',base,head=null,files=['value.txt'],sourceFiles=false,mode='native',reviewer='native:reviewer',implementers=['native:implementer'],spec=null,sourceLocators=null}) {
|
|
10
10
|
const path=join(root,artifact);mkdirSync(dirname(path),{recursive:true});
|
|
11
11
|
let prior=existsSync(path)?readFileSync(path,'utf8'):'';
|
|
12
12
|
prior=prior.split('\n<!-- synthetic-independent-review -->')[0];
|
|
@@ -14,14 +14,14 @@ export function appendReviewFixture({repo,root,artifact='evidence/direct-spec.md
|
|
|
14
14
|
let sources;
|
|
15
15
|
if(sourceLocators) sources=sourceLocators.map(({kind,locator})=>{
|
|
16
16
|
const [file,anchor]=locator.split('#');
|
|
17
|
-
return {kind,locator,sha256:hash(anchor?readArtifactSectionSync(join(
|
|
17
|
+
return {kind,locator,sha256:hash(anchor?readArtifactSectionSync(join(sourceRoot,file),anchor).text:readFileSync(join(sourceRoot,file),'utf8'))};
|
|
18
18
|
});
|
|
19
19
|
else {
|
|
20
20
|
const standards=join(root,'evidence/fixture-standards.md');mkdirSync(dirname(standards),{recursive:true});writeFileSync(standards,'# Fixture standards\nPreserve existing behavior and test changes.\n');
|
|
21
21
|
if(!spec) {spec=join(root,'evidence/fixture-spec.md');writeFileSync(spec,'# Fixture Spec\nAC-001: deliver the accepted behavior.\n');}
|
|
22
|
-
sources=[['standards',standards],['specification',spec]].map(([kind,file])=>({kind,locator:relative(
|
|
22
|
+
sources=[['standards',standards],['specification',spec]].map(([kind,file])=>({kind,locator:relative(sourceRoot,file).split('\\').join('/'),sha256:hash(readFileSync(file,'utf8'))}));
|
|
23
23
|
}
|
|
24
|
-
const scope=resolveReviewScope(head?{repo,mode:'range',base,head}:{repo,mode:'working-tree',base,files});
|
|
24
|
+
const scope=resolveReviewScope(sourceFiles?{repo,mode:'source-files',files}:head?{repo,mode:'range',base,head}:{repo,mode:'working-tree',base,files});
|
|
25
25
|
const input={scope,sources,exclusions:'Fixture-only records and unrelated inputs are outside the implementation scope.'};
|
|
26
26
|
const digest=reviewInputDigest(input);
|
|
27
27
|
const report=axis=>`<a id="${axis}-output"></a>\n## Original ${axis} output\nReviewer: ${reviewer}\nAxis: ${axis}\nResult: pass\nInput-SHA256: ${digest}\nObserved: synthetic fixture reviewer inspected the fixed inputs; no blocking findings.\n`;
|
|
@@ -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 = {};
|