@nonbot/cli 0.5.14 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ import path from 'node:path';
2
+ import { NAME_RE, BRANCH_RE, PANE_ID_RE } from './types.js';
3
+ export function isValidName(s) {
4
+ return typeof s === 'string' && NAME_RE.test(s);
5
+ }
6
+ export function isValidBranch(s) {
7
+ return typeof s === 'string' && BRANCH_RE.test(s);
8
+ }
9
+ export function isValidPaneId(s) {
10
+ return typeof s === 'string' && PANE_ID_RE.test(s);
11
+ }
12
+ export function assertValidName(s, kind = 'name') {
13
+ if (!isValidName(s)) {
14
+ throw new Error(`invalid choir ${kind}: must match ^[a-z0-9][a-z0-9-]{0,30}$`);
15
+ }
16
+ return s;
17
+ }
18
+ export function assertValidBranch(s, kind = 'branch') {
19
+ if (!isValidBranch(s)) {
20
+ throw new Error(`invalid choir ${kind}: must match ^[a-z0-9][a-z0-9/-]{0,60}$`);
21
+ }
22
+ return s;
23
+ }
24
+ export function worktreePathFor(choirRoot, sessionName, paneName) {
25
+ assertValidName(sessionName, 'session');
26
+ assertValidName(paneName, 'pane');
27
+ const box = path.resolve(choirRoot, '.choir/wt');
28
+ const full = path.join(choirRoot, '.choir/wt', paneName);
29
+ const resolved = path.resolve(full);
30
+ if (resolved !== box && !resolved.startsWith(box + path.sep)) {
31
+ throw new Error('choir worktree path escaped .choir/wt containment');
32
+ }
33
+ return full;
34
+ }
35
+ export function branchFor(sessionName, paneName) {
36
+ assertValidName(sessionName, 'session');
37
+ assertValidName(paneName, 'pane');
38
+ return `choir/${sessionName}/${paneName}`;
39
+ }
@@ -0,0 +1,210 @@
1
+ import { SEVERITY_WEIGHT } from './types.js';
2
+ export function itemId(sessionId, paneId, type) {
3
+ return `${sessionId}::${paneId ?? '_session'}::${type}`;
4
+ }
5
+ function entryType(e) {
6
+ const reason = e.refs?.blockReason;
7
+ if (reason === 'agent-crashed' || reason === 'spawn-failed')
8
+ return 'crashed';
9
+ if (reason === 'merge-conflict')
10
+ return 'merge-conflict';
11
+ if (reason === 'build-failed')
12
+ return 'build-failed';
13
+ switch (e.stage) {
14
+ case 'awaiting-input':
15
+ return 'awaiting-input';
16
+ case 'reconcile-blocked':
17
+ return 'reconcile-blocked';
18
+ case 'collision-detected':
19
+ return 'collision';
20
+ case 'stalled':
21
+ return 'stalled';
22
+ case 'monitor-offline':
23
+ return 'monitor-offline';
24
+ case 'pane-failed':
25
+ return 'crashed';
26
+ default:
27
+ return null;
28
+ }
29
+ }
30
+ const PANE_RESUME_STAGES = new Set([
31
+ 'resumed',
32
+ 'working',
33
+ 'agent-started',
34
+ ]);
35
+ const PANE_TERMINAL_OK_STAGES = new Set([
36
+ 'pane-complete',
37
+ 'finished',
38
+ 'pane-stopped',
39
+ ]);
40
+ export function projectNeedsYou(events, _now) {
41
+ const live = new Map();
42
+ const ordered = [...events].sort((a, b) => a.ts - b.ts);
43
+ for (const e of ordered) {
44
+ const paneResolves = e.paneId != null &&
45
+ (PANE_TERMINAL_OK_STAGES.has(e.stage) ||
46
+ (e.terminal && (e.stage === 'pane-complete' || e.stage === 'finished')));
47
+ if (paneResolves) {
48
+ for (const [id, item] of [...live]) {
49
+ if (item.paneId === e.paneId)
50
+ live.delete(id);
51
+ }
52
+ continue;
53
+ }
54
+ if (PANE_RESUME_STAGES.has(e.stage) && e.paneId != null) {
55
+ live.delete(itemId(e.sessionId, e.paneId, 'awaiting-input'));
56
+ live.delete(itemId(e.sessionId, e.paneId, 'stalled'));
57
+ continue;
58
+ }
59
+ if (e.stage === 'collision-cleared') {
60
+ live.delete(itemId(e.sessionId, e.paneId, 'collision'));
61
+ continue;
62
+ }
63
+ if (e.stage === 'reconcile-complete') {
64
+ live.delete(itemId(e.sessionId, e.paneId, 'reconcile-blocked'));
65
+ live.delete(itemId(e.sessionId, e.paneId, 'merge-conflict'));
66
+ continue;
67
+ }
68
+ const type = entryType(e);
69
+ if (!type)
70
+ continue;
71
+ const id = itemId(e.sessionId, e.paneId, type);
72
+ const existing = live.get(id);
73
+ if (existing) {
74
+ existing.severity = e.severity;
75
+ existing.summary = e.summary;
76
+ existing.reason = e.refs?.blockReason ?? existing.reason;
77
+ existing.branch = e.refs?.branch ?? existing.branch;
78
+ }
79
+ else {
80
+ live.set(id, {
81
+ id,
82
+ sessionId: e.sessionId,
83
+ paneId: e.paneId,
84
+ branch: e.refs?.branch,
85
+ type,
86
+ severity: e.severity,
87
+ reason: e.refs?.blockReason,
88
+ summary: e.summary,
89
+ enteredAt: e.ts,
90
+ dismissed: false,
91
+ });
92
+ }
93
+ }
94
+ return [...live.values()];
95
+ }
96
+ const HALT_TYPES = new Set(['crashed', 'reconcile-blocked']);
97
+ function ageMin(item, now) {
98
+ return Math.max(0, (now - item.enteredAt) / 60_000);
99
+ }
100
+ function priority(item, now) {
101
+ return SEVERITY_WEIGHT[item.severity] * 100 + Math.min(ageMin(item, now), 60);
102
+ }
103
+ export function prioritize(items, now) {
104
+ return [...items].sort((a, b) => {
105
+ const aHalt = HALT_TYPES.has(a.type);
106
+ const bHalt = HALT_TYPES.has(b.type);
107
+ if (aHalt !== bHalt)
108
+ return aHalt ? -1 : 1;
109
+ const pd = priority(b, now) - priority(a, now);
110
+ if (pd !== 0)
111
+ return pd;
112
+ return a.enteredAt - b.enteredAt;
113
+ });
114
+ }
115
+ export function dismiss(items, id) {
116
+ return items.map((i) => (i.id === id ? { ...i, dismissed: true } : i));
117
+ }
118
+ const HISTORY_STAGES = new Set([
119
+ 'broadcast-sent',
120
+ 'collision-detected',
121
+ 'collision-cleared',
122
+ 'reconcile-started',
123
+ 'reconcile-complete',
124
+ 'reconcile-blocked',
125
+ 'session-created',
126
+ 'worktrees-provisioned',
127
+ 'panes-spawned',
128
+ 'all-complete',
129
+ 'session-ended',
130
+ 'pane-spawned',
131
+ 'pane-complete',
132
+ 'pane-failed',
133
+ 'pane-stopped',
134
+ 'merge-step',
135
+ 'build-gate',
136
+ 'monitor-offline',
137
+ 'stalled',
138
+ ]);
139
+ function isHistoryStage(e) {
140
+ if (HISTORY_STAGES.has(e.stage))
141
+ return true;
142
+ if (e.severity === 'attention-needed' || e.severity === 'blocked' || e.severity === 'error')
143
+ return true;
144
+ return e.category === 'lifecycle' || e.category === 'coordination' || e.category === 'reconcile' || e.category === 'verification';
145
+ }
146
+ function paneTally(events) {
147
+ const lastStatus = new Map();
148
+ for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
149
+ if (e.paneId == null)
150
+ continue;
151
+ if (PANE_TERMINAL_OK_STAGES.has(e.stage) || (e.terminal && e.stage === 'pane-complete')) {
152
+ lastStatus.set(e.paneId, 'done');
153
+ }
154
+ else if (e.stage === 'pane-failed' || e.stage === 'pane-stopped') {
155
+ lastStatus.set(e.paneId, 'done');
156
+ }
157
+ else {
158
+ lastStatus.set(e.paneId, 'working');
159
+ }
160
+ }
161
+ let done = 0;
162
+ let working = 0;
163
+ for (const v of lastStatus.values())
164
+ v === 'done' ? done++ : working++;
165
+ return { done, working };
166
+ }
167
+ function buildSessionNarrative(sessionId, events) {
168
+ const { done, working } = paneTally(events);
169
+ const lastTs = events.reduce((m, e) => Math.max(m, e.ts), 0);
170
+ const parts = [];
171
+ if (done > 0)
172
+ parts.push(`${done} pane${done === 1 ? '' : 's'} finished`);
173
+ if (working > 0)
174
+ parts.push(`${working} still working`);
175
+ const blocks = events.filter((e) => e.severity === 'blocked' || e.severity === 'error').length;
176
+ if (blocks > 0)
177
+ parts.push(`${blocks} need attention`);
178
+ const narrative = parts.length ? `${parts.join(', ')}.` : 'No notable activity.';
179
+ return { sessionId, narrative, lastTs };
180
+ }
181
+ export function buildDigest(events, lastSeenAt, now) {
182
+ const windowed = events.filter((e) => e.ts > lastSeenAt);
183
+ const projected = projectNeedsYou(events, now).filter((i) => !i.dismissed);
184
+ const needsYou = prioritize(projected, now);
185
+ const bySession = new Map();
186
+ for (const e of windowed) {
187
+ const arr = bySession.get(e.sessionId) ?? [];
188
+ arr.push(e);
189
+ bySession.set(e.sessionId, arr);
190
+ }
191
+ const sessions = [...bySession.entries()]
192
+ .map(([sid, evs]) => buildSessionNarrative(sid, evs))
193
+ .sort((a, b) => b.lastTs - a.lastTs);
194
+ const changes = windowed
195
+ .filter(isHistoryStage)
196
+ .sort((a, b) => b.ts - a.ts)
197
+ .map((e) => ({
198
+ stage: e.stage,
199
+ severity: e.severity,
200
+ paneId: e.paneId,
201
+ summary: e.summary,
202
+ ts: e.ts,
203
+ }));
204
+ const digest = { sessions, needsYou, changes };
205
+ if (needsYou.length === 0) {
206
+ const { done, working } = paneTally(windowed);
207
+ digest.calmLine = `Nothing needs you. ${done} pane${done === 1 ? '' : 's'} done, ${working} still working.`;
208
+ }
209
+ return digest;
210
+ }
@@ -0,0 +1,228 @@
1
+ import { SCHEMA_VERSION, SUMMARY_MAX, EGRESS_ALLOWED_TOP_KEYS, EGRESS_FORBIDDEN_KEYS, BRANCH_RE, } from './types.js';
2
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
3
+ const TIME_LEN = 10;
4
+ const RAND_LEN = 16;
5
+ function encodeTime(ms) {
6
+ let t = Math.floor(ms);
7
+ const out = new Array(TIME_LEN);
8
+ for (let i = TIME_LEN - 1; i >= 0; i--) {
9
+ const mod = t % 32;
10
+ out[i] = CROCKFORD[mod];
11
+ t = Math.floor(t / 32);
12
+ }
13
+ return out.join('');
14
+ }
15
+ function encodeRandom(random) {
16
+ const out = new Array(RAND_LEN);
17
+ for (let i = 0; i < RAND_LEN; i++) {
18
+ out[i] = Math.floor(random() * 32) % 32;
19
+ }
20
+ return out;
21
+ }
22
+ function indicesToString(idx) {
23
+ let s = '';
24
+ for (const i of idx)
25
+ s += CROCKFORD[i];
26
+ return s;
27
+ }
28
+ export function ulid(ms, random = Math.random) {
29
+ return encodeTime(ms) + indicesToString(encodeRandom(random));
30
+ }
31
+ export function makeUlidFactory(deps) {
32
+ const random = deps.random ?? Math.random;
33
+ let lastMs = -1;
34
+ let lastRand = [];
35
+ return function next() {
36
+ const ms = Math.floor(deps.now());
37
+ if (ms === lastMs) {
38
+ lastRand = incrementBase32(lastRand);
39
+ }
40
+ else {
41
+ lastMs = ms;
42
+ lastRand = encodeRandom(random);
43
+ }
44
+ return encodeTime(ms) + indicesToString(lastRand);
45
+ };
46
+ }
47
+ function incrementBase32(idx) {
48
+ const out = idx.slice();
49
+ for (let i = out.length - 1; i >= 0; i--) {
50
+ if (out[i] < 31) {
51
+ out[i] += 1;
52
+ return out;
53
+ }
54
+ out[i] = 0;
55
+ }
56
+ return out;
57
+ }
58
+ const STAGE_TABLE = {
59
+ queued: { category: 'lifecycle', severity: 'info', terminal: false },
60
+ accepted: { category: 'lifecycle', severity: 'info', terminal: false },
61
+ launching: { category: 'provisioning', severity: 'progress', terminal: false },
62
+ 'agent-started': { category: 'lifecycle', severity: 'progress', terminal: false },
63
+ working: { category: 'work', severity: 'progress', terminal: false },
64
+ 'awaiting-input': { category: 'work', severity: 'blocked', terminal: false },
65
+ resumed: { category: 'work', severity: 'progress', terminal: false },
66
+ finished: { category: 'lifecycle', severity: 'info', terminal: true },
67
+ failed: { category: 'fault', severity: 'error', terminal: true },
68
+ stopped: { category: 'control', severity: 'info', terminal: true },
69
+ 'session-created': { category: 'lifecycle', severity: 'info', terminal: false },
70
+ 'worktrees-provisioned': { category: 'provisioning', severity: 'progress', terminal: false },
71
+ 'panes-spawned': { category: 'provisioning', severity: 'progress', terminal: false },
72
+ 'collision-detected': { category: 'coordination', severity: 'attention-needed', terminal: false },
73
+ 'collision-cleared': { category: 'coordination', severity: 'progress', terminal: false },
74
+ 'all-complete': { category: 'lifecycle', severity: 'attention-needed', terminal: false },
75
+ 'reconcile-started': { category: 'reconcile', severity: 'progress', terminal: false },
76
+ 'reconcile-complete': { category: 'reconcile', severity: 'info', terminal: true },
77
+ 'reconcile-blocked': { category: 'reconcile', severity: 'blocked', terminal: true },
78
+ 'session-ended': { category: 'lifecycle', severity: 'info', terminal: true },
79
+ 'pane-spawned': { category: 'provisioning', severity: 'progress', terminal: false },
80
+ 'claim-announced': { category: 'coordination', severity: 'progress', terminal: false },
81
+ 'broadcast-sent': { category: 'coordination', severity: 'attention-needed', terminal: false },
82
+ 'pane-complete': { category: 'work', severity: 'progress', terminal: true },
83
+ 'pane-failed': { category: 'fault', severity: 'error', terminal: true },
84
+ 'pane-stopped': { category: 'control', severity: 'info', terminal: true },
85
+ 'merge-step': { category: 'reconcile', severity: 'progress', terminal: false },
86
+ 'build-gate': { category: 'verification', severity: 'progress', terminal: false },
87
+ slow: { category: 'fault', severity: 'attention-needed', terminal: false },
88
+ stalled: { category: 'fault', severity: 'attention-needed', terminal: false },
89
+ 'monitor-offline': { category: 'fault', severity: 'error', terminal: false },
90
+ };
91
+ const UNKNOWN_STAGE_META = {
92
+ category: 'work',
93
+ severity: 'progress',
94
+ terminal: false,
95
+ };
96
+ export function stageMapping(stage) {
97
+ return STAGE_TABLE[stage] ?? UNKNOWN_STAGE_META;
98
+ }
99
+ function clampStr(s, max = SUMMARY_MAX) {
100
+ if (typeof s !== 'string')
101
+ return '';
102
+ return s.slice(0, max);
103
+ }
104
+ export function makeEvent(input, deps) {
105
+ const meta = stageMapping(input.stage);
106
+ const eventId = ulid(Math.floor(deps.now()), deps.random);
107
+ const event = {
108
+ schemaVersion: SCHEMA_VERSION,
109
+ eventId,
110
+ sessionId: clampStr(input.sessionId),
111
+ engine: input.engine,
112
+ paneId: input.paneId ?? null,
113
+ parentEventId: input.parentEventId ?? null,
114
+ stage: input.stage,
115
+ category: meta.category,
116
+ severity: meta.severity,
117
+ terminal: meta.terminal,
118
+ summary: clampStr(input.summary),
119
+ ts: Math.floor(input.ts),
120
+ };
121
+ if (input.metrics) {
122
+ event.metrics = sanitizeMetrics(input.metrics);
123
+ }
124
+ if (input.refs) {
125
+ event.refs = sanitizeRefs(input.refs);
126
+ }
127
+ return event;
128
+ }
129
+ const FORBIDDEN_KEY_SET = new Set(EGRESS_FORBIDDEN_KEYS);
130
+ const ALLOWED_TOP_SET = new Set(EGRESS_ALLOWED_TOP_KEYS);
131
+ function isForbiddenKey(key) {
132
+ return FORBIDDEN_KEY_SET.has(key);
133
+ }
134
+ function sanitizeMetrics(m) {
135
+ const out = {};
136
+ if (m && typeof m === 'object') {
137
+ for (const [k, v] of Object.entries(m)) {
138
+ if (isForbiddenKey(k))
139
+ continue;
140
+ if (typeof v === 'number' && Number.isFinite(v))
141
+ out[k] = v;
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ function sanitizeRefs(r) {
147
+ return deepClean(r);
148
+ }
149
+ function deepClean(value) {
150
+ if (typeof value === 'string')
151
+ return value.slice(0, SUMMARY_MAX);
152
+ if (typeof value === 'number')
153
+ return Number.isFinite(value) ? value : null;
154
+ if (typeof value === 'boolean' || value === null)
155
+ return value;
156
+ if (Array.isArray(value))
157
+ return value.map((v) => deepClean(v));
158
+ if (value && typeof value === 'object') {
159
+ const out = {};
160
+ for (const [k, v] of Object.entries(value)) {
161
+ if (isForbiddenKey(k))
162
+ continue;
163
+ out[k] = deepClean(v);
164
+ }
165
+ return out;
166
+ }
167
+ return null;
168
+ }
169
+ export function sanitizeForEgress(obj) {
170
+ const out = {};
171
+ if (!obj || typeof obj !== 'object')
172
+ return out;
173
+ for (const [k, v] of Object.entries(obj)) {
174
+ if (!ALLOWED_TOP_SET.has(k))
175
+ continue;
176
+ if (isForbiddenKey(k))
177
+ continue;
178
+ if (k === 'metrics') {
179
+ out[k] = sanitizeMetrics(v);
180
+ continue;
181
+ }
182
+ out[k] = deepClean(v);
183
+ }
184
+ return out;
185
+ }
186
+ const SECRET_RE = /\b(pat_[A-Za-z0-9_-]{4,}|sk_[A-Za-z0-9_-]{4,})/;
187
+ const DIFF_RE = /^(diff --git |@@ |[+-]{3} )|(\n[+-])/;
188
+ const PATH_RE = /(^|[\s'"])\/[\w.-]+\/[\w./-]+/;
189
+ function looksLikePath(s) {
190
+ return PATH_RE.test(s);
191
+ }
192
+ function looksLikeDiff(s) {
193
+ return DIFF_RE.test(s);
194
+ }
195
+ function looksLikeSecret(s) {
196
+ return SECRET_RE.test(s);
197
+ }
198
+ export function assertNoForbiddenFields(payload, keyPath = '$') {
199
+ walk(payload, keyPath, false);
200
+ }
201
+ function walk(value, keyPath, parentIsBranch) {
202
+ if (typeof value === 'string') {
203
+ if (parentIsBranch && BRANCH_RE.test(value))
204
+ return;
205
+ if (looksLikeSecret(value)) {
206
+ throw new Error(`assertNoForbiddenFields: secret-like value at ${keyPath}`);
207
+ }
208
+ if (looksLikeDiff(value)) {
209
+ throw new Error(`assertNoForbiddenFields: diff-like value at ${keyPath}`);
210
+ }
211
+ if (looksLikePath(value)) {
212
+ throw new Error(`assertNoForbiddenFields: path-like value at ${keyPath}`);
213
+ }
214
+ return;
215
+ }
216
+ if (Array.isArray(value)) {
217
+ value.forEach((v, i) => walk(v, `${keyPath}[${i}]`, false));
218
+ return;
219
+ }
220
+ if (value && typeof value === 'object') {
221
+ for (const [k, v] of Object.entries(value)) {
222
+ if (isForbiddenKey(k)) {
223
+ throw new Error(`assertNoForbiddenFields: forbidden key '${k}' at ${keyPath}.${k}`);
224
+ }
225
+ walk(v, `${keyPath}.${k}`, k === 'branch');
226
+ }
227
+ }
228
+ }