@oxygen-agent/cli 1.638.1 → 1.662.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.
- package/README.md +1 -1
- package/dist/command-manifest.js +5 -2
- package/dist/index.js +956 -124
- package/node_modules/@oxygen/shared/dist/axiom-field-budget.d.ts +91 -0
- package/node_modules/@oxygen/shared/dist/axiom-field-budget.js +262 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +55 -0
- package/node_modules/@oxygen/shared/dist/collab.d.ts +228 -0
- package/node_modules/@oxygen/shared/dist/collab.js +410 -0
- package/node_modules/@oxygen/shared/dist/column-types.d.ts +3 -3
- package/node_modules/@oxygen/shared/dist/column-types.js +26 -1
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +30 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +7 -6
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +69 -8
- package/node_modules/@oxygen/shared/dist/tags.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/tags.js +2 -5
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/package.json +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// Collaboration vocabulary (Control-layer interaction state, NOT a sixteenth
|
|
2
|
+
// primitive). A comment thread or an approval request is state ABOUT a GTM
|
|
3
|
+
// artifact — the artifact itself still belongs to its owning primitive, and the
|
|
4
|
+
// approval gate is honored by that primitive's own route (Sequences still owns
|
|
5
|
+
// whether a sequence launches). Same precedent ADR 0013 set for the Workspace
|
|
6
|
+
// Copilot: durable interaction state may own a tenant store, GTM primitive
|
|
7
|
+
// state may not.
|
|
8
|
+
//
|
|
9
|
+
// This module is the ONE vocabulary the tenant schema (`ox_collab`), the
|
|
10
|
+
// `/api/cli/collab/*` routes, the CLI groups, and the MCP tools all read from:
|
|
11
|
+
// which kinds can carry a thread, how a subject is addressed as a string, which
|
|
12
|
+
// gates apply to which kinds, and what the statuses are. Nothing here talks to a
|
|
13
|
+
// database or a transport — it is deliberately importable from every surface.
|
|
14
|
+
//
|
|
15
|
+
// Unlike Tags (a label that fits in a `text[]` column on the row it describes),
|
|
16
|
+
// a thread is an independent entity with its own author, body, replies, and
|
|
17
|
+
// lifecycle, so it is stored separately and addressed by an explicit subject
|
|
18
|
+
// reference — `<kind>:<id>` — rather than by a column on the subject.
|
|
19
|
+
import { OxygenError } from "./cli-result.js";
|
|
20
|
+
/**
|
|
21
|
+
* Workspace objects that can carry a comment thread or an approval request.
|
|
22
|
+
*
|
|
23
|
+
* Seeded from {@link TAG_KINDS}: anything worth labelling for a campaign is
|
|
24
|
+
* worth discussing with the client who signs that campaign off. The list is
|
|
25
|
+
* RESTATED rather than aliased because it is also a stored value — the
|
|
26
|
+
* `ox_collab` CHECK constraint and existing rows pin it — so widening the tag
|
|
27
|
+
* vocabulary must be a deliberate decision here, not a silent side effect that
|
|
28
|
+
* lets the API accept a kind the database rejects. The alignment is enforced
|
|
29
|
+
* both ways: {@link EveryTagKindIsCommentable} fails the build if a taggable
|
|
30
|
+
* kind is missing, and the paired test pins today's list exactly.
|
|
31
|
+
*/
|
|
32
|
+
export const COLLAB_SUBJECT_KINDS = [
|
|
33
|
+
"knowledge_page",
|
|
34
|
+
"publishing_post",
|
|
35
|
+
"sequence",
|
|
36
|
+
"table",
|
|
37
|
+
"workflow",
|
|
38
|
+
"recipe",
|
|
39
|
+
"conversation",
|
|
40
|
+
"mailbox",
|
|
41
|
+
"sender",
|
|
42
|
+
"record",
|
|
43
|
+
"domain",
|
|
44
|
+
"project",
|
|
45
|
+
];
|
|
46
|
+
export function isCollabSubjectKind(value) {
|
|
47
|
+
return typeof value === "string" && COLLAB_SUBJECT_KINDS.includes(value);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* How each subject kind is named in output. Total over CollabSubjectKind, so
|
|
51
|
+
* adding a kind is a compile error until it is labelled — the guarantee
|
|
52
|
+
* `TAG_KIND_LABELS` had to learn the hard way when three hand-maintained copies
|
|
53
|
+
* of the same map drifted apart.
|
|
54
|
+
*
|
|
55
|
+
* `many` keeps proper nouns intact ("CRM records") so an inline count reads
|
|
56
|
+
* correctly; headings run it through {@link collabSubjectHeading}, which only
|
|
57
|
+
* uppercases a leading lowercase letter.
|
|
58
|
+
*/
|
|
59
|
+
export const COLLAB_SUBJECT_LABELS = {
|
|
60
|
+
knowledge_page: { one: "knowledge page", many: "knowledge pages" },
|
|
61
|
+
publishing_post: { one: "publishing post", many: "publishing posts" },
|
|
62
|
+
sequence: { one: "sequence", many: "sequences" },
|
|
63
|
+
table: { one: "table", many: "tables" },
|
|
64
|
+
workflow: { one: "workflow", many: "workflows" },
|
|
65
|
+
recipe: { one: "recipe", many: "recipes" },
|
|
66
|
+
conversation: { one: "conversation", many: "conversations" },
|
|
67
|
+
mailbox: { one: "mailbox", many: "mailboxes" },
|
|
68
|
+
sender: { one: "sender account", many: "sender accounts" },
|
|
69
|
+
record: { one: "CRM record", many: "CRM records" },
|
|
70
|
+
domain: { one: "sending domain", many: "sending domains" },
|
|
71
|
+
project: { one: "project", many: "projects" },
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* What a thread on each kind is usually about, for `--help` and MCP tool
|
|
75
|
+
* descriptions. Total over CollabSubjectKind for the same reason the labels
|
|
76
|
+
* are: a blind-user eval (2026-07-21) found `tags --help` still advertising 7
|
|
77
|
+
* of 13 kinds because the prose was hand-written beside a hand-maintained
|
|
78
|
+
* marker map, so six taggable kinds were invisible from the one place a user
|
|
79
|
+
* looks. Here the help text cannot omit a kind — the compiler rejects the map
|
|
80
|
+
* until the kind is glossed, and {@link COLLAB_SUBJECT_KINDS_PROSE} is DERIVED
|
|
81
|
+
* from the labels rather than typed out a second time.
|
|
82
|
+
*/
|
|
83
|
+
export const COLLAB_SUBJECT_PROSE = {
|
|
84
|
+
knowledge_page: "a wiki page's copy before it becomes canonical",
|
|
85
|
+
publishing_post: "a post's copy before it is published",
|
|
86
|
+
sequence: "a campaign's messaging before it launches",
|
|
87
|
+
table: "a list's rows and columns, or one cell's value",
|
|
88
|
+
workflow: "an automation's graph before it runs live",
|
|
89
|
+
recipe: "an installable motion before it is installed",
|
|
90
|
+
conversation: "a reply in an inbox thread before it is sent",
|
|
91
|
+
mailbox: "a mailbox's configuration or health",
|
|
92
|
+
sender: "a sender account's caps and warmup",
|
|
93
|
+
record: "a CRM record's truth",
|
|
94
|
+
domain: "a sending domain's DNS and warmup",
|
|
95
|
+
project: "a project's scope",
|
|
96
|
+
};
|
|
97
|
+
/** Oxford-comma list: ["a"] -> "a"; ["a","b"] -> "a and b"; ["a","b","c"] -> "a, b, and c". */
|
|
98
|
+
function formatProseList(items) {
|
|
99
|
+
if (items.length <= 1)
|
|
100
|
+
return items[0] ?? "";
|
|
101
|
+
if (items.length === 2)
|
|
102
|
+
return `${items[0]} and ${items[1]}`;
|
|
103
|
+
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Every commentable kind in prose, for CLI `--help` and MCP tool descriptions.
|
|
107
|
+
* Derived from {@link COLLAB_SUBJECT_LABELS} on purpose: a hand-written copy is
|
|
108
|
+
* exactly what drifted in the tags help.
|
|
109
|
+
*/
|
|
110
|
+
export const COLLAB_SUBJECT_KINDS_PROSE = formatProseList(COLLAB_SUBJECT_KINDS.map((kind) => COLLAB_SUBJECT_LABELS[kind].many));
|
|
111
|
+
/** Inline label for a count: `collabSubjectLabel("sequence", 1)` -> "sequence". */
|
|
112
|
+
export function collabSubjectLabel(kind, count) {
|
|
113
|
+
const entry = isCollabSubjectKind(kind) ? COLLAB_SUBJECT_LABELS[kind] : null;
|
|
114
|
+
if (!entry)
|
|
115
|
+
return kind;
|
|
116
|
+
return count === 1 ? entry.one : entry.many;
|
|
117
|
+
}
|
|
118
|
+
/** Section heading for a kind: "Sequences", "CRM records". */
|
|
119
|
+
export function collabSubjectHeading(kind) {
|
|
120
|
+
const entry = isCollabSubjectKind(kind) ? COLLAB_SUBJECT_LABELS[kind] : null;
|
|
121
|
+
const label = entry ? entry.many : kind;
|
|
122
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Render a subject as the `<kind>:<id>` string every surface accepts
|
|
126
|
+
* (`--on sequence:9f1c...`). Throws rather than emitting an unparseable ref,
|
|
127
|
+
* because a malformed ref written into a stored row is drift nobody notices
|
|
128
|
+
* until a read fails.
|
|
129
|
+
*/
|
|
130
|
+
export function formatSubjectRef(ref) {
|
|
131
|
+
if (!isCollabSubjectKind(ref.kind)) {
|
|
132
|
+
throw invalidSubjectRef(`Unknown subject kind "${ref.kind}".`, { kind: ref.kind });
|
|
133
|
+
}
|
|
134
|
+
const id = ref.id.trim();
|
|
135
|
+
if (!id) {
|
|
136
|
+
throw invalidSubjectRef("Subject id is required.", { kind: ref.kind });
|
|
137
|
+
}
|
|
138
|
+
return `${ref.kind}:${id}`;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Parse `<kind>:<id>`. Splits on the FIRST colon only, so an id that itself
|
|
142
|
+
* contains colons survives the round trip; an input with no colon at all is
|
|
143
|
+
* rejected rather than being guessed at (there is no default kind — a bare id
|
|
144
|
+
* would silently address the wrong store).
|
|
145
|
+
*/
|
|
146
|
+
export function tryParseSubjectRef(value) {
|
|
147
|
+
if (typeof value !== "string")
|
|
148
|
+
return null;
|
|
149
|
+
const raw = value.trim();
|
|
150
|
+
const separator = raw.indexOf(":");
|
|
151
|
+
if (separator <= 0)
|
|
152
|
+
return null;
|
|
153
|
+
const kind = raw.slice(0, separator);
|
|
154
|
+
const id = raw.slice(separator + 1).trim();
|
|
155
|
+
if (!isCollabSubjectKind(kind) || !id)
|
|
156
|
+
return null;
|
|
157
|
+
return { kind, id };
|
|
158
|
+
}
|
|
159
|
+
/** Throwing variant of {@link tryParseSubjectRef}, for surfaces that want the typed error. */
|
|
160
|
+
export function parseSubjectRef(value) {
|
|
161
|
+
const parsed = tryParseSubjectRef(value);
|
|
162
|
+
if (parsed)
|
|
163
|
+
return parsed;
|
|
164
|
+
throw invalidSubjectRef(`Subject must be "<kind>:<id>" — for example "sequence:9f1c2b7e". Valid kinds: ${COLLAB_SUBJECT_KINDS.join(", ")}.`, { subject: typeof value === "string" ? value : null });
|
|
165
|
+
}
|
|
166
|
+
function invalidSubjectRef(message, details) {
|
|
167
|
+
return new OxygenError("invalid_subject_ref", message, {
|
|
168
|
+
details: { ...details, valid_kinds: [...COLLAB_SUBJECT_KINDS] },
|
|
169
|
+
exitCode: 2,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Sub-object addressing inside one subject, so commenting on a single cell does
|
|
174
|
+
* not need a thread table per kind: `row:<rowId>` for a whole row,
|
|
175
|
+
* `row:<rowId>#<columnKey>` for one cell, `column:<columnKey>` for a whole
|
|
176
|
+
* column. Unknown prefixes are still rejected rather than stored — an address no
|
|
177
|
+
* reader understands is worse than a refusal.
|
|
178
|
+
*
|
|
179
|
+
* `column:` was added in the second Collaboration pass because a column is what
|
|
180
|
+
* an agency and its client actually argue about ("why is Stage set this way?"),
|
|
181
|
+
* and it had no address at all: every path had to name a row first. It is a
|
|
182
|
+
* SIBLING of `row:`, not a variant of it, which is why the type below is a
|
|
183
|
+
* discriminated union — a column path has no row, and an optional `rowId` would
|
|
184
|
+
* let `{}` typecheck as a valid path.
|
|
185
|
+
*/
|
|
186
|
+
export const SUBJECT_PATH_ROW_PREFIX = "row:";
|
|
187
|
+
export const SUBJECT_PATH_COLUMN_PREFIX = "column:";
|
|
188
|
+
/** Every path form in prose, for `--help`, MCP descriptors, and error messages. */
|
|
189
|
+
export const SUBJECT_PATH_FORMS_PROSE = '"row:<rowId>" for one row, "row:<rowId>#<columnKey>" for one cell, or "column:<columnKey>" for one column';
|
|
190
|
+
/**
|
|
191
|
+
* Render a sub-object path: `{ kind: "row", rowId: "abc" }` -> "row:abc"; with a
|
|
192
|
+
* column -> "row:abc#email"; `{ kind: "column", columnKey: "email" }` ->
|
|
193
|
+
* "column:email".
|
|
194
|
+
*/
|
|
195
|
+
export function formatSubjectPath(path) {
|
|
196
|
+
if (path.kind === "column") {
|
|
197
|
+
const columnKey = path.columnKey.trim();
|
|
198
|
+
if (!columnKey || columnKey.includes("#")) {
|
|
199
|
+
throw invalidSubjectPath('Column key is required and cannot contain "#".', {
|
|
200
|
+
column_key: path.columnKey,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return `${SUBJECT_PATH_COLUMN_PREFIX}${columnKey}`;
|
|
204
|
+
}
|
|
205
|
+
const rowId = path.rowId.trim();
|
|
206
|
+
if (!rowId || rowId.includes("#")) {
|
|
207
|
+
throw invalidSubjectPath('Row id is required and cannot contain "#".', { row_id: path.rowId });
|
|
208
|
+
}
|
|
209
|
+
const columnKey = path.columnKey?.trim() ?? "";
|
|
210
|
+
if (path.columnKey !== undefined && !columnKey) {
|
|
211
|
+
throw invalidSubjectPath("Column key cannot be blank.", { row_id: rowId });
|
|
212
|
+
}
|
|
213
|
+
const base = `${SUBJECT_PATH_ROW_PREFIX}${rowId}`;
|
|
214
|
+
return columnKey ? `${base}#${columnKey}` : base;
|
|
215
|
+
}
|
|
216
|
+
export function tryParseSubjectPath(value) {
|
|
217
|
+
if (typeof value !== "string")
|
|
218
|
+
return null;
|
|
219
|
+
const raw = value.trim();
|
|
220
|
+
if (raw.startsWith(SUBJECT_PATH_COLUMN_PREFIX)) {
|
|
221
|
+
const columnKey = raw.slice(SUBJECT_PATH_COLUMN_PREFIX.length).trim();
|
|
222
|
+
// No `#` inside a column path: a column addresses no row, so a hash here
|
|
223
|
+
// means the caller meant a cell and got the prefix wrong.
|
|
224
|
+
if (!columnKey || columnKey.includes("#"))
|
|
225
|
+
return null;
|
|
226
|
+
return { kind: "column", columnKey };
|
|
227
|
+
}
|
|
228
|
+
if (!raw.startsWith(SUBJECT_PATH_ROW_PREFIX))
|
|
229
|
+
return null;
|
|
230
|
+
const remainder = raw.slice(SUBJECT_PATH_ROW_PREFIX.length);
|
|
231
|
+
const hash = remainder.indexOf("#");
|
|
232
|
+
if (hash < 0) {
|
|
233
|
+
const rowId = remainder.trim();
|
|
234
|
+
return rowId ? { kind: "row", rowId } : null;
|
|
235
|
+
}
|
|
236
|
+
const rowId = remainder.slice(0, hash).trim();
|
|
237
|
+
const columnKey = remainder.slice(hash + 1).trim();
|
|
238
|
+
if (!rowId || !columnKey || columnKey.includes("#"))
|
|
239
|
+
return null;
|
|
240
|
+
return { kind: "row", rowId, columnKey };
|
|
241
|
+
}
|
|
242
|
+
/** Throwing variant of {@link tryParseSubjectPath}. */
|
|
243
|
+
export function parseSubjectPath(value) {
|
|
244
|
+
const parsed = tryParseSubjectPath(value);
|
|
245
|
+
if (parsed)
|
|
246
|
+
return parsed;
|
|
247
|
+
throw invalidSubjectPath(`Path must be ${SUBJECT_PATH_FORMS_PROSE} — for example "row:8f2c1a4d#email".`, { path: typeof value === "string" ? value : null });
|
|
248
|
+
}
|
|
249
|
+
function invalidSubjectPath(message, details) {
|
|
250
|
+
return new OxygenError("invalid_subject_path", message, { details, exitCode: 2 });
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The gates a workspace can require. `launch` is the client's go/no-go before a
|
|
254
|
+
* campaign sends; `signoff` is their sign-off on the list that campaign sends
|
|
255
|
+
* to. Both are OPT-IN per subject (or per subject kind) — an unset gate leaves
|
|
256
|
+
* today's behavior untouched.
|
|
257
|
+
*/
|
|
258
|
+
export const COLLAB_GATE_KINDS = ["launch", "signoff"];
|
|
259
|
+
export function isCollabGateKind(value) {
|
|
260
|
+
return typeof value === "string" && COLLAB_GATE_KINDS.includes(value);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Which subject kinds each gate can be required on. Total over CollabGateKind,
|
|
264
|
+
* so a new gate cannot ship without naming the primitives that must honor it —
|
|
265
|
+
* a gate nobody enforces is worse than no gate, because the workspace believes
|
|
266
|
+
* it is protected.
|
|
267
|
+
*/
|
|
268
|
+
export const GATE_KIND_SUBJECTS = {
|
|
269
|
+
launch: ["sequence"],
|
|
270
|
+
signoff: ["table"],
|
|
271
|
+
};
|
|
272
|
+
/** What each gate means, for `--help` and MCP descriptors. Total over CollabGateKind. */
|
|
273
|
+
export const COLLAB_GATE_PROSE = {
|
|
274
|
+
launch: "approve before a sequence starts sending live",
|
|
275
|
+
signoff: "approve the table's rows before they are worked",
|
|
276
|
+
};
|
|
277
|
+
export function isGateKindValidForSubject(gateKind, subjectKind) {
|
|
278
|
+
if (!isCollabGateKind(gateKind) || !isCollabSubjectKind(subjectKind))
|
|
279
|
+
return false;
|
|
280
|
+
return GATE_KIND_SUBJECTS[gateKind].includes(subjectKind);
|
|
281
|
+
}
|
|
282
|
+
/** Every gate that can be required on a subject kind — drives `approvals gate list`. */
|
|
283
|
+
export function gateKindsForSubject(subjectKind) {
|
|
284
|
+
if (!isCollabSubjectKind(subjectKind))
|
|
285
|
+
return [];
|
|
286
|
+
return COLLAB_GATE_KINDS.filter((gate) => GATE_KIND_SUBJECTS[gate].includes(subjectKind));
|
|
287
|
+
}
|
|
288
|
+
/** A thread is open until somebody resolves it; reopening flips it back. */
|
|
289
|
+
export const COLLAB_THREAD_STATUSES = ["open", "resolved"];
|
|
290
|
+
export function isCollabThreadStatus(value) {
|
|
291
|
+
return typeof value === "string" && COLLAB_THREAD_STATUSES.includes(value);
|
|
292
|
+
}
|
|
293
|
+
export const COLLAB_REQUEST_STATUSES = [
|
|
294
|
+
"pending",
|
|
295
|
+
"approved",
|
|
296
|
+
"changes_requested",
|
|
297
|
+
"rejected",
|
|
298
|
+
"cancelled",
|
|
299
|
+
"expired",
|
|
300
|
+
];
|
|
301
|
+
export function isCollabRequestStatus(value) {
|
|
302
|
+
return typeof value === "string" && COLLAB_REQUEST_STATUSES.includes(value);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The whole feature hinges on this one line: ONLY `approved` opens a gate.
|
|
306
|
+
*
|
|
307
|
+
* `changes_requested` is a decision, and it ends the requester's wait, but it is
|
|
308
|
+
* not consent — the client asked for edits. Treating it as terminal-and-done is
|
|
309
|
+
* the obvious bug (a request that is no longer pending looks "handled"), so the
|
|
310
|
+
* gate check asks this function rather than testing `status !== "pending"`.
|
|
311
|
+
*/
|
|
312
|
+
export function requestStatusOpensGate(status) {
|
|
313
|
+
return status === "approved";
|
|
314
|
+
}
|
|
315
|
+
/** Statuses that no longer await a decision. `pending` is the only live state. */
|
|
316
|
+
export const COLLAB_REQUEST_TERMINAL_STATUSES = [
|
|
317
|
+
"approved",
|
|
318
|
+
"changes_requested",
|
|
319
|
+
"rejected",
|
|
320
|
+
"cancelled",
|
|
321
|
+
"expired",
|
|
322
|
+
];
|
|
323
|
+
export function isTerminalRequestStatus(status) {
|
|
324
|
+
return isCollabRequestStatus(status) && status !== "pending";
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* What an approver may do, as the CLI/MCP spells it — and the status each
|
|
328
|
+
* decision writes. Total over CollabDecision so the API, the CLI flags, and the
|
|
329
|
+
* MCP tool cannot each invent their own mapping (the failure mode being a
|
|
330
|
+
* `changes_requested` decision stored as `rejected`, which reads to the agency
|
|
331
|
+
* as "the client said no" rather than "the client wants edits").
|
|
332
|
+
*/
|
|
333
|
+
export const COLLAB_DECISIONS = ["approve", "reject", "changes_requested"];
|
|
334
|
+
export function isCollabDecision(value) {
|
|
335
|
+
return typeof value === "string" && COLLAB_DECISIONS.includes(value);
|
|
336
|
+
}
|
|
337
|
+
export const COLLAB_DECISION_STATUSES = {
|
|
338
|
+
approve: "approved",
|
|
339
|
+
reject: "rejected",
|
|
340
|
+
changes_requested: "changes_requested",
|
|
341
|
+
};
|
|
342
|
+
/** Why a notification exists. Delivery itself is the notifier's problem. */
|
|
343
|
+
export const COLLAB_NOTIFICATION_KINDS = ["assigned", "mentioned", "decided", "replied"];
|
|
344
|
+
export function isCollabNotificationKind(value) {
|
|
345
|
+
return (typeof value === "string" && COLLAB_NOTIFICATION_KINDS.includes(value));
|
|
346
|
+
}
|
|
347
|
+
export const COLLAB_NOTIFICATION_STATUSES = ["pending", "sent", "failed", "skipped"];
|
|
348
|
+
export function isCollabNotificationStatus(value) {
|
|
349
|
+
return (typeof value === "string" && COLLAB_NOTIFICATION_STATUSES.includes(value));
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Body/field caps. Shared so the API, the CLI, and the tenant column widths
|
|
353
|
+
* agree — three independently chosen limits means the CLI accepts what the API
|
|
354
|
+
* truncates.
|
|
355
|
+
*/
|
|
356
|
+
export const MAX_COMMENT_BODY_LENGTH = 10_000;
|
|
357
|
+
export const MAX_THREAD_TITLE_LENGTH = 200;
|
|
358
|
+
export const MAX_DECISION_NOTE_LENGTH = 2_000;
|
|
359
|
+
/** Bounds the notification fan-out one comment or request can trigger. */
|
|
360
|
+
export const MAX_MENTIONS_PER_COMMENT = 25;
|
|
361
|
+
export const MAX_ASSIGNEES_PER_REQUEST = 10;
|
|
362
|
+
/**
|
|
363
|
+
* Normalize one comment body: trim, reject blanks, non-strings, and anything
|
|
364
|
+
* over {@link MAX_COMMENT_BODY_LENGTH}. Returns `null` so each surface can raise
|
|
365
|
+
* its own typed error (the CLI wants an exit code, the API wants a status).
|
|
366
|
+
*
|
|
367
|
+
* Deliberately does NOT strip or rewrite the body beyond trimming — a comment is
|
|
368
|
+
* the client's words, and Markdown, quoted text, and whitespace layout are part
|
|
369
|
+
* of what they wrote.
|
|
370
|
+
*/
|
|
371
|
+
export function normalizeCommentBody(value) {
|
|
372
|
+
if (typeof value !== "string")
|
|
373
|
+
return null;
|
|
374
|
+
const normalized = value.trim();
|
|
375
|
+
if (!normalized || normalized.length > MAX_COMMENT_BODY_LENGTH)
|
|
376
|
+
return null;
|
|
377
|
+
return normalized;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The Oxygen-side membership role overlay (control DB
|
|
381
|
+
* `organization_memberships.oxygen_role`). NULL means no override — today's
|
|
382
|
+
* behavior for every existing member — and `client` is the restricted role an
|
|
383
|
+
* agency hands its client: read the work, comment on it, decide the approvals
|
|
384
|
+
* assigned to them, and nothing else.
|
|
385
|
+
*
|
|
386
|
+
* Deliberately not a Clerk custom role: those need dashboard configuration, and
|
|
387
|
+
* repo doctrine requires asking a human before touching Clerk. Keep this list in
|
|
388
|
+
* step with the `organization_memberships_oxygen_role_check` constraint in
|
|
389
|
+
* control migration 0095 — the CHECK is what stops a typo from creating a role
|
|
390
|
+
* nothing enforces.
|
|
391
|
+
*/
|
|
392
|
+
export const COLLAB_MEMBER_ROLES = ["client"];
|
|
393
|
+
export function isCollabMemberRole(value) {
|
|
394
|
+
return typeof value === "string" && COLLAB_MEMBER_ROLES.includes(value);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* The CLERK workspace roles that carry admin authority (control DB
|
|
398
|
+
* `organization_memberships.role`). Both spellings of each: Clerk writes the
|
|
399
|
+
* `org:`-prefixed form, older rows carry the bare one.
|
|
400
|
+
*
|
|
401
|
+
* The overlay above and this list are the two halves of one question — "may this
|
|
402
|
+
* member do X" — and a Clerk admin's role OVERRIDES the overlay, which is why
|
|
403
|
+
* granting `client` to one is refused. Keeping the vocabulary here means the
|
|
404
|
+
* collaboration lib can ask without importing the billing graph, and there is
|
|
405
|
+
* one list to change if Clerk ever gains another admin-ish role.
|
|
406
|
+
*/
|
|
407
|
+
export const WORKSPACE_ADMIN_ROLES = ["admin", "owner", "org:admin", "org:owner"];
|
|
408
|
+
export function isWorkspaceAdminRole(role) {
|
|
409
|
+
return WORKSPACE_ADMIN_ROLES.includes(role.toLowerCase());
|
|
410
|
+
}
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
/** Data types an import can infer for a brand-new column. Dates-only by design. */
|
|
14
14
|
export type ImportColumnDataType = "text" | "timestamptz";
|
|
15
|
-
/** Data types a
|
|
16
|
-
export type RetypeableDataType = "text" | "numeric" | "boolean" | "timestamptz";
|
|
15
|
+
/** Data types a plain data column can be explicitly retyped into. */
|
|
16
|
+
export type RetypeableDataType = "text" | "numeric" | "boolean" | "jsonb" | "timestamptz";
|
|
17
17
|
export declare const RETYPEABLE_DATA_TYPES: readonly RetypeableDataType[];
|
|
18
18
|
export declare const EXCEL_SERIAL_MIN = 10000;
|
|
19
19
|
export declare const EXCEL_SERIAL_MAX = 80000;
|
|
@@ -41,7 +41,7 @@ export declare function parseDateValueToIso(value: unknown, options?: {
|
|
|
41
41
|
export declare function inferImportColumnDataType(label: string, values: Iterable<unknown>): ImportColumnDataType;
|
|
42
42
|
export type CoercionResult = {
|
|
43
43
|
ok: true;
|
|
44
|
-
value:
|
|
44
|
+
value: unknown | null;
|
|
45
45
|
} | {
|
|
46
46
|
ok: false;
|
|
47
47
|
reason: string;
|
|
@@ -14,6 +14,7 @@ export const RETYPEABLE_DATA_TYPES = [
|
|
|
14
14
|
"text",
|
|
15
15
|
"numeric",
|
|
16
16
|
"boolean",
|
|
17
|
+
"jsonb",
|
|
17
18
|
"timestamptz",
|
|
18
19
|
];
|
|
19
20
|
// Excel/Sheets store dates as a serial number of days since 1899-12-30.
|
|
@@ -134,7 +135,17 @@ value, dataType) {
|
|
|
134
135
|
}
|
|
135
136
|
switch (dataType) { // skipcq: JS-0047
|
|
136
137
|
case "text":
|
|
137
|
-
|
|
138
|
+
if (typeof value === "string")
|
|
139
|
+
return { ok: true, value };
|
|
140
|
+
if (typeof value === "object") {
|
|
141
|
+
try {
|
|
142
|
+
return { ok: true, value: JSON.stringify(value) };
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return { ok: false, reason: "not JSON-serializable text" };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, value: String(value) };
|
|
138
149
|
case "timestamptz": {
|
|
139
150
|
const iso = parseDateValueToIso(value, { allowExcelSerial: true });
|
|
140
151
|
return iso
|
|
@@ -157,5 +168,19 @@ value, dataType) {
|
|
|
157
168
|
}
|
|
158
169
|
return { ok: false, reason: "not a recognized boolean" };
|
|
159
170
|
}
|
|
171
|
+
case "jsonb": {
|
|
172
|
+
try {
|
|
173
|
+
if (typeof value === "string") {
|
|
174
|
+
return { ok: true, value: JSON.parse(value.trim()) };
|
|
175
|
+
}
|
|
176
|
+
const serialized = JSON.stringify(value);
|
|
177
|
+
if (serialized === undefined)
|
|
178
|
+
return { ok: false, reason: "not valid JSON" };
|
|
179
|
+
return { ok: true, value: JSON.parse(serialized) };
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return { ok: false, reason: "not valid JSON" };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
160
185
|
}
|
|
161
186
|
}
|
|
@@ -11,6 +11,7 @@ export * from "./cell-format.js";
|
|
|
11
11
|
export * from "./cli-envelope.js";
|
|
12
12
|
export * from "./cli-login-code.js";
|
|
13
13
|
export * from "./cli-result.js";
|
|
14
|
+
export * from "./collab.js";
|
|
14
15
|
export * from "./crm-reply-events.js";
|
|
15
16
|
export * from "./crm-activity-events.js";
|
|
16
17
|
export * from "./column-types.js";
|
|
@@ -45,6 +46,7 @@ export * from "./sequences.js";
|
|
|
45
46
|
export * from "./suppression-entries.js";
|
|
46
47
|
export * from "./table-limits.js";
|
|
47
48
|
export * from "./log.js";
|
|
49
|
+
export * from "./axiom-field-budget.js";
|
|
48
50
|
export { sanitizeLogFields } from "./redaction.js";
|
|
49
51
|
export * from "./provider-request-outcomes.js";
|
|
50
52
|
export * from "./schedule-label.js";
|
|
@@ -11,6 +11,7 @@ export * from "./cell-format.js";
|
|
|
11
11
|
export * from "./cli-envelope.js";
|
|
12
12
|
export * from "./cli-login-code.js";
|
|
13
13
|
export * from "./cli-result.js";
|
|
14
|
+
export * from "./collab.js";
|
|
14
15
|
export * from "./crm-reply-events.js";
|
|
15
16
|
export * from "./crm-activity-events.js";
|
|
16
17
|
export * from "./column-types.js";
|
|
@@ -45,6 +46,7 @@ export * from "./sequences.js";
|
|
|
45
46
|
export * from "./suppression-entries.js";
|
|
46
47
|
export * from "./table-limits.js";
|
|
47
48
|
export * from "./log.js";
|
|
49
|
+
export * from "./axiom-field-budget.js";
|
|
48
50
|
// Narrow, deliberate export (ADR 0014): lets telemetry emitters regression-test
|
|
49
51
|
// their field names against the REAL log sanitizer — the unanchored
|
|
50
52
|
// SECRET_KEY_PATTERN redacts any name containing "token", which mocked loggers
|
|
@@ -90,6 +90,36 @@ export declare const VOICE_CREDITS_PER_AMD_CALL = 38;
|
|
|
90
90
|
/** Managed domains: registrar at-cost × 1.25 (≈ a $10.44 .com → ~13,050 credits/yr). */
|
|
91
91
|
export declare const MANAGED_DOMAIN_MARKUP = 1.25;
|
|
92
92
|
export declare const MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL = 13050;
|
|
93
|
+
/**
|
|
94
|
+
* Inbox deliverability (EmailGuard placement seat). A flat monthly price per
|
|
95
|
+
* SUBSCRIBED inbox covering continuous deliverability monitoring (blacklist,
|
|
96
|
+
* SPF/DKIM/DMARC, domain reputation) plus an included allowance of
|
|
97
|
+
* inbox-placement (spam) tests; tests beyond the allowance bill the per-test
|
|
98
|
+
* price.
|
|
99
|
+
*
|
|
100
|
+
* All three are DISPLAY mirrors, per this file's contract. The canonical values
|
|
101
|
+
* live where they are enforced, and seed-parity.test.ts binds them so a mirror
|
|
102
|
+
* can never quietly drift from the number actually billed:
|
|
103
|
+
* seat DELIVERABILITY_UNIT_MONTHLY_CREDITS (@oxygen/control-db,
|
|
104
|
+
* also PRICING_SEED commitment.deliverability_unit) — 1,000 credits
|
|
105
|
+
* ($1.00), restored by the founder on 2026-07-30 after reviewing the
|
|
106
|
+
* Agency-plan unit economics, superseding the 2026-07-26 signature
|
|
107
|
+
* of 2,000.
|
|
108
|
+
* allowance DELIVERABILITY_INCLUDED_TESTS_PER_INBOX (@oxygen/control-db)
|
|
109
|
+
* overage EMAILGUARD_MANAGED_PLACEMENT_CREDITS (@oxygen/providers,
|
|
110
|
+
* derived from the Agency plan rather than typed in)
|
|
111
|
+
*
|
|
112
|
+
* THE ALLOWANCE IS LOAD-BEARING, not a perk. Placement COGS is per TEST
|
|
113
|
+
* ($0.1327 on EmailGuard's Agency plan), not per inbox, so an unbounded seat
|
|
114
|
+
* inverts its own margin: at $1.00/inbox-month the EIGHTH test in a cycle is
|
|
115
|
+
* already sold at a loss. Two included tests cost $0.265 against $1.00 of
|
|
116
|
+
* revenue — a ~73% margin — and everything past them bills
|
|
117
|
+
* PLACEMENT_TEST_OVERAGE_CREDITS. Orgs on their own EmailGuard key (BYOK) pay 0.
|
|
118
|
+
*/
|
|
119
|
+
export declare const DELIVERABILITY_SEAT_CREDITS_PER_MONTH = 1000;
|
|
120
|
+
export declare const DELIVERABILITY_INCLUDED_TESTS_PER_INBOX = 2;
|
|
121
|
+
/** Overage placement test: $0.1327 EmailGuard COGS × 1.25, rounded up. */
|
|
122
|
+
export declare const PLACEMENT_TEST_OVERAGE_CREDITS = 166;
|
|
93
123
|
/**
|
|
94
124
|
* LinkedIn accounts: founder decision (2026-07-25) — a PURE seat model. The
|
|
95
125
|
* flat monthly connection fee per connected account is now the WHOLE LinkedIn
|
|
@@ -82,6 +82,36 @@ export const VOICE_CREDITS_PER_AMD_CALL = 38;
|
|
|
82
82
|
/** Managed domains: registrar at-cost × 1.25 (≈ a $10.44 .com → ~13,050 credits/yr). */
|
|
83
83
|
export const MANAGED_DOMAIN_MARKUP = 1.25;
|
|
84
84
|
export const MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL = 13_050;
|
|
85
|
+
/**
|
|
86
|
+
* Inbox deliverability (EmailGuard placement seat). A flat monthly price per
|
|
87
|
+
* SUBSCRIBED inbox covering continuous deliverability monitoring (blacklist,
|
|
88
|
+
* SPF/DKIM/DMARC, domain reputation) plus an included allowance of
|
|
89
|
+
* inbox-placement (spam) tests; tests beyond the allowance bill the per-test
|
|
90
|
+
* price.
|
|
91
|
+
*
|
|
92
|
+
* All three are DISPLAY mirrors, per this file's contract. The canonical values
|
|
93
|
+
* live where they are enforced, and seed-parity.test.ts binds them so a mirror
|
|
94
|
+
* can never quietly drift from the number actually billed:
|
|
95
|
+
* seat DELIVERABILITY_UNIT_MONTHLY_CREDITS (@oxygen/control-db,
|
|
96
|
+
* also PRICING_SEED commitment.deliverability_unit) — 1,000 credits
|
|
97
|
+
* ($1.00), restored by the founder on 2026-07-30 after reviewing the
|
|
98
|
+
* Agency-plan unit economics, superseding the 2026-07-26 signature
|
|
99
|
+
* of 2,000.
|
|
100
|
+
* allowance DELIVERABILITY_INCLUDED_TESTS_PER_INBOX (@oxygen/control-db)
|
|
101
|
+
* overage EMAILGUARD_MANAGED_PLACEMENT_CREDITS (@oxygen/providers,
|
|
102
|
+
* derived from the Agency plan rather than typed in)
|
|
103
|
+
*
|
|
104
|
+
* THE ALLOWANCE IS LOAD-BEARING, not a perk. Placement COGS is per TEST
|
|
105
|
+
* ($0.1327 on EmailGuard's Agency plan), not per inbox, so an unbounded seat
|
|
106
|
+
* inverts its own margin: at $1.00/inbox-month the EIGHTH test in a cycle is
|
|
107
|
+
* already sold at a loss. Two included tests cost $0.265 against $1.00 of
|
|
108
|
+
* revenue — a ~73% margin — and everything past them bills
|
|
109
|
+
* PLACEMENT_TEST_OVERAGE_CREDITS. Orgs on their own EmailGuard key (BYOK) pay 0.
|
|
110
|
+
*/
|
|
111
|
+
export const DELIVERABILITY_SEAT_CREDITS_PER_MONTH = 1_000;
|
|
112
|
+
export const DELIVERABILITY_INCLUDED_TESTS_PER_INBOX = 2;
|
|
113
|
+
/** Overage placement test: $0.1327 EmailGuard COGS × 1.25, rounded up. */
|
|
114
|
+
export const PLACEMENT_TEST_OVERAGE_CREDITS = 166;
|
|
85
115
|
/**
|
|
86
116
|
* LinkedIn accounts: founder decision (2026-07-25) — a PURE seat model. The
|
|
87
117
|
* flat monthly connection fee per connected account is now the WHOLE LinkedIn
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
* Social channel capability registry.
|
|
3
3
|
*
|
|
4
4
|
* Channel, provider rail, and operation support are deliberately independent.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* channel later means registering a real adapter profile, not adding placeholder UI.
|
|
5
|
+
* Callers resolve capabilities through this contract instead of branching on a
|
|
6
|
+
* channel name. A channel is registered only when a real adapter exists.
|
|
8
7
|
*/
|
|
9
|
-
export declare const SOCIAL_CHANNELS: readonly ["linkedin"];
|
|
8
|
+
export declare const SOCIAL_CHANNELS: readonly ["linkedin", "x"];
|
|
10
9
|
export type SocialChannel = (typeof SOCIAL_CHANNELS)[number];
|
|
11
|
-
export declare const SOCIAL_PROVIDER_RAILS: readonly ["unipile"];
|
|
10
|
+
export declare const SOCIAL_PROVIDER_RAILS: readonly ["unipile", "composio"];
|
|
12
11
|
export type SocialProviderRail = (typeof SOCIAL_PROVIDER_RAILS)[number];
|
|
13
12
|
export declare const SOCIAL_OPERATIONS: readonly ["list_owned_posts", "read_post_metrics", "list_comments", "reply_to_comment"];
|
|
14
13
|
export type SocialOperation = (typeof SOCIAL_OPERATIONS)[number];
|
|
@@ -24,7 +23,7 @@ export type SocialOperationCapability = {
|
|
|
24
23
|
limitation: string | null;
|
|
25
24
|
};
|
|
26
25
|
export type SocialMetricCapability = {
|
|
27
|
-
status:
|
|
26
|
+
status: SocialCapabilityStatus;
|
|
28
27
|
provider_field: string | null;
|
|
29
28
|
reason: string | null;
|
|
30
29
|
};
|
|
@@ -41,3 +40,5 @@ export type SocialCapabilityProfile = {
|
|
|
41
40
|
};
|
|
42
41
|
export declare function listSocialCapabilityProfiles(): readonly SocialCapabilityProfile[];
|
|
43
42
|
export declare function getSocialCapabilityProfile(channel: string, providerRail: string): SocialCapabilityProfile | null;
|
|
43
|
+
/** Resolve the first configured rail that can perform an operation for a channel. */
|
|
44
|
+
export declare function getSocialCapabilityProfileForOperation(channel: string, operation: SocialOperation): SocialCapabilityProfile | null;
|