@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/schema.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Runtime validation for files a human may have edited by hand. The goal is a
3
+ * readable message pointing at the offending field, never a stack trace.
4
+ */
5
+ import { z } from 'zod';
6
+ import { TERM_TYPES } from './types.js';
7
+ import { describeValueTypeError, isValueType } from './valueType.js';
8
+ const termName = z
9
+ .string()
10
+ .min(1)
11
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'must be a bare identifier (letters, digits, underscore)');
12
+ const valueType = z.string().superRefine((raw, ctx) => {
13
+ if (!isValueType(raw)) {
14
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: describeValueTypeError(raw) });
15
+ }
16
+ });
17
+ /** Identity, stamped server-side. `.strict()` schemas below must allow it or a record carrying it is rejected. */
18
+ const authorSchema = z
19
+ .object({
20
+ kind: z.enum(['human', 'spec', 'coder']),
21
+ user: z.string().min(1).optional(),
22
+ })
23
+ .strict();
24
+ /** Project identity, hand-editable in `specs/project.json`. Both fields are required and non-empty. */
25
+ export const projectInfoSchema = z
26
+ .object({
27
+ name: z.string().min(1),
28
+ domain: z.string().min(1),
29
+ })
30
+ .strict();
31
+ export const attributeSchema = z
32
+ .object({
33
+ name: z.string().min(1),
34
+ valueType,
35
+ default: z.unknown().optional(),
36
+ optional: z.boolean().optional(),
37
+ })
38
+ .strict();
39
+ export const termSchema = z
40
+ .object({
41
+ name: termName,
42
+ type: z.enum(TERM_TYPES),
43
+ spec: z.string(),
44
+ parent: termName.nullable().default(null),
45
+ tags: z.array(z.string()).default([]),
46
+ attributes: z.array(attributeSchema).default([]),
47
+ })
48
+ .strict();
49
+ const opSchema = z.discriminatedUnion('op', [
50
+ z
51
+ .object({
52
+ op: z.literal('add_entity'),
53
+ term: termName,
54
+ termType: z.enum(TERM_TYPES).optional(),
55
+ parent: termName.nullable().optional(),
56
+ spec: z.string(),
57
+ tags: z.array(z.string()).optional(),
58
+ attributes: z.array(attributeSchema).optional(),
59
+ })
60
+ .strict(),
61
+ z.object({ op: z.literal('remove_entity'), term: termName }).strict(),
62
+ z.object({ op: z.literal('add_attribute'), term: termName, attribute: attributeSchema }).strict(),
63
+ z
64
+ .object({ op: z.literal('remove_attribute'), term: termName, attribute: z.string().min(1) })
65
+ .strict(),
66
+ z.object({ op: z.literal('modify_spec'), term: termName, spec: z.string() }).strict(),
67
+ ]);
68
+ export const changesetSchema = z
69
+ .object({
70
+ id: z.string().min(1),
71
+ summary: z.string(),
72
+ ops: z.array(opSchema),
73
+ tests: z.array(z.string()).default([]),
74
+ fromQuestion: z.string().min(1).optional(),
75
+ appliedAt: z.string().optional(),
76
+ implementedAt: z.string().nullable().optional(),
77
+ author: authorSchema.optional(),
78
+ })
79
+ .strict();
80
+ export const proposalSchema = z
81
+ .object({
82
+ summary: z.string(),
83
+ ops: z.array(opSchema),
84
+ tests: z.array(z.string()).default([]),
85
+ })
86
+ .strict();
87
+ export const questionSchema = z
88
+ .object({
89
+ id: z.string().min(1),
90
+ asks: z.string().min(1),
91
+ because: z.string().min(1),
92
+ raisedBy: z
93
+ .object({
94
+ pass: z.string().min(1),
95
+ file: z.string().optional(),
96
+ terms: z.array(termName).default([]),
97
+ })
98
+ .strict(),
99
+ options: z
100
+ .array(z
101
+ .object({
102
+ label: z.string().min(1),
103
+ detail: z.string().optional(),
104
+ proposal: proposalSchema.nullable().default(null),
105
+ })
106
+ .strict())
107
+ .default([]),
108
+ author: authorSchema.optional(),
109
+ status: z.enum(['draft', 'ready']).default('ready'),
110
+ rev: z.number().int().positive().default(1),
111
+ answer: z
112
+ .object({
113
+ chose: z.string().nullable(),
114
+ note: z.string(),
115
+ answeredAt: z.string(),
116
+ changesetId: z.string().optional(),
117
+ author: authorSchema.optional(),
118
+ })
119
+ .strict()
120
+ .nullable()
121
+ .default(null),
122
+ })
123
+ .strict()
124
+ .superRefine((question, ctx) => {
125
+ // An answer naming an option that does not exist would silently lose the decision.
126
+ const chose = question.answer?.chose;
127
+ if (chose && !question.options.some((option) => option.label === chose)) {
128
+ ctx.addIssue({
129
+ code: z.ZodIssueCode.custom,
130
+ path: ['answer', 'chose'],
131
+ message: `no option labelled "${chose}"`,
132
+ });
133
+ }
134
+ });
135
+ export const expectationSchema = z
136
+ .object({
137
+ id: z.string().min(1),
138
+ kind: z.enum(['functional', 'non-functional']),
139
+ author: authorSchema.optional(),
140
+ status: z.enum(['draft', 'ready']).default('ready'),
141
+ rev: z.number().int().positive().default(1),
142
+ terms: z.array(termName).default([]),
143
+ given: z.string().default(''),
144
+ expect: z.string().min(1),
145
+ raisedBy: z
146
+ .object({
147
+ pass: z.string().min(1),
148
+ from: z.string().min(1).optional(),
149
+ file: z.string().optional(),
150
+ })
151
+ .strict(),
152
+ supersededBy: z.string().min(1).nullable().default(null),
153
+ retiredBecause: z.string().min(1).optional(),
154
+ contested: z
155
+ .array(z
156
+ .object({
157
+ kind: z.enum(['unknown-term', 'duplicate', 'overlaps', 'contradicts', 'restates']),
158
+ subject: z.string().min(1),
159
+ detail: z.string().min(1),
160
+ quote: z.string().optional(),
161
+ })
162
+ .strict())
163
+ .default([]),
164
+ })
165
+ .strict()
166
+ .superRefine((expectation, ctx) => {
167
+ // A functional expectation with no terms is coverage that can never be counted — it
168
+ // would sit in the file and show up against nothing. Non-functional ones are exempt:
169
+ // "the app survives a refresh" legitimately scopes to no term at all.
170
+ if (expectation.kind === 'functional' && expectation.terms.length === 0) {
171
+ ctx.addIssue({
172
+ code: z.ZodIssueCode.custom,
173
+ path: ['terms'],
174
+ message: 'a functional expectation must name at least one glossary term',
175
+ });
176
+ }
177
+ // Self-supersession would make the expectation both live and retired, and the coverage
178
+ // pass would have to pick one.
179
+ if (expectation.supersededBy === expectation.id) {
180
+ ctx.addIssue({
181
+ code: z.ZodIssueCode.custom,
182
+ path: ['supersededBy'],
183
+ message: 'an expectation cannot supersede itself',
184
+ });
185
+ }
186
+ });
187
+ function formatIssues(error) {
188
+ return error.issues.map((issue) => {
189
+ const path = issue.path.join('.');
190
+ return path ? `${path}: ${issue.message}` : issue.message;
191
+ });
192
+ }
193
+ export function parseTerm(data) {
194
+ const result = termSchema.safeParse(data);
195
+ return result.success
196
+ ? { ok: true, value: result.data }
197
+ : { ok: false, errors: formatIssues(result.error) };
198
+ }
199
+ export function parseChangeset(data) {
200
+ const result = changesetSchema.safeParse(data);
201
+ return result.success
202
+ ? { ok: true, value: result.data }
203
+ : { ok: false, errors: formatIssues(result.error) };
204
+ }
205
+ export function parseQuestion(data) {
206
+ const result = questionSchema.safeParse(data);
207
+ return result.success
208
+ ? { ok: true, value: result.data }
209
+ : { ok: false, errors: formatIssues(result.error) };
210
+ }
211
+ export function parseExpectation(data) {
212
+ const result = expectationSchema.safeParse(data);
213
+ return result.success
214
+ ? { ok: true, value: result.data }
215
+ : { ok: false, errors: formatIssues(result.error) };
216
+ }
217
+ export function parseProjectInfo(data) {
218
+ const result = projectInfoSchema.safeParse(data);
219
+ return result.success
220
+ ? { ok: true, value: result.data }
221
+ : { ok: false, errors: formatIssues(result.error) };
222
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The storage seam for the glossary (GH #3).
3
+ *
4
+ * It lives in `@abseed/spectra-core`, the pure package, on purpose: this interface is the stable public
5
+ * boundary an out-of-repo store implements, and an implementor should depend on it without pulling
6
+ * in a server's Express/agent machinery. It references only domain types (from `./types.js` and
7
+ * `./schema.js`), so it belongs with them. The built-in filesystem and SQL backends live in
8
+ * `@abseed/spectra-server`; a hosted/networked backend is just another implementation of this contract.
9
+ *
10
+ * Why this exists: specs are a shared human artifact, not a byproduct of the code. To make
11
+ * them centrally visible to a team (and later multi-tenant/hosted), the content of `specs/`
12
+ * has to be able to live somewhere other than the local filesystem. This interface is the
13
+ * one place that decides *where*; `FileSystemSpecStore` (today's behaviour) and a future
14
+ * `SqlSpecStore` are peers behind it. "Hosted" is a deployment axis, not a third backend.
15
+ *
16
+ * The boundary, stated so we do not blur it:
17
+ * - SpecStore is PERSISTENCE ONLY — reads, id allocation, and writes. Domain logic stays
18
+ * in the caller: the changeset engine (`applyOps`), validation (`parseTerm`/…),
19
+ * diagnostics, and version-guard decisions all remain in commit.ts / propose.ts /
20
+ * answer.ts / raise.ts / expectations.ts, which compose a SpecStore rather than living
21
+ * inside it.
22
+ * - Records are addressed by DOMAIN ID (`term.name`, `changeset.id`, `q-…`, `e-…`). No
23
+ * filename or path is used to *address* anything — the FS impl keeps its id→file map
24
+ * private, which is what lets a SQL row id stand in with no change to the caller. The one
25
+ * value that crosses the line is `StoredAt`, returned by the create methods purely so the
26
+ * caller can echo "where it landed" in a response; it is opaque and never parsed.
27
+ * - STATE IS DATA, NOT LOCATION. Today "applied" means a file sits in `applied/` and
28
+ * "retired" means it sits in `retired/`; here those are state transitions named as
29
+ * methods. The reads still return the partitioned view; only the FS impl backs it with
30
+ * folders.
31
+ * - `problems` survives: a record that will not load is reported, not thrown. It is an FS
32
+ * truth today (hand-edited files) but an API backend can return partial data too. What
33
+ * does NOT survive is "re-read from disk every call" — that is an FS detail.
34
+ *
35
+ * Not in scope: `data/transcripts.db` (chat history, its own store) and the version guard /
36
+ * `app/specs.snapshot.json` (tabled — a `version()` method belongs here eventually, once the
37
+ * snapshot is reworked into a queryable authority; deliberately left out of this first slice).
38
+ */
39
+ import type { Answer, Changeset, Expectation, Op, ProjectInfo, Question, SourceProblem, Term } from './types.js';
40
+ export interface Glossary {
41
+ terms: Term[];
42
+ problems: SourceProblem[];
43
+ }
44
+ export interface PendingChangesets {
45
+ changesets: Changeset[];
46
+ problems: SourceProblem[];
47
+ /**
48
+ * What has landed and what was turned down, newest first. Each entry is a distinct set of
49
+ * ops, so a changeset applied in two partial passes appears twice — the honest count.
50
+ */
51
+ applied: Changeset[];
52
+ rejected: Changeset[];
53
+ }
54
+ export interface QuestionFeed {
55
+ questions: Question[];
56
+ problems: SourceProblem[];
57
+ }
58
+ export interface ExpectationFeed {
59
+ /** Published, live expectations — what is currently expected to hold. Excludes drafts. */
60
+ expectations: Expectation[];
61
+ /**
62
+ * Drafts — live but not yet published, so they count toward nothing (coverage, the versioned
63
+ * contract, the agents' view) and are returned only for their author's own authoring UI.
64
+ */
65
+ drafts: Expectation[];
66
+ /** Superseded ones, kept so a test citing a retired id still resolves and the reason survives. */
67
+ retired: Expectation[];
68
+ problems: SourceProblem[];
69
+ }
70
+ /**
71
+ * Where the store put a newly created record. Opaque to callers — a filename for the
72
+ * filesystem backend, a row id for SQL — echoed back in responses, never parsed.
73
+ */
74
+ export type StoredAt = string;
75
+ /**
76
+ * One atomic application (#1, Trap 1). commit.ts runs the engine to produce the post-image,
77
+ * then hands the whole thing here as ONE call so the term writes and the pending→applied move
78
+ * commit together. On the filesystem this is the `writeAtomic` dance (which can half-land
79
+ * across files); on SQL it becomes a real transaction.
80
+ */
81
+ export interface CommitApplication {
82
+ /** The pending changeset being (partially) applied. */
83
+ changesetId: string;
84
+ /** Full post-image of the glossary the engine produced — the store reconciles disk to this. */
85
+ nextTerms: Term[];
86
+ /** The ops that landed, recorded into the applied record. */
87
+ appliedOps: Op[];
88
+ /** Ops left unselected: they stay pending. Empty means the pending changeset is removed. */
89
+ remainingOps: Op[];
90
+ /** Caller's clock, so this stays testable. */
91
+ appliedAt: string;
92
+ }
93
+ /** What changed, in the store's own terms (filenames for FS, opaque strings for SQL). */
94
+ export interface CommitResult {
95
+ written: string[];
96
+ deleted: string[];
97
+ /** Where the applied record now lives — the old relative-path `resolvedTo`, kept verbatim. */
98
+ resolvedTo: string;
99
+ }
100
+ /**
101
+ * The result of a mutation guarded by optimistic concurrency.
102
+ *
103
+ * `conflict` carries the revision the record is actually at, never a verdict — the caller
104
+ * compares it to what it expected, the same way `/api/specs/version` reports two numbers. When
105
+ * the caller supplies no `expectedRev`, the write proceeds and only ever returns `ok` or
106
+ * `not-found`; the conflict case appears only for a caller that opted into the check.
107
+ */
108
+ export type MutationResult = {
109
+ ok: true;
110
+ rev: number;
111
+ at: StoredAt;
112
+ } | {
113
+ ok: false;
114
+ reason: 'not-found';
115
+ } | {
116
+ ok: false;
117
+ reason: 'conflict';
118
+ currentRev: number;
119
+ };
120
+ export interface SpecStore {
121
+ /**
122
+ * Who this glossary is — its display name and one-line domain. Glossary content, not
123
+ * deployment config: the filesystem backend reads it from `specs/project.json`, a SQL backend
124
+ * from a row, a hosted one per tenant. A missing or malformed source degrades to a neutral
125
+ * default rather than throwing, the same way an unreadable record becomes a `problem`.
126
+ */
127
+ projectInfo(): Promise<ProjectInfo>;
128
+ readTerms(): Promise<Glossary>;
129
+ readChangesets(): Promise<PendingChangesets>;
130
+ readQuestions(): Promise<QuestionFeed>;
131
+ readExpectations(): Promise<ExpectationFeed>;
132
+ findChangeset(id: string): Promise<Changeset | null>;
133
+ findQuestion(id: string): Promise<Question | null>;
134
+ findExpectation(id: string): Promise<Expectation | null>;
135
+ nextChangesetId(): Promise<string>;
136
+ nextQuestionId(): Promise<string>;
137
+ nextExpectationId(): Promise<string>;
138
+ addChangeset(changeset: Changeset): Promise<StoredAt>;
139
+ addQuestion(question: Question): Promise<StoredAt>;
140
+ addExpectation(expectation: Expectation): Promise<StoredAt>;
141
+ /** Atomic: reconcile terms to the post-image and move the changeset pending → applied. */
142
+ commitApplication(application: CommitApplication): Promise<CommitResult>;
143
+ /** Move a pending changeset to rejected. Returns where it landed, or null if none matched. */
144
+ rejectChangeset(id: string): Promise<string | null>;
145
+ /** Record code written against an applied changeset. Where it lives, or null if none matched. */
146
+ markImplemented(id: string, at: string): Promise<StoredAt | null>;
147
+ /**
148
+ * Write an answer into an open question (open → answered), in place, bumping its rev. When
149
+ * `expectedRev` is given and the question has moved past it, the write is refused as a conflict.
150
+ */
151
+ writeAnswer(questionId: string, answer: Answer, expectedRev?: number): Promise<MutationResult>;
152
+ /** Move a live expectation to retired (bumps its rev). `expectedRev` guards it, as above. */
153
+ retireExpectation(id: string, retired: Expectation, expectedRev?: number): Promise<MutationResult>;
154
+ /** Rewrite a live expectation in place, bumping its rev — e.g. after a recheck or a publish. */
155
+ rewriteExpectation(expectation: Expectation, expectedRev?: number): Promise<MutationResult>;
156
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The storage seam for agent-conversation transcripts — the interface only.
3
+ *
4
+ * Like {@link SpecStore}, it lives in `@abseed/spectra-core`, the pure package, so an out-of-repo store can
5
+ * implement it without depending on a server's Express/agent machinery: the cloud backs it with D1,
6
+ * the open server with node:sqlite ({@link SqliteTranscriptStore} in `@abseed/spectra-server`), and both
7
+ * satisfy this one contract. The concrete backend, the plugin loader, and the on-disk defaults stay
8
+ * in the server; only the shape an implementor must match is here.
9
+ *
10
+ * Every read and write is async. A node:sqlite backend is synchronous underneath and resolves
11
+ * immediately, but the interface awaits because a store that talks to a database over the wire — the
12
+ * kind a multi-instance/hosted deploy needs — cannot answer synchronously, and the interface, not one
13
+ * backend, is the boundary implementors depend on. `close` is the one exception: tearing down a
14
+ * connection has nothing to await for a networked store.
15
+ *
16
+ * One instance serves every project, keyed by `projectId` per call (unlike SpecStore, which binds to
17
+ * one project): the runner is a long-lived singleton and sessions carry globally-unique ids, so only
18
+ * the operations that *scope* — creating and listing sessions — need the project; the rest resolve a
19
+ * session by its id.
20
+ */
21
+ import type { AuthorKind } from './types.js';
22
+ /**
23
+ * `tool_call` rows carry a status so a run interrupted mid-flight can be reasoned about later — on
24
+ * resume, a call left `started` may or may not have taken effect.
25
+ */
26
+ export type EventKind = 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'error' | 'approval';
27
+ export type ToolStatus = 'started' | 'completed' | 'failed';
28
+ export interface TranscriptEvent {
29
+ id: number;
30
+ sessionId: string;
31
+ /** Who produced the event — human or one of the agents. `kind` says what it is; this says who. */
32
+ author: AuthorKind;
33
+ kind: EventKind;
34
+ /** Plain text, kept searchable. For tool events, a one-line summary. */
35
+ text: string | null;
36
+ /** Structured detail as JSON — tool input/output, error causes. */
37
+ payload: unknown;
38
+ toolCallId: string | null;
39
+ status: ToolStatus | null;
40
+ createdAt: string;
41
+ }
42
+ export interface Session {
43
+ id: string;
44
+ /** The project this conversation belongs to. Sessions are listed and reached per project. */
45
+ projectId: string;
46
+ /**
47
+ * The user who owns this conversation, or `null` for an unattributed one. Sessions are per-user:
48
+ * a hosted deployment stamps the authenticated user here and lists each user only their own. On a
49
+ * single-user install there is no account, so it is `null` and listing is not narrowed — the owner
50
+ * of the durable record (who edited the glossary) lives on the changeset's `author.user`, not here.
51
+ */
52
+ ownerId: string | null;
53
+ title: string;
54
+ createdAt: string;
55
+ updatedAt: string;
56
+ }
57
+ export interface NewEvent {
58
+ author: AuthorKind;
59
+ kind: EventKind;
60
+ text?: string | null;
61
+ payload?: unknown;
62
+ toolCallId?: string | null;
63
+ status?: ToolStatus | null;
64
+ }
65
+ export interface TranscriptStore {
66
+ createSession(id: string, projectId: string, ownerId: string | null, title: string, now: string): Promise<Session>;
67
+ renameSession(id: string, title: string, now: string): Promise<void>;
68
+ getSession(id: string): Promise<Session | null>;
69
+ /** Sessions for a project, newest first. `ownerId` narrows to one user's; omit it for all of them. */
70
+ listSessions(projectId: string, ownerId?: string, limit?: number): Promise<Session[]>;
71
+ append(sessionId: string, event: NewEvent, now: string): Promise<number>;
72
+ settleApproval(approvalId: string, decision: 'allow' | 'deny', note: string | null): Promise<void>;
73
+ readApproval(approvalId: string): Promise<TranscriptEvent | null>;
74
+ settleToolCall(toolCallId: string, status: ToolStatus, output: unknown): Promise<void>;
75
+ readToolCall(toolCallId: string): Promise<TranscriptEvent | null>;
76
+ read(sessionId: string, afterId?: number): Promise<TranscriptEvent[]>;
77
+ search(query: string, limit?: number): Promise<Array<TranscriptEvent & {
78
+ title: string;
79
+ }>>;
80
+ deleteSession(id: string): Promise<void>;
81
+ pruneBefore(before: string): Promise<number>;
82
+ close(): void;
83
+ }
@@ -0,0 +1 @@
1
+ export {};