@astrosheep/square 0.3.10 → 0.3.12

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.
Files changed (54) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +1 -1
  2. package/dist/activity.js +6 -7
  3. package/dist/artifact.js +337 -618
  4. package/dist/boundary-presentation.js +1 -1
  5. package/dist/cli/context.js +3 -3
  6. package/dist/cli/harness-command.js +1 -1
  7. package/dist/cli/maintenance-commands.js +12 -58
  8. package/dist/cli/observation-commands.js +14 -34
  9. package/dist/cli/program.js +3 -6
  10. package/dist/cli/registry.js +1 -2
  11. package/dist/cli/square-commands.js +39 -20
  12. package/dist/cmd/notify-once.js +5 -15
  13. package/dist/compact.js +4 -4
  14. package/dist/decisions.js +21 -7
  15. package/dist/delivery-health.js +56 -136
  16. package/dist/delivery.js +11 -47
  17. package/dist/file-lock.js +112 -0
  18. package/dist/harness-codex.js +35 -29
  19. package/dist/harness-links.js +0 -3
  20. package/dist/harness-pi.js +57 -0
  21. package/dist/harness.js +10 -15
  22. package/dist/help.js +16 -18
  23. package/dist/index.js +11 -5
  24. package/dist/list.js +3 -47
  25. package/dist/model.js +4 -6
  26. package/dist/notifications.js +217 -32
  27. package/dist/paseo-connection.js +135 -0
  28. package/dist/paseo-delivery.js +73 -144
  29. package/dist/paseo-state.js +1 -1
  30. package/dist/paseo-timeline.js +32 -42
  31. package/dist/presentation.js +24 -39
  32. package/dist/presented.js +10 -72
  33. package/dist/registry.js +23 -24
  34. package/dist/routes.js +153 -0
  35. package/dist/runtime.js +6 -21
  36. package/dist/square-application.js +56 -127
  37. package/dist/square-core.js +56 -9
  38. package/dist/stream.js +1 -1
  39. package/dist/wake-attempts.js +175 -0
  40. package/dist/wake-evidence.js +35 -0
  41. package/dist/wake-port.js +22 -0
  42. package/dist/wake-sink.js +45 -6
  43. package/dist/watch.js +1 -2
  44. package/guides/participant.md +7 -174
  45. package/package.json +6 -3
  46. package/skills/brainstorm/SKILL.md +28 -28
  47. package/skills/square/.claude-plugin/plugin.json +1 -1
  48. package/skills/square/SKILL.md +23 -14
  49. package/skills/square-feedback/SKILL.md +7 -7
  50. package/dist/doctor.js +0 -35
  51. package/dist/notification-failures.js +0 -54
  52. package/template.md +0 -4
  53. package/templates/architect.md +0 -4
  54. package/templates/brainstorm.md +0 -4
package/dist/presented.js CHANGED
@@ -2,13 +2,12 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
+ import { withFileLockSync } from './file-lock.js';
5
6
  import { canonicalSquarePath, lookupParticipant, lookupSessionBindings } from './registry.js';
6
7
  import { sameName } from './model.js';
7
8
  const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
8
9
  const LOCK_STALE_MS = 5 * 60_000;
9
10
  const LOCK_RETRY_MS = 10;
10
- const lockWait = new Int32Array(new SharedArrayBuffer(4));
11
- const heldLocks = new Set();
12
11
  export function presentedPath(env = process.env) {
13
12
  return env.SQUARE_PRESENTED || path.join(os.homedir(), '.square', 'presented.ndjsonl');
14
13
  }
