@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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Writing expectations, over the {@link SpecStore} seam — governed asymmetrically, and the asymmetry
3
+ * is the design.
4
+ *
5
+ * **Adding is free.** A new expectation changes no term and cannot alter what the app does; the most
6
+ * it can do is turn a check red, which reveals a defect rather than hiding one — safe by construction,
7
+ * the way `raiseQuestion` is. **Weakening is reviewed.** Superseding replaces a statement someone
8
+ * relies on — the one move that can turn a red check green without touching code — so it does not
9
+ * happen in place: the old expectation keeps its id, gains `supersededBy`, and moves to retired.
10
+ *
11
+ * Pure (the semantic re-check is *injected*, never called here), so it lives in core beside the seam:
12
+ * every coordinator raises, publishes, rechecks and supersedes identically.
13
+ */
14
+ import { parseExpectation } from './schema.js';
15
+ /** Maps a store mutation onto an ExpectationOutcome — not-found → 404, a stale rev → 409. */
16
+ function fromMutation(id, result, written) {
17
+ if (result.ok)
18
+ return { ok: true, id, file: result.at, expectation: { ...written, rev: result.rev } };
19
+ if (result.reason === 'not-found')
20
+ return { ok: false, error: `No live expectation "${id}".`, status: 404 };
21
+ return {
22
+ ok: false,
23
+ status: 409,
24
+ error: `"${id}" moved since you last read it — it is now at revision ${result.currentRev}.`,
25
+ currentRev: result.currentRev,
26
+ };
27
+ }
28
+ export async function raiseExpectation(store, request, author) {
29
+ const id = await store.nextExpectationId();
30
+ const expectation = {
31
+ id,
32
+ kind: request.kind,
33
+ author,
34
+ status: request.status ?? 'ready',
35
+ rev: 1,
36
+ terms: request.terms,
37
+ given: request.given ?? '',
38
+ expect: request.expect,
39
+ raisedBy: {
40
+ pass: request.pass,
41
+ ...(request.from ? { from: request.from } : {}),
42
+ ...(request.file ? { file: request.file } : {}),
43
+ },
44
+ supersededBy: null,
45
+ contested: request.contested ?? [],
46
+ };
47
+ // Validated before it reaches disk, not after — an invalid expectation would otherwise come
48
+ // back as a source problem in the UI instead of an error the caller can act on.
49
+ const parsed = parseExpectation(expectation);
50
+ if (!parsed.ok)
51
+ return { ok: false, error: parsed.errors.join('; '), status: 400 };
52
+ const file = await store.addExpectation(expectation);
53
+ return { ok: true, id, file, expectation };
54
+ }
55
+ /**
56
+ * Publish a draft expectation — draft → ready. A draft counts toward nothing; publishing puts it into
57
+ * coverage and the versioned contract. Rewrites in place, keeping its id and file. Idempotent.
58
+ */
59
+ export async function publishExpectation(store, id, expectedRev) {
60
+ const expectation = await store.findExpectation(id);
61
+ if (!expectation)
62
+ return { ok: false, error: `No live expectation "${id}".`, status: 404 };
63
+ const updated = { ...expectation, status: 'ready' };
64
+ return fromMutation(id, await store.rewriteExpectation(updated, expectedRev), updated);
65
+ }
66
+ /**
67
+ * Re-reads a live expectation against the specs as they are now, and rewrites what it clashes with.
68
+ * The `check` is injected — a coordinator provides how a clash is found (a model pass, or the core
69
+ * checks only). It never retires anything: if the disagreement survives, the expectation stays live
70
+ * and contested and a human decides.
71
+ */
72
+ export async function recheckExpectation(store, id, check, expectedRev) {
73
+ const expectation = await store.findExpectation(id);
74
+ if (!expectation)
75
+ return { ok: false, error: `No live expectation "${id}".`, status: 404 };
76
+ const { expectations } = await store.readExpectations();
77
+ const others = expectations.filter((candidate) => candidate.id !== id);
78
+ const contested = await check(expectation, others);
79
+ const updated = { ...expectation, contested };
80
+ return fromMutation(id, await store.rewriteExpectation(updated, expectedRev), updated);
81
+ }
82
+ /**
83
+ * Retire an expectation, optionally replacing it. The replacement is written first and the original
84
+ * moved second, so a crash between them leaves a duplicate-looking pair rather than a gap.
85
+ */
86
+ export async function supersedeExpectation(store, id, request, author, expectedRev) {
87
+ const original = await store.findExpectation(id);
88
+ if (!original)
89
+ return { ok: false, error: `No live expectation "${id}".`, status: 404 };
90
+ let replacement = null;
91
+ if (request.replacement) {
92
+ const raised = await raiseExpectation(store, { ...request.replacement, pass: request.replacement.pass ?? 'supersedes', from: id }, author);
93
+ if (!raised.ok)
94
+ return { ok: false, error: raised.error, status: raised.status ?? 400 };
95
+ replacement = raised.expectation;
96
+ }
97
+ const retired = {
98
+ ...original,
99
+ supersededBy: replacement?.id ?? null,
100
+ retiredBecause: request.note,
101
+ };
102
+ const moved = await store.retireExpectation(id, retired, expectedRev);
103
+ if (!moved.ok) {
104
+ if (moved.reason === 'not-found')
105
+ return { ok: false, error: `No live expectation "${id}".`, status: 404 };
106
+ return {
107
+ ok: false,
108
+ status: 409,
109
+ error: `"${id}" moved since you last read it — it is now at revision ${moved.currentRev}.`,
110
+ currentRev: moved.currentRev,
111
+ };
112
+ }
113
+ return { ok: true, retired: id, replacement };
114
+ }
@@ -0,0 +1,15 @@
1
+ export * from './types.js';
2
+ export * from './valueType.js';
3
+ export * from './schema.js';
4
+ export * from './backlinks.js';
5
+ export * from './changeset.js';
6
+ export * from './conflicts.js';
7
+ export * from './coverage.js';
8
+ export * from './expectationCheck.js';
9
+ export * from './specStore.js';
10
+ export * from './transcriptStore.js';
11
+ export * from './propose.js';
12
+ export * from './raise.js';
13
+ export * from './commit.js';
14
+ export * from './answer.js';
15
+ export * from './expectations.js';
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ export * from './types.js';
2
+ export * from './valueType.js';
3
+ export * from './schema.js';
4
+ export * from './backlinks.js';
5
+ export * from './changeset.js';
6
+ export * from './conflicts.js';
7
+ export * from './coverage.js';
8
+ export * from './expectationCheck.js';
9
+ export * from './specStore.js';
10
+ export * from './transcriptStore.js';
11
+ export * from './propose.js';
12
+ export * from './raise.js';
13
+ export * from './commit.js';
14
+ export * from './answer.js';
15
+ export * from './expectations.js';
@@ -0,0 +1,19 @@
1
+ import type { Author, Changeset, Op } from './types.js';
2
+ import type { SpecStore } from './specStore.js';
3
+ export interface ProposeRequest {
4
+ summary: string;
5
+ ops: Op[];
6
+ tests: string[];
7
+ /** Set when the proposal follows from a question that has already been answered. */
8
+ fromQuestion?: string;
9
+ }
10
+ export type ProposeOutcome = {
11
+ ok: false;
12
+ error: string;
13
+ } | {
14
+ ok: true;
15
+ id: string;
16
+ file: string;
17
+ changeset: Changeset;
18
+ };
19
+ export declare function proposeChangeset(store: SpecStore, request: ProposeRequest, author: Author): Promise<ProposeOutcome>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Minting a changeset — the canonical write, over the {@link SpecStore} seam.
3
+ *
4
+ * It lives in `@abseed/spectra-core` for the same reason the seam interfaces do: it is pure (it validates a
5
+ * request and writes through the store, with no server, filesystem, or SDK dependency), and both the
6
+ * open server and an out-of-repo backend need to propose *identically*. Keeping it beside the seam is
7
+ * what makes "propose a changeset" one operation with one meaning, whichever process runs it.
8
+ *
9
+ * Safe for the same reason `raiseQuestion` is: a changeset lands in the pending queue and changes
10
+ * *nothing*. It still has to be reviewed and applied through the same panel, with the same diff
11
+ * preview and conflict detection. "Writes a file" is not "changes the glossary", so this needs no
12
+ * approval prompt of its own — the approval already exists downstream.
13
+ */
14
+ import { parseChangeset } from './schema.js';
15
+ export async function proposeChangeset(store, request, author) {
16
+ const id = await store.nextChangesetId();
17
+ const changeset = {
18
+ id,
19
+ summary: request.summary,
20
+ ops: request.ops,
21
+ tests: request.tests,
22
+ ...(request.fromQuestion ? { fromQuestion: request.fromQuestion } : {}),
23
+ author,
24
+ };
25
+ // Validate before writing: a malformed changeset on disk comes back as a source problem in
26
+ // the UI, which is a worse way to learn about it than a message here.
27
+ const parsed = parseChangeset(changeset);
28
+ if (!parsed.ok)
29
+ return { ok: false, error: parsed.errors.join('; ') };
30
+ const file = await store.addChangeset(changeset);
31
+ return { ok: true, id, file, changeset };
32
+ }
@@ -0,0 +1,26 @@
1
+ import type { Author, Proposal, Question, RecordStatus } from './types.js';
2
+ import type { SpecStore } from './specStore.js';
3
+ export interface RaiseRequest {
4
+ asks: string;
5
+ because: string;
6
+ pass: string;
7
+ file?: string;
8
+ terms: string[];
9
+ options: Array<{
10
+ label: string;
11
+ detail?: string;
12
+ proposal?: Proposal | null;
13
+ }>;
14
+ /** Draft or published. Absent means `ready`; agents always raise `ready`. */
15
+ status?: RecordStatus;
16
+ }
17
+ export type RaiseOutcome = {
18
+ ok: false;
19
+ error: string;
20
+ } | {
21
+ ok: true;
22
+ id: string;
23
+ file: string;
24
+ question: Question;
25
+ };
26
+ export declare function raiseQuestion(store: SpecStore, request: RaiseRequest, author: Author): Promise<RaiseOutcome>;
package/dist/raise.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Raising a question — the one write an agent is allowed to make against the glossary, over the
3
+ * {@link SpecStore} seam.
4
+ *
5
+ * Like {@link proposeChangeset}, it lives in `@abseed/spectra-core` because it is pure and both the open
6
+ * server and an out-of-repo backend must raise a question identically — one operation, one meaning.
7
+ *
8
+ * Safe by construction: a question changes no term and applies no op. It is a request for a decision,
9
+ * and every path out of it (answering, then reviewing the changeset it mints) still runs through the
10
+ * human. That is why this needs no approval prompt while `proposeChangeset` will.
11
+ */
12
+ import { parseQuestion } from './schema.js';
13
+ export async function raiseQuestion(store, request, author) {
14
+ const id = await store.nextQuestionId();
15
+ const options = request.options.map((option) => ({
16
+ label: option.label,
17
+ ...(option.detail ? { detail: option.detail } : {}),
18
+ proposal: option.proposal ?? null,
19
+ }));
20
+ const question = {
21
+ id,
22
+ asks: request.asks,
23
+ because: request.because,
24
+ raisedBy: {
25
+ pass: request.pass,
26
+ ...(request.file ? { file: request.file } : {}),
27
+ terms: request.terms,
28
+ },
29
+ author,
30
+ status: request.status ?? 'ready',
31
+ rev: 1,
32
+ options,
33
+ answer: null,
34
+ };
35
+ // Validate before writing rather than after: a malformed question would otherwise land on
36
+ // disk and come back as a source problem in the UI.
37
+ const parsed = parseQuestion(question);
38
+ if (!parsed.ok)
39
+ return { ok: false, error: parsed.errors.join('; ') };
40
+ const file = await store.addQuestion(question);
41
+ return { ok: true, id, file, question };
42
+ }