@axiom-lattice/protocols 3.0.4 → 4.0.1

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,324 @@
1
+ /** A single canonical belief recorded in a task description. */
2
+ export interface TaskBeliefEntry {
3
+ key: string;
4
+ probability: number;
5
+ target: number;
6
+ basis: string;
7
+ }
8
+
9
+ /** The canonical belief snapshot embedded in task Markdown. */
10
+ export interface TaskBeliefState {
11
+ entries: TaskBeliefEntry[];
12
+ }
13
+
14
+ /** Stable diagnostic identifiers returned by the Belief State parser. */
15
+ export type TaskBeliefDiagnosticCode =
16
+ | "MISSING_BELIEF_STATE"
17
+ | "DUPLICATE_BELIEF_STATE"
18
+ | "INVALID_BELIEF_HEADERS"
19
+ | "MALFORMED_BELIEF_ROW"
20
+ | "INVALID_BELIEF_KEY"
21
+ | "INVALID_BELIEF_PERCENT"
22
+ | "DUPLICATE_BELIEF_KEY";
23
+
24
+ /** A structured failure produced while parsing a task Belief State. */
25
+ export interface TaskBeliefParseFailure {
26
+ success: false;
27
+ code: TaskBeliefDiagnosticCode;
28
+ message: string;
29
+ line?: number;
30
+ key?: string;
31
+ column?: "probability" | "target";
32
+ }
33
+
34
+ /** A successfully parsed task Belief State. */
35
+ export interface TaskBeliefParseSuccess {
36
+ success: true;
37
+ state: TaskBeliefState;
38
+ }
39
+
40
+ /** The discriminated result of parsing a task Belief State. */
41
+ export type TaskBeliefParseResult = TaskBeliefParseSuccess | TaskBeliefParseFailure;
42
+
43
+ interface MarkdownLine {
44
+ text: string;
45
+ start: number;
46
+ end: number;
47
+ fenced: boolean;
48
+ lineNumber: number;
49
+ }
50
+
51
+ interface SectionRange {
52
+ headingLine: number;
53
+ start: number;
54
+ end: number;
55
+ }
56
+
57
+ const BELIEF_HEADING = "## Belief State";
58
+ const ACCEPTANCE_HEADING = "## Acceptance Criteria";
59
+ const EXPECTED_HEADERS = ["Belief Key", "Probability", "Target", "Basis"];
60
+ const KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
61
+ const PERCENT_PATTERN = /^(?:100|[0-9]{1,2})%$/;
62
+
63
+ function markdownLines(markdown: string): MarkdownLine[] {
64
+ const lines: MarkdownLine[] = [];
65
+ let start = 0;
66
+ let fence: { marker: "`" | "~"; length: number } | undefined;
67
+
68
+ while (start <= markdown.length) {
69
+ const newline = markdown.indexOf("\n", start);
70
+ const end = newline === -1 ? markdown.length : newline + 1;
71
+ const textEnd = newline === -1 ? markdown.length : newline;
72
+ const rawText = markdown.slice(start, textEnd);
73
+ const text = rawText.endsWith("\r") ? rawText.slice(0, -1) : rawText;
74
+ const fenceMatch = text.match(/^ {0,3}(`{3,}|~{3,})/);
75
+ const fenced = fence !== undefined;
76
+
77
+ if (!fence && fenceMatch) {
78
+ const marker = fenceMatch[1][0] as "`" | "~";
79
+ fence = { marker, length: fenceMatch[1].length };
80
+ } else if (fence) {
81
+ const closePattern = new RegExp(`^ {0,3}\\${fence.marker}{${fence.length},}[ \\t]*$`);
82
+ if (closePattern.test(text)) fence = undefined;
83
+ }
84
+
85
+ lines.push({
86
+ text,
87
+ start,
88
+ end,
89
+ fenced: fenced || fenceMatch !== null,
90
+ lineNumber: lines.length + 1,
91
+ });
92
+ if (newline === -1) break;
93
+ start = end;
94
+ }
95
+
96
+ return lines;
97
+ }
98
+
99
+ function sectionRanges(markdown: string, heading: string): SectionRange[] {
100
+ const lines = markdownLines(markdown);
101
+ const ranges: SectionRange[] = [];
102
+
103
+ for (let index = 0; index < lines.length; index += 1) {
104
+ const line = lines[index];
105
+ if (line.fenced || line.text.trimEnd().replace(/^ {0,3}/, "") !== heading) continue;
106
+
107
+ let end = markdown.length;
108
+ for (let next = index + 1; next < lines.length; next += 1) {
109
+ if (!lines[next].fenced && /^ {0,3}#{1,2}(?:[ \t]+|$)/.test(lines[next].text)) {
110
+ end = lines[next].start;
111
+ break;
112
+ }
113
+ }
114
+ ranges.push({ headingLine: index, start: line.start, end });
115
+ }
116
+
117
+ return ranges;
118
+ }
119
+
120
+ function splitTableRow(line: string): string[] | undefined {
121
+ const trimmed = line.trim();
122
+ const finalPipe = trimmed.length - 1;
123
+ if (!trimmed.startsWith("|") || !trimmed.endsWith("|") || isEscapedPipe(trimmed, finalPipe)) {
124
+ return undefined;
125
+ }
126
+
127
+ const cells: string[] = [];
128
+ let cell = "";
129
+ for (let index = 1; index < trimmed.length - 1; index += 1) {
130
+ const character = trimmed[index];
131
+ if (character === "|" && isEscapedPipe(trimmed, index)) {
132
+ cell += character;
133
+ } else if (character === "|") {
134
+ cells.push(unescapeTableCell(cell.trim()));
135
+ cell = "";
136
+ } else {
137
+ cell += character;
138
+ }
139
+ }
140
+ cells.push(unescapeTableCell(cell.trim()));
141
+ return cells;
142
+ }
143
+
144
+ function unescapeTableCell(cell: string): string {
145
+ let unescaped = "";
146
+ for (let index = 0; index < cell.length; index += 1) {
147
+ if (cell[index] === "\\" && (cell[index + 1] === "\\" || cell[index + 1] === "|")) {
148
+ index += 1;
149
+ }
150
+ unescaped += cell[index];
151
+ }
152
+ return unescaped;
153
+ }
154
+
155
+ function isEscapedPipe(line: string, pipeIndex: number): boolean {
156
+ let backslashes = 0;
157
+ for (let index = pipeIndex - 1; index >= 0 && line[index] === "\\"; index -= 1) {
158
+ backslashes += 1;
159
+ }
160
+ return backslashes % 2 === 1;
161
+ }
162
+
163
+ function failure(
164
+ code: TaskBeliefDiagnosticCode,
165
+ message: string,
166
+ details: Pick<TaskBeliefParseFailure, "line" | "key" | "column"> = {},
167
+ ): TaskBeliefParseFailure {
168
+ return { success: false, code, message, ...details };
169
+ }
170
+
171
+ function normalizedText(value: string): string {
172
+ return value.trim().replace(/\s+/g, " ");
173
+ }
174
+
175
+ function validateTaskBeliefState(state: TaskBeliefState): void {
176
+ const keys = new Set<string>();
177
+ for (const entry of state.entries) {
178
+ if (!KEY_PATTERN.test(entry.key)) {
179
+ throw new Error("Belief keys must be kebab-case without backticks or newlines.");
180
+ }
181
+ if (keys.has(entry.key)) {
182
+ throw new Error(`Belief key '${entry.key}' appears more than once.`);
183
+ }
184
+ for (const field of ["probability", "target"] as const) {
185
+ const value = entry[field];
186
+ if (!Number.isInteger(value) || value < 0 || value > 100) {
187
+ throw new Error(`Belief ${field} must be an integer from 0 to 100.`);
188
+ }
189
+ }
190
+ if (entry.basis.trim().length === 0 || /[\r\n]/.test(entry.basis)) {
191
+ throw new Error("Belief basis must be a nonempty single line.");
192
+ }
193
+ keys.add(entry.key);
194
+ }
195
+ }
196
+
197
+ function formatTaskBeliefState(state: TaskBeliefState): string {
198
+ validateTaskBeliefState(state);
199
+ const rows = state.entries.map(({ key, probability, target, basis }) => {
200
+ const escapedBasis = normalizedText(basis).replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
201
+ return `| \`${key}\` | ${probability}% | ${target}% | ${escapedBasis} |`;
202
+ });
203
+ return [
204
+ BELIEF_HEADING,
205
+ "",
206
+ "| Belief Key | Probability | Target | Basis |",
207
+ "|---|---:|---:|---|",
208
+ ...rows,
209
+ ].join("\n");
210
+ }
211
+
212
+ /** Parses the unique non-code-fenced canonical Belief State section in Markdown. */
213
+ export function parseTaskBeliefState(markdown: string): TaskBeliefParseResult {
214
+ const sections = sectionRanges(markdown, BELIEF_HEADING);
215
+ if (sections.length === 0) {
216
+ return failure("MISSING_BELIEF_STATE", "Markdown does not contain a Belief State section.");
217
+ }
218
+ if (sections.length > 1) {
219
+ return failure("DUPLICATE_BELIEF_STATE", "Markdown contains more than one Belief State section.");
220
+ }
221
+
222
+ const lines = markdownLines(markdown);
223
+ const section = sections[0];
224
+ const content = lines
225
+ .slice(section.headingLine + 1)
226
+ .filter((line) => line.start < section.end && line.text.trim() !== "");
227
+ const header = content[0] && splitTableRow(content[0].text);
228
+ const separator = content[1] && splitTableRow(content[1].text);
229
+ if (
230
+ !header ||
231
+ header.length !== EXPECTED_HEADERS.length ||
232
+ header.some((cell, index) => cell !== EXPECTED_HEADERS[index]) ||
233
+ !separator ||
234
+ separator.length !== EXPECTED_HEADERS.length ||
235
+ separator.some((cell) => !/^:?-{3,}:?$/.test(cell))
236
+ ) {
237
+ return failure("INVALID_BELIEF_HEADERS", "Belief State must use the canonical four-column headers.");
238
+ }
239
+
240
+ const entries: TaskBeliefEntry[] = [];
241
+ const keys = new Set<string>();
242
+ for (const line of content.slice(2)) {
243
+ const cells = splitTableRow(line.text);
244
+ if (!cells || cells.length !== 4 || cells[3].length === 0) {
245
+ return failure("MALFORMED_BELIEF_ROW", "Belief State contains a malformed row.", {
246
+ line: line.lineNumber,
247
+ });
248
+ }
249
+
250
+ const keyMatch = cells[0].match(/^`([^`]+)`$/);
251
+ if (!keyMatch || !KEY_PATTERN.test(keyMatch[1])) {
252
+ return failure("INVALID_BELIEF_KEY", "Belief keys must be backtick-wrapped kebab-case.", {
253
+ line: line.lineNumber,
254
+ });
255
+ }
256
+ const key = keyMatch[1];
257
+ if (keys.has(key)) {
258
+ return failure("DUPLICATE_BELIEF_KEY", `Belief key '${key}' appears more than once.`, {
259
+ line: line.lineNumber,
260
+ key,
261
+ });
262
+ }
263
+
264
+ for (const [index, column] of [[1, "probability"], [2, "target"]] as const) {
265
+ if (!PERCENT_PATTERN.test(cells[index])) {
266
+ return failure("INVALID_BELIEF_PERCENT", `Belief ${column} must be an integer from 0% to 100%.`, {
267
+ line: line.lineNumber,
268
+ column,
269
+ });
270
+ }
271
+ }
272
+
273
+ keys.add(key);
274
+ entries.push({
275
+ key,
276
+ probability: Number.parseInt(cells[1], 10),
277
+ target: Number.parseInt(cells[2], 10),
278
+ basis: cells[3],
279
+ });
280
+ }
281
+
282
+ return { success: true, state: { entries } };
283
+ }
284
+
285
+ /** Compares two Belief States while ignoring entry order and insignificant whitespace. */
286
+ export function taskBeliefStatesEqual(left: TaskBeliefState, right: TaskBeliefState): boolean {
287
+ if (left.entries.length !== right.entries.length) return false;
288
+
289
+ const byKey = new Map(right.entries.map((entry) => [entry.key, entry]));
290
+ return left.entries.every((entry) => {
291
+ const other = byKey.get(entry.key);
292
+ return other !== undefined &&
293
+ entry.probability === other.probability &&
294
+ entry.target === other.target &&
295
+ normalizedText(entry.basis) === normalizedText(other.basis);
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Replaces a unique Belief State section, or inserts one after Acceptance Criteria content.
301
+ *
302
+ * @throws {Error} If the state is noncanonical or the Markdown contains duplicate sections.
303
+ */
304
+ export function replaceTaskBeliefState(markdown: string, state: TaskBeliefState): string {
305
+ const replacement = formatTaskBeliefState(state);
306
+ const sections = sectionRanges(markdown, BELIEF_HEADING);
307
+ if (sections.length > 1) {
308
+ throw new Error("Cannot replace duplicate Belief State sections.");
309
+ }
310
+ if (sections.length === 1) {
311
+ const section = sections[0];
312
+ const originalSection = markdown.slice(section.start, section.end);
313
+ const trailingWhitespace = originalSection.match(/\s*$/)?.[0] ?? "";
314
+ return markdown.slice(0, section.start) + replacement + trailingWhitespace + markdown.slice(section.end);
315
+ }
316
+
317
+ const acceptance = sectionRanges(markdown, ACCEPTANCE_HEADING)[0];
318
+ const insertion = acceptance?.end ?? markdown.length;
319
+ const before = markdown.slice(0, insertion);
320
+ const after = markdown.slice(insertion);
321
+ const leadingBreaks = before.length === 0 ? "" : before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
322
+ const trailingBreaks = after.length === 0 ? "" : after.startsWith("\n") ? "\n" : "\n\n";
323
+ return before + leadingBreaks + replacement + trailingBreaks + after;
324
+ }
@@ -122,6 +122,12 @@ export interface TaskItem {
122
122
  * Create task request type
123
123
  */
124
124
  export interface CreateTaskRequest {
125
+ /**
126
+ * Caller-provided task identifier. When set, the store uses this id instead of
127
+ * generating one — used for single-ID mapping with external systems (e.g. A2A).
128
+ */
129
+ id?: string;
130
+
125
131
  /**
126
132
  * Task title
127
133
  */
@@ -226,6 +232,8 @@ export interface TaskFileRef {
226
232
  uri: string;
227
233
  /** Display name (useful when uri is a uuid or bare path) */
228
234
  name?: string;
235
+ /** MIME type of the referenced file (e.g. "application/pdf"), when known */
236
+ mimeType?: string;
229
237
  /** Who attached the file: the user (reference material) or an agent (artifact) */
230
238
  addedBy?: "user" | "agent";
231
239
  }
@@ -287,7 +295,7 @@ export interface UpdateTaskRequest {
287
295
  /**
288
296
  * Additional contextual data
289
297
  */
290
- context?: Record<string, unknown>;
298
+ context?: Record<string, unknown> | null;
291
299
 
292
300
  /**
293
301
  * Owner type
@@ -312,12 +320,12 @@ export interface UpdateTaskRequest {
312
320
  /**
313
321
  * Task result output
314
322
  */
315
- result?: string;
323
+ result?: string | null;
316
324
 
317
325
  /**
318
326
  * Reason for task failure (when status is 'failed')
319
327
  */
320
- failureReason?: string;
328
+ failureReason?: string | null;
321
329
 
322
330
  /** File references attached to this task */
323
331
  files?: TaskFileRef[];
@@ -424,6 +432,89 @@ export interface TaskStore {
424
432
  */
425
433
  update(tenantId: string, id: string, updates: UpdateTaskRequest): Promise<TaskItem | null>;
426
434
 
435
+ /**
436
+ * Atomically update a task only when its current status is expected.
437
+ *
438
+ * Storage implementations must evaluate the status predicate in the same
439
+ * atomic mutation that applies `updates`; callers must not emulate this with
440
+ * a separate read followed by {@link update}.
441
+ *
442
+ * @param tenantId Tenant identifier.
443
+ * @param id Task identifier.
444
+ * @param updates Partial task data to update.
445
+ * @param expectedStatuses Current statuses that permit the update.
446
+ * @returns The updated task, or `null` when the task is missing or its status is not expected.
447
+ */
448
+ updateIfStatusIn(
449
+ tenantId: string,
450
+ id: string,
451
+ updates: UpdateTaskRequest,
452
+ expectedStatuses: TaskItem["status"][],
453
+ ): Promise<TaskItem | null>;
454
+
455
+ /**
456
+ * Atomically updates a task only when its status and update timestamp match a read snapshot.
457
+ *
458
+ * Implementations must normalize `Date` and string timestamps to the same stable ISO
459
+ * representation and evaluate both predicates in the mutation itself.
460
+ *
461
+ * @param tenantId Tenant identifier.
462
+ * @param id Task identifier.
463
+ * @param updates Partial task data to update.
464
+ * @param expectedStatuses Current statuses that permit the update.
465
+ * @param expectedUpdatedAt Update timestamp captured from the validated task snapshot.
466
+ * @returns The updated task, or `null` when the task is missing or either snapshot predicate differs.
467
+ */
468
+ updateIfStatusAndUpdatedAt(
469
+ tenantId: string,
470
+ id: string,
471
+ updates: UpdateTaskRequest,
472
+ expectedStatuses: TaskItem["status"][],
473
+ expectedUpdatedAt: Date | string,
474
+ ): Promise<TaskItem | null>;
475
+
476
+ /**
477
+ * Atomically updates a child only when both child and parent snapshots match.
478
+ *
479
+ * @param tenantId Tenant identifier shared by the child and parent.
480
+ * @param id Child task identifier.
481
+ * @param updates Partial child task data to update.
482
+ * @param expectedStatuses Child statuses that permit the update.
483
+ * @param expectedUpdatedAt Child update timestamp captured during validation.
484
+ * @param parentId Parent task identifier captured during validation.
485
+ * @param expectedParentUpdatedAt Parent update timestamp captured during validation.
486
+ * @returns The updated child, or `null` when either task is missing or either snapshot differs.
487
+ */
488
+ updateIfStatusUpdatedAtAndParentUpdatedAt(
489
+ tenantId: string,
490
+ id: string,
491
+ updates: UpdateTaskRequest,
492
+ expectedStatuses: TaskItem["status"][],
493
+ expectedUpdatedAt: Date | string,
494
+ parentId: string,
495
+ expectedParentUpdatedAt: Date | string,
496
+ ): Promise<TaskItem | null>;
497
+
498
+ /**
499
+ * Atomically update a task unless its current status is blocked.
500
+ *
501
+ * Storage implementations must evaluate the status predicate in the same
502
+ * atomic mutation that applies `updates`; callers must not emulate this with
503
+ * a separate read followed by {@link update}.
504
+ *
505
+ * @param tenantId Tenant identifier.
506
+ * @param id Task identifier.
507
+ * @param updates Partial task data to update.
508
+ * @param blockedStatuses Current statuses that prevent the update.
509
+ * @returns The updated task, or `null` when the task is missing or blocked.
510
+ */
511
+ updateIfStatusNotIn(
512
+ tenantId: string,
513
+ id: string,
514
+ updates: UpdateTaskRequest,
515
+ blockedStatuses: TaskItem["status"][],
516
+ ): Promise<TaskItem | null>;
517
+
427
518
  /**
428
519
  * Delete a task by ID
429
520
  * @param tenantId Tenant identifier
@@ -17,6 +17,8 @@ export interface TaskWorkItem {
17
17
  summary?: string;
18
18
  detail?: Record<string, unknown>;
19
19
  attempt?: number;
20
+ /** Deterministic task-scoped identity used for idempotent event replay. */
21
+ eventKey?: string;
20
22
  createdAt: Date;
21
23
  }
22
24
 
@@ -33,12 +35,23 @@ export interface CreateWorkItemRequest {
33
35
  attempt?: number;
34
36
  }
35
37
 
38
+ /**
39
+ * Work-item creation request requiring a deterministic event identity.
40
+ *
41
+ * Event keys are unique within a tenant and task, not globally.
42
+ */
43
+ export interface CreateWorkItemIfAbsentRequest extends CreateWorkItemRequest {
44
+ /** Deterministic task-scoped identity used for idempotent event replay. */
45
+ eventKey: string;
46
+ }
47
+
36
48
  export interface TaskWorkItemListFilter {
37
49
  tenantId: string;
38
50
  taskId: string;
39
51
  workspaceId?: string;
40
52
  projectId?: string;
41
53
  action?: string;
54
+ order?: 'asc' | 'desc';
42
55
  limit?: number;
43
56
  offset?: number;
44
57
  }
@@ -46,4 +59,23 @@ export interface TaskWorkItemListFilter {
46
59
  export interface TaskWorkItemStore {
47
60
  create(params: CreateWorkItemRequest): Promise<TaskWorkItem>;
48
61
  list(filter: TaskWorkItemListFilter): Promise<TaskWorkItem[]>;
62
+
63
+ /**
64
+ * Find an event by deterministic identity without list pagination.
65
+ *
66
+ * @param tenantId Tenant identifier.
67
+ * @param taskId Task identifier that scopes the event key.
68
+ * @param eventKey Deterministic event identity.
69
+ * @returns The matching item, or `null` when absent.
70
+ */
71
+ findByEventKey(tenantId: string, taskId: string, eventKey: string): Promise<TaskWorkItem | null>;
72
+
73
+ /**
74
+ * Atomically create an event unless its task-scoped key already exists.
75
+ * Existing events are returned unchanged, preserving immutable replay.
76
+ *
77
+ * @param params Work-item fields including the required event key.
78
+ * @returns The existing or newly created work item.
79
+ */
80
+ createIfAbsentByEventKey(params: CreateWorkItemIfAbsentRequest): Promise<TaskWorkItem>;
49
81
  }
@@ -50,6 +50,13 @@ export interface WorkspaceStore {
50
50
  deleteWorkspace(tenantId: string, id: string): Promise<boolean>;
51
51
  }
52
52
 
53
+ /**
54
+ * Project kind classification
55
+ *
56
+ * Defaults to "business" for legacy rows and omitted input.
57
+ */
58
+ export type ProjectKind = "business" | "training" | "personal";
59
+
53
60
  /**
54
61
  * Project type definition
55
62
  */
@@ -61,6 +68,8 @@ export interface Project {
61
68
  description?: string;
62
69
  /** Application-specific configuration stored as JSON */
63
70
  config?: Record<string, unknown>;
71
+ /** Project classification; defaults to "business" when omitted */
72
+ kind?: ProjectKind;
64
73
  createdAt: Date;
65
74
  updatedAt: Date;
66
75
  }
@@ -73,6 +82,8 @@ export interface CreateProjectRequest {
73
82
  description?: string;
74
83
  /** Application-specific configuration stored as JSON (optional) */
75
84
  config?: Record<string, unknown>;
85
+ /** Project classification; defaults to "business" when omitted */
86
+ kind?: ProjectKind;
76
87
  }
77
88
 
78
89
  /**
@@ -87,6 +98,15 @@ export interface UpdateProjectRequest {
87
98
  description?: string;
88
99
  /** Application-specific configuration stored as JSON (replaces existing if provided) */
89
100
  config?: Record<string, unknown>;
101
+ /** Project classification */
102
+ kind?: ProjectKind;
103
+ }
104
+
105
+ /**
106
+ * Filter options for listing projects within a workspace
107
+ */
108
+ export interface ProjectFilter {
109
+ kind?: ProjectKind;
90
110
  }
91
111
 
92
112
  /**
@@ -94,7 +114,7 @@ export interface UpdateProjectRequest {
94
114
  * Provides CRUD operations for project data
95
115
  */
96
116
  export interface ProjectStore {
97
- getProjectsByWorkspace(tenantId: string, workspaceId: string): Promise<Project[]>;
117
+ getProjectsByWorkspace(tenantId: string, workspaceId: string, filter?: ProjectFilter): Promise<Project[]>;
98
118
  getProjectById(tenantId: string, id: string): Promise<Project | null>;
99
119
  createProject(tenantId: string, workspaceId: string, id: string, data: CreateProjectRequest): Promise<Project>;
100
120
  updateProject(tenantId: string, id: string, updates: UpdateProjectRequest): Promise<Project | null>;