@@ -33,7 +32,7 @@ function readRows(filePath, now = Date.now()) {
33
32
  try {
34
33
  const parsed = JSON.parse(line);
35
34
  if (parsed.v !== 2 ||
36
- typeof parsed.ts !== 'number' ||
35
+ typeof parsed.ts !== 'number' || !Number.isFinite(parsed.ts) ||
37
36
  typeof parsed.owner_id !== 'string' ||
38
37
  typeof parsed.square_path !== 'string' ||
39
38
  typeof parsed.name !== 'string' ||
@@ -57,67 +56,6 @@ function writeRows(filePath, rows) {
57
56
  });
58
57
  fs.renameSync(temp, filePath);
59
58
  }
60
- function lockOwnerState(lockPath) {
61
- let pid;
62
- try {
63
- pid = Number.parseInt(fs.readFileSync(lockPath, 'utf8').split('\n')[0], 10);
64
- }
65
- catch {
66
- return 'unknown';
67
- }
68
- if (!Number.isSafeInteger(pid) || pid <= 0)
69
- return 'unknown';
70
- try {
71
- process.kill(pid, 0);
72
- return 'alive';
73
- }
74
- catch (error) {
75
- return error.code === 'ESRCH' ? 'dead' : 'alive';
76
- }
77
- }
78
- function withFileLock(lockPath, fn) {
79
- if (heldLocks.has(lockPath))
80
- throw new Error(`Reentrant presented lock: ${lockPath}`);
81
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
82
- while (true) {
83
- let acquired = false;
84
- try {
85
- const fd = fs.openSync(lockPath, 'wx', 0o600);
86
- try {
87
- fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
88
- }
89
- finally {
90
- fs.closeSync(fd);
91
- }
92
- acquired = true;
93
- heldLocks.add(lockPath);
94
- return fn();
95
- }
96
- catch (error) {
97
- const errno = error;
98
- if (acquired || errno.code !== 'EEXIST')
99
- throw error;
100
- try {
101
- const stat = fs.statSync(lockPath);
102
- if (lockOwnerState(lockPath) === 'dead' || Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
103
- fs.unlinkSync(lockPath);
104
- continue;
105
- }
106
- }
107
- catch { }
108
- Atomics.wait(lockWait, 0, 0, LOCK_RETRY_MS);
109
- }
110
- finally {
111
- if (acquired) {
112
- heldLocks.delete(lockPath);
113
- try {
114
- fs.unlinkSync(lockPath);
115
- }
116
- catch { }
117
- }
118
- }
119
- }
120
- }
121
59
  function membershipKey(membership) {
122
60
  return `${canonicalSquarePath(membership.squarePath)}\u0000${membership.name.toLocaleLowerCase()}`;
123
61
  }
@@ -130,7 +68,7 @@ function withAttentionLocks(filePath, inbox, fn) {
130
68
  function acquire(index) {
131
69
  if (index >= lockPaths.length)
132
70
  return fn();
133
- return withFileLock(lockPaths[index], () => acquire(index + 1));
71
+ return withFileLockSync(lockPaths[index], { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, () => acquire(index + 1));
134
72
  }
135
73
  return acquire(0);
136
74
  }
@@ -154,24 +92,24 @@ function selectUnpresented(sessionId, inbox, rows) {
154
92
  : [{ membership: { ...membership, notifications }, ownerId }];
155
93
  });
156
94
  }
