@cohortapp/agent-sdk 2.9.1 → 2.11.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.
Files changed (64) hide show
  1. package/.claude/commands/init-maestro.md +16 -9
  2. package/docs/guides/mac-mini.md +11 -1
  3. package/docs/runbooks/cohort-cutover.md +16 -0
  4. package/lib/channels/inbox-item.mjs +4 -0
  5. package/lib/comms/send-gate.mjs +23 -1
  6. package/lib/comms/send-gate.test.mjs +24 -0
  7. package/lib/mcp/server.test.mjs +16 -4
  8. package/lib/model-router/economics.mjs +53 -1
  9. package/lib/model-router/economics.test.mjs +76 -0
  10. package/lib/model-router/resolve.mjs +57 -4
  11. package/lib/model-router.mjs +95 -8
  12. package/lib/model-router.test.mjs +305 -5
  13. package/lib/org/client.mjs +58 -1
  14. package/lib/org/inbound/project.mjs +9 -5
  15. package/lib/org/messaging.mjs +6 -1
  16. package/lib/org/protocol.checksum +1 -1
  17. package/lib/org/protocol.mjs +176 -3
  18. package/lib/org/protocol.test.mjs +31 -2
  19. package/lib/org/resource-tools.mjs +317 -0
  20. package/lib/org/resource-tools.test.mjs +361 -0
  21. package/lib/org/tool-access.mjs +176 -0
  22. package/lib/org/tool-access.test.mjs +144 -0
  23. package/lib/org/tool-surface.mjs +431 -5
  24. package/lib/org/tool-surface.test.mjs +385 -8
  25. package/lib/org/ui-parity.mjs +196 -3
  26. package/lib/org/ui-parity.test.mjs +126 -7
  27. package/lib/tool-definitions.js +23 -2
  28. package/package.json +2 -2
  29. package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
  30. package/plugins/maestro-skills/plugin.json +4 -0
  31. package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
  32. package/policies/information-barriers.yaml +34 -7
  33. package/scripts/ci/check-no-residual-identity.mjs +281 -9
  34. package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
  35. package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
  36. package/scripts/cloud-relay/voice/server.mjs +42 -2
  37. package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
  38. package/scripts/cost/track-claude-usage.mjs +113 -4
  39. package/scripts/daemon/agent-daemon.mjs +150 -3
  40. package/scripts/daemon/agent-daemon.test.mjs +190 -0
  41. package/scripts/daemon/assurance.mjs +50 -16
  42. package/scripts/daemon/assurance.test.mjs +39 -1
  43. package/scripts/daemon/classifier-identity.test.mjs +137 -0
  44. package/scripts/daemon/classifier.mjs +98 -17
  45. package/scripts/daemon/deliver.mjs +457 -33
  46. package/scripts/daemon/deliver.test.mjs +564 -0
  47. package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
  48. package/scripts/daemon/prompt-builder.mjs +264 -41
  49. package/scripts/daemon/prompt-builder.test.mjs +5 -5
  50. package/scripts/daemon/responder-history.test.mjs +18 -2
  51. package/scripts/daemon/responder.mjs +7 -1
  52. package/scripts/disclosure_boundaries.py +56 -5
  53. package/scripts/huddle/huddle-prompt.test.mjs +176 -0
  54. package/scripts/huddle/huddle-server.mjs +128 -13
  55. package/scripts/local-triggers/autoupdate.sh +83 -0
  56. package/scripts/local-triggers/generate-plists.sh +9 -0
  57. package/scripts/local-triggers/generate-plists.test.mjs +12 -10
  58. package/scripts/media-generation/brand-clause.test.mjs +135 -0
  59. package/scripts/media-generation/gemini-image-client.mjs +27 -9
  60. package/scripts/media-generation/generate-assets.mjs +102 -7
  61. package/scripts/pre-draft-context.py +91 -15
  62. package/scripts/spawn-session.sh +36 -6
  63. package/scripts/test-employer-grounding.py +348 -0
  64. package/scripts/validate_outbound.py +190 -26
@@ -76,6 +76,34 @@ export const FAMILIES = Object.freeze([
76
76
  // seat can PERSIST one it learned rather than re-deriving it every session.
77
77
  // The chain rows carry subject + domain + key only — never the value.
78
78
  "preference",
79
+ // GENESIS (2026-08): the founder's pre-commit workspace draft — the ten-stage
80
+ // run, its editable sections, and the conversational planner over them. The
81
+ // family exists so an agent reaches the SAME draft the founder's own form
82
+ // does; every method is a thin shell over the server action behind the
83
+ // button, never a second implementation of its gates.
84
+ //
85
+ // Its chain rows land in the DRAFTING org — the tenant the founder is signed
86
+ // into while they draft — and say only that a mutation happened, by whom, to
87
+ // which section. The content-level provenance is a different spine: the
88
+ // action's own `GenesisFieldEdit` rows, which `bufferGenesisAudit` replays
89
+ // into the NEW workspace's chain at commit. Both are true and neither
90
+ // replaces the other.
91
+ "genesis",
92
+ // ARCHITECTURE (2026-08): the four tier review decisions and the one whole-
93
+ // architecture approval that gate a Genesis commit. Split from `genesis`
94
+ // because these are REVIEW acts over a draft rather than edits to it, and
95
+ // because the same surface outlives the run — the Org › Architecture view
96
+ // reads this state for a workspace that has long since committed.
97
+ "architecture",
98
+ // RESOURCE (2026-08): the venture-deliverable catalogue — `OrgResource`, the
99
+ // workspace's own index of what it has to show for itself (site, deck,
100
+ // one-pager, memo, brand book, film, brand assets, repo). The table has been
101
+ // written by the portal and rendered by `/resources` since the three-tier
102
+ // workforce migration and was reachable from NO agent code path at all: zero
103
+ // hits across hq's methods/, mcp/ and llm-responder/ trees, so the venture's
104
+ // own colleagues could not list, add, correct, reorder or remove one of
105
+ // their own deliverables. This family is that surface.
106
+ "resource",
79
107
  ]);
