@abseed/spectra-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commit.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Committing a changeset — the review write, over the {@link SpecStore} seam.
3
+ *
4
+ * The engine here decides *what* the glossary should become and re-validates that against the
5
+ * glossary as it is *now* — the source may have been hand-edited since the client last read it. The
6
+ * persistence — which entries change, and the atomic move to applied — belongs to the store. It lives
7
+ * in `@abseed/spectra-core`, beside the seam, so every coordinator applies, marks, and rejects identically
8
+ * (the same reason `proposeChangeset`/`raiseQuestion` do).
9
+ */
10
+ import { applyOps } from './changeset.js';
11
+ export async function applyChangeset(store, id, request) {
12
+ const changeset = await store.findChangeset(id);
13
+ if (!changeset)
14
+ return { ok: false, status: 404, error: `No pending changeset with id "${id}".` };
15
+ const indices = [...new Set(request.opIndices)].sort((a, b) => a - b);
16
+ if (indices.length === 0) {
17
+ return { ok: false, status: 400, error: 'No ops selected.' };
18
+ }
19
+ if (indices.some((index) => !Number.isInteger(index) || !changeset.ops[index])) {
20
+ return { ok: false, status: 400, error: `Op indices out of range for changeset "${id}".` };
21
+ }
22
+ const { terms: before } = await store.readTerms();
23
+ // Re-run the same validation the UI ran, against the glossary as it is *now*.
24
+ const result = applyOps(before, indices.map((index) => changeset.ops[index]));
25
+ // The engine only saw the selected ops, so its indices count within that subset. Report them
26
+ // as positions in the changeset the caller actually sent.
27
+ const diagnostics = result.diagnostics.map((diagnostic) => ({
28
+ ...diagnostic,
29
+ opIndex: diagnostic.opIndex === null ? null : (indices[diagnostic.opIndex] ?? null),
30
+ }));
31
+ const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error');
32
+ const warnings = diagnostics.filter((diagnostic) => diagnostic.severity === 'warning');
33
+ if (errors.length > 0) {
34
+ return {
35
+ ok: false,
36
+ status: 409,
37
+ error: 'This selection would leave the glossary broken.',
38
+ diagnostics,
39
+ needsAcknowledgement: false,
40
+ };
41
+ }
42
+ if (warnings.length > 0 && !request.acknowledgeWarnings) {
43
+ return {
44
+ ok: false,
45
+ status: 409,
46
+ error: 'This selection orphans references that still point at what it removes.',
47
+ diagnostics,
48
+ needsAcknowledgement: true,
49
+ };
50
+ }
51
+ // The applied ops move to applied; anything left unselected stays pending, so a cherry-pick
52
+ // never silently discards the ops the human did not accept.
53
+ const appliedOps = indices.map((index) => changeset.ops[index]);
54
+ const remainingOps = changeset.ops.filter((_, index) => !indices.includes(index));
55
+ const { written, deleted, resolvedTo } = await store.commitApplication({
56
+ changesetId: id,
57
+ nextTerms: result.terms,
58
+ appliedOps,
59
+ remainingOps,
60
+ appliedAt: new Date().toISOString(),
61
+ });
62
+ return {
63
+ ok: true,
64
+ appliedOps: indices.length,
65
+ remainingOps: remainingOps.length,
66
+ written,
67
+ deleted,
68
+ resolvedTo,
69
+ diagnostics,
70
+ };
71
+ }
72
+ /**
73
+ * Records that code has been written for an applied changeset — the human presses a button after
74
+ * re-running the implementation pass. (The agent's own `mark_implemented` tool guards this with a
75
+ * snapshot version; this human path just records it.)
76
+ */
77
+ export async function markImplemented(store, id, at) {
78
+ const file = await store.markImplemented(id, at);
79
+ if (file === null)
80
+ return { ok: false, status: 404, error: `No applied changeset with id "${id}".` };
81
+ return { ok: true, file };
82
+ }
83
+ export async function rejectChangeset(store, id) {
84
+ const resolvedTo = await store.rejectChangeset(id);
85
+ if (resolvedTo === null)
86
+ return { ok: false, status: 404, error: `No pending changeset with id "${id}".` };
87
+ return {
88
+ ok: true,
89
+ appliedOps: 0,
90
+ remainingOps: 0,
91
+ written: [],
92
+ deleted: [],
93
+ resolvedTo,
94
+ diagnostics: [],
95
+ };
96
+ }
@@ -0,0 +1,29 @@
1
+ import type { Diagnostic, Op } from './types.js';
2
+ import type { Term } from './types.js';
3
+ export interface PendingItem {
4
+ /** Changeset id, or `<questionId>:<option label>` for a question's proposal. */
5
+ id: string;
6
+ kind: 'changeset' | 'question-option';
7
+ label: string;
8
+ ops: Op[];
9
+ }
10
+ export interface ItemReport {
11
+ id: string;
12
+ kind: PendingItem['kind'];
13
+ label: string;
14
+ /** Problems this item has on its own, against the glossary as it stands. */
15
+ diagnostics: Diagnostic[];
16
+ }
17
+ export interface Conflict {
18
+ /** Applying `first` before `second` is what causes the problems below. */
19
+ first: string;
20
+ second: string;
21
+ diagnostics: Diagnostic[];
22
+ }
23
+ export interface ConflictReport {
24
+ items: ItemReport[];
25
+ conflicts: Conflict[];
26
+ /** Items that interact with nothing else — safe in any order, at any time. */
27
+ independent: string[];
28
+ }
29
+ export declare function analyzePending(terms: Term[], items: PendingItem[]): ConflictReport;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * "Where should I start?" as a computation rather than a judgement call.
3
+ *
4
+ * Every pending item — a changeset, or one option of an unanswered question — is a list
5
+ * of ops. Whether two of them interact is not a question about term names overlapping
6
+ * (cs-001 and q-001 both touch `Task` and get along fine); it is a question about whether
7
+ * applying one *breaks* the other. So rather than analysing statically, this replays them
8
+ * through the same engine that commits them and reads the diagnostics.
9
+ *
10
+ * Order matters and that is the useful part: adding `Project.archived` before dropping
11
+ * `Project` is fine, and doing it after is an error. "Do this one first" is exactly the
12
+ * answer being asked for.
13
+ */
14
+ import { applyOps } from './changeset.js';
15
+ function fingerprint(diagnostic) {
16
+ return `${diagnostic.severity}|${diagnostic.message}`;
17
+ }
18
+ /**
19
+ * Problems that only exist because `first` ran before `second` — anything either one
20
+ * already had on its own is not the pair's fault.
21
+ */
22
+ function newProblems(terms, first, second) {
23
+ const alone = new Set([
24
+ ...applyOps(terms, first.ops).diagnostics.map(fingerprint),
25
+ ...applyOps(terms, second.ops).diagnostics.map(fingerprint),
26
+ ]);
27
+ return applyOps(terms, [...first.ops, ...second.ops]).diagnostics.filter((diagnostic) => !alone.has(fingerprint(diagnostic)));
28
+ }
29
+ export function analyzePending(terms, items) {
30
+ const reports = items.map((item) => ({
31
+ id: item.id,
32
+ kind: item.kind,
33
+ label: item.label,
34
+ diagnostics: applyOps(terms, item.ops).diagnostics,
35
+ }));
36
+ const conflicts = [];
37
+ const entangled = new Set();
38
+ for (const first of items) {
39
+ for (const second of items) {
40
+ if (first.id === second.id)
41
+ continue;
42
+ // Two options of the same question are alternatives, not a sequence — nobody will
43
+ // ever apply both, so reporting them as conflicting is noise.
44
+ if (first.kind === 'question-option' && second.kind === 'question-option') {
45
+ if (questionOf(first.id) === questionOf(second.id))
46
+ continue;
47
+ }
48
+ const diagnostics = newProblems(terms, first, second);
49
+ if (diagnostics.length === 0)
50
+ continue;
51
+ conflicts.push({ first: first.id, second: second.id, diagnostics });
52
+ entangled.add(first.id);
53
+ entangled.add(second.id);
54
+ }
55
+ }
56
+ return {
57
+ items: reports,
58
+ conflicts,
59
+ independent: items.filter((item) => !entangled.has(item.id)).map((item) => item.id),
60
+ };
61
+ }
62
+ function questionOf(id) {
63
+ return id.split(':')[0] ?? id;
64
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Coverage, meaning "did we think about this thing's lifecycle" — not "is it correct".
3
+ *
4
+ * Nothing here judges an implementation. A term with five expectations is a term someone
5
+ * thought about five times, which is not the same as a term that works. What this answers is
6
+ * narrower and more useful: we named `RecurringTask` and gave it attributes, but did anyone
7
+ * ever say what should happen when a Project holding one is deleted? That question has a
8
+ * yes-or-no answer computable from the glossary, and an empty answer is a real gap whether or
9
+ * not the code happens to do the right thing.
10
+ *
11
+ * The unit is therefore the **pair**, not the term. Counting expectations per term would have
12
+ * called `RecurringTask` covered from the day q-002 landed — it had expectations about
13
+ * completion — while the hole sat in its interaction with deletion. q-004 is exactly that
14
+ * hole, and it took a second implementation pass to find:
15
+ *
16
+ * deleteProject --ref:Project--> Project <--Task.project-- Task <--parent-- RecurringTask
17
+ *
18
+ * Two hops, one of them traversed backwards, plus a subtype step. Nobody sees that by reading
19
+ * a file, which is the entire argument for computing it.
20
+ *
21
+ * Distance is reported rather than filtered on, because it sorts the work. Pairs at distance 1
22
+ * are usually already covered by the prose — the function's own spec names the entity it
23
+ * takes. The ones at 2 and beyond are where the questions come from.
24
+ */
25
+ import type { Expectation, Term } from './types.js';
26
+ export interface CoveragePair {
27
+ entity: string;
28
+ action: string;
29
+ /** 1 when the action names the entity directly; 2+ when reached through the entity graph. */
30
+ distance: number;
31
+ /** Ids of live expectations naming both ends. */
32
+ expectations: string[];
33
+ }
34
+ export interface EntityCoverage {
35
+ entity: string;
36
+ pairs: CoveragePair[];
37
+ covered: number;
38
+ uncovered: number;
39
+ }
40
+ export interface Coverage {
41
+ entities: EntityCoverage[];
42
+ /** Every uncovered pair, nearest first. The work list, in the order worth working it. */
43
+ gaps: CoveragePair[];
44
+ /**
45
+ * Non-functional expectations, listed and never matched. They are properties of a build
46
+ * rather than of the vocabulary, so they have no pair to land on — counting them here keeps
47
+ * them visible without letting them inflate a number that means something else.
48
+ */
49
+ nonFunctional: string[];
50
+ /**
51
+ * Expectations that disagree with a term's spec, and what they disagree with.
52
+ *
53
+ * These cover nothing and are not gaps either — they are decisions nobody has made. Whoever
54
+ * reads this has to settle which side gives before the pair can be covered at all.
55
+ */
56
+ contested: Array<{
57
+ expectation: string;
58
+ subject: string;
59
+ quote?: string;
60
+ }>;
61
+ /** Expectations naming a term the glossary does not have — renamed, or a typo. */
62
+ dangling: Array<{
63
+ expectation: string;
64
+ term: string;
65
+ }>;
66
+ }
67
+ export interface CoverageOptions {
68
+ /**
69
+ * How far from an action to look for entities it touches. Two reaches the q-004 shape and
70
+ * keeps the pair list readable; three connects most glossaries to themselves.
71
+ */
72
+ maxDistance?: number;
73
+ }
74
+ /**
75
+ * An expectation covers a pair when it names *both* ends.
76
+ *
77
+ * Deliberately strict. Loosening it to "names either" would mark
78
+ * `deleteProject × RecurringTask` covered on the strength of an expectation about deleting an
79
+ * empty Project, which is the exact blindness this exists to remove. The cost is that the
80
+ * board starts almost entirely empty, including for pairs the prose already handles — that is
81
+ * the honest starting position, not a defect in the measure.
82
+ */
83
+ export declare function computeCoverage(terms: Term[], expectations: Expectation[], options?: CoverageOptions): Coverage;
@@ -0,0 +1,148 @@
1
+ import { computeBacklinks } from './backlinks.js';
2
+ /** Terms that *do* something — the lifecycle side of a pair. */
3
+ const ACTION_TYPES = new Set(['function', 'event']);
4
+ /**
5
+ * Terms a lifecycle happens *to*. `attribute-type` is deliberately absent: Priority and
6
+ * RecurrenceRule are value shapes carried by other terms, not things with a life of their own,
7
+ * and walking through them would connect everything to everything.
8
+ */
9
+ const SUBJECT_TYPES = new Set(['entity']);
10
+ /** A term and every term that inherits from it, transitively. */
11
+ function withSubtypes(backlinks, name) {
12
+ const found = new Set();
13
+ const queue = [name];
14
+ while (queue.length > 0) {
15
+ const current = queue.shift();
16
+ if (found.has(current))
17
+ continue;
18
+ found.add(current);
19
+ queue.push(...(backlinks.children[current] ?? []));
20
+ }
21
+ return [...found];
22
+ }
23
+ /**
24
+ * Which entities each action touches, and how far away they are.
25
+ *
26
+ * Subtypes come free — they sit at the same distance as their supertype rather than one hop
27
+ * further, because a RecurringTask *is* a Task. Anything that touches Task touches every
28
+ * RecurringTask by definition, and charging a hop for that would push the most interesting
29
+ * pairs out past the limit.
30
+ *
31
+ * Supertypes are deliberately not walked. An action naming `RecurringTask` outright has said
32
+ * what it means; adding `Task` back would list a pair nobody asked about.
33
+ */
34
+ function reachFrom(terms, backlinks, action, maxDistance) {
35
+ const byName = new Map(terms.map((term) => [term.name, term]));
36
+ const isSubject = (name) => SUBJECT_TYPES.has(byName.get(name)?.type ?? '');
37
+ const distances = new Map();
38
+ const admit = (name, distance) => {
39
+ if (!isSubject(name))
40
+ return;
41
+ for (const subtype of withSubtypes(backlinks, name)) {
42
+ const known = distances.get(subtype);
43
+ if (known === undefined || known > distance)
44
+ distances.set(subtype, distance);
45
+ }
46
+ };
47
+ for (const reference of backlinks.bySource[action.name] ?? []) {
48
+ if (reference.kind === 'attribute')
49
+ admit(reference.to, 1);
50
+ }
51
+ for (let distance = 2; distance <= maxDistance; distance += 1) {
52
+ for (const [name, known] of [...distances]) {
53
+ if (known !== distance - 1)
54
+ continue;
55
+ // Undirected on purpose. `Task.project: ref:Project` is the only edge between those two
56
+ // terms, and it points the wrong way for the question being asked — deleteProject
57
+ // reaches Project, and Project's Tasks are what block it.
58
+ for (const reference of backlinks.bySource[name] ?? []) {
59
+ if (reference.kind === 'attribute')
60
+ admit(reference.to, distance);
61
+ }
62
+ for (const reference of backlinks.byTarget[name] ?? []) {
63
+ if (reference.kind === 'attribute')
64
+ admit(reference.from, distance);
65
+ }
66
+ }
67
+ }
68
+ return distances;
69
+ }
70
+ /**
71
+ * An expectation covers a pair when it names *both* ends.
72
+ *
73
+ * Deliberately strict. Loosening it to "names either" would mark
74
+ * `deleteProject × RecurringTask` covered on the strength of an expectation about deleting an
75
+ * empty Project, which is the exact blindness this exists to remove. The cost is that the
76
+ * board starts almost entirely empty, including for pairs the prose already handles — that is
77
+ * the honest starting position, not a defect in the measure.
78
+ */
79
+ export function computeCoverage(terms, expectations, options = {}) {
80
+ const maxDistance = options.maxDistance ?? 2;
81
+ const backlinks = computeBacklinks(terms);
82
+ const known = new Set(terms.map((term) => term.name));
83
+ // Retired expectations are history: they record what was once expected and must not keep a
84
+ // pair looking covered after the statement that covered it was withdrawn.
85
+ const live = expectations.filter((expectation) => expectation.supersededBy === null);
86
+ /**
87
+ * An expectation that contradicts a spec covers nothing, and this is the load-bearing line.
88
+ *
89
+ * Coverage means somebody has said what should happen *and the glossary agrees*. A
90
+ * contradicted expectation is not a settled answer wearing an expectation's clothes — it is
91
+ * an open disagreement, and counting it would mark the pair as thought-through at the exact
92
+ * moment it is most in dispute. So the pair stays a gap, and the disagreement shows up
93
+ * separately in `contested`, where it reads as work rather than as coverage.
94
+ */
95
+ const contested = live.filter((expectation) => expectation.contested.some((clash) => clash.kind === 'contradicts'));
96
+ const contestedIds = new Set(contested.map((expectation) => expectation.id));
97
+ const functional = live.filter((expectation) => expectation.kind === 'functional' && !contestedIds.has(expectation.id));
98
+ const actions = terms.filter((term) => ACTION_TYPES.has(term.type));
99
+ const entities = new Map(terms.filter((term) => SUBJECT_TYPES.has(term.type)).map((term) => [term.name, []]));
100
+ for (const action of actions) {
101
+ for (const [entity, distance] of reachFrom(terms, backlinks, action, maxDistance)) {
102
+ const pairs = entities.get(entity);
103
+ if (!pairs)
104
+ continue;
105
+ pairs.push({
106
+ entity,
107
+ action: action.name,
108
+ distance,
109
+ expectations: functional
110
+ .filter((expectation) => expectation.terms.includes(entity) && expectation.terms.includes(action.name))
111
+ .map((expectation) => expectation.id),
112
+ });
113
+ }
114
+ }
115
+ const summaries = [...entities]
116
+ .map(([entity, pairs]) => {
117
+ pairs.sort((left, right) => left.distance - right.distance || left.action.localeCompare(right.action));
118
+ return {
119
+ entity,
120
+ pairs,
121
+ covered: pairs.filter((pair) => pair.expectations.length > 0).length,
122
+ uncovered: pairs.filter((pair) => pair.expectations.length === 0).length,
123
+ };
124
+ })
125
+ .sort((left, right) => left.entity.localeCompare(right.entity));
126
+ return {
127
+ entities: summaries,
128
+ gaps: summaries
129
+ .flatMap((summary) => summary.pairs)
130
+ .filter((pair) => pair.expectations.length === 0)
131
+ .sort((left, right) => left.distance - right.distance ||
132
+ left.entity.localeCompare(right.entity) ||
133
+ left.action.localeCompare(right.action)),
134
+ nonFunctional: live
135
+ .filter((expectation) => expectation.kind === 'non-functional')
136
+ .map((expectation) => expectation.id),
137
+ contested: contested.flatMap((expectation) => expectation.contested
138
+ .filter((clash) => clash.kind === 'contradicts')
139
+ .map((clash) => ({
140
+ expectation: expectation.id,
141
+ subject: clash.subject,
142
+ ...(clash.quote ? { quote: clash.quote } : {}),
143
+ }))),
144
+ dangling: live.flatMap((expectation) => expectation.terms
145
+ .filter((term) => !known.has(term))
146
+ .map((term) => ({ expectation: expectation.id, term }))),
147
+ };
148
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Checking a draft expectation *before* it exists.
3
+ *
4
+ * The write path for expectations used to be direct, on the argument that adding one is safe
5
+ * by construction — the worst it can do is turn a check red. That is true and it is not the
6
+ * whole cost. An expectation that contradicts a term's spec fails an implementation pass that
7
+ * cannot fix it: the implementer may not edit `specs/`, and deliberately has no tool to retire
8
+ * an expectation, so the round trip ends in "ask the human" having spent a pass to get there.
9
+ * Cheap to write down, expensive to discover.
10
+ *
11
+ * The other reason is the one only the author knows: "I just noticed something" is often "I
12
+ * forgot what we already decided". A draft is worth reading against the glossary for the same
13
+ * reason a changeset is — which is what every other write here already does. Expectations were
14
+ * the exception, and the exception was wrong.
15
+ *
16
+ * What this file catches is only what can be decided by looking, never by understanding:
17
+ * a term that does not exist, and a draft already written down. Whether a draft *contradicts*
18
+ * a spec is a question about meaning and belongs to @spec — see server/src/expectationCheck.ts.
19
+ * Splitting them this way matters: these findings are always available, instantly, with no
20
+ * credential and no model, so the gate still does something useful when the agent cannot run.
21
+ */
22
+ import type { Clash, Expectation, Term } from './types.js';
23
+ /**
24
+ * A finding and a stored conflict are the same thing on purpose.
25
+ *
26
+ * What the check reports is exactly what gets written onto the expectation when the author
27
+ * raises it anyway. Two shapes here would mean a translation step, and a translation step is
28
+ * where the quote gets dropped and the finding degrades into "there was a problem once".
29
+ */
30
+ export type Finding = Clash;
31
+ export type FindingKind = Clash['kind'];
32
+ export interface ExpectationDraft {
33
+ kind: Expectation['kind'];
34
+ terms: string[];
35
+ given: string;
36
+ expect: string;
37
+ }
38
+ export declare function checkDraft(draft: ExpectationDraft, terms: Term[], expectations: Expectation[]): Finding[];
39
+ /** The spec text of every term a draft names — what @spec has to read to judge it. */
40
+ export declare function materialFor(draft: ExpectationDraft, terms: Term[]): Array<{
41
+ name: string;
42
+ spec: string;
43
+ }>;
@@ -0,0 +1,86 @@
1
+ const STOPWORDS = new Set([
2
+ 'a', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'been', 'but', 'by', 'does', 'for', 'from',
3
+ 'has', 'have', 'in', 'is', 'it', 'its', 'not', 'of', 'on', 'or', 'that', 'the', 'then', 'this',
4
+ 'to', 'was', 'were', 'when', 'which', 'with',
5
+ ]);
6
+ /** Lowercase, punctuation to spaces, stopwords dropped — so wording varies but content does not. */
7
+ function contentTokens(text) {
8
+ return new Set(text
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, ' ')
11
+ .split(' ')
12
+ .filter((token) => token.length > 1 && !STOPWORDS.has(token)));
13
+ }
14
+ function jaccard(left, right) {
15
+ if (left.size === 0 || right.size === 0)
16
+ return 0;
17
+ let shared = 0;
18
+ for (const token of left)
19
+ if (right.has(token))
20
+ shared += 1;
21
+ return shared / (left.size + right.size - shared);
22
+ }
23
+ /**
24
+ * How alike two statements have to be before this calls them the same thing.
25
+ *
26
+ * Set by what it costs to be wrong in each direction. A false duplicate is one line of text
27
+ * the author reads and dismisses; a missed duplicate is two statements of one rule that drift
28
+ * apart until they disagree and nobody knows which is current. So it leans towards flagging,
29
+ * and the finding is advisory — it never refuses the write.
30
+ */
31
+ const DUPLICATE = 0.6;
32
+ export function checkDraft(draft, terms, expectations) {
33
+ const findings = [];
34
+ const known = new Set(terms.map((term) => term.name));
35
+ for (const term of draft.terms) {
36
+ if (!known.has(term)) {
37
+ findings.push({
38
+ kind: 'unknown-term',
39
+ subject: term,
40
+ detail: `No term named "${term}". Coverage matches on names, so this would count towards nothing.`,
41
+ });
42
+ }
43
+ }
44
+ const draftTokens = contentTokens(`${draft.given} ${draft.expect}`);
45
+ const draftSet = new Set(draft.terms);
46
+ const draftTerms = [...draft.terms].sort().join(',');
47
+ for (const existing of expectations) {
48
+ if (existing.supersededBy !== null)
49
+ continue;
50
+ const similarity = jaccard(draftTokens, contentTokens(`${existing.given} ${existing.expect}`));
51
+ const sameTerms = [...existing.terms].sort().join(',') === draftTerms;
52
+ // Wording alone is not enough, and this cost a false positive to learn: "reopenTask
53
+ // changes nothing and does not error" scored 0.6 against "completeTask changes nothing
54
+ // and does not error", because the idempotence boilerplate is most of both sentences and
55
+ // the one word that distinguishes them is the function name. Two statements about
56
+ // different functions are not the same statement however alike they read — so a duplicate
57
+ // also has to be about the same subject, meaning one term set contains the other.
58
+ const aboutTheSameThing = sameTerms ||
59
+ existing.terms.every((term) => draftSet.has(term)) ||
60
+ draft.terms.every((term) => existing.terms.includes(term));
61
+ if (similarity >= DUPLICATE && aboutTheSameThing) {
62
+ findings.push({
63
+ kind: 'duplicate',
64
+ subject: existing.id,
65
+ detail: `${existing.id} already says close to this. Two statements of one rule drift apart; supersede it instead if the wording needs fixing.`,
66
+ quote: existing.given ? `Given ${existing.given} — ${existing.expect}` : existing.expect,
67
+ });
68
+ }
69
+ else if (sameTerms) {
70
+ findings.push({
71
+ kind: 'overlaps',
72
+ subject: existing.id,
73
+ detail: `${existing.id} is about exactly the same terms. Fine if it covers a different situation — worth a look if it does not.`,
74
+ quote: existing.given ? `Given ${existing.given} — ${existing.expect}` : existing.expect,
75
+ });
76
+ }
77
+ }
78
+ return findings;
79
+ }
80
+ /** The spec text of every term a draft names — what @spec has to read to judge it. */
81
+ export function materialFor(draft, terms) {
82
+ return draft.terms
83
+ .map((name) => terms.find((term) => term.name === name))
84
+ .filter((term) => term !== undefined)
85
+ .map((term) => ({ name: term.name, spec: term.spec }));
86
+ }
@@ -0,0 +1,66 @@
1
+ import type { Author, Clash, Expectation, ExpectationKind, RecordStatus } from './types.js';
2
+ import type { SpecStore } from './specStore.js';
3
+ export interface RaiseExpectationRequest {
4
+ kind: ExpectationKind;
5
+ terms: string[];
6
+ given?: string;
7
+ expect: string;
8
+ pass: string;
9
+ from?: string;
10
+ file?: string;
11
+ /** Draft or published. Absent means `ready` — agents omit it; a human may save a draft. */
12
+ status?: RecordStatus;
13
+ /**
14
+ * What the check found and the author went ahead regardless. Carried on the write rather than
15
+ * recomputed here — the check is a separate call, and recomputing would let a draft be accepted
16
+ * against one glossary and stored against another.
17
+ */
18
+ contested?: Clash[];
19
+ }
20
+ export type ExpectationOutcome = {
21
+ ok: false;
22
+ error: string;
23
+ status?: number;
24
+ currentRev?: number;
25
+ } | {
26
+ ok: true;
27
+ id: string;
28
+ file: string;
29
+ expectation: Expectation;
30
+ };
31
+ export declare function raiseExpectation(store: SpecStore, request: RaiseExpectationRequest, author: Author): Promise<ExpectationOutcome>;
32
+ /**
33
+ * Publish a draft expectation — draft → ready. A draft counts toward nothing; publishing puts it into
34
+ * coverage and the versioned contract. Rewrites in place, keeping its id and file. Idempotent.
35
+ */
36
+ export declare function publishExpectation(store: SpecStore, id: string, expectedRev?: number): Promise<ExpectationOutcome>;
37
+ /**
38
+ * Re-reads a live expectation against the specs as they are now, and rewrites what it clashes with.
39
+ * The `check` is injected — a coordinator provides how a clash is found (a model pass, or the core
40
+ * checks only). It never retires anything: if the disagreement survives, the expectation stays live
41
+ * and contested and a human decides.
42
+ */
43
+ export declare function recheckExpectation(store: SpecStore, id: string, check: (expectation: Expectation, others: Expectation[]) => Promise<Clash[]>, expectedRev?: number): Promise<ExpectationOutcome>;
44
+ export interface SupersedeRequest {
45
+ /** What the replacement says. Omit to retire the expectation outright. */
46
+ replacement?: Omit<RaiseExpectationRequest, 'pass' | 'from' | 'file'> & {
47
+ pass?: string;
48
+ };
49
+ /** Why it moved. Recorded on the replacement's origin, or lost. */
50
+ note: string;
51
+ }
52
+ export type SupersedeOutcome = {
53
+ ok: false;
54
+ error: string;
55
+ status: number;
56
+ currentRev?: number;
57
+ } | {
58
+ ok: true;
59
+ retired: string;
60
+ replacement: Expectation | null;
61
+ };
62
+ /**
63
+ * Retire an expectation, optionally replacing it. The replacement is written first and the original
64
+ * moved second, so a crash between them leaves a duplicate-looking pair rather than a gap.
65
+ */
66
+ export declare function supersedeExpectation(store: SpecStore, id: string, request: SupersedeRequest, author: Author, expectedRev?: number): Promise<SupersedeOutcome>;