@astrosheep/square 0.3.4 → 0.3.6
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/codex-plugin/.codex-plugin/plugin.json +3 -2
- package/dist/activity-feed.js +26 -18
- package/dist/activity.js +23 -22
- package/dist/artifact.js +126 -202
- package/dist/claude-hook.js +45 -21
- package/dist/cli/context.js +143 -0
- package/dist/cli/harness-command.js +50 -0
- package/dist/cli/maintenance-commands.js +76 -0
- package/dist/cli/meta-commands.js +28 -0
- package/dist/cli/observation-commands.js +453 -0
- package/dist/cli/program.js +48 -0
- package/dist/cli/registry.js +40 -0
- package/dist/cli/square-commands.js +219 -0
- package/dist/cmd/notify-once.js +23 -21
- package/dist/compact.js +6 -19
- package/dist/decisions.js +53 -86
- package/dist/delivery-health.js +104 -210
- package/dist/delivery.js +68 -18
- package/dist/doctor.js +9 -8
- package/dist/harness-claude.js +68 -0
- package/dist/harness-codex.js +119 -0
- package/dist/harness-links.js +123 -0
- package/dist/harness-stage.js +36 -0
- package/dist/harness.js +94 -576
- package/dist/help.js +44 -35
- package/dist/inbox.js +12 -11
- package/dist/index.js +30 -129
- package/dist/list.js +1 -1
- package/dist/model.js +0 -6
- package/dist/notification-failures.js +54 -0
- package/dist/notifications.js +47 -62
- package/dist/paseo-timeline.js +58 -188
- package/dist/presentation.js +55 -63
- package/dist/presented.js +9 -8
- package/dist/registry.js +55 -45
- package/dist/runtime.js +26 -137
- package/dist/square-application.js +264 -0
- package/dist/square-core.js +3 -11
- package/dist/square.js +5 -1362
- package/dist/stream.js +27 -126
- package/dist/wake-sink.js +134 -188
- package/dist/watch.js +79 -138
- package/extensions/square-opencode.js +1 -1
- package/extensions/square-pi.js +8 -130
- package/guides/architect.md +3 -3
- package/guides/participant.md +25 -16
- package/package.json +2 -2
- package/skills/brainstorm/SKILL.md +25 -32
- package/skills/square/.claude-plugin/plugin.json +1 -1
- package/skills/square/SKILL.md +39 -107
- package/skills/square-feedback/SKILL.md +4 -4
- package/dist/terminal.js +0 -125
package/dist/registry.js
CHANGED
|
@@ -9,11 +9,19 @@ 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';
|
|
12
13
|
import { nameKey, sameName } from './model.js';
|
|
14
|
+
import { isCurrentlyJoined } from './runtime.js';
|
|
13
15
|
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
16
|
const COMPACT_BYTES = 64 * 1024;
|
|
15
17
|
const COMPACT_LINES = 1000;
|
|
16
18
|
const VALID_CHANNELS = new Set(['claude-code', 'codex', 'opencode', 'pi', 'paseo', 'unknown']);
|
|
19
|
+
const LOCAL_SESSION_SOURCES = [
|
|
20
|
+
{ variable: 'CLAUDE_CODE_SESSION_ID', channel: 'claude-code', child: 'CLAUDE_CODE_CHILD_SESSION' },
|
|
21
|
+
{ variable: 'CODEX_THREAD_ID', channel: 'codex' },
|
|
22
|
+
{ variable: 'OPENCODE_SESSION_ID', channel: 'opencode' },
|
|
23
|
+
{ variable: 'SQUARE_PI_SESSION_ID', channel: 'pi' },
|
|
24
|
+
];
|
|
17
25
|
export function registryPath() {
|
|
18
26
|
if (process.env['SQUARE_REGISTRY'])
|
|
19
27
|
return process.env['SQUARE_REGISTRY'];
|
|
@@ -113,9 +121,8 @@ function foldRegistry(raw, now) {
|
|
|
113
121
|
}
|
|
114
122
|
return active.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
115
123
|
}
|
|
116
|
-
function
|
|
117
|
-
const
|
|
118
|
-
const compacted = active
|
|
124
|
+
function writeRegistryBindings(filePath, bindings) {
|
|
125
|
+
const compacted = bindings
|
|
119
126
|
.slice()
|
|
120
127
|
.reverse()
|
|
121
128
|
.map((binding) => JSON.stringify({
|
|
@@ -135,6 +142,9 @@ function compactRegistry(filePath, raw, now) {
|
|
|
135
142
|
fs.writeFileSync(temporary, compacted === '' ? '' : `${compacted}\n`, { mode: 0o600 });
|
|
136
143
|
fs.renameSync(temporary, filePath);
|
|
137
144
|
}
|
|
145
|
+
function compactRegistry(filePath, raw, now) {
|
|
146
|
+
writeRegistryBindings(filePath, foldRegistry(raw, now));
|
|
147
|
+
}
|
|
138
148
|
function maybeCompactRegistry(filePath, now) {
|
|
139
149
|
let stat;
|
|
140
150
|
try {
|
|
@@ -214,53 +224,53 @@ export function lookupParticipant(squarePath, name, now = Date.now()) {
|
|
|
214
224
|
const canonicalPath = canonicalSquarePath(squarePath);
|
|
215
225
|
return readActiveBindings(now).filter((binding) => binding.squarePath === canonicalPath && sameName(binding.name, name));
|
|
216
226
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
/** Resolve the current local harness owner for a participant, if one is registered. */
|
|
228
|
+
export function localParticipantOwner(squarePath, name, env = process.env, now = Date.now()) {
|
|
229
|
+
const sessionIds = new Set(localSessionIdentities(env).map((identity) => identity.sessionId));
|
|
230
|
+
if (sessionIds.size === 0)
|
|
231
|
+
return undefined;
|
|
232
|
+
return lookupParticipant(squarePath, name, now).find((binding) => sessionIds.has(binding.sessionId))?.ownerId;
|
|
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);
|
|
228
239
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
sessionId: codexThreadId,
|
|
233
|
-
channel: 'codex',
|
|
234
|
-
child: false,
|
|
235
|
-
...(paseoAgentId ? { paseoAgentId } : {}),
|
|
236
|
-
});
|
|
240
|
+
catch {
|
|
241
|
+
// A temporarily unreadable artifact is uncertain, so preserve its binding.
|
|
242
|
+
return false;
|
|
237
243
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
});
|
|
244
|
+
}
|
|
245
|
+
/** Compact the registry and remove only bindings disproved by their authoritative artifact. */
|
|
246
|
+
export function pruneRegistry(now = Date.now()) {
|
|
247
|
+
const filePath = registryPath();
|
|
248
|
+
let raw;
|
|
249
|
+
try {
|
|
250
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
246
251
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
channel: 'pi',
|
|
252
|
-
child: false,
|
|
253
|
-
...(paseoAgentId ? { paseoAgentId } : {}),
|
|
254
|
-
});
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (error.code === 'ENOENT')
|
|
254
|
+
return { removed: 0, kept: 0 };
|
|
255
|
+
throw error;
|
|
255
256
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
257
|
+
const active = foldRegistry(raw, now);
|
|
258
|
+
const kept = active.filter((binding) => !bindingIsProvablyObsolete(binding));
|
|
259
|
+
writeRegistryBindings(filePath, kept);
|
|
260
|
+
return { removed: active.length - kept.length, kept: kept.length };
|
|
261
|
+
}
|
|
262
|
+
function addLocalSession(identities, sessionId, channel, child, paseoAgentId) {
|
|
263
|
+
if (!sessionId || identities.some((identity) => identity.sessionId === sessionId))
|
|
264
|
+
return;
|
|
265
|
+
identities.push({ sessionId, channel, child, ...(paseoAgentId ? { paseoAgentId } : {}) });
|
|
266
|
+
}
|
|
267
|
+
export function localSessionIdentities(env = process.env) {
|
|
268
|
+
const paseoAgentId = env['PASEO_AGENT_ID']?.trim() || undefined;
|
|
269
|
+
const identities = [];
|
|
270
|
+
for (const source of LOCAL_SESSION_SOURCES) {
|
|
271
|
+
addLocalSession(identities, env[source.variable]?.trim(), source.channel, source.child !== undefined && env[source.child] === '1', paseoAgentId);
|
|
263
272
|
}
|
|
273
|
+
addLocalSession(identities, paseoAgentId, 'paseo', false, paseoAgentId);
|
|
264
274
|
return identities;
|
|
265
275
|
}
|
|
266
276
|
/** True when this process belongs to a harness that can deliver Square attention without a foreground catch. */
|
package/dist/runtime.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
1
|
import { fold } from './square-core.js';
|
|
5
|
-
import { renderSquareDoc, saveRuntimeSidecar } from './artifact.js';
|
|
6
2
|
import { SquareError, findParticipantName, nameKey, sameName, } from './model.js';
|
|
7
3
|
function parseIntegerEnvValue(name, raw, fallback) {
|
|
8
4
|
if (raw === undefined)
|
|
@@ -63,8 +59,13 @@ export function extractMentions(body) {
|
|
|
63
59
|
matches.push(match[1]);
|
|
64
60
|
return matches;
|
|
65
61
|
}
|
|
66
|
-
/** Pure
|
|
62
|
+
/** Pure directed-activity filter. Broadcast bodies (no @) match any named viewer. */
|
|
67
63
|
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
|
+
}
|
|
68
69
|
const mentions = extractMentions(act.body);
|
|
69
70
|
if (mention === true)
|
|
70
71
|
return mentions.length > 0;
|
|
@@ -108,24 +109,12 @@ export function sayNumberFor(acts, target) {
|
|
|
108
109
|
export function doneNames(acts) {
|
|
109
110
|
return new Set(fold(acts).done.map((participant) => nameKey(participant)));
|
|
110
111
|
}
|
|
111
|
-
export function hasJoined(acts, name) {
|
|
112
|
-
return fold(acts).participants.some((participant) => sameName(participant.name, name) && participant.joined);
|
|
113
|
-
}
|
|
114
112
|
export function joinedNames(acts) {
|
|
115
113
|
return new Set(acts.filter((act) => act.kind === 'join').map((act) => nameKey(act.actor)));
|
|
116
114
|
}
|
|
117
115
|
export function isCurrentlyJoined(acts, name) {
|
|
118
116
|
return fold(acts).participants.some((participant) => sameName(participant.name, name) && participant.joined);
|
|
119
117
|
}
|
|
120
|
-
/** Timestamp of the recipient's most recent join act, if any. */
|
|
121
|
-
export function lastJoinAt(acts, name) {
|
|
122
|
-
let last;
|
|
123
|
-
for (const act of acts) {
|
|
124
|
-
if (act.kind === 'join' && sameName(act.actor, name))
|
|
125
|
-
last = act.at;
|
|
126
|
-
}
|
|
127
|
-
return last;
|
|
128
|
-
}
|
|
129
118
|
/** Stable index of the recipient's most recent join act, if any. */
|
|
130
119
|
export function lastJoinIndex(acts, name) {
|
|
131
120
|
let last;
|
|
@@ -135,11 +124,6 @@ export function lastJoinIndex(acts, name) {
|
|
|
135
124
|
}
|
|
136
125
|
return last;
|
|
137
126
|
}
|
|
138
|
-
/** Notifications are live only when they land after the recipient joined. */
|
|
139
|
-
export function isPostJoinActivity(acts, name, actIndex) {
|
|
140
|
-
const joinIndex = lastJoinIndex(acts, name);
|
|
141
|
-
return joinIndex !== undefined && actIndex > joinIndex;
|
|
142
|
-
}
|
|
143
127
|
export function actStableIndex(act) {
|
|
144
128
|
if (act.index === undefined)
|
|
145
129
|
throw new Error(`act ${act.kind} is missing a stable index`);
|
|
@@ -162,26 +146,13 @@ export function currentHold(acts) {
|
|
|
162
146
|
export function publicActs(acts) {
|
|
163
147
|
return acts.filter((act) => act.kind === 'say' || act.kind === 'done');
|
|
164
148
|
}
|
|
165
|
-
export function roomChangeActs(acts) {
|
|
166
|
-
return acts.filter((act) => act.kind !== 'say' && act.kind !== 'read');
|
|
167
|
-
}
|
|
168
|
-
export function throttleDelayMs(doc, at) {
|
|
169
|
-
const limit = doc.throttlePerMinute;
|
|
170
|
-
if (limit === undefined)
|
|
171
|
-
return 0;
|
|
172
|
-
const recent = foldedState(doc).throttleActivityAts.filter((eventAt) => at - eventAt < THROTTLE_WINDOW_MS).sort((a, b) => a - b);
|
|
173
|
-
if (recent.length < limit)
|
|
174
|
-
return 0;
|
|
175
|
-
const releaseAt = recent[recent.length - limit] + THROTTLE_WINDOW_MS;
|
|
176
|
-
return Math.max(1, releaseAt - at);
|
|
177
|
-
}
|
|
178
149
|
function canonicalRuntimeName(doc, name) {
|
|
179
150
|
return resolveRosterName(doc, name) ?? name;
|
|
180
151
|
}
|
|
181
|
-
export function advanceCursor(doc, name, index,
|
|
182
|
-
return touchPresenceCursor(doc, name, updatedAt,
|
|
152
|
+
export function advanceCursor(doc, name, index, updatedAt = Date.now()) {
|
|
153
|
+
return touchPresenceCursor(doc, name, updatedAt, index);
|
|
183
154
|
}
|
|
184
|
-
export function touchPresenceCursor(doc, name, at,
|
|
155
|
+
export function touchPresenceCursor(doc, name, at, consumedThroughIndex) {
|
|
185
156
|
if (!Number.isFinite(at))
|
|
186
157
|
return false;
|
|
187
158
|
if (consumedThroughIndex !== undefined && (!Number.isInteger(consumedThroughIndex) || consumedThroughIndex < 0))
|
|
@@ -192,113 +163,31 @@ export function touchPresenceCursor(doc, name, at, source, consumedThroughIndex)
|
|
|
192
163
|
? (current?.consumedThroughIndex ?? readCursor(doc, key))
|
|
193
164
|
: Math.max(current?.consumedThroughIndex ?? -1, consumedThroughIndex);
|
|
194
165
|
const updatedAt = current === undefined ? at : Math.max(current.updatedAt, at);
|
|
195
|
-
if (current?.consumedThroughIndex === nextIndex && current.updatedAt === updatedAt
|
|
166
|
+
if (current?.consumedThroughIndex === nextIndex && current.updatedAt === updatedAt)
|
|
196
167
|
return false;
|
|
197
|
-
doc.runtime.cursors[key] = { consumedThroughIndex: nextIndex, updatedAt
|
|
168
|
+
doc.runtime.cursors[key] = { consumedThroughIndex: nextIndex, updatedAt };
|
|
198
169
|
return true;
|
|
199
170
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const id = actId(actOrIndex);
|
|
203
|
-
// Delivery planners and runtime writers pass roster-canonical names. Keeping
|
|
204
|
-
// this a direct lookup is important: pending/status scans must stay linear in
|
|
205
|
-
// activities rather than folding the whole square once per notification.
|
|
206
|
-
return doc.runtime.mentionReceipts[name]?.[id];
|
|
207
|
-
}
|
|
208
|
-
export function isDeliveryDelivered(doc, name, actOrIndex) {
|
|
209
|
-
return deliveryReceipt(doc, name, actOrIndex)?.status === 'delivered';
|
|
210
|
-
}
|
|
211
|
-
export function recordDeliveredDelivery(doc, name, actOrIndex, receipt) {
|
|
212
|
-
const key = canonicalRuntimeName(doc, name);
|
|
213
|
-
const id = actId(actOrIndex);
|
|
214
|
-
const current = deliveryReceipt(doc, key, actOrIndex);
|
|
215
|
-
if (current?.status === 'delivered')
|
|
216
|
-
return false;
|
|
217
|
-
const receipts = doc.runtime.mentionReceipts[key] ?? {};
|
|
218
|
-
receipts[id] = { status: 'delivered', ...receipt };
|
|
219
|
-
doc.runtime.mentionReceipts[key] = receipts;
|
|
220
|
-
return true;
|
|
221
|
-
}
|
|
222
|
-
export function markDeliveredMention(doc, name, actOrIndex, at = Date.now()) {
|
|
223
|
-
return recordDeliveredDelivery(doc, name, actOrIndex, { at });
|
|
224
|
-
}
|
|
225
|
-
export function mentionDeliveredStatus(doc, name, actOrIndex) {
|
|
226
|
-
return isDeliveryDelivered(doc, name, actOrIndex) ? 'delivered' : undefined;
|
|
227
|
-
}
|
|
228
|
-
export function markDeliveredMentions(doc, name, delivered, at = Date.now()) {
|
|
229
|
-
let changed = false;
|
|
230
|
-
for (const item of delivered) {
|
|
231
|
-
const act = item.act;
|
|
232
|
-
if (act.kind !== 'say')
|
|
233
|
-
continue;
|
|
234
|
-
// A cursor says only where feed reading reached. The recipient/act receipt is
|
|
235
|
-
// the delivery fact, so never create one for a broadcast or unrelated say.
|
|
236
|
-
const directed = act.reach === 'bell' ||
|
|
237
|
-
(act.reach !== undefined && sameName(act.reach.beside, name)) ||
|
|
238
|
-
extractMentions(act.body).some((mention) => sameName(mention, name));
|
|
239
|
-
if (!directed)
|
|
240
|
-
continue;
|
|
241
|
-
changed = markDeliveredMention(doc, name, actStableIndex(act), at) || changed;
|
|
242
|
-
}
|
|
243
|
-
return changed;
|
|
244
|
-
}
|
|
245
|
-
export function writeSquareDoc(squarePath, doc) {
|
|
246
|
-
const dir = path.dirname(squarePath);
|
|
247
|
-
const base = path.basename(squarePath);
|
|
248
|
-
const tempPath = path.join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
249
|
-
fs.writeFileSync(tempPath, renderSquareDoc(doc));
|
|
250
|
-
fs.renameSync(tempPath, squarePath);
|
|
251
|
-
saveRuntimeSidecar(squarePath, doc.runtime);
|
|
252
|
-
}
|
|
253
|
-
export function appendAct(squarePath, doc, act) {
|
|
254
|
-
const indexed = { ...act, index: doc.runtime.nextActIndex };
|
|
255
|
-
doc.runtime.nextActIndex++;
|
|
256
|
-
doc.acts.push(indexed);
|
|
257
|
-
if (indexed.actor !== undefined)
|
|
258
|
-
touchPresenceCursor(doc, indexed.actor, indexed.at, indexed.kind === 'join' ? 'join' : 'api', actStableIndex(indexed));
|
|
259
|
-
writeSquareDoc(squarePath, doc);
|
|
260
|
-
return indexed;
|
|
261
|
-
}
|
|
262
|
-
export function latestIndexedActIndex(items) {
|
|
263
|
-
return items.reduce((max, item) => Math.max(max, item.index), -1);
|
|
171
|
+
export function latestActIndex(acts) {
|
|
172
|
+
return acts.reduce((max, act) => Math.max(max, act.index), -1);
|
|
264
173
|
}
|
|
265
174
|
export function freshWatchLease(doc, name, at = Date.now()) {
|
|
266
175
|
const key = canonicalRuntimeName(doc, name);
|
|
267
|
-
const lease = doc
|
|
176
|
+
const lease = watchLease(doc, key);
|
|
268
177
|
if (lease === undefined || lease.expiresAt <= at || at - lease.heartbeatAt > WATCH_STALE_MS)
|
|
269
178
|
return undefined;
|
|
270
179
|
return lease;
|
|
271
180
|
}
|
|
272
|
-
export
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
finally {
|
|
285
|
-
try {
|
|
286
|
-
fs.unlinkSync(lockPath);
|
|
287
|
-
}
|
|
288
|
-
catch { }
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
catch (err) {
|
|
292
|
-
const errno = err;
|
|
293
|
-
if (errno.code !== 'EEXIST')
|
|
294
|
-
throw err;
|
|
295
|
-
try {
|
|
296
|
-
const stat = fs.statSync(lockPath);
|
|
297
|
-
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS)
|
|
298
|
-
fs.unlinkSync(lockPath);
|
|
299
|
-
}
|
|
300
|
-
catch { }
|
|
301
|
-
await sleep(LOCK_RETRY_MS);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
181
|
+
export function watchLease(doc, name) {
|
|
182
|
+
return doc.runtime.leases[canonicalRuntimeName(doc, name)];
|
|
183
|
+
}
|
|
184
|
+
export function writeWatchLease(doc, name, lease) {
|
|
185
|
+
doc.runtime.leases[canonicalRuntimeName(doc, name)] = lease;
|
|
186
|
+
}
|
|
187
|
+
export function removeWatchLease(doc, name, leaseId) {
|
|
188
|
+
const key = canonicalRuntimeName(doc, name);
|
|
189
|
+
if (doc.runtime.leases[key]?.leaseId !== leaseId)
|
|
190
|
+
return false;
|
|
191
|
+
delete doc.runtime.leases[key];
|
|
192
|
+
return true;
|
|
304
193
|
}
|
|
@@ -0,0 +1,264 @@
|
|
|
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';
|
|
5
|
+
import { coreCompact, coreDone, coreHold, coreResume, decideAct, decideJoin, resolveKnownName } from './decisions.js';
|
|
6
|
+
import { dispatchActNotifications } from './notifications.js';
|
|
7
|
+
import { planRepair } from './doctor.js';
|
|
8
|
+
import { stageReplacement } from './harness-stage.js';
|
|
9
|
+
import { SquareError } from './model.js';
|
|
10
|
+
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. */
|
|
12
|
+
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
|
+
}
|
|
54
|
+
}
|
|
55
|
+
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);
|
|
60
|
+
}
|
|
61
|
+
export function appendAct(squarePath, doc, act) {
|
|
62
|
+
const stored = applyActs(doc, [act])[0];
|
|
63
|
+
writeSquareDoc(squarePath, doc);
|
|
64
|
+
return stored;
|
|
65
|
+
}
|
|
66
|
+
function applyActs(doc, acts, mutateRuntime) {
|
|
67
|
+
const stored = [];
|
|
68
|
+
for (const act of acts) {
|
|
69
|
+
const item = { ...act, index: doc.runtime.nextActIndex };
|
|
70
|
+
doc.runtime.nextActIndex++;
|
|
71
|
+
doc.acts.push(item);
|
|
72
|
+
if (item.actor !== undefined)
|
|
73
|
+
touchPresenceCursor(doc, item.actor, item.at, item.index);
|
|
74
|
+
stored.push(item);
|
|
75
|
+
}
|
|
76
|
+
mutateRuntime?.(doc);
|
|
77
|
+
return stored;
|
|
78
|
+
}
|
|
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') : '') {
|
|
84
|
+
return stageReplacement(filePath, (stage) => {
|
|
85
|
+
fs.writeFileSync(stage, `${existing}${existing === '' ? '' : '\n'}${block}\n`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function plan(doc, intent) {
|
|
89
|
+
switch (intent.type) {
|
|
90
|
+
case 'join': {
|
|
91
|
+
const decision = decideJoin(doc, intent.name, intent.now);
|
|
92
|
+
return { result: decision, acts: [decision.joinAct] };
|
|
93
|
+
}
|
|
94
|
+
case 'say': {
|
|
95
|
+
const decision = decideAct(doc, intent);
|
|
96
|
+
return {
|
|
97
|
+
result: decision,
|
|
98
|
+
acts: decision.type === 'sent' ? [decision.act] : [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case 'hold':
|
|
102
|
+
return { result: undefined, acts: [coreHold(doc, intent.actor, intent.body, intent.now)] };
|
|
103
|
+
case 'resume':
|
|
104
|
+
return { result: undefined, acts: [coreResume(doc, intent.actor, intent.now)] };
|
|
105
|
+
case 'done':
|
|
106
|
+
return { result: undefined, acts: [coreDone(doc, intent.name, intent.body, intent.now)] };
|
|
107
|
+
case 'lease': {
|
|
108
|
+
const name = resolveKnownName(doc, intent.name);
|
|
109
|
+
const existing = freshWatchLease(doc, name, intent.at);
|
|
110
|
+
if (existing !== undefined && !intent.force) {
|
|
111
|
+
return { result: { type: 'active', lease: existing }, acts: [] };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
result: { type: 'started', name, replaced: existing !== undefined },
|
|
115
|
+
acts: [],
|
|
116
|
+
mutateRuntime: (nextDoc) => {
|
|
117
|
+
writeWatchLease(nextDoc, name, {
|
|
118
|
+
leaseId: intent.leaseId,
|
|
119
|
+
...(intent.ownerId === undefined ? {} : { ownerId: intent.ownerId }),
|
|
120
|
+
heartbeatAt: intent.at,
|
|
121
|
+
expiresAt: intent.expiresAt,
|
|
122
|
+
...(intent.filter === undefined ? {} : { filter: intent.filter }),
|
|
123
|
+
});
|
|
124
|
+
touchPresenceCursor(nextDoc, name, intent.at);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
case 'release-lease': {
|
|
129
|
+
const name = resolveKnownName(doc, intent.name);
|
|
130
|
+
if (watchLease(doc, name)?.leaseId !== intent.leaseId) {
|
|
131
|
+
return { result: { released: false }, acts: [] };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
result: { released: true },
|
|
135
|
+
acts: [],
|
|
136
|
+
mutateRuntime: (nextDoc) => { removeWatchLease(nextDoc, name, intent.leaseId); },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
case 'consume': {
|
|
140
|
+
const name = resolveKnownName(doc, intent.name);
|
|
141
|
+
return {
|
|
142
|
+
result: { name },
|
|
143
|
+
acts: [],
|
|
144
|
+
mutateRuntime: (nextDoc) => { advanceCursor(nextDoc, name, intent.throughIndex, intent.at); },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
case 'compact': {
|
|
148
|
+
const result = coreCompact(doc, intent.keep);
|
|
149
|
+
const archive = result.archived;
|
|
150
|
+
return {
|
|
151
|
+
result,
|
|
152
|
+
acts: [],
|
|
153
|
+
replaceDoc: result.doc,
|
|
154
|
+
preparePersistence: archive.length === 0
|
|
155
|
+
? 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
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
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
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function commitPlan(squarePath, doc, planned) {
|
|
180
|
+
const nextDoc = planned.replaceDoc ?? doc;
|
|
181
|
+
const committed = { result: planned.result, acts: applyActs(nextDoc, planned.acts, planned.mutateRuntime) };
|
|
182
|
+
if (planned.acts.length === 0 && planned.mutateRuntime === undefined && planned.replaceDoc === undefined) {
|
|
183
|
+
return committed;
|
|
184
|
+
}
|
|
185
|
+
let persistence;
|
|
186
|
+
try {
|
|
187
|
+
persistence = planned.preparePersistence?.();
|
|
188
|
+
writeSquareDoc(squarePath, nextDoc);
|
|
189
|
+
persistence?.finalize();
|
|
190
|
+
return committed;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
try {
|
|
194
|
+
persistence?.rollback();
|
|
195
|
+
}
|
|
196
|
+
catch { }
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** The one mutation pipeline shared by package and CLI adapters. */
|
|
201
|
+
export async function execute(squarePath, intent) {
|
|
202
|
+
const committed = await withSquareLock(squarePath, () => {
|
|
203
|
+
const doc = loadSquare(squarePath);
|
|
204
|
+
return commitPlan(squarePath, doc, plan(doc, intent));
|
|
205
|
+
});
|
|
206
|
+
for (const act of committed.acts) {
|
|
207
|
+
if (act.kind === 'say')
|
|
208
|
+
await dispatchActNotifications(squarePath, act);
|
|
209
|
+
}
|
|
210
|
+
return committed;
|
|
211
|
+
}
|
|
212
|
+
/** Application-owned artifact creation; adapters provide validated options and stdin text only. */
|
|
213
|
+
export async function createSquare(squarePath, options, snippet) {
|
|
214
|
+
await withSquareLock(squarePath, () => {
|
|
215
|
+
if (fs.existsSync(squarePath) && !options.force) {
|
|
216
|
+
throw new SquareError('conflict', `Refusing to overwrite existing square: ${squarePath}\nPass -f to overwrite.`);
|
|
217
|
+
}
|
|
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 };
|
|
262
|
+
});
|
|
263
|
+
return result.repair;
|
|
264
|
+
}
|
package/dist/square-core.js
CHANGED
|
@@ -26,14 +26,6 @@ function touchParticipant(byKey, ordered, actor) {
|
|
|
26
26
|
ordered.push(created);
|
|
27
27
|
return created;
|
|
28
28
|
}
|
|
29
|
-
export function isWarm(lastSeen, now, threshold) {
|
|
30
|
-
if (lastSeen === undefined)
|
|
31
|
-
return false;
|
|
32
|
-
if (!Number.isFinite(lastSeen) || !Number.isFinite(now) || !Number.isFinite(threshold) || threshold <= 0)
|
|
33
|
-
return false;
|
|
34
|
-
const delta = now - lastSeen;
|
|
35
|
-
return delta >= 0 && delta <= threshold;
|
|
36
|
-
}
|
|
37
29
|
function pushThrottleAt(state, at) {
|
|
38
30
|
if (typeof at === 'number' && Number.isFinite(at))
|
|
39
31
|
state.throttleActivityAts.push(at);
|
|
@@ -59,7 +51,7 @@ function bellRecentAt(state, actor, at, windowMs) {
|
|
|
59
51
|
}
|
|
60
52
|
return latest;
|
|
61
53
|
}
|
|
62
|
-
export function fold(acts
|
|
54
|
+
export function fold(acts) {
|
|
63
55
|
const ordered = [];
|
|
64
56
|
const byKey = new Map();
|
|
65
57
|
const hold = { active: false };
|
|
@@ -168,12 +160,12 @@ export function validate(state, act, options = {}) {
|
|
|
168
160
|
return { ok: true };
|
|
169
161
|
}
|
|
170
162
|
}
|
|
171
|
-
export function perceive(state, act, viewer
|
|
163
|
+
export function perceive(state, act, viewer) {
|
|
172
164
|
void state;
|
|
173
165
|
if (act.kind !== 'say')
|
|
174
166
|
return 'full';
|
|
175
167
|
const actor = act.actor;
|
|
176
|
-
if (
|
|
168
|
+
if (sameName(actor, viewer))
|
|
177
169
|
return 'full';
|
|
178
170
|
if (act.reach === undefined || act.reach === 'bell')
|
|
179
171
|
return 'full';
|