@drawbridge/drawbridge-agents 0.1.31 → 0.1.35
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.
|
@@ -21,13 +21,25 @@ need to retest. Use this right after merging the work to `develop` (which deploy
|
|
|
21
21
|
(`type` / `resource_subtype` is a comment, not a system story), take the **most recent**
|
|
22
22
|
one's `created_by`. This is the reassignee — do not hardcode a person. If there are no
|
|
23
23
|
comments, ask the user who to assign to.
|
|
24
|
-
3. **Draft the comment.**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
3. **Draft the comment — short.** Two parts, nothing else: what changed (and, if they
|
|
25
|
+
reported a bug, what actually caused it), then a **Retest** section with the steps and
|
|
26
|
+
the expected result. Ground the steps in the behaviour that actually changed.
|
|
27
|
+
|
|
28
|
+
**Hard limit: ~200 words, and never more than 6 short paragraphs/bullets.** These
|
|
29
|
+
comments have been far too long — reading them costs the reader more than the fix did.
|
|
30
|
+
Cut: the investigation narrative, why the bug shipped, code/file/commit detail, anything
|
|
31
|
+
restating what they already reported, and any sentence that would not change what they
|
|
32
|
+
do next.
|
|
33
|
+
|
|
34
|
+
**Short is not partial.** Include everything they need in ONE comment — every scenario to
|
|
35
|
+
retest, any setup or data gotcha, and anything about the result that would otherwise look
|
|
36
|
+
wrong (an odd amount, a delay, a cosmetic leftover). A follow-up comment correcting or
|
|
37
|
+
completing this one is the failure this limit exists to prevent, so check the draft for
|
|
38
|
+
omissions *before* trimming words.
|
|
39
|
+
|
|
40
|
+
**Open with an @-mention of the reassignee** — use `html_text` and start the body with
|
|
41
|
+
`<a data-asana-gid="<their user gid>"/>` (Asana expands it into a proper mention). A
|
|
42
|
+
comment without a mention may notify no one; reassignment alone is easy to miss.
|
|
31
43
|
4. **Confirm before writing.** This posts to an external system. Show the user the chosen
|
|
32
44
|
assignee and the drafted comment and get a go-ahead, unless they've told you to proceed
|
|
33
45
|
without asking.
|
|
@@ -42,5 +54,7 @@ need to retest. Use this right after merging the work to `develop` (which deploy
|
|
|
42
54
|
- If the Asana MCP isn't connected, stop and tell the user to authorize it — don't fabricate a
|
|
43
55
|
handoff.
|
|
44
56
|
- Keep the comment user/QA-facing: what to test and expect, not internal implementation detail.
|
|
57
|
+
The reader is verifying behaviour, not reviewing the fix — if a sentence only makes sense to
|
|
58
|
+
someone who has read the diff, it does not belong in the comment.
|
|
45
59
|
- Consider running `drawbridge-ship-feature` instead if docs/graph updates for this ship
|
|
46
60
|
haven't been done yet — this skill is the final step of that flow.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@./node_modules/@drawbridge/drawbridge-agents/claude/CLAUDE.md
|
package/bin/sync-claude.js
CHANGED
|
@@ -16,10 +16,18 @@ const rmrf = (p) => {
|
|
|
16
16
|
|
|
17
17
|
const copied = []
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// the
|
|
19
|
+
// The complete manifest of what .root-template installs, as stored name -> installed name.
|
|
20
|
+
// EVERY file in .root-template needs an entry, including one that installs under its own name:
|
|
21
|
+
// test/package.test.js asserts the map covers the directory, and that only works as a tripwire
|
|
22
|
+
// if the map is exhaustive. Adding a file without an entry is the mistake being guarded against
|
|
23
|
+
// — a dotfile stored dotless would land in consumers without its dot and silently do nothing.
|
|
24
|
+
//
|
|
25
|
+
// Dotfiles are stored dotless because npm strips a literal `.npmrc` from published tarballs;
|
|
26
|
+
// the rest follow for one consistent rule: nothing in a template directory starts with a dot.
|
|
27
|
+
// CLAUDE.md is not a dotfile and installs unchanged — its entry is an identity mapping, which
|
|
28
|
+
// is a deliberate declaration rather than an oversight.
|
|
22
29
|
const RENAME = {
|
|
30
|
+
'CLAUDE.md': 'CLAUDE.md',
|
|
23
31
|
editorconfig: '.editorconfig',
|
|
24
32
|
'mcp.json': '.mcp.json',
|
|
25
33
|
nvmrc: '.nvmrc',
|
|
@@ -54,6 +54,24 @@ skill) so it stops living only in tribal memory.
|
|
|
54
54
|
index manually is not enough; until the boot collMod lands, every insert carrying the field
|
|
55
55
|
fails Mongo 121 (not E11000), so keyed-idempotency writers silently drop their writes
|
|
56
56
|
(2026-07-30 `notification.key` dev outage, Sentry DRAWBRIDGE-SYNC-32).
|
|
57
|
+
- **Absent keys become BSON null on validated writes** (api ↔ sync): neither app's MongoClient
|
|
58
|
+
sets `ignoreUndefined`, so a JS `undefined` property serializes as an explicit `null` — which
|
|
59
|
+
strict validators reject wherever the key is typed non-nullable (bare `bsonType : 'string'`).
|
|
60
|
+
Submission `fields` items legally lack `label` (nothing per-item is required), so any consumer
|
|
61
|
+
copying them into a **validated** write must include keys conditionally
|
|
62
|
+
(`...( typeof label === 'string' && { label } )`), never `label : maybeUndefined`. Violated by
|
|
63
|
+
sync `buildContact` until 2026-08-22: contact CREATE is the one validated write in the upsert
|
|
64
|
+
(updates bypass), so one label-less lead failed the insert with Mongo 121 and killed the org's
|
|
65
|
+
entire nightly segment sync (Sentry DRAWBRIDGE-SYNC-32, prod).
|
|
66
|
+
- **Share-page freshness is invalidation-driven, not TTL-driven** (sync ↔ api ↔ share). The api
|
|
67
|
+
render cache (`lib/render-cache.js`, 5 min TTL) and share's HTML cache (SWR, 60 s soft) are
|
|
68
|
+
both busted ONLY through sync's seam: `RENDER_AFFECTING` in
|
|
69
|
+
`lib/render-affecting-collections.js` → `render-invalidate` worker →
|
|
70
|
+
`invalidateSubdomainPageKeys`. Any collection baked into the public page payload MUST be in
|
|
71
|
+
that allowlist or its edits sit stale for the full TTL with no error anywhere — `product` was
|
|
72
|
+
missing until 2026-08-13 (Shopify renames took 5+ min to reach share pages). Product scope is
|
|
73
|
+
special: the doc carries no campaign/org refs, so the worker resolves featuring campaigns via
|
|
74
|
+
`campaign.products[]`, per-page org (canonical product docs can span orgs).
|
|
57
75
|
- Scraped-asset file reuse keys on `organization + meta.origin + meta.element` **plus
|
|
58
76
|
`meta.render` for pipeline-rendered marks** (sync `resolveAsset` writes/queries it; the field
|
|
59
77
|
and its index live in drawbridge-api `schema/file.js`). `origin` identifies the source asset,
|
|
@@ -116,6 +134,12 @@ skill) so it stops living only in tribal memory.
|
|
|
116
134
|
`schema/action.js`) is a shared dependency: sync's `computeCycleActions` (billing, by `createdAt`
|
|
117
135
|
window) AND api's date-ranged reporting (`route/organization-usage.js`) both match on it. Drop it
|
|
118
136
|
→ COLLSCAN on every invoice + usage query.
|
|
137
|
+
- An `action` row's **`units` are BILLABLE units, never a display metric**. A `submission` row
|
|
138
|
+
carries accepted entries on a giveaway (drawbridge-sync `stream/submission.js`) but 1 per
|
|
139
|
+
submission on a default campaign, which collects no entries at all (drawbridge-api
|
|
140
|
+
`route/subdomain-submission.js`) — so both writers stamp the real count at `meta.entries`, and
|
|
141
|
+
reporting (`route/organization-usage.js`) reads that. Reading `units` as Entries made the Usage
|
|
142
|
+
page's Entries card mirror Submissions for every default campaign (Asana 1217406893229252).
|
|
119
143
|
- The `action` ledger is **permanent — no TTL** (drawbridge-api `schema/action.js`): the
|
|
120
144
|
proof-of-billing record AND the source for date-ranged usage reporting, both read as raw rows by
|
|
121
145
|
`createdAt`. Re-adding a TTL shorter than any billing interval silently under-bills long cycles
|
|
@@ -164,6 +188,37 @@ skill) so it stops living only in tribal memory.
|
|
|
164
188
|
land on the wrong day). The param rides each endpoint's existing query shape (top-level vs
|
|
165
189
|
`filters`) — do NOT normalise the shapes.
|
|
166
190
|
|
|
191
|
+
## OTC dispatch: silence is never success (api ↔ app-web ↔ sync)
|
|
192
|
+
|
|
193
|
+
- The auth request endpoints (`/oauth/signin/request`, `/oauth/signup/request`) answer `200
|
|
194
|
+
{ result : { otcId : null } }` for a malformed address and for an internal error — deliberately
|
|
195
|
+
shaped like success so neither can be probed. **A missing `otcId` means NOTHING was dispatched;
|
|
196
|
+
a client must never advance to a "we sent you a code" screen on one.** app-web enforces this in
|
|
197
|
+
one place, `lib/otc.js` `requestOtc()`. Advancing anyway is invisible to every alarm we have:
|
|
198
|
+
the user waits on mail that does not exist and nothing anywhere records a failure.
|
|
199
|
+
- The **reCAPTCHA verdict is NOT an enumeration signal** — it is computed before the email is
|
|
200
|
+
parsed — so an auth block answers explicitly (403 refresh / 503 outage), unlike the email-shape
|
|
201
|
+
branches. Don't "harmonise" it back to the silent 200.
|
|
202
|
+
- `google.recaptcha.verify()` **throws** on both a refused token and a Google outage (the outage
|
|
203
|
+
carries `status: 503`). Express 4 does not route a rejected promise to the error handler, so any
|
|
204
|
+
async middleware calling it MUST try/catch and `next( error )` — unguarded it dangles the request
|
|
205
|
+
with no response at all (`route/organization-issue.js` did exactly this).
|
|
206
|
+
- Auth code emails are dispatched by **sync** (`stream/otc.js`, on the change-stream update that
|
|
207
|
+
writes `code.text`) and must state the window **api** enforces: `lifetimes.code` from
|
|
208
|
+
drawbridge-utils (15 min). The prize-draw branch is a genuinely different 5 min
|
|
209
|
+
(api `route/otc.js` CODE_TTL_MINUTES) — the two are not interchangeable.
|
|
210
|
+
|
|
211
|
+
## Growth attribution tags (growth ↔ api)
|
|
212
|
+
|
|
213
|
+
- drawbridge-growth `lib/tags.js` MIRRORS drawbridge-api `lib/utm.js` — allow-listed keys, trim,
|
|
214
|
+
255 cap then lowercase `source`/`medium`, in that order. The API side is what actually stores a
|
|
215
|
+
tag when a click lands; a drifted mirror shows salespeople a tuple that never existed. Growth's
|
|
216
|
+
`test/tags.test.js` asserts parity against the API's source file — changing `lib/utm.js` fails
|
|
217
|
+
growth's suite, which is the intended tripwire, not an accident.
|
|
218
|
+
- `campaign.destination.utm.id` is the ONLY key a signup joins back on and is unique-indexed
|
|
219
|
+
(partial, `$type: 'string'`) in growth's schema. Two campaigns sharing a utm_id don't split
|
|
220
|
+
signups — each claims all of them and both read as working, invisibly.
|
|
221
|
+
|
|
167
222
|
## `@drawbridge/*` package coordination
|
|
168
223
|
|
|
169
224
|
- Pins are version-exact across the whole family; bumping one package means auditing every
|
|
@@ -208,6 +263,32 @@ skill) so it stops living only in tribal memory.
|
|
|
208
263
|
parsed milliseconds — `frameMilliseconds` in api `route/organization-file.js`, `lib/frames.js`
|
|
209
264
|
in app-web.
|
|
210
265
|
|
|
266
|
+
## File uploads (utils ↔ api ↔ sync ↔ app-web)
|
|
267
|
+
|
|
268
|
+
- `drawbridge-utils/lib/upload.js` is the ONLY allowlist of what may be uploaded, and now also
|
|
269
|
+
owns `maximumUploadBytes` + `uploadPartSize()`. api validates against it, sync mimes-checks
|
|
270
|
+
against it, app-web derives its `accept` attribute, "Accepted formats" row and uploader
|
|
271
|
+
restrictions from it. Adding a format = edit + publish utils + bump the pin in all three; a
|
|
272
|
+
hand-written copy anywhere is how a format gets accepted by one side and rejected by another.
|
|
273
|
+
- **app-web must compute the part size itself**, not read it from the create response — Uppy's
|
|
274
|
+
`MultipartUploader` builds its chunk array in its constructor, *before* `createMultipartUpload`
|
|
275
|
+
is called. Both sides call the same `uploadPartSize()` so the boundaries cannot disagree.
|
|
276
|
+
- **Never derive a file's extension from the mime subtype.** That works only where subtype ==
|
|
277
|
+
extension; an iPhone `.mov` is `video/quicktime`, so the naive split sends `quicktime` and the
|
|
278
|
+
upload 400s before a byte moves. app-web `fileParts()` in `lib/helpers.js`.
|
|
279
|
+
- **api and sync deploy together for multipart.** The presigned routes create the `file` doc
|
|
280
|
+
BEFORE the bytes (`status: 'pending'`, `bytes: 0`), so without sync's update-side
|
|
281
|
+
`fileBytesDelta` the file counts as ZERO toward `organization.totals.storage` forever — the
|
|
282
|
+
storage restriction passes indefinitely and we under-bill, with nothing erroring.
|
|
283
|
+
- **Spaces bucket CORS must set `ExposeHeaders: [ ETag ]`** for the dashboard origin. Without it
|
|
284
|
+
`@uppy/aws-s3`'s `uploadPartBytes` cannot read the part ETag and returns without resolving or
|
|
285
|
+
rejecting — the upload hangs forever with no error, no retry and no timeout.
|
|
286
|
+
- Abandoned multipart parts are BILLED by Spaces while invisible to bucket listings, so the
|
|
287
|
+
`file.sweep` job in drawbridge-sync (hourly, 6h window) is not optional. Golden Retriever's
|
|
288
|
+
`expires` in app-web must match that window.
|
|
289
|
+
- `sizes.original` must be set when a multipart upload completes, not at create — the resize
|
|
290
|
+
worker reads it as its source, and a file without it is shaped unlike every other file.
|
|
291
|
+
|
|
211
292
|
## Import surfaces
|
|
212
293
|
|
|
213
294
|
- Import names against a package's **actual exports** — a missing export resolves to `undefined`
|
|
@@ -220,9 +301,28 @@ skill) so it stops living only in tribal memory.
|
|
|
220
301
|
message `'Shopify product not found'`** when the Storefront API can't see a product
|
|
221
302
|
(unpublished from the Drawbridge channel / deleted); transport and GraphQL failures throw
|
|
222
303
|
`'Shopify storefront error: …'`. drawbridge-sync's product worker (`queue/product.js`)
|
|
223
|
-
string-matches the not-found message to
|
|
224
|
-
retrying. Reword the message → unpublished products retry-loop forever and
|
|
225
|
-
feedback; drawbridge-api's shopifyProduct route relies on the not-found
|
|
304
|
+
string-matches the not-found message to deactivate the product and send ResourceFeedback
|
|
305
|
+
instead of retrying. Reword the message → unpublished products retry-loop forever and
|
|
306
|
+
merchants get no feedback; drawbridge-api's shopifyProduct route relies on the not-found
|
|
307
|
+
throw staying a throw.
|
|
308
|
+
- **`sendProductResourceFeedback` returns `null` for states Shopify won't carry — that is not a
|
|
309
|
+
failure.** Shopify accepts product feedback only for a product published to the calling app's
|
|
310
|
+
channel, and refuses anything else with `'… not available to the channel'`. That is exactly
|
|
311
|
+
the state REQUIRES_ACTION describes (unpublished, or drafted — drafting also removes it), so
|
|
312
|
+
the refusal is matched and returned as `null` rather than thrown. Treating `null` as an error
|
|
313
|
+
re-creates the 2026-08-12 QA finding: a per-sync failure report for an expected condition,
|
|
314
|
+
which is what buried the real defect. The embedded app's publishing section is the surface for
|
|
315
|
+
these products (App Store 5.7.8/5.7.11), not the product page.
|
|
316
|
+
- **Feedback must forward the product's own `updatedAt`, never a fresh timestamp.** Shopify
|
|
317
|
+
versions feedback on `productUpdatedAt` and refuses a payload older than the version it holds,
|
|
318
|
+
so stamping `new Date()` records a version ahead of the product and every later send comes back
|
|
319
|
+
`'Feedback for a later version of this resource was already accepted'`. `@drawbridge/shopify`
|
|
320
|
+
reads it (costing one extra `read_products` query per send) so callers cannot get this wrong.
|
|
321
|
+
- **ResourceFeedback messages are format-validated by Shopify, not by us**: one message, ≤ 100
|
|
322
|
+
characters, leading capital, trailing period, not all caps. A breach fails the whole send.
|
|
323
|
+
- Feedback failures in sync go through `logger.error`, never `logger.warn` — a warn reaches
|
|
324
|
+
Sentry **Logs** but not **Issues**, which is how a feedback path failing on every send survived
|
|
325
|
+
two rounds of QA that were explicitly pointed at Issues.
|
|
226
326
|
- The `write_resource_feedbacks` and `read_publications` scopes live in drawbridge-shopify-app's
|
|
227
327
|
`shopify.app.*.toml` but power other repos: sync's product ResourceFeedback sends and the
|
|
228
328
|
embedded app's publishing section. Removing either → silent Shopify userErrors, no crash.
|
package/package.json
CHANGED
package/scripts/channels.js
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
// What communication channels are actually flowing, HubSpot's count against ours.
|
|
2
|
-
//
|
|
3
|
-
// Exists because "the integration is connected" and "the data is arriving" are
|
|
4
|
-
// different claims, and the second is the only one that matters here. HubSpot's
|
|
5
|
-
// communication object holds SMS, WhatsApp and LinkedIn messages, told apart by
|
|
6
|
-
// channel — so whatever writes LinkedIn messages into that object, this pipeline
|
|
7
|
-
// reads. The question is never whether we can read them; it is whether anything is
|
|
8
|
-
// writing them.
|
|
9
|
-
//
|
|
10
|
-
// Same shape as the `missing` figure on the contact ingest, which is what caught 50
|
|
11
|
-
// contacts that had been silently absent for the life of the project.
|
|
12
|
-
|
|
13
|
-
const { growth } = require( '../lib/mongodb' );
|
|
14
|
-
const { enabled, request } = require( '../lib/hubspot' );
|
|
15
|
-
const { run } = require( '../lib/cycle' );
|
|
16
|
-
|
|
17
|
-
// The channels HubSpot files under the communication object. PHYSICAL_MAIL is
|
|
18
|
-
// hidden on this portal but counted anyway, because a hidden property still holds
|
|
19
|
-
// records.
|
|
20
|
-
const CHANNELS = [
|
|
21
|
-
'LINKEDIN_MESSAGE',
|
|
22
|
-
'SMS',
|
|
23
|
-
'WHATS_APP',
|
|
24
|
-
'PHYSICAL_MAIL',
|
|
25
|
-
'CUSTOM_CHANNEL_CONVERSATION'
|
|
26
|
-
];
|
|
27
|
-
|
|
28
|
-
run( 'channels', async () => {
|
|
29
|
-
|
|
30
|
-
if( ! enabled() ) return { skipped : 'HUBSPOT_ACCESS_TOKEN not set' };
|
|
31
|
-
|
|
32
|
-
const { db, database } = await growth();
|
|
33
|
-
|
|
34
|
-
const report = {};
|
|
35
|
-
|
|
36
|
-
for( const channel of CHANNELS ){
|
|
37
|
-
|
|
38
|
-
const body = await request({
|
|
39
|
-
body : {
|
|
40
|
-
filterGroups : [
|
|
41
|
-
{
|
|
42
|
-
filters : [
|
|
43
|
-
{
|
|
44
|
-
operator : 'EQ',
|
|
45
|
-
propertyName : 'hs_communication_channel_type',
|
|
46
|
-
value : channel
|
|
47
|
-
}
|
|
48
|
-
]
|
|
49
|
-
}
|
|
50
|
-
],
|
|
51
|
-
limit : 1
|
|
52
|
-
},
|
|
53
|
-
method : 'POST',
|
|
54
|
-
path : '/crm/v3/objects/communications/search'
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const inHubspot = Number( body?.total || 0 );
|
|
58
|
-
|
|
59
|
-
// Ours is counted on the touch, which is one row per contact on the message,
|
|
60
|
-
// so it can legitimately exceed HubSpot's count of messages.
|
|
61
|
-
const held = await database.collection( 'touch' ).countDocuments({
|
|
62
|
-
channel,
|
|
63
|
-
type : 'communication'
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
report[ channel ] = {
|
|
67
|
-
held,
|
|
68
|
-
inHubspot,
|
|
69
|
-
// The number that answers "is anything writing this channel".
|
|
70
|
-
missing : inHubspot === 0 ? 0 : Math.max( 0, inHubspot - held )
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const flowing = Object.entries( report )
|
|
76
|
-
.filter( ( [ , value ] ) => value.inHubspot > 0 )
|
|
77
|
-
.map( ( [ key ] ) => key );
|
|
78
|
-
|
|
79
|
-
return {
|
|
80
|
-
byChannel : report,
|
|
81
|
-
// Empty means nothing outside email, calls, meetings, notes and tasks is
|
|
82
|
-
// reaching HubSpot at all - which is a setup answer, not a pipeline answer.
|
|
83
|
-
flowing : flowing.length ? flowing : 'nothing beyond email/calls/meetings/notes/tasks',
|
|
84
|
-
note : 'LINKEDIN_MESSAGE at 0 means no tool is writing LinkedIn messages into'
|
|
85
|
-
+ ' HubSpot. Whatever writes them, this pipeline already reads them.'
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
});
|