@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.
Files changed (52) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +3 -2
  2. package/dist/activity-feed.js +26 -18
  3. package/dist/activity.js +23 -22
  4. package/dist/artifact.js +126 -202
  5. package/dist/claude-hook.js +45 -21
  6. package/dist/cli/context.js +143 -0
  7. package/dist/cli/harness-command.js +50 -0
  8. package/dist/cli/maintenance-commands.js +76 -0
  9. package/dist/cli/meta-commands.js +28 -0
  10. package/dist/cli/observation-commands.js +453 -0
  11. package/dist/cli/program.js +48 -0
  12. package/dist/cli/registry.js +40 -0
  13. package/dist/cli/square-commands.js +219 -0
  14. package/dist/cmd/notify-once.js +23 -21
  15. package/dist/compact.js +6 -19
  16. package/dist/decisions.js +53 -86
  17. package/dist/delivery-health.js +104 -210
  18. package/dist/delivery.js +68 -18
  19. package/dist/doctor.js +9 -8
  20. package/dist/harness-claude.js +68 -0
  21. package/dist/harness-codex.js +119 -0
  22. package/dist/harness-links.js +123 -0
  23. package/dist/harness-stage.js +36 -0
  24. package/dist/harness.js +94 -576
  25. package/dist/help.js +44 -35
  26. package/dist/inbox.js +12 -11
  27. package/dist/index.js +30 -129
  28. package/dist/list.js +1 -1
  29. package/dist/model.js +0 -6
  30. package/dist/notification-failures.js +54 -0
  31. package/dist/notifications.js +47 -62
  32. package/dist/paseo-timeline.js +58 -188
  33. package/dist/presentation.js +55 -63
  34. package/dist/presented.js +9 -8
  35. package/dist/registry.js +55 -45
  36. package/dist/runtime.js +26 -137
  37. package/dist/square-application.js +264 -0
  38. package/dist/square-core.js +3 -11
  39. package/dist/square.js +5 -1362
  40. package/dist/stream.js +27 -126
  41. package/dist/wake-sink.js +134 -188
  42. package/dist/watch.js +79 -138
  43. package/extensions/square-opencode.js +1 -1
  44. package/extensions/square-pi.js +8 -130
  45. package/guides/architect.md +3 -3
  46. package/guides/participant.md +25 -16
  47. package/package.json +2 -2
  48. package/skills/brainstorm/SKILL.md +25 -32
  49. package/skills/square/.claude-plugin/plugin.json +1 -1
  50. package/skills/square/SKILL.md +39 -107
  51. package/skills/square-feedback/SKILL.md +4 -4
  52. package/dist/terminal.js +0 -125
@@ -1,249 +1,143 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { loadSquare, saveRuntimeSidecar } from './artifact.js';
5
- import { SquareError, sameName } from './model.js';
6
- import { planActNotifications } from './notifications.js';
4
+ import { loadSquare } from './artifact.js';
5
+ import { deriveDeliveryModel } from './delivery.js';
6
+ import { readNotificationFailures } from './notification-failures.js';
7
+ import { isCurrentlyJoined } from './runtime.js';
8
+ import { sameName } from './model.js';
7
9
  import { formatDuration } from './time.js';