80
108
 
81
109
  /**
@@ -292,7 +320,35 @@ export const METHODS = Object.freeze({
292
320
  // the transactional lane. Still messaging.write scope: faking "X is
293
321
  // composing" in a room is a social write a read-only key must not have. ---
294
322
  "messaging.typing": { family: "messaging", scope: "messaging.write", sideEffecting: false },
323
+ // --- messaging.synthesizeVoiceNote (2026-08): a colleague SPEAKS when asked
324
+ // to. hq could already synthesise an agent's voice note, but only as a
325
+ // reflex — it answered a HUMAN's spoken note in kind and nothing else —
326
+ // so an agent asked in TEXT ("send me a voicenote with this update")
327
+ // truthfully answered that it could not. This is the verb that makes the
328
+ // ability reachable: it records the acting seat's OWN designed voice and
329
+ // returns the READY fileId, which `messaging.send` then attaches like any
330
+ // other file (same ACL, same outbound gate, same redacted audit row — no
331
+ // parallel send path). Split in two deliberately: synthesis is a provider
332
+ // round-trip with a 20s timeout and cannot ride hq's 5s transactional
333
+ // lane, so this half is sideEffecting:false and self-audits its own
334
+ // `files`/`media.voice.synthesized` chain row, exactly as `books.ask`
335
+ // does for its model call. messaging.write scope, not read: putting words
336
+ // in a named colleague's mouth is a social write of the first order. ---
337
+ "messaging.synthesizeVoiceNote": { family: "messaging", scope: "messaging.write", sideEffecting: false },
295
338
  "messaging.channels": { family: "messaging", scope: "messaging.read", sideEffecting: false },
339
+ // --- messaging.electResponder (2026-08): the SERVER-SIDE "should anyone answer,
340
+ // and who?" election for a multi-participant channel. A fleet daemon calls it
341
+ // with just {messageId, channelId}; hq assembles the RouteInput server-side
342
+ // (roster/trigger/channel, org from the KEY) and runs the SAME response
343
+ // planner the in-process responder uses, returning ONE verdict every seat
344
+ // converges on — replacing each daemon's local, room-blind directed_at_agent
345
+ // gate. messaging.read scope (every paired seat may ask; in DEFAULT_AGENT_
346
+ // SCOPES). Declared sideEffecting:false though it appends: the verdict is a
347
+ // write-once ledger event ("responder"/"responder.election", keyed on
348
+ // messageId) and a CACHE HIT must return with ZERO appends — which the
349
+ // side-effecting exactly-one-append contract forbids — so it self-audits its
350
+ // own chain row like messaging.synthesizeVoiceNote and books.ask do. ---
351
+ "messaging.electResponder": { family: "messaging", scope: "messaging.read", sideEffecting: false },
296
352
  // --- calling (SP3): call lifecycle in the human app. NOT reserved-admin. ---
297
353
  "calling.start": { family: "calling", scope: "calling.write", sideEffecting: true },
298
354
  "calling.join": { family: "calling", scope: "calling.write", sideEffecting: true },
@@ -424,7 +480,12 @@ export const METHODS = Object.freeze({
424
480
  // --- file ---
425
481
  "file.pin": { family: "file", scope: "org.write", sideEffecting: true, idempotent: true },
426
482
  "file.unpin": { family: "file", scope: "org.write", sideEffecting: true },
427
- "file.addComment": { family: "file", scope: "org.write", sideEffecting: true },
483
+ // idempotent: a COMMENT CREATE keyed on the caller's message id. Without
484
+ // the flag hq ignores `x-idempotency-key` entirely (dispatch.ts replays only
485
+ // for `def.idempotent === true`), so a daemon retry after a timeout-post-
486
+ // commit posts the same comment two or three times on the thread. Same
487
+ // reasoning as messaging.send, which has carried the flag from the start.
488
+ "file.addComment": { family: "file", scope: "org.write", sideEffecting: true, idempotent: true },
428
489
  "file.listComments": { family: "file", scope: "org.read", sideEffecting: false },
429
490
  "file.listPinned": { family: "file", scope: "org.read", sideEffecting: false },
430
491
  // --- memory ---
@@ -458,7 +519,7 @@ export const METHODS = Object.freeze({
458
519
  "notification.updatePreferences": { family: "notification", scope: "org.write", sideEffecting: true },
459
520
  "notification.getPreferences": { family: "notification", scope: "org.read", sideEffecting: false },
460
521
  // --- decision ---
461
- "decision.comment": { family: "decision", scope: "decision.write", sideEffecting: true },
522
+ "decision.comment": { family: "decision", scope: "decision.write", sideEffecting: true, idempotent: true },
462
523
  "decision.sign": { family: "decision", scope: "decision.write", sideEffecting: true },
463
524
  "decision.reverse": { family: "decision", scope: "decision.write", sideEffecting: true },
464
525
  "decision.requestAdjustment": { family: "decision", scope: "decision.write", sideEffecting: true },
@@ -479,7 +540,7 @@ export const METHODS = Object.freeze({
479
540
  "board.updateTask": { family: "board", scope: "board.write", sideEffecting: true },
480
541
  "board.moveTask": { family: "board", scope: "board.write", sideEffecting: true },
481
542
  "board.assignTask": { family: "board", scope: "board.write", sideEffecting: true },
482
- "board.addTaskComment": { family: "board", scope: "board.write", sideEffecting: true },
543
+ "board.addTaskComment": { family: "board", scope: "board.write", sideEffecting: true, idempotent: true },
483
544
  "board.addTaskAttachment": { family: "board", scope: "board.write", sideEffecting: true, idempotent: true },
484
545
  "board.updateTaskAttachment": { family: "board", scope: "board.write", sideEffecting: true },
485
546
  "board.removeTaskAttachment": { family: "board", scope: "board.write", sideEffecting: true },
@@ -933,6 +994,118 @@ export const METHODS = Object.freeze({
933
994
  // dispatcher cannot keep: the protocol advertises the method, callers get
934
995
  // through scope resolution, and the call 500s. Register it in the same change
935
996
  // that adds src/server/methods/escalation/answer.ts.
997
+ // --- GENESIS + ARCHITECTURE (2026-08): the pre-commit workspace draft and the
998
+ // review gates over it. 1:1 with the founder's own form surface — the ask
999
+ // was parity, so the bar is EQUAL power, never more.
1000
+ //
1001
+ // SCOPES REUSE `org.read` / `org.write` rather than minting a genesis.*
1002
+ // pair. `org.write` is already defined as "EDITOR-equivalent: write
1003
+ // org-structural content (members, teams, personas, ... reporting lines,
1004
+ // chart layout)" and already sits in DEFAULT_AGENT_SCOPES under the
1005
+ // comment "agent UI-parity". A Genesis draft IS that content, one commit
1006
+ // earlier; a private scope would be a second name for a permission the
1007
+ // org already grants, and every operator who had reasoned about who may
1008
+ // edit their structure would have to reason about it twice.
1009
+ //
1010
+ // WHY REGISTERING THEM AT ALL IS THE FIX. `scopeForMethod()` resolves an
1011
+ // UNKNOWN method to `admin`, and `admin` is in neither
1012
+ // HUMAN_DEFAULT_SCOPES nor DEFAULT_AGENT_SCOPES — so an undeclared method
1013
+ // is not merely undocumented, it is unreachable by every principal that
1014
+ // exists. That default is correct and it was firing: the handlers shipped
1015
+ // without descriptors and could not serve one request.
1016
+ //
1017
+ // EVERY WRITE IS `sideEffecting:true` AND APPENDS ONE CHAIN EVENT, which
1018
+ // is not bookkeeping — dispatch REQUIRES exactly one append from a
1019
+ // side-effecting handler and throws INTERNAL on zero. The tempting escape
1020
+ // (declare a durable write `sideEffecting:false` so the requirement never
1021
+ // applies) would buy a green test with a lie the whole protocol reads:
1022
+ // no chain row, no idempotency replay, and `READS`-shaped callers free to
1023
+ // retry a mutation they were told was safe.
1024
+ //
1025
+ // `idempotent:true` on the writes is opt-in at the caller (it engages only
1026
+ // when an idempotencyKey is sent) and it is the honest answer for this
1027
+ // family: every write already carries an `(attemptId, editSeq)` base and a
1028
+ // replay of a committed op is refused CONFLICT by the stale-base gate, so
1029
+ // without the cache a dropped response turns a SUCCEEDED edit into a
1030
+ // confusing conflict the caller cannot distinguish from a real race.
1031
+ //
1032
+ // genesis.plan is a READ that spends tokens: it asks a model to turn an
1033
+ // instruction into ops and mutates nothing. Whether it costs money is not
1034
+ // what `sideEffecting` means. ---
1035
+ "genesis.outline": { family: "genesis", scope: "org.read", sideEffecting: false },
1036
+ "genesis.entity": { family: "genesis", scope: "org.read", sideEffecting: false },
1037
+ "genesis.grid": { family: "genesis", scope: "org.read", sideEffecting: false },
1038
+ "genesis.plan": { family: "genesis", scope: "org.read", sideEffecting: false },
1039
+ "genesis.set": { family: "genesis", scope: "org.write", sideEffecting: true, idempotent: true },
1040
+ "genesis.insert": { family: "genesis", scope: "org.write", sideEffecting: true, idempotent: true },
1041
+ "genesis.remove": { family: "genesis", scope: "org.write", sideEffecting: true, idempotent: true },
1042
+ "genesis.reorder": { family: "genesis", scope: "org.write", sideEffecting: true, idempotent: true },
1043
+ "genesis.revert": { family: "genesis", scope: "org.write", sideEffecting: true, idempotent: true },
1044
+ "genesis.applyPlan": { family: "genesis", scope: "org.write", sideEffecting: true, idempotent: true },
1045
+ "architecture.status": { family: "architecture", scope: "org.read", sideEffecting: false },
1046
+ "architecture.accept": { family: "architecture", scope: "org.write", sideEffecting: true, idempotent: true },
1047
+ "architecture.approve": { family: "architecture", scope: "org.write", sideEffecting: true, idempotent: true },
1048
+ // --- resource (2026-08): THE VENTURE-DELIVERABLE CATALOGUE. `OrgResource` is
1049
+ // the org-scoped index of a venture's own artifacts — one row per
1050
+ // deliverable, keyed `(orgId, slug)`, provisioned by the portal
1051
+ // (`source:"ftlab-portal"`), by Genesis, by the artifact ingest, or by
1052
+ // hand. Eight methods: list/get (reads) and create/update/delete/reorder
1053
+ // + attachFile/detachFile (writes).
1054
+ //
1055
+ // SCOPES REUSE `org.read` / `org.write` rather than minting a
1056
+ // `resource.*` pair — the subagent/genesis precedent, and here it is also
1057
+ // the whole ACCESS-LEVEL point. A catalogue card is org-structural
1058
+ // authored content exactly like a persona, an SOP or an annotation, which
1059
+ // is what `org.write` already names; `org.read` is in hq's VIEWER tier
1060
+ // (src/server/auth/role-scopes.ts) and both are in DEFAULT_AGENT_SCOPES.
1061
+ // So on the day these descriptors land, every seat that exists can READ
1062
+ // its venture's catalogue and every EDITOR seat can maintain it, with no
1063
+ // key reminted and no tier table edited. A private scope pair would have
1064
+ // to be threaded through SCOPES, DEFAULT_AGENT_SCOPES and the VIEWER tier
1065
+ // before ONE existing credential could reach the family — which is the
1066
+ // same unreachable-on-arrival failure this declaration exists to end.
1067
+ // (`org.write` is deliberately NOT in hq's HUMAN_DEFAULT_SCOPES: the
1068
+ // human mobile bearer is intentionally narrower than an agent, and the
1069
+ // founder's own catalogue editing runs through the web app's server
1070
+ // actions under `requireOrgContext()`, never this bearer.)
1071
+ //
1072
+ // THE FILES BRIDGE IS A POINTER, NOT A SECOND DRIVE. The artifact ingest
1073
+ // files a venture's artifacts as real `WorkspaceFile` rows and stamps
1074
+ // `OrgResource.fileId`. `resource.*` returns that id and the card's own
1075
+ // columns and NOTHING derived from the drive row — no bytes, no content,
1076
+ // no name, size, version or share list. That is a share boundary, not
1077
+ // tidiness: the catalogue is ORG-WIDE under `org.read` while the drive is
1078
+ // SEAT-scoped by `driveVisibilityWhere` (hq methods/files/list.ts), so a
1079
+ // `resource.*` that re-served file content would be a one-call bypass of
1080
+ // the share filter — the exact defect that handler's docblock warns
1081
+ // about. A card is a citation; `files.get` / `files.docRead` /
1082
+ // `files.exportRequest` are the retrieval path and already exist.
1083
+ // `resource.attachFile` is the ONE place the families touch, and it
1084
+ // resolves the file through the drive's own `getFileOrThrow` (org pin +
1085
+ // `canSeeDriveFile`, NOT_FOUND either way) so it can neither cite another
1086
+ // tenant's file nor act as an existence oracle for one this seat cannot
1087
+ // see. `files.uploadRegister`'s `.strict()` schema is untouched.
1088
+ //
1089
+ // TENANCY, twice over. Slug-addressed rows go through the compound unique
1090
+ // `orgId_slug` — the tenant is PART OF THE KEY, so another org's card is
1091
+ // unaddressable. Id-addressed rows go through `ctx.scopeWhere({ id })` on
1092
+ // findFirst/updateMany/deleteMany, never `findUnique({ where: { id } })`,
1093
+ // which would cross tenants.
1094
+ //
1095
+ // Writes are `sideEffecting:true` and append exactly ONE chain event each
1096
+ // under `family:"resource"`; `idempotent:true` is opt-in at the caller.
1097
+ // `resource.delete` removes the CARD only — the `WorkspaceFile` keeps its
1098
+ // own 30-day soft-delete lane, which is why `fileId` is a plain String
1099
+ // and not a relation (schema.prisma: a cascade would lose the record that
1100
+ // the venture ever had a deck). ---
1101
+ "resource.list": { family: "resource", scope: "org.read", sideEffecting: false },
1102
+ "resource.get": { family: "resource", scope: "org.read", sideEffecting: false },
1103
+ "resource.create": { family: "resource", scope: "org.write", sideEffecting: true, idempotent: true },
1104
+ "resource.update": { family: "resource", scope: "org.write", sideEffecting: true, idempotent: true },
1105
+ "resource.delete": { family: "resource", scope: "org.write", sideEffecting: true, idempotent: true },
1106
+ "resource.reorder": { family: "resource", scope: "org.write", sideEffecting: true, idempotent: true },
1107
+ "resource.attachFile": { family: "resource", scope: "org.write", sideEffecting: true, idempotent: true },
1108
+ "resource.detachFile": { family: "resource", scope: "org.write", sideEffecting: true, idempotent: true },
936
1109
  });
937
1110
 
938
1111
  /**
@@ -134,7 +134,16 @@ test("protocol: frozen family + method counts (additive evolution guard)", () =>
134
134
  // The 2026-08 protocol-convergence pass adds 4: subagent (the org-scoped
135
135
  // sub-agent registry), agent (the agent.wait control lane), mandate (the
136
136
  // MANDATE spine) and preference (member-scoped preferences) → 51.
137
- assert.equal(FAMILIES.length, 51, "family count");
137
+ // The 2026-08 Genesis agent-surface pass adds 2: genesis (the founder's
138
+ // pre-commit workspace draft — the ten-stage run and its editable sections)
139
+ // and architecture (the four tier review decisions and the one whole-
140
+ // architecture approval, which outlive the run as the Org › Architecture
141
+ // view) → 53.
142
+ // The 2026-08 venture-deliverable pass adds 1: resource (the OrgResource
143
+ // catalogue — a venture's own deliverables). The table predates every agent
144
+ // plane and was reachable from none of them, so a venture's colleagues could
145
+ // not list, add, correct, reorder or remove one of their own artifacts → 54.
146
+ assert.equal(FAMILIES.length, 54, "family count");
138
147
  // 357 = the mesh-protocol + agent UI-parity + calling + branding + email
139
148
  // methods, plus the Live Integrations surface: integration.toolsetVersion +
140
149
  // integration.listAgentTools (SP1) and integration.invokeTool (SP5, execute a
@@ -176,7 +185,27 @@ test("protocol: frozen family + method counts (additive evolution guard)", () =>
176
185
  // `server/work/ledger.ts` directly, the daemon reaches it over this wire, so
177
186
  // the threshold and the idempotency cannot fork → 461.
178
187
  // the descriptors; the checksum (read at runtime) is the primary drift guard.
179
- assert.equal(Object.keys(METHODS).length, 461, "method count");
188
+ // The 2026-08 Genesis agent-surface pass adds 13, the 1:1 programmatic
189
+ // parity for the founder's own Generate surface: genesis 10 (outline/entity/
190
+ // grid/plan reads + set/insert/remove/reorder/revert/applyPlan writes) and
191
+ // architecture 3 (status read + accept/approve review gates). The handlers
192
+ // had shipped WITHOUT descriptors, which made all 13 unreachable — dispatch
193
+ // rejects unknown methods and scopeForMethod resolves them to `admin`, which
194
+ // no principal holds → 474.
195
+ // The 2026-08 voice-capability pass adds 1: messaging.synthesizeVoiceNote —
196
+ // hq could always synthesise a colleague's voice note but only as a REFLEX
197
+ // (answering a human's spoken note in kind), so an agent asked in text for a
198
+ // voice note truthfully answered that it could not. This is the verb that
199
+ // makes it askable on every plane; the POST stays plain messaging.send → 475.
200
+ // The 2026-08 venture-deliverable pass adds 8: resource.list/get (reads) and
201
+ // resource.create/update/delete/reorder/attachFile/detachFile (writes). Both
202
+ // reads ride `org.read` and every write `org.write`, so the family is
203
+ // reachable by the VIEWER and EDITOR tiers that already exist rather than by
204
+ // a scope nobody has been minted. attachFile/detachFile are the ONLY bridge
205
+ // to `files.*`: a card carries a fileId POINTER and the drive keeps its own
206
+ // seat-scoped read surface → 483. Then +1: messaging.electResponder (the
207
+ // fleet's server-side respond-election) → 484.
208
+ assert.equal(Object.keys(METHODS).length, 484, "method count");
180
209
  });
181
210
 
182
211
  test("protocol SP3: messaging + calling families/methods/scopes", async () => {
@@ -0,0 +1,317 @@
1
+ /**
2
+ * lib/org/resource-tools.mjs — the `resource_*` desk tools, GENERATED.
3
+ *
4
+ * THE FAMILY. `OrgResource` is a venture's own deliverable catalogue: one
5
+ * org-scoped card per artifact (deck, one-pager, film, site, data room…),
6
+ * keyed `(orgId, slug)`, written by the FTLAB portal, by Genesis, by the
7
+ * artifact ingest, or by hand, and rendered to humans at `/resources`. Until
8
+ * this wave it was reachable from NO agent code path in either plane — a
9
+ * venture's own AI colleagues could not list, add, correct, reorder or remove
10
+ * a single one of the venture's deliverables. Eight protocol methods now
11
+ * exist; these are their curated tools.
12
+ *
13
+ * WHY THIS FILE IS A GENERATOR AND NOT A TABLE. hq's responder desks expose
14
+ * 239 ops across 11 families; maestro's curated table exposed 69 desk tools
15
+ * across 9 — files 29 vs 7, crm 44 vs 8. That divergence has one cause: the two
16
+ * tool tables are hand-written twice, so they drift the moment one side moves
17
+ * and nobody hand-copies the delta. This family refuses to be able to drift the
18
+ * same way. The tool NAME, the ACCESS TIER, the BINDING and the DESK TAG are
19
+ * all DERIVED from the vendored protocol declaration — the same declaration
20
+ * hq's handlers are registered from — and the only thing a human writes here is
21
+ * PRESENTATION: the title, the description, and the input schema. Add a ninth
22
+ * `resource.*` method to `lib/org/protocol.mjs` and sync it, and a ninth tool
23
+ * appears on the next import with no edit to this file; `resource-tools.test.mjs`
24
+ * then goes red until someone gives it a real description. The capability is
25
+ * never dark and the gap is never quiet — which is the whole thesis of the
26
+ * wave that shipped the family.
27
+ *
28
+ * ACCESS. `expectedAccessFor` derives the tier from the method's protocol
29
+ * SCOPE, never from a literal typed here: `resource.list` / `resource.get` ride
30
+ * `org.read` → `access:"read"` (every tier's lookup set, including
31
+ * least-privilege "default"); the six writes ride `org.write` →
32
+ * `access:"write"` (ceo + leadership). NONE is `access:"admin"` — that is the
33
+ * CEO-only escape-hatch tier, and a venture's colleagues are default- and
34
+ * leadership-tier agents, so parking their own deliverables there would ship
35
+ * this family exactly as dark as it was before. See lib/org/tool-access.mjs.
36
+ *
37
+ * NO `outbound` FLAG ANYWHERE IN THIS BLOCK. A card is org-internal metadata;
38
+ * nothing here puts free text a human reads onto an outbound channel, and the
39
+ * send-gate lane stays exactly messaging_send / email_send / email_draft_send /
40
+ * org_call_share_step.
41
+ *
42
+ * THE FILES BRIDGE IS A POINTER, NOT A SECOND DRIVE. A card returns its own
43
+ * columns plus a `fileId`, and NOTHING derived from the `WorkspaceFile` — no
44
+ * bytes, name, mime, size, version or share list. The catalogue is org-wide
45
+ * under `org.read` while the drive is seat-scoped by `driveVisibilityWhere`, so
46
+ * a resource tool that re-served file content would be a one-call bypass of the
47
+ * share filter. Bytes are read with `files_get` / `files_doc_read` /
48
+ * `org_rpc files.exportRequest`. `resource_attach_file` is the one seam, and hq
49
+ * resolves the id through the drive's own `getFileOrThrow` (org pin +
50
+ * `canSeeDriveFile`, NOT_FOUND either way), so it can neither cite another
51
+ * tenant's file nor act as an existence oracle for one this seat cannot see.
52
+ *
53
+ * Node builtins only. ESM.
54
+ *
55
+ * @module lib/org/resource-tools
56
+ */
57
+
58
+ "use strict";
59
+
60
+ import { METHODS } from "./protocol.mjs";
61
+ import { expectedAccessFor } from "./tool-access.mjs";
62
+
63
+ /** The protocol family these tools are generated from. */
64
+ export const RESOURCE_FAMILY = "resource";
65
+
66
+ /** The desk tag every generated entry carries (gates on DESK_PROBES.resource). */
67
+ export const RESOURCE_DESK = "resource";
68
+
69
+ // Schema helpers — same shapes tool-surface.mjs builds its entries with.
70
+ const S = (properties, required = []) => ({ type: "object", properties, required });
71
+ const str = (description) => ({ type: "string", description });
72
+ const num = (description) => ({ type: "number", description });
73
+ const bool = (description) => ({ type: "boolean", description });
74
+ const obj = (description) => ({ type: "object", description });
75
+ const arr = (items, description) => ({ type: "array", items, description });
76
+
77
+ /**
78
+ * PRESENTATION ONLY, keyed by protocol method. No `name`, no `access`, no
79
+ * `binding`, no `desk` — those four are derived, and a literal here would be
80
+ * the hand-copy this file exists to prevent.
81
+ *
82
+ * Each description carries what the schema cannot: the semantics an agent gets
83
+ * wrong on the first try (total-vs-partial reorder, shallow-merge metadata,
84
+ * what survives a delete) and the refusals it will otherwise learn by
85
+ * BAD_REQUEST.
86
+ * @type {Readonly<Record<string, {title: string, description: string, input_schema: object}>>}
87
+ */
88
+ export const RESOURCE_PRESENTATION = Object.freeze({
89
+ "resource.list": {
90
+ title: "List venture deliverables",
91
+ description:
92
+ "This venture's deliverable catalogue (resource.list) — the same cards, in the same " +
93
+ "order, that humans see at /resources: sortOrder asc, then createdAt asc. Each card is " +
94
+ "{id, slug, kind, title, description, url, previewImageUrl, source, fileId, metadata, " +
95
+ "sortOrder, createdAt, updatedAt}. `source` says who put it there — \"ftlab-portal\" " +
96
+ "(pushed by the venture portal and re-upserted on every push), \"genesis\", or " +
97
+ "\"manual\" (created through this desk). `fileId` non-null means the artifact is also " +
98
+ "filed in the workspace drive: read it with files_get / files_doc_read, NOT from here — " +
99
+ "this desk returns the card's own columns and nothing about the file itself. Filters: " +
100
+ "`kind`, `source`, free-text `q` (matches title OR description, case-insensitive), " +
101
+ "`hasFile` (true = only drive-backed cards, false = only link-only cards, omit = both). " +
102
+ "No cursor — raise `limit` (max 200) if the catalogue is long.",
103
+ input_schema: S({
104
+ kind: str("Filter by card kind, e.g. \"deck\", \"one-pager\", \"film\", \"site\" (≤64 chars)."),
105
+ source: str("Filter by provenance: \"ftlab-portal\" | \"genesis\" | \"manual\" (≤64 chars)."),
106
+ q: str("Free text; matches title OR description, case-insensitive (≤200 chars)."),
107
+ hasFile: bool("true = only cards with a drive fileId; false = only link-only cards; omit = both."),
108
+ limit: num("Max cards 1..200 (default 100)."),
109
+ }),
110
+ },
111
+ "resource.get": {
112
+ title: "Get one deliverable",
113
+ description:
114
+ "One catalogue card by `slug` (the stable org-scoped natural key the portal, the " +
115
+ "/resources page and every tool here use) or by `resourceId` — pass EXACTLY ONE; " +
116
+ "neither or both is a BAD_REQUEST. Returns the card's own columns only. A card in " +
117
+ "another workspace answers NOT_FOUND identically to one that never existed, so this is " +
118
+ "not an existence oracle — do not read NOT_FOUND as \"it belongs to someone else\".",
119
+ input_schema: S({
120
+ slug: str("Card slug, e.g. \"deck-seed-round\" (preferred — stable across re-pushes)."),
121
+ resourceId: str("Card id (cuid). Alternative to slug; never pass both."),
122
+ }),
123
+ },
124
+ "resource.create": {
125
+ title: "Add a deliverable",
126
+ description:
127
+ "Add a card to the venture's catalogue (resource.create). `url` OR `fileId` must be " +
128
+ "present — a card pointing nowhere is the exact state this desk exists to end. Omit " +
129
+ "`slug` and the server derives one from kind + title (and suffixes -2, -3 on collision) " +
130
+ "using the SAME transform the portal uses, so a portal push and an agent create agree. " +
131
+ "`source` is NOT an input: the server stamps \"manual\", so an agent cannot relabel its " +
132
+ "own card as portal-provenance. This is NOT an upsert — an existing slug answers " +
133
+ "CONFLICT rather than overwriting a portal row someone guessed the slug of; correct an " +
134
+ "existing card with resource_update. `metadata` is free JSON (object only, ≤16KB) for " +
135
+ "provenance such as sha256/runId/documentVersion — NEVER put a credential in it; only " +
136
+ "its KEY NAMES ride the event chain.",
137
+ input_schema: S(
138
+ {
139
+ kind: str("Card kind, e.g. \"deck\" | \"one-pager\" | \"film\" | \"site\" (≤64 chars)."),
140
+ title: str("Human title as it appears at /resources (≤200 chars)."),
141
+ url: str("Public https URL (≤2048) or an in-app path like \"/files/abc123\". Required unless fileId is given."),
142
+ fileId: str("Workspace drive file id to cite. Required unless url is given; must be a file this seat can already see."),
143
+ slug: str("Optional explicit slug: lowercase a-z0-9 words joined by single hyphens, ≤64. Omit to let the server derive it."),
144
+ description: str("One or two sentences a human reads under the title (≤2000 chars)."),
145
+ previewImageUrl: str("Thumbnail URL (≤2048)."),
146
+ metadata: obj("Free-JSON provenance object, ≤16KB serialized. No secrets."),
147
+ sortOrder: num("Position 0..10000 (ties break on createdAt asc). Omit to append."),
148
+ },
149
+ ["kind", "title"],
150
+ ),
151
+ },
152
+ "resource.update": {
153
+ title: "Correct a deliverable",
154
+ description:
155
+ "Edit an existing card (resource.update), addressed by EXACTLY ONE of `slug` or " +
156
+ "`resourceId`. At least one mutable field is required, else BAD_REQUEST. `metadata` is a " +
157
+ "SHALLOW MERGE over what is already there — {...prior, ...patch} — and a key you pass as " +
158
+ "null is DELETED. It is deliberately not a whole-object replace: the artifact ingest " +
159
+ "writes sha256/runId/documentVersion/byteState/driveUrl into metadata, and a replace " +
160
+ "would let a one-field edit destroy the delivery provenance. Three fields are NOT " +
161
+ "editable here, each on purpose: `slug` (it is the ref AND the portal's re-sync key — " +
162
+ "renaming it would orphan the upsert and duplicate the card on the next push), `fileId` " +
163
+ "(re-pointing a card at a different drive row is a separately-audited act — use " +
164
+ "resource_attach_file), and `source` (provenance is server-owned).",
165
+ input_schema: S({
166
+ slug: str("Card slug to edit (preferred). Never pass both slug and resourceId."),
167
+ resourceId: str("Card id (cuid). Alternative to slug."),
168
+ kind: str("New kind (≤64 chars)."),
169
+ title: str("New title (≤200 chars)."),
170
+ description: str("New description (≤2000 chars); null clears it."),
171
+ url: str("New https URL (≤2048) or in-app path."),
172
+ previewImageUrl: str("New thumbnail URL (≤2048); null clears it."),
173
+ metadata: obj("Metadata PATCH — shallow-merged; a key set to null is removed. ≤16KB serialized."),
174
+ sortOrder: num("New position 0..10000. This is the PARTIAL nudge; resource_reorder is the total re-sort."),
175
+ }),
176
+ },
177
+ "resource.delete": {
178
+ title: "Remove a deliverable",
179
+ description:
180
+ "Remove a card from the catalogue (resource.delete), addressed by EXACTLY ONE of `slug` " +
181
+ "or `resourceId`. THE CARD ONLY: the WorkspaceFile it cited is NOT deleted and not even " +
182
+ "soft-deleted — the drive owns its own 30-day recovery lane — which is why the reply " +
183
+ "carries `fileRetained: true`. Relay that truth; do not tell a human the file is gone. " +
184
+ "The card's Cortex SOURCES entry goes with it, so a removed deliverable stops being " +
185
+ "discoverable. CAVEAT worth saying out loud: deleting a card whose `source` is " +
186
+ "\"ftlab-portal\" is not permanent — the next portal push re-upserts it on its natural " +
187
+ "key. If a portal card is wrong, fix it upstream or correct it with resource_update.",
188
+ input_schema: S({
189
+ slug: str("Card slug to remove (preferred). Never pass both slug and resourceId."),
190
+ resourceId: str("Card id (cuid). Alternative to slug."),
191
+ }),
192
+ },
193
+ "resource.reorder": {
194
+ title: "Re-sort the catalogue",
195
+ description:
196
+ "Set the whole catalogue's order (resource.reorder) — this is what changes what a human " +
197
+ "sees FIRST at /resources. TOTAL, not partial: `slugs` must name EVERY card in this " +
198
+ "workspace exactly once, and each gets sortOrder = its index. Anything else is a " +
199
+ "CONFLICT that names the missing, unknown and duplicated slugs back to you — which is " +
200
+ "the feature, not the friction: a card created by the portal while you were composing " +
201
+ "the list surfaces as a refusal instead of silently scrambling the order. So list first " +
202
+ "(resource_list), reorder from THAT list, and re-list if it refuses. To nudge one card " +
203
+ "without touching the rest, use resource_update { sortOrder } instead. Returns the full " +
204
+ "catalogue in its new order — no re-list needed.",
205
+ input_schema: S(
206
+ {
207
+ slugs: arr(
208
+ { type: "string" },
209
+ "Every slug in the workspace, exactly once, in the order you want (1..500).",
210
+ ),
211
+ },
212
+ ["slugs"],
213
+ ),
214
+ },
215
+ "resource.attachFile": {
216
+ title: "Cite a drive file on a card",
217
+ description:
218
+ "Point a catalogue card at a workspace drive file (resource.attachFile), addressed by " +
219
+ "EXACTLY ONE of `slug` or `resourceId`. This is a LINK write, not a byte write: it sets " +
220
+ "fileId and merges {driveUrl:\"/files/<fileId>\"} into metadata. hq resolves the file " +
221
+ "through the drive's own permission check, so you may only cite a file this seat can " +
222
+ "already see, and a file you cannot see answers NOT_FOUND exactly like one that does not " +
223
+ "exist. `setUrl` defaults to FALSE and should usually stay there: a deck, one-pager or " +
224
+ "film keeps its public URL because the venture's own site and outreach link that object " +
225
+ "— pass setUrl:true only when the card's destination really should become the in-app " +
226
+ "file. Attaching does not copy, move or re-share the file.",
227
+ input_schema: S(
228
+ {
229
+ fileId: str("Workspace drive file id to cite."),
230
+ slug: str("Card slug (preferred). Never pass both slug and resourceId."),
231
+ resourceId: str("Card id (cuid). Alternative to slug."),
232
+ setUrl: bool("Also rewrite the card's url to /files/<fileId>. Default false — an external URL is usually load-bearing."),
233
+ },
234
+ ["fileId"],
235
+ ),
236
+ },
237
+ "resource.detachFile": {
238
+ title: "Stop citing a drive file",
239
+ description:
240
+ "Clear a card's drive citation (resource.detachFile), addressed by EXACTLY ONE of `slug` " +
241
+ "or `resourceId`: fileId → null and the `driveUrl` key drops out of metadata. The " +
242
+ "WorkspaceFile is untouched. The card's `url` is left exactly as it was UNLESS you pass " +
243
+ "a replacement `url`. One guard: if the card's only destination IS the file being " +
244
+ "detached (its url is /files/<fileId>), `url` is REQUIRED and the call answers CONFLICT " +
245
+ "without it — otherwise this verb would manufacture the dead card the whole family " +
246
+ "exists to prevent.",
247
+ input_schema: S({
248
+ slug: str("Card slug (preferred). Never pass both slug and resourceId."),
249
+ resourceId: str("Card id (cuid). Alternative to slug."),
250
+ url: str("Replacement destination — required when the card's url is the /files/<fileId> being detached."),
251
+ }),
252
+ },
253
+ });
254
+
255
+ /**
256
+ * `resource.attachFile` → `resource_attach_file`. Dots become underscores and
257
+ * camelCase humps split, so the tool name is a pure function of the protocol
258
+ * name and cannot be typed differently from it.
259
+ * @param {string} method
260
+ * @returns {string}
261
+ */
262
+ export function toolNameForMethod(method) {
263
+ return String(method)
264
+ .replace(/\./g, "_")
265
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
266
+ .toLowerCase();
267
+ }
268
+
269
+ /**
270
+ * Build the curated `resource_*` entries from the VENDORED protocol table.
271
+ *
272
+ * Order follows the protocol declaration, so the tool block reads in the order
273
+ * the family was designed in. Returns `[]` when the vendored protocol carries
274
+ * no `resource` family at all — an older protocol degrades cleanly, exactly
275
+ * like the desk gates.
276
+ *
277
+ * A family method with no presentation entry still gets a tool, built from the
278
+ * descriptor: an under-described capability is a documentation bug, but a
279
+ * MISSING one is the failure this whole wave was called to fix, so drift
280
+ * surfaces as a red test (resource-tools.test.mjs asserts the presentation
281
+ * table covers the family exactly) and never as a dark verb.
282
+ *
283
+ * @param {Record<string, {family?:string, scope?:string, sideEffecting?:boolean}>} [methods]
284
+ * @returns {Array<object>}
285
+ */
286
+ export function buildResourceTools(methods = METHODS) {
287
+ const out = [];
288
+ for (const [method, def] of Object.entries(methods || {})) {
289
+ if (!def || def.family !== RESOURCE_FAMILY) continue;
290
+ // Scope is the authority (it is what hq gates on). The `??` arm only fires
291
+ // for a method the REAL vendored table does not carry — i.e. a synthetic
292
+ // table under test — and mirrors sideEffecting so such a row still lands on
293
+ // the correct side of the read/write line rather than defaulting open.
294
+ const access = expectedAccessFor(method) ?? (def.sideEffecting ? "write" : "read");
295
+ const p = RESOURCE_PRESENTATION[method];
296
+ out.push({
297
+ name: toolNameForMethod(method),
298
+ title: p ? p.title : method,
299
+ description: p
300
+ ? p.description
301
+ : `Undocumented ${RESOURCE_FAMILY} verb (${method}) — generated from the protocol ` +
302
+ `declaration so the capability is reachable; add a presentation entry in ` +
303
+ `lib/org/resource-tools.mjs.`,
304
+ input_schema: p ? p.input_schema : S({}),
305
+ access,
306
+ desk: RESOURCE_DESK,
307
+ binding: { kind: "rpc", method },
308
+ });
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /**
314
+ * The generated block, spliced into `ORG_TOOLS` by lib/org/tool-surface.mjs.
315
+ * @type {ReadonlyArray<object>}
316
+ */
317
+ export const RESOURCE_TOOLS = Object.freeze(buildResourceTools());