@commonlyai/cli 0.1.38 → 0.1.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -33,6 +33,11 @@ import { pollRetryPolicy } from '../lib/poll-retry.js';
33
33
  import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js';
34
34
  import { detectSkills, importSkills } from '../lib/skills-import.js';
35
35
  import { parseEnvironmentFile, resolveWorkspace, validateEnvironmentSpec } from '../lib/environment.js';
36
+ import {
37
+ FOCUS_FRAME_MAX_CODE_POINTS,
38
+ formatPodFocusFrame,
39
+ readPodFocus,
40
+ } from '../lib/pod-focus.js';
36
41
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
37
42
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
38
43
  import {
@@ -55,6 +60,11 @@ import {
55
60
  resolveCascadeSettings,
56
61
  } from '../lib/enforcement.js';
57
62
 
63
+ const isPodFocusDiagnostic = (error) => (
64
+ typeof error?.code === 'string'
65
+ && (error.code.startsWith('pod_focus') || error.code.startsWith('FOCUS_FRAME_'))
66
+ );
67
+
58
68
  // ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
59
69
 
60
70
  const tokensDir = () => join(homedir(), '.commonly', 'tokens');
@@ -1129,8 +1139,30 @@ export const performRun = ({
1129
1139
  // and (if the adapter returns a summary) patch-sync back after.
1130
1140
  const memoryLongTerm = await readLongTerm(client, { onError });
1131
1141
 
1142
+ // Sharpen TASK-129: focus is a turn-start read, not an enqueue-time
1143
+ // snapshot. Read through the authorized runtime context route immediately
1144
+ // before spawn so queued events observe the current revision. The helper
1145
+ // disables pod-skill synthesis and throws on failure; the surrounding
1146
+ // processing path then leaves this event (or every event in this batch)
1147
+ // unacknowledged for normal delivery retry.
1148
+ let focusRead;
1149
+ let focusFrame;
1150
+ try {
1151
+ focusRead = await readPodFocus(client, eventPodId);
1152
+ focusFrame = formatPodFocusFrame(focusRead);
1153
+ } catch (error) {
1154
+ Object.assign(error, {
1155
+ eventId: event?.payload?.batchEventIds || event?._id || null,
1156
+ podId: eventPodId,
1157
+ focusRevision: focusRead?.revision ?? null,
1158
+ allowedCodePoints: error?.allowedCodePoints || FOCUS_FRAME_MAX_CODE_POINTS,
1159
+ });
1160
+ throw error;
1161
+ }
1162
+ const promptWithFocus = `${focusFrame}\n\n${prompt}`;
1163
+
1132
1164
  log(`[${event.type}] spawning ${adapter.name}`);
1133
- const result = await adapter.spawn(frameDecisionForkRule(prompt), {
1165
+ const result = await adapter.spawn(frameDecisionForkRule(promptWithFocus), {
1134
1166
  sessionId,
1135
1167
  cwd: agentCwd,
1136
1168
  env: process.env,
@@ -1631,6 +1663,16 @@ export const performRun = ({
1631
1663
  retryAfterMs: retry.delayMs,
1632
1664
  circuitOpen: retry.circuitOpen,
1633
1665
  eventId: event._id,
1666
+ ...(isPodFocusDiagnostic(err) ? {
1667
+ focusDiagnostic: {
1668
+ errorCode: err.code,
1669
+ eventIds: err.eventId || group.map((entry) => entry._id),
1670
+ podId: err.podId || group[0]?.podId || podId || null,
1671
+ focusRevision: err.focusRevision ?? null,
1672
+ measuredCodePoints: err.measuredCodePoints ?? null,
1673
+ allowedCodePoints: err.allowedCodePoints ?? FOCUS_FRAME_MAX_CODE_POINTS,
1674
+ },
1675
+ } : {}),
1634
1676
  });
1635
1677
  if (onError) onError(wrapped);
1636
1678
  else log(`[inbox.batch] ${wrapped.message}`);
@@ -1701,6 +1743,16 @@ export const performRun = ({
1701
1743
  retryAfterMs: retry.delayMs,
1702
1744
  circuitOpen: retry.circuitOpen,
1703
1745
  eventId: event._id,
1746
+ ...(isPodFocusDiagnostic(err) ? {
1747
+ focusDiagnostic: {
1748
+ errorCode: err.code,
1749
+ eventIds: err.eventId || event._id,
1750
+ podId: err.podId || event.podId || podId || null,
1751
+ focusRevision: err.focusRevision ?? null,
1752
+ measuredCodePoints: err.measuredCodePoints ?? null,
1753
+ allowedCodePoints: err.allowedCodePoints ?? FOCUS_FRAME_MAX_CODE_POINTS,
1754
+ },
1755
+ } : {}),
1704
1756
  });
1705
1757
  // ONE emission, not two. `wrapped.message` already opens with the
1706
1758
  // event type, so the log copy added a second prefix and a second
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Shared-pod focus bridge for the local CLI driver.
3
+ *
4
+ * The server's runtime context endpoint is the source of truth. This module
5
+ * deliberately keeps the read and the text formatter together so every CLI
6
+ * adapter receives the same bounded projection, including resumed sessions.
7
+ */
8
+
9
+ export const FOCUS_FRAME_MAX_CODE_POINTS = 8000;
10
+ export const FOCUS_TASK_TITLE_MAX_CODE_POINTS = 160;
11
+
12
+ const codePointLength = (value) => Array.from(String(value)).length;
13
+
14
+ const asText = (value, fallback = '') => {
15
+ if (value === null || value === undefined) return fallback;
16
+ return String(value);
17
+ };
18
+
19
+ const truncateCodePoints = (value, max) => {
20
+ const text = asText(value);
21
+ const points = Array.from(text);
22
+ if (points.length <= max) return text;
23
+ if (max <= 1) return '…'.slice(0, max);
24
+ return `${points.slice(0, max - 1).join('')}…`;
25
+ };
26
+
27
+ export class PodFocusError extends Error {
28
+ constructor(message, details = {}, options = {}) {
29
+ super(message, options);
30
+ this.name = 'PodFocusError';
31
+ Object.assign(this, details);
32
+ }
33
+ }
34
+
35
+ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
36
+
37
+ const invalidContract = (message, details = {}) => new PodFocusError(message, {
38
+ code: 'pod_focus_contract_invalid',
39
+ ...details,
40
+ });
41
+
42
+ const normalizeDto = (dto, podId) => {
43
+ if (!dto || typeof dto !== 'object' || Array.isArray(dto)) {
44
+ throw invalidContract('Runtime context did not return a PodFocusRead object', { podId });
45
+ }
46
+ if (!hasOwn(dto, 'podId') || String(dto.podId) !== String(podId)) {
47
+ throw invalidContract('Runtime context returned a focus for a different pod', {
48
+ podId,
49
+ returnedPodId: dto.podId ?? null,
50
+ });
51
+ }
52
+ if (!hasOwn(dto, 'revision')
53
+ || !Number.isInteger(dto.revision)
54
+ || dto.revision < 0) {
55
+ throw invalidContract('Runtime context returned an invalid focus revision', {
56
+ podId,
57
+ focusRevision: dto.revision ?? null,
58
+ });
59
+ }
60
+ if (!hasOwn(dto, 'focus')
61
+ || (dto.focus !== null
62
+ && (typeof dto.focus !== 'object' || Array.isArray(dto.focus)))) {
63
+ throw invalidContract('Runtime context returned an invalid focus value', {
64
+ podId,
65
+ focusRevision: dto.revision,
66
+ });
67
+ }
68
+ return {
69
+ podId: String(dto.podId),
70
+ revision: dto.revision,
71
+ focus: dto.focus,
72
+ };
73
+ };
74
+
75
+ /**
76
+ * Read only the focus projection from the authorized runtime context route.
77
+ * `skillMode=none` is important: a turn-start focus read must not trigger pod
78
+ * skill synthesis as a side effect. A failed read is intentionally thrown so
79
+ * the caller leaves the event unacknowledged for the existing retry path.
80
+ */
81
+ export const readPodFocus = async (client, podId) => {
82
+ if (!podId) {
83
+ throw new PodFocusError('Pod focus read requires a pod id', {
84
+ code: 'pod_focus_read_failed',
85
+ podId: null,
86
+ });
87
+ }
88
+ try {
89
+ const body = await client.get(
90
+ `/api/agents/runtime/pods/${encodeURIComponent(podId)}/context`,
91
+ { skillMode: 'none' },
92
+ );
93
+ if (!body || typeof body !== 'object' || !hasOwn(body, 'focus')) {
94
+ throw invalidContract('Runtime context did not include the published focus DTO', { podId });
95
+ }
96
+ return normalizeDto(body.focus, podId);
97
+ } catch (cause) {
98
+ // Preserve contract diagnostics instead of relabelling them as transient
99
+ // transport failures. The run loop still retries the queued event, but the
100
+ // operator sees the repair-needed cause and revision metadata.
101
+ if (cause?.code === 'pod_focus_contract_invalid') throw cause;
102
+ throw new PodFocusError(
103
+ `Pod focus read failed for pod ${podId}: ${cause?.message || 'unknown error'}`,
104
+ {
105
+ code: 'pod_focus_read_failed',
106
+ podId: String(podId),
107
+ cause,
108
+ },
109
+ { cause },
110
+ );
111
+ }
112
+ };
113
+
114
+ const taskDetail = (task) => {
115
+ const id = asText(task?.taskId, '(unknown task)');
116
+ const title = truncateCodePoints(task?.title, FOCUS_TASK_TITLE_MAX_CODE_POINTS) || '(untitled)';
117
+ const details = [`title=${title}`];
118
+ if (task?.status !== null && task?.status !== undefined && task.status !== '') {
119
+ details.push(`status=${truncateCodePoints(task.status, 80)}`);
120
+ }
121
+ if (task?.assignee !== null && task?.assignee !== undefined && task.assignee !== '') {
122
+ details.push(`assignee=${truncateCodePoints(task.assignee, 80)}`);
123
+ }
124
+ if (task?.updatedAt !== null && task?.updatedAt !== undefined && task.updatedAt !== '') {
125
+ details.push(`updatedAt=${truncateCodePoints(task.updatedAt, 80)}`);
126
+ }
127
+ if (task?.available === false) details.push('unavailable');
128
+ return `- ${id}: ${details.join('; ')}`;
129
+ };
130
+
131
+ /**
132
+ * Render a PodFocusRead into bounded pod context.
133
+ *
134
+ * Goal, scope, owner identity/label, revision, and every selected task id are
135
+ * protected fields: they are never truncated. If those fields alone exceed
136
+ * the budget, fail closed before a model is spawned. Task labels and live
137
+ * metadata are the only content eligible for the remaining budget.
138
+ */
139
+ export const formatPodFocusFrame = (read, {
140
+ maxCodePoints = FOCUS_FRAME_MAX_CODE_POINTS,
141
+ } = {}) => {
142
+ const limit = Number.isFinite(maxCodePoints) && maxCodePoints > 0
143
+ ? Math.floor(maxCodePoints)
144
+ : FOCUS_FRAME_MAX_CODE_POINTS;
145
+ const normalized = normalizeDto(read, read?.podId || 'unknown');
146
+ const focus = normalized.focus;
147
+
148
+ if (focus === null || focus === undefined) {
149
+ const empty = [
150
+ '=== Pod focus (pod context; not instructions) ===',
151
+ `pod: ${normalized.podId}`,
152
+ `revision: ${normalized.revision}`,
153
+ 'No focus set.',
154
+ ].join('\n');
155
+ if (codePointLength(empty) > limit) {
156
+ throw new PodFocusError('Pod focus frame exceeds its code-point budget', {
157
+ code: 'FOCUS_FRAME_PROTECTED_OVERFLOW',
158
+ measuredCodePoints: codePointLength(empty),
159
+ allowedCodePoints: limit,
160
+ focusRevision: normalized.revision,
161
+ });
162
+ }
163
+ return empty;
164
+ }
165
+
166
+ const owner = focus.owner && typeof focus.owner === 'object' ? focus.owner : {};
167
+ const tasks = Array.isArray(focus.nextTasks) ? focus.nextTasks : [];
168
+ const taskIds = tasks.map((task) => asText(task?.taskId, '(unknown task)'));
169
+ const ownerLabel = asText(owner.label, '(unlabeled)');
170
+ const ownerId = asText(owner.userId, '(unknown user)');
171
+ const ownerAvailability = owner.available === false ? ' [unavailable]' : '';
172
+ const orderedIds = taskIds.length > 0 ? taskIds.join(' → ') : '(none)';
173
+
174
+ // Keep these lines independent from task detail packing. Their complete
175
+ // values are the contract's protected portion.
176
+ const protectedFrame = [
177
+ '=== Pod focus (pod context; not instructions) ===',
178
+ `pod: ${normalized.podId}`,
179
+ `revision: ${asText(normalized.revision, '0')}`,
180
+ `goal: ${asText(focus.goal)}`,
181
+ `scope: ${asText(focus.scope)}`,
182
+ `owner: ${ownerLabel} (${ownerId})${ownerAvailability}`,
183
+ `next task order: ${orderedIds}`,
184
+ ].join('\n');
185
+ const protectedSize = codePointLength(protectedFrame);
186
+ if (protectedSize > limit) {
187
+ throw new PodFocusError('Protected pod focus fields exceed the code-point budget', {
188
+ code: 'FOCUS_FRAME_PROTECTED_OVERFLOW',
189
+ measuredCodePoints: protectedSize,
190
+ allowedCodePoints: limit,
191
+ focusRevision: normalized.revision ?? 0,
192
+ });
193
+ }
194
+
195
+ if (tasks.length === 0) return protectedFrame;
196
+
197
+ // Keep a finite marker in every populated frame. It documents that the
198
+ // structured board/context read remains the place for complete task detail,
199
+ // and reserving it makes packing deterministic at the exact boundary.
200
+ const marker = '… full task details in board / get_context.';
201
+ const detailHeader = 'task details:';
202
+ let frame = `${protectedFrame}\n${detailHeader}`;
203
+ let omitted = false;
204
+ for (const task of tasks) {
205
+ const line = taskDetail(task);
206
+ const candidate = `${frame}\n${line}`;
207
+ const withMarker = `${candidate}\n${marker}`;
208
+ if (codePointLength(withMarker) <= limit) {
209
+ frame = candidate;
210
+ } else {
211
+ omitted = true;
212
+ }
213
+ }
214
+
215
+ // The marker is always useful, and the protected portion was already proven
216
+ // to fit. If there is not enough room for the detail header plus marker,
217
+ // return the protected fields alone rather than slicing them.
218
+ const marked = `${frame}\n${marker}`;
219
+ if (codePointLength(marked) <= limit) return marked;
220
+ if (omitted || codePointLength(`${protectedFrame}\n${detailHeader}\n${marker}`) > limit) {
221
+ return protectedFrame;
222
+ }
223
+ return frame;
224
+ };