8
- import { isCurrentlyJoined, isDeliveryDelivered, lastJoinAt, recordDeliveredDelivery, resolveRosterName, rosterNames, } from './runtime.js';
9
- const DEFAULT_STALE_MS = 60_000;
10
- /** Only acts inside this lookback can prove "hooks are dead right now". Older = historical debt. */
11
- const DEFAULT_LOOKBACK_MS = 60 * 60 * 1000;
12
- function parsePositiveIntegerEnv(name, fallback) {
13
- const raw = process.env[name];
10
+ const STALE_MS = 60_000;
11
+ const LOOKBACK_MS = 60 * 60 * 1000;
12
+ function positive(name, fallback, env) {
13
+ const raw = env[name];
14
14
  if (raw === undefined)
15
15
  return fallback;
16
- const value = Number.parseInt(raw, 10);
17
- if (!Number.isFinite(value) || value <= 0) {
18
- throw new SquareError('invalid_args', `Invalid ${name}: expected a positive integer.`);
19
- }
16
+ const value = Number(raw);
17
+ if (!Number.isInteger(value) || value <= 0)
18
+ throw new Error(`Invalid ${name}: expected a positive integer.`);
20
19
  return value;
21
20
  }
22
- /** How long a mention/bell may remain undelivered before harness liveness is suspect. */
23
- export function deliveryStaleMs() {
24
- return parsePositiveIntegerEnv('SQUARE_DELIVERY_STALE_MS', DEFAULT_STALE_MS);
25
- }
26
- /** How far back liveness scans look. Older unreceipted mentions are backlog, not hook-death. */
27
- export function deliveryLookbackMs() {
28
- return parsePositiveIntegerEnv('SQUARE_DELIVERY_LOOKBACK_MS', DEFAULT_LOOKBACK_MS);
29
- }
30
- function baselinePath(env = process.env) {
31
- return env.SQUARE_DELIVERY_BASELINE || path.join(os.homedir(), '.square', 'delivery-baseline.json');
32
- }
33
- function readBaselines(env = process.env) {
34
- const file = baselinePath(env);
35
- if (!fs.existsSync(file))
36
- return {};
37
- try {
38
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
39
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
40
- return {};
41
- return parsed;
42
- }
43
- catch {
44
- return {};
45
- }
46
- }
47
- export function recordBacklogBaseline(squarePath, backlogCount, env = process.env, at = Date.now()) {
48
- const file = baselinePath(env);
49
- const data = readBaselines(env);
50
- data[path.resolve(squarePath)] = { backlogCount, at };
51
- fs.mkdirSync(path.dirname(file), { recursive: true });
52
- const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
53
- fs.writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
54
- fs.renameSync(temp, file);
21
+ export function deliveryStaleMs(env = process.env) {
22
+ return positive('SQUARE_DELIVERY_STALE_MS', STALE_MS, env);
55
23
  }
56
- export function previousBacklogCount(squarePath, env = process.env) {
57
- return readBaselines(env)[path.resolve(squarePath)]?.backlogCount;
24
+ export function deliveryLookbackMs(env = process.env) {
25
+ return positive('SQUARE_DELIVERY_LOOKBACK_MS', LOOKBACK_MS, env);
58
26
  }
59
- function isReceipted(_squarePath, doc, recipient, actIndex, _env) {
60
- // This sidecar record is the only delivery authority. A machine-local
61
- // presentation cache may suppress duplicate hook text, but never clears debt.
62
- return isDeliveryDelivered(doc, recipient, actIndex);
27
+ function actedAfter(doc, recipient, actIndex) {
28
+ return doc.acts.some((act) => act.actor !== undefined && sameName(act.actor, recipient) && act.index > actIndex);
63
29
  }
64
- function actedAfterMention(doc, recipient, mentionAt, mentionIndex) {
65
- return doc.acts.some((act) => act.actor !== undefined &&
66
- sameName(act.actor, recipient) &&
67
- (act.at > mentionAt || act.index > mentionIndex));
68
- }
69
- function collectUnreceiptedMentions(squarePath, doc, now, opts = {}) {
70
- const env = opts.env ?? process.env;
71
- const pending = [];
72
- for (const act of doc.acts) {
73
- if (act.kind !== 'say')
74
- continue;
75
- const item = { act, index: act.index };
76
- for (const planned of planActNotifications(doc, item)) {
77
- if (planned.via !== 'mention' && planned.via !== 'bell')
78
- continue;
79
- const recipient = resolveRosterName(doc, planned.recipient) ?? planned.recipient;
80
- if (opts.onlyJoined && !isCurrentlyJoined(doc.acts, recipient))
81
- continue;
82
- if (isReceipted(squarePath, doc, recipient, act.index, env))
83
- continue;
84
- pending.push({
85
- squarePath,
86
- recipient,
87
- actIndex: act.index,
88
- actor: act.actor,
89
- at: act.at,
90
- ageMs: now - act.at,
91
- via: planned.via,
92
- actedAfterWithoutDelivery: actedAfterMention(doc, recipient, act.at, act.index),
93
- });
94
- }
95
- }
96
- return pending;
30
+ function pending(squarePath, now) {
31
+ const doc = loadSquare(squarePath);
32
+ const model = deriveDeliveryModel(doc);
33
+ const recipients = [...new Set(doc.acts.filter((act) => act.kind === 'join').map((act) => act.actor))]
34
+ .filter((name) => isCurrentlyJoined(doc.acts, name));
35
+ return recipients.flatMap((recipient) => model.pendingFor(recipient).map((note) => ({
36
+ squarePath,
37
+ recipient: note.recipient,
38
+ actIndex: note.item.index,
39
+ actor: note.item.actor,
40
+ at: note.item.at,
41
+ ageMs: now - note.item.at,
42
+ route: note.route,
43
+ actedAfterWithoutDelivery: actedAfter(doc, note.recipient, note.item.index),
44
+ })));
97
45
  }
98
- /**
99
- * Partition unreceipted mention/bell traffic.
100
- * Liveness failure requires: past stale, inside lookback, currently joined, mention after last join.
101
- */
102
46
  export function partitionPendingDeliveries(squarePath, opts = {}) {
103
47
  const now = opts.now ?? Date.now();
104
48
  const staleMs = opts.staleMs ?? deliveryStaleMs();
105
49
  const lookbackMs = Math.max(opts.lookbackMs ?? deliveryLookbackMs(), staleMs);
106
- const doc = opts.doc ?? loadSquare(squarePath);
107
50
  const recent = [];
108
51
  const historical = [];
109
- // Include not-joined for historical/reconcile; liveness only uses joined.
110
- for (const item of collectUnreceiptedMentions(squarePath, doc, now, {
111
- onlyJoined: false,
112
- env: opts.env,
113
- })) {
114
- const joined = isCurrentlyJoined(doc.acts, item.recipient);
115
- const joinAt = lastJoinAt(doc.acts, item.recipient);
116
- const afterJoin = joinAt === undefined || item.at >= joinAt;
117
- const inLookback = item.ageMs <= lookbackMs;
118
- const pastStale = item.ageMs >= staleMs;
119
- if (joined && pastStale && inLookback && afterJoin) {
52
+ for (const item of pending(squarePath, now)) {
53
+ if (item.ageMs >= staleMs && item.ageMs <= lookbackMs)
120
54
  recent.push(item);
121
- continue;
122
- }
123
- // Still in grace window and would otherwise be live → ignore (not historical yet).
124
- if (joined && !pastStale && inLookback && afterJoin)
125
- continue;
126
- historical.push(item);
55
+ else if (item.ageMs >= staleMs)
56
+ historical.push(item);
127
57
  }
128
58
  return { recent, historical };
129
59
  }
130
- /** Recent stale only — the liveness signal. */
131
- export function findStalePendingMentions(squarePath, opts = {}) {
132
- return partitionPendingDeliveries(squarePath, opts).recent;
133
- }
134
- function summarizeByRecipient(items) {
135
- const byRecipient = new Map();
136
- for (const item of items) {
137
- const list = byRecipient.get(item.recipient) ?? [];
138
- list.push(item);
139
- byRecipient.set(item.recipient, list);
140
- }
141
- const lines = [];
142
- for (const [recipient, group] of byRecipient) {
143
- const oldest = group.reduce((a, b) => (a.at <= b.at ? a : b));
144
- const ageSec = Math.max(1, Math.round(oldest.ageMs / 1000));
145
- const adapter = group.some((item) => item.actedAfterWithoutDelivery);
146
- lines.push(adapter
147
- ? ` · @${recipient}: ${group.length} pending — acted after the mention, but delivery was not acknowledged (oldest act_${oldest.actIndex}, ~${ageSec}s)`
148
- : ` · @${recipient}: ${group.length} pending (oldest act_${oldest.actIndex} from @${oldest.actor}, ~${ageSec}s)`);
149
- }
150
- return lines;
60
+ function byRecipient(items) {
61
+ const groups = new Map();
62
+ for (const item of items)
63
+ groups.set(item.recipient, [...(groups.get(item.recipient) ?? []), item]);
64
+ return [...groups].map(([recipient, notes]) => {
65
+ const oldest = notes.reduce((first, item) => item.at < first.at ? item : first);
66
+ const adapterFault = notes.some((item) => item.actedAfterWithoutDelivery);
67
+ return adapterFault
68
+ ? ` · @${recipient}: ${notes.length} pending; they acted after it without a receipt (act_${oldest.actIndex})`
69
+ : ` · @${recipient}: ${notes.length} pending (oldest act_${oldest.actIndex} from @${oldest.actor})`;
70
+ });
151
71
  }
152
72
  export function formatStaleDeliveryWarnings(recent, historical = [], opts = {}) {
153
- const lines = [];
73
+ const out = [];
154
74
  if (recent.length > 0) {
155
75
  const adapterFaults = recent.filter((item) => item.actedAfterWithoutDelivery);
156
- if (adapterFaults.length > 0) {
157
- lines.push(`⚠ ${adapterFaults.length} recent mention(s): the recipient acted after the mention, but delivery was not acknowledged.`);
158
- lines.push(' This points to an adapter failure, not participant behavior.');
159
- lines.push(' » square harness doctor');
160
- }
161
- const other = recent.filter((item) => !item.actedAfterWithoutDelivery);
162
- if (other.length > 0) {
163
- lines.push(`⚠ ${other.length} recent mention or bell notification(s) were not acknowledged inside the delivery window.`);
164
- lines.push(' The installed hook or extension may not be consuming notifications.');
165
- }
166
- lines.push(...summarizeByRecipient(recent));
167
- if (adapterFaults.length === 0)
168
- lines.push(' » square harness doctor');
76
+ out.push(adapterFaults.length > 0
77
+ ? `✕ ${adapterFaults.length} pending notification(s) point to an adapter/pull dead path.`
78
+ : `✕ ${recent.length} recent notification(s) have no delivered receipt.`);
79
+ out.push(...byRecipient(recent));
169
80
  }
170
81
  if (historical.length > 0) {
171
- lines.push(`○ ${historical.length} historical or pre-join mention(s) remain unacknowledged backlog, not a current delivery failure.`);
172
- const counts = new Map();
173
- for (const item of historical) {
174
- counts.set(item.recipient, (counts.get(item.recipient) ?? 0) + 1);
175
- }
176
- lines.push(` · ${[...counts.entries()].map(([name, n]) => `@${name}:${n}`).join(', ')}`);
82
+ out.push(`○ ${historical.length} older pending notification(s) remain as historical backlog.`);
83
+ out.push(...byRecipient(historical));
177
84
  if (opts.previousBacklog !== undefined) {
178
85
  const delta = historical.length - opts.previousBacklog;
179
- if (delta > 0) {
180
- lines.push(` ⚠ backlog grew by ${delta} since last doctor (something aged out of lookback unnoticed).`);
181
- }
182
- else if (delta < 0) {
183
- lines.push(` · backlog shrank by ${-delta} since last doctor.`);
184
- }
185
- else {
186
- lines.push(' · backlog unchanged since last doctor.');
187
- }
86
+ out.push(delta === 0 ? ' · backlog unchanged since last doctor.' : delta > 0 ? ` · backlog grew by ${delta} since last doctor.` : ` · backlog shrank by ${-delta} since last doctor.`);
188
87
  }
189
88
  }
190
- else if (opts.previousBacklog !== undefined && opts.previousBacklog > 0) {
191
- lines.push(`○ backlog cleared (was ${opts.previousBacklog}).`);
192
- }
193
- return lines;
89
+ else if ((opts.previousBacklog ?? 0) > 0)
90
+ out.push(`○ backlog cleared (was ${opts.previousBacklog}).`);
91
+ return out;
194
92
  }
195
- export function doctorDeliveryHealth(squarePath, now = Date.now(), env = process.env) {
93
+ function baselineFile(env) {
94
+ return env.SQUARE_DELIVERY_BASELINE ?? path.join(os.homedir(), '.square', 'delivery-baseline.json');
95
+ }
96
+ function baseline(squarePath, env) {
196
97
  try {
197
- const doc = loadSquare(squarePath);
198
- const { recent, historical } = partitionPendingDeliveries(squarePath, { now, doc, env });
199
- const previous = previousBacklogCount(squarePath, env);
200
- const lines = [
201
- `· stale after ${formatDuration(deliveryStaleMs())} · scan window ${formatDuration(deliveryLookbackMs())}`,
202
- `· participants ${rosterNames(doc).join(', ') || '(none)'}`,
203
- ];
204
- if (recent.length === 0 && historical.length === 0) {
205
- lines.push('✓ no stale undelivered mentions or bells');
206
- }
207
- else {
208
- if (recent.length === 0) {
209
- lines.push('✓ no current delivery failures inside the scan window');
210
- }
211
- lines.push(...formatStaleDeliveryWarnings(recent, historical, { previousBacklog: previous }).map((line) => line.startsWith('⚠ ') ? line.replace(/^⚠ /, '✕ ') : line));
212
- }
213
- recordBacklogBaseline(squarePath, historical.length, env, now);
214
- return lines;
98
+ return JSON.parse(fs.readFileSync(baselineFile(env), 'utf8'))[path.resolve(squarePath)]?.backlogCount;
215
99
  }
216
- catch (error) {
217
- return [`✕ delivery health unreadable: ${error instanceof Error ? error.message : String(error)}`];
100
+ catch {
101
+ return undefined;
218
102
  }
219
103
  }
220
- /** Act-path only screams for recent liveness failures, not historical backlog. */
221
- export function hasStalePendingForAny(squarePath, now = Date.now()) {
222
- return findStalePendingMentions(squarePath, { now }).length > 0;
223
- }
224
- export function notificationMessageId(squarePath, actIndex) {
225
- return `square:${squarePath}#act_${actIndex}`;
226
- }
227
- /**
228
- * Write delivered-class receipts with reason=reconciled for backlog items.
229
- * Does not touch recent liveness failures.
230
- */
231
- export function reconcileDeliveryBacklog(squarePath, opts = {}) {
232
- const now = opts.now ?? Date.now();
233
- const doc = loadSquare(squarePath);
234
- const { recent, historical } = partitionPendingDeliveries(squarePath, {
235
- now,
236
- doc,
237
- lookbackMs: opts.lookbackMs,
238
- staleMs: opts.staleMs,
239
- });
240
- const actor = opts.actor ?? 'doctor --fix reconcile-backlog';
241
- let reconciled = 0;
242
- for (const item of historical) {
243
- if (recordDeliveredDelivery(doc, item.recipient, item.actIndex, { at: now, reason: 'reconciled', actor }))
244
- reconciled += 1;
104
+ function writeBaseline(squarePath, backlogCount, env, at) {
105
+ const file = baselineFile(env);
106
+ let rows = {};
107
+ try {
108
+ rows = JSON.parse(fs.readFileSync(file, 'utf8'));
245
109
  }
246
- if (reconciled > 0)
247
- saveRuntimeSidecar(squarePath, doc.runtime);
248
- return { reconciled, skippedRecent: recent.length, items: historical };
110
+ catch { }
111
+ rows[path.resolve(squarePath)] = { backlogCount, at };
112
+ fs.mkdirSync(path.dirname(file), { recursive: true });
113
+ fs.writeFileSync(file, `${JSON.stringify(rows, null, 2)}\n`, { mode: 0o600 });
114
+ }
115
+ function formatFailures(squarePath, recent, env) {
116
+ const failures = readNotificationFailures(squarePath, env);
117
+ if (failures.length === 0)
118
+ return [];
119
+ const pendingKeys = new Set(recent.map((item) => `${item.recipient}\0${item.actIndex}`));
120
+ const current = failures.filter((item) => item.recipient !== undefined && pendingKeys.has(`${item.recipient}\0${item.actIndex}`));
121
+ const rows = current.length > 0 ? current : failures;
122
+ const historical = current.length === 0;
123
+ const latest = rows.at(-1);
124
+ const diagnostic = latest.diagnostic;
125
+ return [
126
+ historical ? `○ ${rows.length} historical notification failure(s) retained: ${latest.message}` : `✕ ${rows.length} notification attempt(s) failed: ${latest.message}; receipt remains pending.`,
127
+ ...(diagnostic?.passwordPresent === false ? [' · PASEO_PASSWORD absent; pass PASEO_PASSWORD to the Codex process.'] : []),
128
+ ` · ${notificationFailuresPathForDisplay(squarePath, env)}`,
129
+ ];
130
+ }
131
+ function notificationFailuresPathForDisplay(squarePath, env) {
132
+ return env.SQUARE_NOTIFICATION_FAILURES ?? path.join(path.dirname(squarePath), 'notification-failures.ndjsonl');
133
+ }
134
+ export function doctorDeliveryHealth(squarePath, now = Date.now(), env = process.env) {
135
+ const { recent, historical } = partitionPendingDeliveries(squarePath, { now, staleMs: deliveryStaleMs(env), lookbackMs: deliveryLookbackMs(env) });
136
+ const prior = baseline(squarePath, env);
137
+ writeBaseline(squarePath, historical.length, env, now);
138
+ return [
139
+ `· stale after ${formatDuration(deliveryStaleMs(env))} · scan window ${formatDuration(deliveryLookbackMs(env))}`,
140
+ ...(recent.length === 0 && historical.length === 0 ? ['✓ no stale undelivered notifications'] : formatStaleDeliveryWarnings(recent, historical, { previousBacklog: prior })),
141
+ ...formatFailures(squarePath, recent, env),
142
+ ];
249
143
  }
package/dist/delivery.js CHANGED
@@ -1,5 +1,37 @@
1
1
  import { findParticipantName, sameName, } from './model.js';
2
- import { extractMentions, isDeliveryDelivered, lastJoinIndex, matchesMentionTarget, rosterNames } from './runtime.js';
2
+ import { actId, extractMentions, isCurrentlyJoined, lastJoinIndex, matchesMentionTarget, resolveRosterName, rosterNames } from './runtime.js';
3
+ export function notificationMessageId(squarePath, actIndex) {
4
+ return `square:${squarePath}#act_${actIndex}`;
5
+ }
6
+ export function isPendingNotification(notification) {
7
+ return notification.route !== 'broadcast';
8
+ }
9
+ function canonicalRecipient(doc, name) {
10
+ return resolveRosterName(doc, name) ?? name;
11
+ }
12
+ /** Delivery receipts are the only durable acknowledgement of directed attention. */
13
+ export function deliveryReceipt(doc, name, actOrIndex) {
14
+ return deliveryReceiptFromRuntime(doc.runtime, canonicalRecipient(doc, name), actOrIndex);
15
+ }
16
+ export function deliveryReceiptFromRuntime(runtime, recipient, actOrIndex) {
17
+ return runtime.deliveryReceipts[recipient]?.[actId(actOrIndex)];
18
+ }
19
+ export function isDeliveryDelivered(doc, name, actOrIndex) {
20
+ return deliveryReceipt(doc, name, actOrIndex)?.status === 'delivered';
21
+ }
22
+ export function recordDeliveredDelivery(doc, name, actOrIndex, receipt) {
23
+ return recordDeliveredRuntime(doc.runtime, canonicalRecipient(doc, name), actOrIndex, receipt);
24
+ }
25
+ export function recordDeliveredRuntime(runtime, recipient, actOrIndex, receipt) {
26
+ const id = actId(actOrIndex);
27
+ if (deliveryReceiptFromRuntime(runtime, recipient, actOrIndex)?.status === 'delivered')
28
+ return false;
29
+ (runtime.deliveryReceipts[recipient] ??= {})[id] = { status: 'delivered', ...receipt };
30
+ return true;
31
+ }
32
+ export function markDeliveredDelivery(doc, name, actOrIndex, at = Date.now()) {
33
+ return recordDeliveredDelivery(doc, name, actOrIndex, { at });
34
+ }
3
35
  function uniqueKnownMentions(body, roster) {
4
36
  const recipients = [];
5
37
  for (const mention of extractMentions(body)) {
@@ -15,30 +47,30 @@ function uniqueKnownMentions(body, roster) {
15
47
  * All consumers share these targets instead of reinterpreting artifact text or cursor state.
16
48
  */
17
49
  export function deriveDeliveryModel(doc) {
18
- const roster = rosterNames(doc);
50
+ const roster = rosterNames(doc).filter((name) => isCurrentlyJoined(doc.acts, name));
19
51
  let pendingByRecipient;
20
52
  function plan(item) {
21
- if (item.act.kind !== 'say')
53
+ if (item.kind !== 'say')
22
54
  return [];
23
55
  const sayItem = item;
24
- const actor = sayItem.act.actor;
25
- if (sayItem.act.reach === 'bell') {
56
+ const actor = sayItem.actor;
57
+ if (sayItem.reach === 'bell') {
26
58
  return roster
27
59
  .filter((recipient) => !sameName(recipient, actor))
28
- .map((recipient) => ({ item: sayItem, recipient, via: 'bell' }));
60
+ .map((recipient) => ({ item: sayItem, recipient, route: 'bell' }));
29
61
  }
30
- if (sayItem.act.reach !== undefined) {
31
- const recipient = findParticipantName(roster, sayItem.act.reach.beside);
62
+ if (sayItem.reach !== undefined) {
63
+ const recipient = findParticipantName(roster, sayItem.reach.beside);
32
64
  return recipient === undefined || sameName(recipient, actor)
33
65
  ? []
34
- : [{ item: sayItem, recipient, via: 'mention' }];
66
+ : [{ item: sayItem, recipient, route: 'beside' }];
35
67
  }
36
- const mentions = uniqueKnownMentions(sayItem.act.body, roster).filter((recipient) => !sameName(recipient, actor));
68
+ const mentions = uniqueKnownMentions(sayItem.body, roster).filter((recipient) => !sameName(recipient, actor));
37
69
  const recipients = mentions.length > 0
38
70
  ? mentions
39
71
  : roster.filter((recipient) => !sameName(recipient, actor));
40
- const via = mentions.length > 0 ? 'mention' : 'broadcast';
41
- return recipients.map((recipient) => ({ item: sayItem, recipient, via }));
72
+ const route = mentions.length > 0 ? 'mention' : 'broadcast';
73
+ return recipients.map((recipient) => ({ item: sayItem, recipient, route }));
42
74
  }
43
75
  function pendingFor(requestedRecipient) {
44
76
  const recipient = findParticipantName(roster, requestedRecipient);
@@ -54,15 +86,15 @@ export function deriveDeliveryModel(doc) {
54
86
  // avoids allocating one planned notification per participant per activity.
55
87
  if (act.reach === undefined && extractMentions(act.body).length === 0)
56
88
  continue;
57
- for (const planned of plan({ act, index: act.index })) {
58
- if (planned.via === 'broadcast')
89
+ for (const planned of plan(act)) {
90
+ if (planned.route === 'broadcast')
59
91
  continue;
60
92
  const joinedAt = joinedAfter.get(planned.recipient);
61
93
  if (joinedAt === undefined || act.index <= joinedAt)
62
94
  continue;
63
95
  if (isDeliveryDelivered(doc, planned.recipient, act.index))
64
96
  continue;
65
- pendingByRecipient.get(planned.recipient)?.push({ ...planned, via: planned.via });
97
+ pendingByRecipient.get(planned.recipient)?.push({ ...planned, route: planned.route });
66
98
  }
67
99
  }
68
100
  }
@@ -73,21 +105,39 @@ export function deriveDeliveryModel(doc) {
73
105
  export function planActNotifications(doc, item) {
74
106
  return deriveDeliveryModel(doc).plan(item);
75
107
  }
108
+ /** Mark only the directed notifications selected by the canonical pending projection. */
109
+ export function markDeliveredNotifications(doc, recipient, delivered, at = Date.now()) {
110
+ const deliveredIndexes = new Set(delivered.map((item) => item.index));
111
+ let changed = false;
112
+ for (const notification of deriveDeliveryModel(doc).pendingFor(recipient)) {
113
+ if (!deliveredIndexes.has(notification.item.index))
114
+ continue;
115
+ changed = recordDeliveredDelivery(doc, notification.recipient, notification.item.index, { at }) || changed;
116
+ }
117
+ return changed;
118
+ }
76
119
  /** Canonical say-activity filter shared by catch selection and hook ownership. */
77
120
  export function matchesCatchFilter(activity, filter) {
78
- if (activity.bell)
121
+ if (activity.reach === 'bell')
79
122
  return true;
80
123
  if (filter.participants !== undefined &&
81
124
  !filter.participants.some((participant) => sameName(participant, activity.actor))) {
82
125
  return false;
83
126
  }
84
- return filter.mention === undefined || matchesMentionTarget({ body: activity.body }, filter.mention);
127
+ return filter.mention === undefined || matchesMentionTarget(activity, filter.mention);
85
128
  }
86
129
  /** True only when the live catch's own filters would deliver this notification. */
87
130
  export function leaseOwnsNotification(lease, notification) {
131
+ const recipient = notification.recipient;
132
+ if (notification.route === 'beside' && recipient === undefined)
133
+ return false;
88
134
  return matchesCatchFilter({
89
135
  actor: notification.actor,
90
136
  body: notification.body,
91
- bell: notification.via === 'bell',
137
+ reach: notification.route === 'bell'
138
+ ? 'bell'
139
+ : notification.route === 'beside'
140
+ ? { beside: recipient }
141
+ : undefined,
92
142
  }, lease.filter ?? {});
93
143
  }
package/dist/doctor.js CHANGED
@@ -4,17 +4,19 @@ export function planRepair(text) {
4
4
  if (diagnosis.unfixable)
5
5
  return { diagnosis };
6
6
  const actions = [];
7
- const acts = diagnosis.acts.map(({ act }, index) => ({ ...act, index }));
8
- if (diagnosis.acts.some(({ act }, index) => act.index !== index)) {
7
+ const diagnosedFirstIndex = diagnosis.acts[0]?.act.index ?? 0;
8
+ const preservesStableIndexes = diagnosis.acts.every(({ act }, index) => act.index === diagnosedFirstIndex + index);
9
+ const acts = diagnosis.acts.map(({ act }, index) => ({
10
+ ...act,
11
+ index: preservesStableIndexes ? act.index : index,
12
+ }));
13
+ if (!preservesStableIndexes) {
9
14
  actions.push({ message: 'renumbered act indexes to be contiguous' });
10
15
  }
11
16
  if (diagnosis.quarantined.length > 0) {
12
17
  actions.push({ message: `quarantined ${diagnosis.quarantined.length} unparseable act block(s)` });
13
18
  }
14
- if (diagnosis.legacyParticipants.length > 0) {
15
- actions.push({ message: 'dropped frontmatter participants; roster now derives from join/done acts' });
16
- }
17
- const nextActIndex = acts.length;
19
+ const nextActIndex = acts.length > 0 ? acts[acts.length - 1].index + 1 : 0;
18
20
  const doc = {
19
21
  hardCap: diagnosis.hardCap,
20
22
  throttlePerMinute: diagnosis.throttlePerMinute,
@@ -24,9 +26,8 @@ export function planRepair(text) {
24
26
  runtime: {
25
27
  version: 2,
26
28
  nextActIndex,
27
- firstActIndex: acts.length > 0 ? (acts[0].index ?? 0) : 0,
28
29
  cursors: {},
29
- mentionReceipts: {},
30
+ deliveryReceipts: {},
30
31
  leases: {},
31
32
  },
32
33
  };
@@ -0,0 +1,68 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { SQUARE_IDENTITY } from './identity.js';
6
+ import { stageReplacement } from './harness-stage.js';
7
+ export const CLAUDE_PLUGIN_ID = SQUARE_IDENTITY.pluginId;
8
+ export const CLAUDE_MARKETPLACE_NAME = SQUARE_IDENTITY.marketplaceName;
9
+ export function claudeMarketplaceRoot(homeDir) {
10
+ return path.join(homeDir, '.square', 'claude', 'marketplaces', CLAUDE_MARKETPLACE_NAME);
11
+ }
12
+ function runClaude(homeDir, args) {
13
+ const result = spawnSync(process.env.SQUARE_CLAUDE_BIN || 'claude', args, {
14
+ encoding: 'utf8', env: { ...process.env, HOME: homeDir, CLAUDE_CONFIG_DIR: path.join(homeDir, '.claude') }, timeout: 30_000,
15
+ });
16
+ if (result.error)
17
+ throw result.error;
18
+ return { status: result.status ?? 1, stdout: result.stdout || '', stderr: result.stderr || '' };
19
+ }
20
+ function requireSuccess(result, operation, allowMissing = false) {
21
+ if (result.status === 0 || (allowMissing && /not configured|not installed|not found/i.test(result.stderr)))
22
+ return;
23
+ throw new Error(`Claude ${operation} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
24
+ }
25
+ function writeJson(file, value) {
26
+ fs.mkdirSync(path.dirname(file), { recursive: true });
27
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
28
+ }
29
+ export async function installClaudePlugin(homeDir, run = runClaude) {
30
+ const marketplaceRoot = claudeMarketplaceRoot(homeDir);
31
+ const staged = stageReplacement(marketplaceRoot, (stage) => {
32
+ const plugin = path.join(stage, 'plugins', SQUARE_IDENTITY.pluginName);
33
+ fs.cpSync(fileURLToPath(new URL('../skills/square/', import.meta.url)), plugin, { recursive: true });
34
+ writeJson(path.join(stage, '.claude-plugin', 'marketplace.json'), {
35
+ name: CLAUDE_MARKETPLACE_NAME,
36
+ plugins: [{ name: SQUARE_IDENTITY.pluginName, source: './plugins/square' }],
37
+ });
38
+ });
39
+ try {
40
+ const add = run(homeDir, ['plugin', 'marketplace', 'add', marketplaceRoot]);
41
+ requireSuccess(add, 'marketplace install', true);
42
+ requireSuccess(run(homeDir, ['plugin', 'install', CLAUDE_PLUGIN_ID]), 'plugin install');
43
+ requireSuccess(run(homeDir, ['plugin', 'update', CLAUDE_PLUGIN_ID]), 'plugin update');
44
+ staged.finalize();
45
+ }
46
+ catch (error) {
47
+ staged.rollback();
48
+ throw error;
49
+ }
50
+ return { marketplaceRoot, pluginRoot: path.join(marketplaceRoot, 'plugins', SQUARE_IDENTITY.pluginName) };
51
+ }
52
+ export async function uninstallClaudePlugin(homeDir, run = runClaude) {
53
+ const root = claudeMarketplaceRoot(homeDir);
54
+ requireSuccess(run(homeDir, ['plugin', 'remove', CLAUDE_PLUGIN_ID]), 'plugin removal', true);
55
+ requireSuccess(run(homeDir, ['plugin', 'marketplace', 'remove', CLAUDE_MARKETPLACE_NAME]), 'marketplace removal', true);
56
+ fs.rmSync(root, { recursive: true, force: true });
57
+ return { paths: [root], notes: [] };
58
+ }
59
+ export async function doctorClaudePlugin(homeDir, run = runClaude) {
60
+ const root = claudeMarketplaceRoot(homeDir);
61
+ const bundle = path.join(root, 'plugins', SQUARE_IDENTITY.pluginName);
62
+ const listed = run(homeDir, ['plugin', 'list', '--json']);
63
+ const installed = listed.status === 0 && listed.stdout.includes(CLAUDE_PLUGIN_ID);
64
+ return [
65
+ fs.existsSync(bundle) ? `✓ Square Claude plugin bundle ${root}` : `○ Square Claude plugin bundle missing ${root}`,
66
+ installed ? `✓ ${CLAUDE_PLUGIN_ID} installed` : `○ ${CLAUDE_PLUGIN_ID} unavailable`,
67
+ ];
68
+ }