157
- export function hasPresentedForOwner(ownerId, squarePath, name, actIndex, env = process.env) {
95
+ export function hasPresentedForOwner(ownerId, squarePath, name, actIndex, env = process.env, now = Date.now()) {
158
96
  const resolved = canonicalSquarePath(squarePath);
159
- return readRows(presentedPath(env)).some((row) => row.owner_id === ownerId &&
97
+ return readRows(presentedPath(env), now).some((row) => row.owner_id === ownerId &&
160
98
  canonicalSquarePath(row.square_path) === resolved &&
161
99
  sameName(row.name, name) &&
162
100
  row.act_index === actIndex);
163
101
  }
164
102
  /** True when any current participant owner has already received this attention. */
165
- export function hasPresentedAttention(squarePath, name, actIndex, env = process.env) {
166
- const ownerIds = new Set(lookupParticipant(squarePath, name).map((binding) => binding.ownerId));
103
+ export function hasPresentedAttention(squarePath, name, actIndex, env = process.env, now = Date.now()) {
104
+ const ownerIds = new Set(lookupParticipant(squarePath, name, now).map((binding) => binding.ownerId));
167
105
  if (ownerIds.size === 0)
168
106
  return false;
169
- return [...ownerIds].some((ownerId) => hasPresentedForOwner(ownerId, squarePath, name, actIndex, env));
107
+ return [...ownerIds].some((ownerId) => hasPresentedForOwner(ownerId, squarePath, name, actIndex, env, now));
170
108
  }
171
109
  /**
172
110
  * Serialize presentation only for the affected participants. Delivery runs
173
111
  * outside the short ledger-write lock, so unrelated owners never wait on an
174
- * adapter. A throwing callback leaves no row and remains retryable.
112
+ * adapter. A throwing callback leaves no row and remains unpresented.
175
113
  */
176
114
  export function presentOnce(sessionId, lookup, deliver, env = process.env, at = Date.now()) {
177
115
  const filePath = presentedPath(env);
@@ -185,7 +123,7 @@ export function presentOnce(sessionId, lookup, deliver, env = process.env, at =
185
123
  if (selected.length === 0)
186
124
  return undefined;
187
125
  const result = deliver(selected.map(({ membership }) => membership));
188
- withFileLock(`${filePath}.lock`, () => {
126
+ withFileLockSync(`${filePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, () => {
189
127
  const rows = readRows(filePath, at);
190
128
  const known = new Set(rows.map(rowKey));
191
129
  for (const { membership, ownerId } of selected) {
package/dist/registry.js CHANGED
@@ -9,9 +9,9 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { randomUUID } from 'node:crypto';
12
- import { loadSquare } from './artifact.js';
13
12
  import { nameKey, sameName } from './model.js';
14
13
  import { isCurrentlyJoined } from './runtime.js';
14
+ import { publishWakeRoutes, retireOwnerWakeRoutes } from './routes.js';
15
15
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
16
16
  const COMPACT_BYTES = 64 * 1024;
17
17
  const COMPACT_LINES = 1000;
@@ -231,19 +231,11 @@ export function localParticipantOwner(squarePath, name, env = process.env, now =
231
231
  return undefined;
232
232
  return lookupParticipant(squarePath, name, now).find((binding) => sessionIds.has(binding.sessionId))?.ownerId;
233
233
  }
234
- function bindingIsProvablyObsolete(binding) {
235
- if (!fs.existsSync(binding.squarePath))
236
- return true;
237
- try {
238
- return !isCurrentlyJoined(loadSquare(binding.squarePath).acts, binding.name);
239
- }
240
- catch {
241
- // A temporarily unreadable artifact is uncertain, so preserve its binding.
242
- return false;
243
- }
234
+ function bindingIsProvablyObsolete(binding, acts) {
235
+ return acts !== undefined && !isCurrentlyJoined(acts, binding.name);
244
236
  }
245
237
  /** Compact the registry and remove only bindings disproved by their authoritative artifact. */
246
- export function pruneRegistry(now = Date.now()) {
238
+ export function pruneRegistry(readActs, now = Date.now()) {
247
239
  const filePath = registryPath();
248
240
  let raw;
249
241
  try {
@@ -255,7 +247,7 @@ export function pruneRegistry(now = Date.now()) {
255
247
  throw error;
256
248
  }
257
249
  const active = foldRegistry(raw, now);
258
- const kept = active.filter((binding) => !bindingIsProvablyObsolete(binding));
250
+ const kept = active.filter((binding) => !bindingIsProvablyObsolete(binding, readActs(binding.squarePath)));
259
251
  writeRegistryBindings(filePath, kept);
260
252
  return { removed: active.length - kept.length, kept: kept.length };
261
253
  }
@@ -280,25 +272,29 @@ export function hasAutomaticDeliveryIdentity(env = process.env) {
280
272
  export function recordLocalJoin(name, squarePath, env = process.env) {
281
273
  const at = Date.now();
282
274
  const identities = localSessionIdentities(env);
283
- const identityIds = new Set(identities.map((identity) => identity.sessionId));
284
275
  const current = lookupParticipant(squarePath, name, at);
285
- const ownerId = current.find((binding) => identityIds.has(binding.sessionId))?.ownerId ?? nextOwnerId();
276
+ const ownerId = nextOwnerId();
277
+ for (const binding of current) {
278
+ recordDone(binding.sessionId, binding.name, binding.squarePath, {
279
+ channel: binding.channel,
280
+ child: binding.child,
281
+ ...(binding.paseoAgentId ? { paseoAgentId: binding.paseoAgentId } : {}),
282
+ at,
283
+ });
284
+ }
286
285
  for (const identity of identities) {
287
286
  recordJoin(identity.sessionId, name, squarePath, { ...identity, at, ownerId });
288
287
  }
288
+ publishWakeRoutes(ownerId, { at, env });
289
+ for (const previousOwnerId of new Set(current.map((binding) => binding.ownerId))) {
290
+ if (previousOwnerId !== ownerId)
291
+ retireOwnerWakeRoutes(previousOwnerId, { at, env });
292
+ }
289
293
  }
290
294
  export function recordLocalDone(name, squarePath, env = process.env) {
291
295
  const at = Date.now();
292
- const identities = localSessionIdentities(env);
293
- const identityIds = new Set(identities.map((identity) => identity.sessionId));
294
296
  const current = lookupParticipant(squarePath, name, at);
295
- const ownerId = current.find((binding) => identityIds.has(binding.sessionId))?.ownerId;
296
- if (ownerId === undefined) {
297
- for (const identity of identities)
298
- recordDone(identity.sessionId, name, squarePath, { ...identity, at });
299
- return;
300
- }
301
- for (const binding of current.filter((candidate) => candidate.ownerId === ownerId)) {
297
+ for (const binding of current) {
302
298
  recordDone(binding.sessionId, binding.name, binding.squarePath, {
303
299
  channel: binding.channel,
304
300
  child: binding.child,
@@ -306,4 +302,7 @@ export function recordLocalDone(name, squarePath, env = process.env) {
306
302
  at,
307
303
  });
308
304
  }
305
+ for (const ownerId of new Set(current.map((binding) => binding.ownerId))) {
306
+ retireOwnerWakeRoutes(ownerId, { at, env });
307
+ }
309
308
  }
package/dist/routes.js ADDED
@@ -0,0 +1,153 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { WAKE_ROUTE_KINDS, isWakeRouteKind } from './model.js';
5
+ export { isWakeRouteKind, WAKE_ROUTE_KINDS } from './model.js';
6
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
7
+ export const ROUTE_FRESH_MS = 24 * 60 * 60 * 1000;
8
+ /**
9
+ * Dispatch priority doubles as the kind declaration order: native kinds
10
+ * precede paseo. Within one kind, the freshest route wins.
11
+ */
12
+ const ROUTE_KIND_PRIORITY = new Map(WAKE_ROUTE_KINDS.map((kind, index) => [kind, index]));
13
+ export function routesPath(env = process.env) {
14
+ return env.SQUARE_ROUTES || path.join(os.homedir(), '.square', 'routes.ndjsonl');
15
+ }
16
+ function routeKey(ownerId, kind) {
17
+ return `${ownerId}\0${kind}`;
18
+ }
19
+ function stringRecord(value) {
20
+ return value !== null && typeof value === 'object' && !Array.isArray(value) &&
21
+ Object.values(value).every((item) => typeof item === 'string');
22
+ }
23
+ function parseRow(raw, now) {
24
+ let value;
25
+ try {
26
+ value = JSON.parse(raw);
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ if (value === null || typeof value !== 'object')
32
+ return undefined;
33
+ const row = value;
34
+ if (row.v !== 1 ||
35
+ (row.op !== 'upsert' && row.op !== 'retire') ||
36
+ typeof row.ts !== 'number' || !Number.isFinite(row.ts) || row.ts > now || now - row.ts > RETENTION_MS ||
37
+ typeof row.owner_id !== 'string' || row.owner_id === '' ||
38
+ typeof row.session_id !== 'string' || row.session_id === '' ||
39
+ !isWakeRouteKind(row.kind))
40
+ return undefined;
41
+ if (row.op === 'upsert' && !stringRecord(row.address))
42
+ return undefined;
43
+ return row;
44
+ }
45
+ function readRows(env, now) {
46
+ let raw;
47
+ try {
48
+ raw = fs.readFileSync(routesPath(env), 'utf8');
49
+ }
50
+ catch (error) {
51
+ if (error.code === 'ENOENT')
52
+ return [];
53
+ throw error;
54
+ }
55
+ return raw.split('\n').filter(Boolean).map((line) => parseRow(line, now)).filter((row) => row !== undefined);
56
+ }
57
+ export function readWakeRoutes(opts = {}) {
58
+ const now = opts.now ?? Date.now();
59
+ const state = new Map();
60
+ for (const row of readRows(opts.env ?? process.env, now)) {
61
+ const key = routeKey(row.owner_id, row.kind);
62
+ const current = state.get(key);
63
+ if (current === undefined || row.ts >= current.ts)
64
+ state.set(key, row);
65
+ }
66
+ return [...state.values()]
67
+ .filter((row) => row.op === 'upsert')
68
+ .map((row) => ({
69
+ ownerId: row.owner_id,
70
+ sessionId: row.session_id,
71
+ kind: row.kind,
72
+ address: row.address,
73
+ updatedAt: row.ts,
74
+ }))
75
+ .filter((route) => opts.ownerId === undefined || route.ownerId === opts.ownerId)
76
+ .filter((route) => opts.freshOnly !== true || now - route.updatedAt < ROUTE_FRESH_MS)
77
+ .sort((a, b) => (ROUTE_KIND_PRIORITY.get(a.kind) ?? 0) - (ROUTE_KIND_PRIORITY.get(b.kind) ?? 0) ||
78
+ b.updatedAt - a.updatedAt);
79
+ }
80
+ function appendRouteRow(row, env) {
81
+ const file = routesPath(env);
82
+ fs.mkdirSync(path.dirname(file), { recursive: true });
83
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
84
+ }
85
+ export function upsertWakeRoute(route, opts = {}) {
86
+ const at = opts.at ?? Date.now();
87
+ appendRouteRow({
88
+ v: 1,
89
+ ts: at,
90
+ op: 'upsert',
91
+ owner_id: route.ownerId,
92
+ session_id: route.sessionId,
93
+ kind: route.kind,
94
+ address: route.address,
95
+ }, opts.env ?? process.env);
96
+ }
97
+ export function retireOwnerWakeRoutes(ownerId, opts = {}) {
98
+ const at = opts.at ?? Date.now();
99
+ const env = opts.env ?? process.env;
100
+ for (const route of readWakeRoutes({ ownerId, now: at, env })) {
101
+ appendRouteRow({
102
+ v: 1,
103
+ ts: at,
104
+ op: 'retire',
105
+ owner_id: ownerId,
106
+ session_id: route.sessionId,
107
+ kind: route.kind,
108
+ }, env);
109
+ }
110
+ }
111
+ /** Publication requires a non-blank session and a non-empty address of non-blank values. */
112
+ function completeRouteEvidence(value) {
113
+ if (value === undefined || value === null)
114
+ return false;
115
+ if (typeof value.sessionId !== 'string' || value.sessionId.trim() === '')
116
+ return false;
117
+ const address = value.address;
118
+ if (address === null || typeof address !== 'object' || Array.isArray(address))
119
+ return false;
120
+ const entries = Object.entries(address);
121
+ return entries.length > 0 && entries.every(([key, item]) => key.trim() !== '' && typeof item === 'string' && item.trim() !== '');
122
+ }
123
+ /**
124
+ * One probe per kind. A probe publishes only when its provider's complete
125
+ * endpoint evidence is present; session identity alone never publishes. The
126
+ * four native transports have no endpoint lifecycle in this delivery, so
127
+ * their probes publish nothing until those transports land.
128
+ */
129
+ export const WAKE_ROUTE_PROBES = {
130
+ 'opencode-server': () => undefined,
131
+ 'codex-app-server': () => undefined,
132
+ 'claude-native': () => undefined,
133
+ 'pi-extension': () => undefined,
134
+ paseo: (env) => {
135
+ const agentId = env.PASEO_AGENT_ID?.trim();
136
+ return agentId ? { sessionId: agentId, address: { agentId } } : undefined;
137
+ },
138
+ };
139
+ /** The kind-neutral publication loop; probes supply complete evidence per kind. */
140
+ export function publishWakeRoutesFrom(ownerId, probes, opts = {}) {
141
+ const at = opts.at ?? Date.now();
142
+ const env = opts.env ?? process.env;
143
+ for (const kind of WAKE_ROUTE_KINDS) {
144
+ const evidence = probes[kind](env);
145
+ if (!completeRouteEvidence(evidence))
146
+ continue;
147
+ upsertWakeRoute({ ownerId, sessionId: evidence.sessionId, kind, address: evidence.address }, { at, env });
148
+ }
149
+ }
150
+ /** Publication boundary: every route written is complete provider evidence. */
151
+ export function publishWakeRoutes(ownerId, opts = {}) {
152
+ publishWakeRoutesFrom(ownerId, WAKE_ROUTE_PROBES, opts);
153
+ }
package/dist/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { fold } from './square-core.js';
1
+ import { audienceIncludes, audienceOf, fold, formatActivityId } from './square-core.js';
2
2
  import { SquareError, findParticipantName, nameKey, sameName, } from './model.js';
3
3
  function parseIntegerEnvValue(name, raw, fallback) {
4
4
  if (raw === undefined)
@@ -51,27 +51,12 @@ export function nowMs() {
51
51
  }
52
52
  return value;
53
53
  }
54
- export function extractMentions(body) {
55
- const matches = [];
56
- const re = /@([\p{L}\p{N}_-]+)/gu;
57
- let match;
58
- while ((match = re.exec(body)) !== null)
59
- matches.push(match[1]);
60
- return matches;
61
- }
62
- /** Pure directed-activity filter. Broadcast bodies (no @) match any named viewer. */
54
+ /** Pure directed-activity filter. Bell matches every viewer; mentions match by audience. */
63
55
  export function matchesMentionTarget(act, mention) {
64
- if (act.reach === 'bell')
65
- return true;
66
- if (act.reach !== undefined) {
67
- return mention === true || sameName(act.reach.beside, mention);
68
- }
69
- const mentions = extractMentions(act.body);
56
+ const audience = audienceOf(act);
70
57
  if (mention === true)
71
- return mentions.length > 0;
72
- if (mentions.length === 0)
73
- return true;
74
- return mentions.some((name) => sameName(name, mention));
58
+ return audience.kind === 'bell' || audience.names.length > 0;
59
+ return audienceIncludes(audience, mention);
75
60
  }
76
61
  export function foldedState(doc) {
77
62
  return fold(doc.acts);
@@ -131,7 +116,7 @@ export function actStableIndex(act) {
131
116
  }
132
117
  export function actId(actOrIndex) {
133
118
  const index = typeof actOrIndex === 'number' ? actOrIndex : actStableIndex(actOrIndex);
134
- return `act_${index}`;
119
+ return formatActivityId(index);
135
120
  }
136
121
  export function getReadState(doc, name) {
137
122
  return doc.runtime.cursors[name] ?? Object.entries(doc.runtime.cursors).find(([participant]) => sameName(participant, name))?.[1];
@@ -1,62 +1,16 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { setTimeout as sleep } from 'node:timers/promises';
4
- import { emptyRuntimeState, loadRuntimeSidecar, loadSquare, mergeRuntimeState, renderArtifactAct, renderSquare, renderSquareDoc, saveRuntimeSidecar } from './artifact.js';
2
+ import { createSquareDoc, loadArchive, loadSquare, writeArchiveFile, writeSquareFile } from './artifact.js';
5
3
  import { coreCompact, coreDone, coreHold, coreResume, decideAct, decideJoin, resolveKnownName } from './decisions.js';
6
- import { dispatchActNotifications } from './notifications.js';
7
- import { planRepair } from './doctor.js';
4
+ import { withFileLock } from './file-lock.js';
8
5
  import { stageReplacement } from './harness-stage.js';
9
6
  import { SquareError } from './model.js';
10
7
  import { advanceCursor, freshWatchLease, LOCK_RETRY_MS, LOCK_STALE_MS, removeWatchLease, touchPresenceCursor, watchLease, writeWatchLease } from './runtime.js';
11
- /** The only persistence primitive: one per-square lock, one Markdown write, one sidecar write. */
8
+ /** The only mutation boundary: one per-square lock around one complete snapshot commit. */
12
9
  export async function withSquareLock(squarePath, fn) {
13
- const lockPath = `${squarePath}.lock`;
14
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
15
- while (true) {
16
- let fd;
17
- try {
18
- fd = fs.openSync(lockPath, 'wx');
19
- fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
20
- }
21
- catch (error) {
22
- if (fd !== undefined) {
23
- try {
24
- fs.closeSync(fd);
25
- }
26
- catch { }
27
- try {
28
- fs.unlinkSync(lockPath);
29
- }
30
- catch { }
31
- }
32
- const errno = error;
33
- if (errno.code !== 'EEXIST')
34
- throw error;
35
- try {
36
- if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS)
37
- fs.unlinkSync(lockPath);
38
- }
39
- catch { }
40
- await sleep(LOCK_RETRY_MS);
41
- continue;
42
- }
43
- fs.closeSync(fd);
44
- try {
45
- return await fn();
46
- }
47
- finally {
48
- try {
49
- fs.unlinkSync(lockPath);
50
- }
51
- catch { }
52
- }
53
- }
10
+ return withFileLock(`${squarePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, fn);
54
11
  }
55
12
  export function writeSquareDoc(squarePath, doc) {
56
- const temporary = path.join(path.dirname(squarePath), `.${path.basename(squarePath)}.${process.pid}.${Date.now()}.tmp`);
57
- fs.writeFileSync(temporary, renderSquareDoc(doc));
58
- fs.renameSync(temporary, squarePath);
59
- saveRuntimeSidecar(squarePath, doc.runtime);
13
+ writeSquareFile(squarePath, doc);
60
14
  }
61
15
  export function appendAct(squarePath, doc, act) {
62
16
  const stored = applyActs(doc, [act])[0];
@@ -76,13 +30,11 @@ function applyActs(doc, acts, mutateRuntime) {
76
30
  mutateRuntime?.(doc);
77
31
  return stored;
78
32
  }
79
- /**
80
- * Publish a dependent persistence file before the Square document. A retained
81
- * backup lets a failed document commit restore the prior file exactly.
82
- */
83
- function prepareAppend(filePath, block, existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '') {
33
+ /** Publish a dependent archive before the main snapshot, retaining rollback evidence. */
34
+ function prepareArchive(filePath, acts) {
35
+ const existing = fs.existsSync(filePath) ? loadArchive(filePath) : [];
84
36
  return stageReplacement(filePath, (stage) => {
85
- fs.writeFileSync(stage, `${existing}${existing === '' ? '' : '\n'}${block}\n`);
37
+ writeArchiveFile(stage, [...existing, ...acts]);
86
38
  });
87
39
  }
88
40
  function plan(doc, intent) {
@@ -136,6 +88,50 @@ function plan(doc, intent) {
136
88
  mutateRuntime: (nextDoc) => { removeWatchLease(nextDoc, name, intent.leaseId); },
137
89
  };
138
90
  }
91
+ case 'claim-notify': {
92
+ const current = doc.runtime.notifyLeases[intent.key];
93
+ if (current !== undefined && current.expiresAt > intent.at)
94
+ return { result: { type: 'busy' }, acts: [] };
95
+ if (current?.phase === 'dispatching')
96
+ return { result: { type: 'ambiguous', lease: current }, acts: [] };
97
+ return {
98
+ result: { type: 'acquired', leaseId: intent.leaseId },
99
+ acts: [],
100
+ mutateRuntime: (nextDoc) => {
101
+ nextDoc.runtime.notifyLeases[intent.key] = {
102
+ leaseId: intent.leaseId,
103
+ expiresAt: intent.expiresAt,
104
+ phase: 'claimed',
105
+ };
106
+ },
107
+ };
108
+ }
109
+ case 'transition-notify': {
110
+ if (doc.runtime.notifyLeases[intent.key]?.leaseId !== intent.leaseId)
111
+ return { result: { updated: false }, acts: [] };
112
+ return {
113
+ result: { updated: true },
114
+ acts: [],
115
+ mutateRuntime: (nextDoc) => {
116
+ nextDoc.runtime.notifyLeases[intent.key] = {
117
+ leaseId: intent.leaseId,
118
+ expiresAt: intent.expiresAt,
119
+ phase: intent.phase,
120
+ ...(intent.attemptN === undefined ? {} : { attemptN: intent.attemptN }),
121
+ ...(intent.routeKind === undefined ? {} : { routeKind: intent.routeKind }),
122
+ };
123
+ },
124
+ };
125
+ }
126
+ case 'release-notify': {
127
+ if (doc.runtime.notifyLeases[intent.key]?.leaseId !== intent.leaseId)
128
+ return { result: { released: false }, acts: [] };
129
+ return {
130
+ result: { released: true },
131
+ acts: [],
132
+ mutateRuntime: (nextDoc) => { delete nextDoc.runtime.notifyLeases[intent.key]; },
133
+ };
134
+ }
139
135
  case 'consume': {
140
136
  const name = resolveKnownName(doc, intent.name);
141
137
  return {
@@ -153,27 +149,9 @@ function plan(doc, intent) {
153
149
  replaceDoc: result.doc,
154
150
  preparePersistence: archive.length === 0
155
151
  ? undefined
156
- : () => {
157
- const existing = fs.existsSync(intent.archivePath) ? fs.readFileSync(intent.archivePath, 'utf8') : '';
158
- const block = archive
159
- .map((act, index) => renderArtifactAct(act, { first: existing === '' && index === 0 }))
160
- .join('\n');
161
- return prepareAppend(intent.archivePath, block, existing);
162
- },
152
+ : () => prepareArchive(intent.archivePath, archive),
163
153
  };
164
154
  }
165
- case 'repair':
166
- return {
167
- result: undefined,
168
- acts: [],
169
- replaceDoc: intent.doc,
170
- preparePersistence: intent.quarantine === undefined || intent.quarantine.blocks.length === 0
171
- ? undefined
172
- : () => {
173
- const block = intent.quarantine.blocks.join('\n\n');
174
- return prepareAppend(intent.quarantine.path, block);
175
- },
176
- };
177
155
  }
178
156
  }
179
157
  function commitPlan(squarePath, doc, planned) {
@@ -199,15 +177,10 @@ function commitPlan(squarePath, doc, planned) {
199
177
  }
200
178
  /** The one mutation pipeline shared by package and CLI adapters. */
201
179
  export async function execute(squarePath, intent) {
202
- const committed = await withSquareLock(squarePath, () => {
180
+ return withSquareLock(squarePath, () => {
203
181
  const doc = loadSquare(squarePath);
204
182
  return commitPlan(squarePath, doc, plan(doc, intent));
205
183
  });
206
- for (const act of committed.acts) {
207
- if (act.kind === 'say')
208
- await dispatchActNotifications(squarePath, act);
209
- }
210
- return committed;
211
184
  }
212
185
  /** Application-owned artifact creation; adapters provide validated options and stdin text only. */
213
186
  export async function createSquare(squarePath, options, snippet) {
@@ -215,50 +188,6 @@ export async function createSquare(squarePath, options, snippet) {
215
188
  if (fs.existsSync(squarePath) && !options.force) {
216
189
  throw new SquareError('conflict', `Refusing to overwrite existing square: ${squarePath}\nPass -f to overwrite.`);
217
190
  }
218
- const temporary = path.join(path.dirname(squarePath), `.${path.basename(squarePath)}.${process.pid}.${Date.now()}.tmp`);
219
- fs.mkdirSync(path.dirname(squarePath), { recursive: true });
220
- fs.writeFileSync(temporary, renderSquare(options, snippet));
221
- fs.renameSync(temporary, squarePath);
222
- saveRuntimeSidecar(squarePath, emptyRuntimeState(0));
223
- });
224
- }
225
- /** Keep artifact repair planning and dependent quarantine persistence inside the application boundary. */
226
- export async function repairSquare(squarePath) {
227
- const result = await withSquareLock(squarePath, () => {
228
- let text;
229
- try {
230
- text = fs.readFileSync(squarePath, 'utf8');
231
- }
232
- catch (error) {
233
- if (error.code === 'ENOENT')
234
- throw new SquareError('not_found', `square file not found: ${squarePath}`);
235
- throw error;
236
- }
237
- const repair = planRepair(text);
238
- if (repair.diagnosis.unfixable || repair.repaired === undefined)
239
- return { repair };
240
- // Repair changes Markdown only. Keep the sidecar's runtime metadata and
241
- // merge history boundaries so a doctor run cannot erase delivery state or
242
- // reuse a stable activity index.
243
- const sidecarRuntime = loadRuntimeSidecar(squarePath, repair.repaired.doc.runtime);
244
- const indexesPreserved = repair.diagnosis.acts.every(({ act }, index) => repair.repaired.doc.acts[index]?.index === act.index);
245
- if (indexesPreserved) {
246
- repair.repaired.doc.runtime = mergeRuntimeState(repair.repaired.doc.runtime, sidecarRuntime);
247
- }
248
- else {
249
- repair.repaired.doc.runtime = emptyRuntimeState(Math.max(repair.repaired.doc.runtime.nextActIndex, sidecarRuntime.nextActIndex));
250
- repair.repaired.actions.push({ message: 'reset runtime delivery metadata because act indexes changed' });
251
- }
252
- const quarantinePath = squarePath.replace(/\.md$/, '') + '.quarantine.md';
253
- const intent = {
254
- type: 'repair',
255
- doc: repair.repaired.doc,
256
- ...(repair.repaired.quarantinedBlocks.length === 0
257
- ? {}
258
- : { quarantine: { path: quarantinePath, blocks: repair.repaired.quarantinedBlocks } }),
259
- };
260
- commitPlan(squarePath, repair.repaired.doc, plan(repair.repaired.doc, intent));
261
- return { repair };
191
+ writeSquareFile(squarePath, createSquareDoc(options, snippet));
262
192
  });
263
- return result.repair;
264
193
  }