thoth-agents 0.3.9 → 0.3.11
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/.claude-plugin/marketplace.json +1 -1
- package/dist/agents/prompt-dialects.d.ts +3 -0
- package/dist/{chunk-MY2GIOOH.js → chunk-CB3EBA7H.js} +4 -1
- package/dist/{chunk-2TLT6WWY.js → chunk-GIUU6FNE.js} +340 -296
- package/dist/{chunk-XEEYNMPC.js → chunk-ZEAAWBF2.js} +28 -83
- package/dist/cli/index.js +3 -3
- package/dist/cli/tui/index.js +2 -2
- package/dist/config/constants.d.ts +3 -3
- package/dist/harness/adapters/claude-code.d.ts +8 -0
- package/dist/harness/adapters/codex-surfaces.d.ts +2 -2
- package/dist/harness/core/agent-pack.d.ts +38 -3
- package/dist/harness/core/sdd.d.ts +9 -8
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/.thoth-agents-plugin-assets.json +7 -7
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/agents/deep.md +10 -31
- package/plugin/agents/designer.md +10 -31
- package/plugin/agents/explorer.md +10 -30
- package/plugin/agents/librarian.md +10 -30
- package/plugin/agents/oracle.md +10 -30
- package/plugin/agents/orchestrator.md +39 -31
- package/plugin/agents/quick.md +10 -31
- package/plugin/skills/thoth-archive/SKILL.md +7 -0
- package/plugin/skills/thoth-archive/scripts/archive.mjs +58 -84
- package/plugin/skills/thoth-sdd/SKILL.md +13 -1
- package/plugin/skills/thoth-sdd/references/phases/implement.md +33 -6
- package/plugin/skills/thoth-sdd/references/phases/specify.md +12 -1
- package/plugin/skills/thoth-sdd/scripts/durable-deltas.mjs +158 -0
- package/plugin/skills/thoth-sdd/scripts/validate.mjs +67 -28
- package/plugin/skills/thoth-sdd/templates/spec.md +9 -1
- package/skills/thoth-archive/SKILL.md +7 -0
- package/skills/thoth-archive/scripts/archive.mjs +58 -84
- package/skills/thoth-sdd/SKILL.md +13 -1
- package/skills/thoth-sdd/references/phases/implement.md +33 -6
- package/skills/thoth-sdd/references/phases/specify.md +12 -1
- package/skills/thoth-sdd/scripts/durable-deltas.mjs +158 -0
- package/skills/thoth-sdd/scripts/validate.mjs +67 -28
- package/skills/thoth-sdd/templates/spec.md +9 -1
|
@@ -10,8 +10,13 @@ import {
|
|
|
10
10
|
writeFileSync,
|
|
11
11
|
} from 'node:fs';
|
|
12
12
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
CAPABILITY_PATTERN,
|
|
15
|
+
parseCanonicalSpec,
|
|
16
|
+
parseRequirementDelta as parseSharedRequirementDelta,
|
|
17
|
+
preflightRequirementDeltas,
|
|
18
|
+
} from '../../thoth-sdd/scripts/durable-deltas.mjs';
|
|
13
19
|
|
|
14
|
-
const CAPABILITY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
20
|
const TEST_FAULTS = new Set(
|
|
16
21
|
(process.env.THOTH_ARCHIVE_TEST_FAULT ?? '')
|
|
17
22
|
.split(',')
|
|
@@ -270,26 +275,8 @@ function assertArchivable(change, contract) {
|
|
|
270
275
|
}
|
|
271
276
|
|
|
272
277
|
function parseDeltaMetadata(metadata) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const change = /^(ADDED|MODIFIED|REMOVED)\s+(\S+)$/.exec(metadata);
|
|
276
|
-
if (change && CAPABILITY_PATTERN.test(change[2])) {
|
|
277
|
-
return { operation: change[1], capability: change[2] };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const rename = /^RENAMED\s+(\S+)\s+FROM\s+(.+)$/.exec(metadata);
|
|
281
|
-
if (
|
|
282
|
-
rename &&
|
|
283
|
-
CAPABILITY_PATTERN.test(rename[1]) &&
|
|
284
|
-
rename[2].trim().length > 0
|
|
285
|
-
) {
|
|
286
|
-
return {
|
|
287
|
-
operation: 'RENAMED',
|
|
288
|
-
capability: rename[1],
|
|
289
|
-
previousTitle: rename[2].trim(),
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
278
|
+
const delta = parseSharedRequirementDelta(metadata);
|
|
279
|
+
if (delta) return delta;
|
|
293
280
|
throw new Error(`Invalid requirement delta metadata: [${metadata}]`);
|
|
294
281
|
}
|
|
295
282
|
|
|
@@ -313,16 +300,36 @@ function parseStoryScenarios(spec) {
|
|
|
313
300
|
const covers =
|
|
314
301
|
/^\*\*Covers\*\*:\s*(.+)$/im.exec(story)?.[1].match(/\bFR-\d{3}\b/g) ??
|
|
315
302
|
[];
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
303
|
+
const acceptanceMarker = /^\*\*Acceptance scenarios\*\*:\s*$/im.exec(story);
|
|
304
|
+
const acceptance = acceptanceMarker
|
|
305
|
+
? story.slice((acceptanceMarker.index ?? 0) + acceptanceMarker[0].length)
|
|
306
|
+
: '';
|
|
307
|
+
const scenarioStarts = [
|
|
308
|
+
...acceptance.matchAll(/^\d+\.\s+\*\*Given\*\*/gim),
|
|
309
|
+
];
|
|
310
|
+
const scenarios = scenarioStarts.map((scenarioStart, scenarioIndex) => {
|
|
311
|
+
const start = scenarioStart.index ?? 0;
|
|
312
|
+
const end = scenarioStarts[scenarioIndex + 1]?.index ?? acceptance.length;
|
|
313
|
+
const normalized = acceptance
|
|
314
|
+
.slice(start, end)
|
|
315
|
+
.replace(/\s+/g, ' ')
|
|
316
|
+
.trim();
|
|
317
|
+
const scenario =
|
|
318
|
+
/^\d+\.\s+\*\*Given\*\*\s+(.+?),\s+\*\*When\*\*\s+(.+?),\s+\*\*Then\*\*\s+(.+?)\.?\s*$/i.exec(
|
|
319
|
+
normalized,
|
|
320
|
+
);
|
|
321
|
+
if (!scenario) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`US${storyMatch[1]} acceptance scenario ${scenarioIndex + 1} must use Given, When, and Then`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
title: `US${storyMatch[1]} - ${storyMatch[2].trim()} ${scenarioIndex + 1}`,
|
|
328
|
+
given: scenario[1].trim(),
|
|
329
|
+
when: scenario[2].trim(),
|
|
330
|
+
result: scenario[3].trim(),
|
|
331
|
+
};
|
|
332
|
+
});
|
|
326
333
|
|
|
327
334
|
for (const requirementId of covers) {
|
|
328
335
|
const current = scenariosByRequirement.get(requirementId) ?? [];
|
|
@@ -418,27 +425,6 @@ function parseChangeSpec(spec) {
|
|
|
418
425
|
};
|
|
419
426
|
}
|
|
420
427
|
|
|
421
|
-
function parseCanonicalSpec(content) {
|
|
422
|
-
const headings = [...content.matchAll(/^### Requirement:\s+(.+?)\s*$/gm)];
|
|
423
|
-
const requirements = new Map();
|
|
424
|
-
const prefix =
|
|
425
|
-
headings.length === 0
|
|
426
|
-
? content.trimEnd()
|
|
427
|
-
: content.slice(0, headings[0].index).trimEnd();
|
|
428
|
-
|
|
429
|
-
for (const [index, heading] of headings.entries()) {
|
|
430
|
-
const start = heading.index ?? 0;
|
|
431
|
-
const end = headings[index + 1]?.index ?? content.length;
|
|
432
|
-
const title = heading[1].trim();
|
|
433
|
-
if (requirements.has(title)) {
|
|
434
|
-
throw new Error(`Canonical specification repeats requirement: ${title}`);
|
|
435
|
-
}
|
|
436
|
-
requirements.set(title, content.slice(start, end).trim());
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
return { prefix, requirements };
|
|
440
|
-
}
|
|
441
|
-
|
|
442
428
|
function newCanonicalSpec(capability) {
|
|
443
429
|
const title = capability
|
|
444
430
|
.split('-')
|
|
@@ -480,41 +466,34 @@ function planCanonicalUpdates(specRoot, deltas) {
|
|
|
480
466
|
for (const [capability, capabilityDeltas] of byCapability) {
|
|
481
467
|
const path = join(specRoot, capability, 'spec.md');
|
|
482
468
|
const present = existsSync(path);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
469
|
+
let canonical;
|
|
470
|
+
try {
|
|
471
|
+
canonical = present
|
|
472
|
+
? parseCanonicalSpec(readFileSync(path, 'utf8'))
|
|
473
|
+
: newCanonicalSpec(capability);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`SDD-SPEC-DELTA-BASELINE: ${capability} canonical baseline is invalid: ${errorMessage(error)}`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
const preflight = preflightRequirementDeltas({
|
|
480
|
+
capability,
|
|
481
|
+
present,
|
|
482
|
+
requirements: canonical.requirements,
|
|
483
|
+
deltas: capabilityDeltas,
|
|
484
|
+
});
|
|
485
|
+
if (preflight.errors.length > 0) {
|
|
486
|
+
const [first] = preflight.errors;
|
|
487
|
+
throw new Error(`${first.code}: ${first.message}`);
|
|
488
|
+
}
|
|
486
489
|
|
|
487
490
|
for (const delta of capabilityDeltas) {
|
|
488
491
|
if (delta.operation === 'ADDED') {
|
|
489
|
-
if (canonical.requirements.has(delta.title)) {
|
|
490
|
-
throw new Error(
|
|
491
|
-
`${capability} already contains requirement: ${delta.title}`,
|
|
492
|
-
);
|
|
493
|
-
}
|
|
494
492
|
canonical.requirements.set(delta.title, renderRequirement(delta));
|
|
495
493
|
continue;
|
|
496
494
|
}
|
|
497
495
|
|
|
498
|
-
if (!present) {
|
|
499
|
-
throw new Error(
|
|
500
|
-
`${capability} has no canonical specification for ${delta.operation}`,
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
496
|
if (delta.operation === 'RENAMED') {
|
|
505
|
-
if (!canonical.requirements.has(delta.previousTitle)) {
|
|
506
|
-
throw new Error(
|
|
507
|
-
`${capability} does not contain requirement: ${delta.previousTitle}`,
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
|
-
if (
|
|
511
|
-
delta.previousTitle !== delta.title &&
|
|
512
|
-
canonical.requirements.has(delta.title)
|
|
513
|
-
) {
|
|
514
|
-
throw new Error(
|
|
515
|
-
`${capability} already contains requirement: ${delta.title}`,
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
497
|
const entries = [...canonical.requirements.entries()];
|
|
519
498
|
canonical.requirements = new Map(
|
|
520
499
|
entries.map(([title, block]) =>
|
|
@@ -526,11 +505,6 @@ function planCanonicalUpdates(specRoot, deltas) {
|
|
|
526
505
|
continue;
|
|
527
506
|
}
|
|
528
507
|
|
|
529
|
-
if (!canonical.requirements.has(delta.title)) {
|
|
530
|
-
throw new Error(
|
|
531
|
-
`${capability} does not contain requirement: ${delta.title}`,
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
508
|
if (delta.operation === 'MODIFIED') {
|
|
535
509
|
canonical.requirements.set(delta.title, renderRequirement(delta));
|
|
536
510
|
} else if (delta.operation === 'REMOVED') {
|
|
@@ -31,7 +31,19 @@ Accelerated the minimum recommendation but does not force it.
|
|
|
31
31
|
|
|
32
32
|
- Root owns sequential coordination artifacts, gate execution, and archive.
|
|
33
33
|
- Explorer owns Full-route repository discovery.
|
|
34
|
-
-
|
|
34
|
+
- SDD routes govern artifacts and gates, not implementation ownership. Root,
|
|
35
|
+
`designer`, `quick`, or `deep` may implement in Direct, Accelerated, or Full.
|
|
36
|
+
- Root decides ownership from explicit safe user direction and demonstrated net
|
|
37
|
+
gain. Delegation benefits include specialization, context isolation,
|
|
38
|
+
independent bounded work, quality, latency, or total cost; root continuity
|
|
39
|
+
benefits include short or sequential work, shared mutable state, accumulated
|
|
40
|
+
context, rediscovery, and coordination overhead. Route, file count, or cheaper
|
|
41
|
+
model price alone never selects an owner.
|
|
42
|
+
- Only after deciding to delegate implementation, select `designer` for UI/UX,
|
|
43
|
+
`quick` for known narrow low-risk work, or `deep` for coupled, edge-case-heavy,
|
|
44
|
+
migration, concurrency, shared-contract, or high-risk work.
|
|
45
|
+
- Each mutable surface has one writer; independent non-overlapping surfaces may
|
|
46
|
+
split, while coupled surfaces use one `deep` writer and ordered handoffs.
|
|
35
47
|
- Oracle owns `plan-review` when the user selects it and **every** `verify`. The
|
|
36
48
|
implementation writer never approves its own work.
|
|
37
49
|
|
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
# Implement contract
|
|
2
2
|
|
|
3
|
-
**Owner**: root, designer
|
|
3
|
+
**Owner**: adaptive root, `designer`, `quick`, or `deep` in every route; exactly
|
|
4
|
+
one writer per mutable surface
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
SDD routes govern artifacts and gates, not implementation ownership. Before
|
|
7
|
+
implementation or dispatch, root records the decided owner, task-shape/net-gain
|
|
8
|
+
rationale, exact paths, accepted FR/buildable-SC scope, non-goals, and
|
|
9
|
+
verification commands. Route, file count, or cheaper model price alone is not an
|
|
10
|
+
ownership rationale.
|
|
11
|
+
|
|
12
|
+
Root may retain implementation when short or sequential work, shared mutable
|
|
13
|
+
state, accumulated context, rediscovery, or coordination overhead outweighs
|
|
14
|
+
delegation benefit. Delegate when specialization, context isolation, independent
|
|
15
|
+
bounded work, quality, latency, or total cost creates demonstrated net gain. If
|
|
16
|
+
root owns the surface, do not dispatch a writer. If a specialist owns it, send a
|
|
17
|
+
bounded handoff with the exact surface, requirements, and checks.
|
|
18
|
+
|
|
19
|
+
Root marks selected artifact-backed tasks `[~]`; Direct or no-artifact work has
|
|
20
|
+
no task state. Use the mandatory `tdd` skill for behavior changes and work in
|
|
21
|
+
vertical red/green slices. Child writers never edit task state; root marks `[x]`
|
|
22
|
+
only after checking task-specific evidence.
|
|
23
|
+
|
|
24
|
+
After deciding delegation creates net gain, select deterministically:
|
|
25
|
+
|
|
26
|
+
- `designer` for user-facing UI/UX, interaction, accessibility, responsive, or
|
|
27
|
+
visual-quality work;
|
|
28
|
+
- `quick` for a known narrow mechanical low-risk surface with exact targets;
|
|
29
|
+
- `deep` for coupled multi-file, shared-contract, migration, concurrency,
|
|
30
|
+
edge-case-heavy, or high-risk work.
|
|
31
|
+
|
|
32
|
+
Proven independent surfaces may use different writers only with non-overlapping
|
|
33
|
+
ownership. Overlapping or compatibility-coupled work uses one `deep` writer and
|
|
34
|
+
ordered handoffs. A writer escalates before expanding beyond its routing
|
|
35
|
+
boundary. The implementation writer never performs final verification; root
|
|
36
|
+
hands the unchanged candidate to a fresh Oracle.
|
|
10
37
|
|
|
11
38
|
Implementation evidence may refine an artifact without restarting the workflow:
|
|
12
39
|
|
|
@@ -11,6 +11,16 @@ implementation-neutral.
|
|
|
11
11
|
`None`).
|
|
12
12
|
- Define prioritized, independently testable `US#` stories. Every story needs
|
|
13
13
|
`Covers: FR-###, SC-###` and Given/When/Then acceptance scenarios.
|
|
14
|
+
- Before choosing durable delta metadata, read
|
|
15
|
+
`openspec/specs/<capability>/spec.md` when it exists and compare the exact
|
|
16
|
+
`### Requirement:` titles and normative behavior:
|
|
17
|
+
- use `ADDED` only when no canonical requirement already expresses the
|
|
18
|
+
behavior;
|
|
19
|
+
- use `MODIFIED` or `REMOVED` only with the exact existing title;
|
|
20
|
+
- use `RENAMED <capability> FROM <exact previous title>` when the title changes;
|
|
21
|
+
- only `ADDED` is valid when the canonical capability does not exist.
|
|
22
|
+
A validator warning on `ADDED` into an existing nonempty capability requires
|
|
23
|
+
semantic-overlap review; exact-title validity alone does not prove new intent.
|
|
14
24
|
- Give every sequential FR a descriptive title, a normative `MUST`/`SHALL`
|
|
15
25
|
statement, and exactly one delta marker: `INTERNAL`, `ADDED`, `MODIFIED`,
|
|
16
26
|
`REMOVED`, or `RENAMED ... FROM ...`.
|
|
@@ -20,4 +30,5 @@ implementation-neutral.
|
|
|
20
30
|
- State assumptions, dependencies, edge cases, and explicit non-goals.
|
|
21
31
|
|
|
22
32
|
Do not advance while a material ambiguity would change scope, behavior,
|
|
23
|
-
architecture, or durable delta intent.
|
|
33
|
+
architecture, or durable delta intent. Run the `specify` gate before planning;
|
|
34
|
+
do not defer canonical delta reconciliation until archive.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export const CAPABILITY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2
|
+
|
|
3
|
+
export function parseRequirementDelta(metadata) {
|
|
4
|
+
if (metadata === 'INTERNAL') return { operation: 'INTERNAL' };
|
|
5
|
+
|
|
6
|
+
const change = /^(ADDED|MODIFIED|REMOVED)\s+(\S+)$/.exec(metadata);
|
|
7
|
+
if (change && CAPABILITY_PATTERN.test(change[2])) {
|
|
8
|
+
return { operation: change[1], capability: change[2] };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const rename = /^RENAMED\s+(\S+)\s+FROM\s+(.+)$/.exec(metadata);
|
|
12
|
+
if (
|
|
13
|
+
rename &&
|
|
14
|
+
CAPABILITY_PATTERN.test(rename[1]) &&
|
|
15
|
+
rename[2].trim().length > 0
|
|
16
|
+
) {
|
|
17
|
+
return {
|
|
18
|
+
operation: 'RENAMED',
|
|
19
|
+
capability: rename[1],
|
|
20
|
+
previousTitle: rename[2].trim(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseCanonicalSpec(content) {
|
|
28
|
+
const candidates = [...content.matchAll(/^### Requirement:[^\r\n]*$/gm)];
|
|
29
|
+
const headings = [
|
|
30
|
+
...content.matchAll(/^### Requirement:[ \t]+(\S(?:[^\r\n]*?\S)?)[ \t]*$/gm),
|
|
31
|
+
];
|
|
32
|
+
if (candidates.length !== headings.length) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'Canonical specification contains a malformed Requirement heading',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const requirements = new Map();
|
|
38
|
+
const prefix =
|
|
39
|
+
headings.length === 0
|
|
40
|
+
? content.trimEnd()
|
|
41
|
+
: content.slice(0, headings[0].index).trimEnd();
|
|
42
|
+
|
|
43
|
+
for (const [index, heading] of headings.entries()) {
|
|
44
|
+
const start = heading.index ?? 0;
|
|
45
|
+
const end = headings[index + 1]?.index ?? content.length;
|
|
46
|
+
const title = heading[1].trim();
|
|
47
|
+
if (requirements.has(title)) {
|
|
48
|
+
throw new Error(`Canonical specification repeats requirement: ${title}`);
|
|
49
|
+
}
|
|
50
|
+
requirements.set(title, content.slice(start, end).trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { prefix, requirements };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function issue(code, capability, title, message) {
|
|
57
|
+
return { code, capability, title, message };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function preflightRequirementDeltas({
|
|
61
|
+
capability,
|
|
62
|
+
present,
|
|
63
|
+
requirements,
|
|
64
|
+
deltas,
|
|
65
|
+
}) {
|
|
66
|
+
const initialTitles = [...requirements.keys()];
|
|
67
|
+
const titles = new Set(initialTitles);
|
|
68
|
+
const errors = [];
|
|
69
|
+
const warnings = [];
|
|
70
|
+
|
|
71
|
+
for (const delta of deltas) {
|
|
72
|
+
if (delta.operation === 'ADDED') {
|
|
73
|
+
if (titles.has(delta.title)) {
|
|
74
|
+
errors.push(
|
|
75
|
+
issue(
|
|
76
|
+
'SDD-SPEC-DELTA-ADDED-EXISTS',
|
|
77
|
+
capability,
|
|
78
|
+
delta.title,
|
|
79
|
+
`${capability} already contains requirement: ${delta.title}`,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (present && initialTitles.length > 0) {
|
|
85
|
+
warnings.push(
|
|
86
|
+
issue(
|
|
87
|
+
'SDD-SPEC-DELTA-ADDED-REVIEW',
|
|
88
|
+
capability,
|
|
89
|
+
delta.title,
|
|
90
|
+
`${capability} already contains canonical requirements; confirm ${delta.title} does not overlap: ${initialTitles.join(', ')}`,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
titles.add(delta.title);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!present) {
|
|
99
|
+
const code =
|
|
100
|
+
delta.operation === 'RENAMED'
|
|
101
|
+
? 'SDD-SPEC-DELTA-RENAMED-SOURCE-MISSING'
|
|
102
|
+
: `SDD-SPEC-DELTA-${delta.operation}-MISSING`;
|
|
103
|
+
errors.push(
|
|
104
|
+
issue(
|
|
105
|
+
code,
|
|
106
|
+
capability,
|
|
107
|
+
delta.title,
|
|
108
|
+
`${capability} has no canonical specification for ${delta.operation}: ${delta.title}`,
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (delta.operation === 'RENAMED') {
|
|
115
|
+
if (!titles.has(delta.previousTitle)) {
|
|
116
|
+
errors.push(
|
|
117
|
+
issue(
|
|
118
|
+
'SDD-SPEC-DELTA-RENAMED-SOURCE-MISSING',
|
|
119
|
+
capability,
|
|
120
|
+
delta.title,
|
|
121
|
+
`${capability} does not contain requirement: ${delta.previousTitle}`,
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (delta.previousTitle !== delta.title && titles.has(delta.title)) {
|
|
127
|
+
errors.push(
|
|
128
|
+
issue(
|
|
129
|
+
'SDD-SPEC-DELTA-RENAMED-TARGET-EXISTS',
|
|
130
|
+
capability,
|
|
131
|
+
delta.title,
|
|
132
|
+
`${capability} already contains requirement: ${delta.title}`,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
titles.delete(delta.previousTitle);
|
|
138
|
+
titles.add(delta.title);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!titles.has(delta.title)) {
|
|
143
|
+
errors.push(
|
|
144
|
+
issue(
|
|
145
|
+
`SDD-SPEC-DELTA-${delta.operation}-MISSING`,
|
|
146
|
+
capability,
|
|
147
|
+
delta.title,
|
|
148
|
+
`${capability} does not contain requirement: ${delta.title}`,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (delta.operation === 'REMOVED') titles.delete(delta.title);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { errors, warnings, titles };
|
|
158
|
+
}
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { join, posix, resolve } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
CAPABILITY_PATTERN,
|
|
7
|
+
parseCanonicalSpec,
|
|
8
|
+
parseRequirementDelta,
|
|
9
|
+
preflightRequirementDeltas,
|
|
10
|
+
} from './durable-deltas.mjs';
|
|
5
11
|
|
|
6
12
|
function parseArgs(argv) {
|
|
7
13
|
const values = { route: 'full', through: 'ready', json: false };
|
|
@@ -86,34 +92,6 @@ function checkUniqueSequential(ids, prefix, artifact, errors) {
|
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
const CAPABILITY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
90
|
-
|
|
91
|
-
function parseRequirementDelta(metadata) {
|
|
92
|
-
if (metadata === 'INTERNAL') {
|
|
93
|
-
return { operation: 'INTERNAL' };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const change = /^(ADDED|MODIFIED|REMOVED)\s+(\S+)$/.exec(metadata);
|
|
97
|
-
if (change && CAPABILITY_PATTERN.test(change[2])) {
|
|
98
|
-
return { operation: change[1], capability: change[2] };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const rename = /^RENAMED\s+(\S+)\s+FROM\s+(.+)$/.exec(metadata);
|
|
102
|
-
if (
|
|
103
|
-
rename &&
|
|
104
|
-
CAPABILITY_PATTERN.test(rename[1]) &&
|
|
105
|
-
rename[2].trim().length > 0
|
|
106
|
-
) {
|
|
107
|
-
return {
|
|
108
|
-
operation: 'RENAMED',
|
|
109
|
-
capability: rename[1],
|
|
110
|
-
previousTitle: rename[2].trim(),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
95
|
function parseAffectedCapabilities(intentSection) {
|
|
118
96
|
const match = /^\*\*Affected capabilities\*\*:\s*(.+)$/im.exec(
|
|
119
97
|
intentSection ?? '',
|
|
@@ -264,6 +242,7 @@ function validateSpec(content, errors) {
|
|
|
264
242
|
),
|
|
265
243
|
);
|
|
266
244
|
}
|
|
245
|
+
const durableDeltas = [];
|
|
267
246
|
if (frIds.length === 0) {
|
|
268
247
|
errors.push(issue('SDD-SPEC-FR', artifact, 'Add FR-### requirements.'));
|
|
269
248
|
} else {
|
|
@@ -298,6 +277,11 @@ function validateSpec(content, errors) {
|
|
|
298
277
|
`FR-${match[1]} targets undeclared capability ${delta.capability}.`,
|
|
299
278
|
),
|
|
300
279
|
);
|
|
280
|
+
} else if (delta.operation !== 'INTERNAL') {
|
|
281
|
+
durableDeltas.push({
|
|
282
|
+
title: match[2].trim(),
|
|
283
|
+
...delta,
|
|
284
|
+
});
|
|
301
285
|
}
|
|
302
286
|
}
|
|
303
287
|
}
|
|
@@ -369,9 +353,56 @@ function validateSpec(content, errors) {
|
|
|
369
353
|
outcomeScIds: successCriteria
|
|
370
354
|
.filter((match) => match[2].toLowerCase() === 'outcome')
|
|
371
355
|
.map((match) => match[1]),
|
|
356
|
+
durableDeltas,
|
|
372
357
|
};
|
|
373
358
|
}
|
|
374
359
|
|
|
360
|
+
function validateDurableDeltaIntent(specRoot, deltas, errors, warnings) {
|
|
361
|
+
const byCapability = new Map();
|
|
362
|
+
for (const delta of deltas) {
|
|
363
|
+
const current = byCapability.get(delta.capability) ?? [];
|
|
364
|
+
current.push(delta);
|
|
365
|
+
byCapability.set(delta.capability, current);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
for (const [capability, capabilityDeltas] of byCapability) {
|
|
369
|
+
const path = join(specRoot, capability, 'spec.md');
|
|
370
|
+
const present = existsSync(path);
|
|
371
|
+
let canonical;
|
|
372
|
+
try {
|
|
373
|
+
canonical = present
|
|
374
|
+
? parseCanonicalSpec(readFileSync(path, 'utf8'))
|
|
375
|
+
: { requirements: new Map() };
|
|
376
|
+
} catch (error) {
|
|
377
|
+
errors.push(
|
|
378
|
+
issue(
|
|
379
|
+
'SDD-SPEC-DELTA-BASELINE',
|
|
380
|
+
'spec.md',
|
|
381
|
+
`${capability} canonical baseline is invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
382
|
+
),
|
|
383
|
+
);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const result = preflightRequirementDeltas({
|
|
388
|
+
capability,
|
|
389
|
+
present,
|
|
390
|
+
requirements: canonical.requirements,
|
|
391
|
+
deltas: capabilityDeltas,
|
|
392
|
+
});
|
|
393
|
+
errors.push(
|
|
394
|
+
...result.errors.map((entry) =>
|
|
395
|
+
issue(entry.code, 'spec.md', entry.message),
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
warnings.push(
|
|
399
|
+
...result.warnings.map((entry) =>
|
|
400
|
+
issue(entry.code, 'spec.md', entry.message),
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
375
406
|
function constitutionEntries(content) {
|
|
376
407
|
if (content === undefined) return [];
|
|
377
408
|
return content
|
|
@@ -1047,6 +1078,14 @@ function validate({ change, route, through }) {
|
|
|
1047
1078
|
|
|
1048
1079
|
const specContract =
|
|
1049
1080
|
spec === undefined ? undefined : validateSpec(spec, errors);
|
|
1081
|
+
if (specContract !== undefined) {
|
|
1082
|
+
validateDurableDeltaIntent(
|
|
1083
|
+
join(root, '..', '..', 'specs'),
|
|
1084
|
+
specContract.durableDeltas,
|
|
1085
|
+
errors,
|
|
1086
|
+
warnings,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1050
1089
|
const constitution = readArtifact(
|
|
1051
1090
|
join(root, '..', '..'),
|
|
1052
1091
|
'memory/constitution.md',
|
|
@@ -30,7 +30,15 @@ As a [actor], I can [capability] so that [value].
|
|
|
30
30
|
|
|
31
31
|
## Functional requirements
|
|
32
32
|
|
|
33
|
-
- **FR-001 — [Requirement name]**: `[
|
|
33
|
+
- **FR-001 — [Requirement name]**: `[DELTA capability-slug]` The system MUST [observable behavior].
|
|
34
|
+
|
|
35
|
+
Replace `DELTA` after inspecting `openspec/specs/<capability>/spec.md`. Use
|
|
36
|
+
`ADDED` only for behavior not expressed by a canonical requirement, `MODIFIED`
|
|
37
|
+
or `REMOVED` only with the exact existing requirement title, and
|
|
38
|
+
`RENAMED capability-slug FROM Exact previous title` when the title changes. Only
|
|
39
|
+
`ADDED` is valid when the canonical capability is absent. An addition to an
|
|
40
|
+
existing nonempty capability requires semantic-overlap review even when its
|
|
41
|
+
exact title is new.
|
|
34
42
|
|
|
35
43
|
Use exactly one delta marker per FR: `[INTERNAL]`, `[ADDED capability-slug]`,
|
|
36
44
|
`[MODIFIED capability-slug]`, `[REMOVED capability-slug]`, or
|
|
@@ -31,6 +31,13 @@ The script reads delta metadata directly from `spec.md`:
|
|
|
31
31
|
- `[RENAMED capability FROM Previous title]` renames and replaces it.
|
|
32
32
|
- `[INTERNAL]` never changes `openspec/specs/`.
|
|
33
33
|
|
|
34
|
+
The SDD validator preflights these operations against the canonical requirement
|
|
35
|
+
titles from `specify` onward. Archive reuses the same ordered parser and
|
|
36
|
+
preflight as a final defense before staging writes, preserving stable
|
|
37
|
+
`SDD-SPEC-DELTA-*` incompatibility codes. An `ADDED` warning for an existing
|
|
38
|
+
nonempty capability requires semantic-overlap review because differently named
|
|
39
|
+
requirements cannot be proven distinct by exact-title tooling alone.
|
|
40
|
+
|
|
34
41
|
All delta targets are validated before any permanent specification changes. The
|
|
35
42
|
operation stages and rolls back the full canonical update if a delta, report
|
|
36
43
|
update, or final move raises a handled error in the active process. Report and
|