spexcode 0.6.6 → 0.6.7

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.
@@ -2,6 +2,7 @@ export type PendingMessage = {
2
2
  mid: string;
3
3
  text: string;
4
4
  from: string | null;
5
+ attributes?: Record<string, string>;
5
6
  dispatch?: {
6
7
  operation: string;
7
8
  requestDigest: string;
@@ -63,6 +63,11 @@ function read(id) {
63
63
  return raw.filter((m) => !!m && typeof m === 'object'
64
64
  && typeof m.mid === 'string'
65
65
  && typeof m.text === 'string'
66
+ && (m.attributes === undefined
67
+ || (!!m.attributes
68
+ && typeof m.attributes === 'object'
69
+ && !Array.isArray(m.attributes)
70
+ && Object.values(m.attributes).every((value) => typeof value === 'string')))
66
71
  && (m.dispatch === undefined
67
72
  || (typeof m.dispatch?.operation === 'string'
68
73
  && typeof m.dispatch?.requestDigest === 'string')));
@@ -71,6 +76,11 @@ function read(id) {
71
76
  return [];
72
77
  } // absent, empty, or unparseable all mean the honest thing: nothing owed
73
78
  }
79
+ function sameAttributes(left, right) {
80
+ const leftEntries = Object.entries(left ?? {}).sort(([a], [b]) => a.localeCompare(b));
81
+ const rightEntries = Object.entries(right ?? {}).sort(([a], [b]) => a.localeCompare(b));
82
+ return JSON.stringify(leftEntries) === JSON.stringify(rightEntries);
83
+ }
74
84
  // Written whole and atomically; an empty queue is REMOVED rather than left as `[]`, so "is anything owed?" is
75
85
  // one existsSync on the sweep's hot path.
76
86
  function write(id, msgs) {
@@ -165,7 +175,8 @@ export async function drain(id, insert, timeoutMs = 5_000) {
165
175
  if (head.dispatch) {
166
176
  const receipt = sentDispatchReceipt(id, head.dispatch.operation, head.dispatch.requestDigest);
167
177
  if (!receipt || receipt.mid !== head.mid || !receipt.delivery
168
- || receipt.delivery.text !== head.text || receipt.delivery.from !== head.from)
178
+ || receipt.delivery.text !== head.text || receipt.delivery.from !== head.from
179
+ || !sameAttributes(receipt.delivery.attributes, head.attributes))
169
180
  return { delivered, remaining: queued.length };
170
181
  if (receipt.delivered)
171
182
  ok = true;
@@ -1,5 +1,5 @@
1
1
  export { acceptMessage, MessageKeyConflict, type AcceptMessageOptions, type MessageIdempotency, type PreparedMessage } from './message.js';
2
2
  export { drain, owesDelivery, pendingMessages, type PendingMessage } from './delivery-queue.js';
3
- export { publishRuntimeSessionState, readRuntimeSession, registerRuntimeSession, runtimeSessionChildren, RuntimeSessionConflict, type RuntimeSessionRecord, type RuntimeSessionRegistration, type RuntimeSessionState, } from './runtime-session.js';
3
+ export { publishRuntimeSessionState, readRuntimeSession, registerRuntimeSession, runtimeSessionNotification, runtimeSessionChildren, RuntimeSessionConflict, type RuntimeSessionRecord, type RuntimeSessionNotification, type RuntimeSessionRegistration, type RuntimeSessionState, } from './runtime-session.js';
4
4
  export { advanceFollow, followedSessions, followCursor, readCursors, unreadSince, type Cursors } from './session-cursors.js';
5
5
  export { currentHumanTurn, lastHumanSendVia, recordStatus, timelineEvents, timelineStamp, timelineTail, type SessionTurn, type TimelineEvent, } from './session-timeline.js';
@@ -1,5 +1,5 @@
1
1
  export { acceptMessage, MessageKeyConflict } from './message.js';
2
2
  export { drain, owesDelivery, pendingMessages } from './delivery-queue.js';
3
- export { publishRuntimeSessionState, readRuntimeSession, registerRuntimeSession, runtimeSessionChildren, RuntimeSessionConflict, } from './runtime-session.js';
3
+ export { publishRuntimeSessionState, readRuntimeSession, registerRuntimeSession, runtimeSessionNotification, runtimeSessionChildren, RuntimeSessionConflict, } from './runtime-session.js';
4
4
  export { advanceFollow, followedSessions, followCursor, readCursors, unreadSince } from './session-cursors.js';
5
5
  export { currentHumanTurn, lastHumanSendVia, recordStatus, timelineEvents, timelineStamp, timelineTail, } from './session-timeline.js';
@@ -12,6 +12,7 @@ const keyedPending = (receipt, mid, delivery) => ({
12
12
  mid,
13
13
  text: delivery.text,
14
14
  from: delivery.from,
15
+ ...(delivery.attributes ? { attributes: delivery.attributes } : {}),
15
16
  dispatch: { operation: receipt.operation, requestDigest: receipt.requestDigest },
16
17
  });
17
18
  // The durable acceptance boundary. Product validation and prompt composition run while the record fence is
@@ -1,4 +1,5 @@
1
1
  import { type SessionLifecycle, type SessionProposal } from '@spexcode/spec-core';
2
+ import { type PendingMessage } from './delivery-queue.js';
2
3
  export type RuntimeSessionRegistration = {
3
4
  sessionId: string;
4
5
  runtimeOwner: string;
@@ -35,6 +36,16 @@ export type RuntimeSessionRecord = {
35
36
  note: string | null;
36
37
  createdAt: number;
37
38
  };
39
+ export type RuntimeSessionNotification = {
40
+ childSessionId: string;
41
+ runtimeOwner: string;
42
+ runtimeState: string;
43
+ revision: string;
44
+ lifecycle: SessionLifecycle;
45
+ proposal: SessionProposal | null;
46
+ note: string | null;
47
+ runtimeMetadata: Record<string, string>;
48
+ };
38
49
  export declare class RuntimeSessionConflict extends Error {
39
50
  readonly code = "runtime_session_conflict";
40
51
  constructor(message: string);
@@ -42,6 +53,7 @@ export declare class RuntimeSessionConflict extends Error {
42
53
  export declare function registerRuntimeSession(input: RuntimeSessionRegistration): Promise<{
43
54
  replayed: boolean;
44
55
  }>;
56
+ export declare function runtimeSessionNotification(parentSessionId: string, message: PendingMessage): RuntimeSessionNotification | null;
45
57
  export declare function publishRuntimeSessionState(input: RuntimeSessionState): Promise<{
46
58
  notified: string[];
47
59
  replayed: boolean;
@@ -14,6 +14,7 @@ export class RuntimeSessionConflict extends Error {
14
14
  }
15
15
  const digest = (value) => createHash('sha256').update(value).digest('hex');
16
16
  const watchPath = (target) => sessionArtifactPath(target, 'watchers.json');
17
+ const RUNTIME_NOTIFICATION_KIND = 'spex.runtime-state.v1';
17
18
  function scalar(value, field) {
18
19
  const normalized = value.trim();
19
20
  if (!normalized)
@@ -183,14 +184,52 @@ function stateMessage(record) {
183
184
  const note = record.note ? ` — ${record.note}` : '';
184
185
  return `[spex watch] ${record.sessionId} is ${record.runtimeState ?? record.lifecycle}${proposal}${note}`;
185
186
  }
187
+ function notificationAttributes(record) {
188
+ if (!record.revision || !record.runtimeState)
189
+ throw new RuntimeSessionConflict(`runtime session ${record.sessionId} has no publishable state revision`);
190
+ return {
191
+ kind: RUNTIME_NOTIFICATION_KIND,
192
+ runtimeOwner: record.runtimeOwner,
193
+ runtimeState: record.runtimeState,
194
+ revision: record.revision,
195
+ lifecycle: record.lifecycle,
196
+ proposal: record.proposal ?? '',
197
+ note: record.note ?? '',
198
+ };
199
+ }
186
200
  function pendingFor(receipt, mid) {
187
201
  return {
188
202
  mid,
189
203
  text: receipt.delivery.text,
190
204
  from: receipt.delivery.from,
205
+ ...(receipt.delivery.attributes ? { attributes: receipt.delivery.attributes } : {}),
191
206
  dispatch: { operation: receipt.operation, requestDigest: receipt.requestDigest },
192
207
  };
193
208
  }
209
+ export function runtimeSessionNotification(parentSessionId, message) {
210
+ const parent = scalar(parentSessionId, 'parentSessionId');
211
+ const child = message.from?.trim();
212
+ const attributes = message.attributes;
213
+ if (!child || !attributes || attributes.kind !== RUNTIME_NOTIFICATION_KIND)
214
+ return null;
215
+ const record = readRuntimeSession(child);
216
+ if (!record || record.parentSessionId !== parent || record.runtimeOwner !== attributes.runtimeOwner)
217
+ return null;
218
+ if (!attributes.revision || !attributes.runtimeState || !isSessionLifecycle(attributes.lifecycle))
219
+ return null;
220
+ if (attributes.proposal && !isSessionProposal(attributes.proposal))
221
+ return null;
222
+ return {
223
+ childSessionId: child,
224
+ runtimeOwner: attributes.runtimeOwner,
225
+ runtimeState: attributes.runtimeState,
226
+ revision: attributes.revision,
227
+ lifecycle: attributes.lifecycle,
228
+ proposal: isSessionProposal(attributes.proposal) ? attributes.proposal : null,
229
+ note: attributes.note || null,
230
+ runtimeMetadata: record.runtimeMetadata,
231
+ };
232
+ }
194
233
  export async function publishRuntimeSessionState(input) {
195
234
  const id = scalar(input.sessionId, 'sessionId');
196
235
  const owner = scalar(input.runtimeOwner, 'runtimeOwner');
@@ -222,10 +261,11 @@ export async function publishRuntimeSessionState(input) {
222
261
  runtime_revision: revision,
223
262
  });
224
263
  const message = stateMessage(candidate);
264
+ const attributes = notificationAttributes(candidate);
225
265
  const historical = watchers.map((watcher) => {
226
266
  const operation = `runtime-state:${id}`;
227
267
  const requestDigest = digest(`${id}\0${watcher}\0${revision}`);
228
- const payloadHash = digest(`${operation}\0${requestDigest}\0${message}`);
268
+ const payloadHash = digest(`${operation}\0${requestDigest}\0${message}\0${JSON.stringify(attributes)}`);
229
269
  const prior = sentDispatchReceipt(watcher, operation, requestDigest);
230
270
  if (prior && prior.payloadHash !== payloadHash)
231
271
  throw new RuntimeSessionConflict(`runtime revision ${revision} notification is already bound to different bytes`);
@@ -252,7 +292,7 @@ export async function publishRuntimeSessionState(input) {
252
292
  operation,
253
293
  requestDigest,
254
294
  payloadHash,
255
- delivery: { text: message, from: id },
295
+ delivery: { text: message, from: id, attributes },
256
296
  };
257
297
  if (prior) {
258
298
  if (!prior.delivered)
@@ -21,6 +21,7 @@ export type SentDispatchReceipt = {
21
21
  delivery?: {
22
22
  text: string;
23
23
  from: string | null;
24
+ attributes?: Record<string, string>;
24
25
  };
25
26
  };
26
27
  export declare function recordStatus(id: string, status: SessionLifecycle, proposal: SessionProposal | null, note: string | null): void;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/session-core",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "description": "SpexCode's durable file-based session communication protocol.",
6
6
  "files": [
@@ -23,7 +23,7 @@
23
23
  "test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts && node --test scripts/public-boundary.test.mjs"
24
24
  },
25
25
  "dependencies": {
26
- "@spexcode/spec-core": "0.6.6"
26
+ "@spexcode/spec-core": "0.6.7"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^20.16.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-cli",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "description": "SpexCode CLI + server. The root spexcode package delegates to this compiled package; dashboard assets live in @spexcode/spec-dashboard.",
6
6
  "bin": {
@@ -36,10 +36,10 @@
36
36
  "test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
37
37
  },
38
38
  "dependencies": {
39
- "@spexcode/session-core": "0.6.6",
40
- "@spexcode/spec-core": "0.6.6",
41
- "@spexcode/spec-eval": "0.6.6",
42
- "@spexcode/spec-forge": "0.6.6"
39
+ "@spexcode/session-core": "0.6.7",
40
+ "@spexcode/spec-core": "0.6.7",
41
+ "@spexcode/spec-eval": "0.6.7",
42
+ "@spexcode/spec-forge": "0.6.7"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^20.16.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-core",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "description": "SpexCode's dependency-minimal spec graph core.",
6
6
  "files": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-eval",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
27
27
  },
28
28
  "dependencies": {
29
- "@spexcode/spec-core": "0.6.6"
29
+ "@spexcode/spec-core": "0.6.7"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^20.16.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-forge",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "test": "tsx --test src/*.test.ts"
23
23
  },
24
24
  "dependencies": {
25
- "@spexcode/spec-core": "0.6.6"
25
+ "@spexcode/spec-core": "0.6.7"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^20.16.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "prepack": "node scripts/sync-init-plugins.mjs --check && node scripts/prepack.mjs"
39
39
  },
40
40
  "dependencies": {
41
- "@spexcode/spec-cli": "0.6.6"
41
+ "@spexcode/spec-cli": "0.6.7"
42
42
  },
43
43
  "publishConfig": {
44
44
  "access": "public"