@drawbridge/drawbridge-agents 0.1.72 → 0.1.76

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.
@@ -23,8 +23,9 @@ bulleted section beside it. In order:
23
23
  1. What changed, in plain past tense. Numbered.
24
24
  2. What they need before the first step: a permission to grant, a setting to switch on, a
25
25
  record that has to exist. One item each.
26
- 3. What to do and what to expect. One step per item, each one something they can actually do
27
- or check. Every step starts with the click path from where they already are (Admin,
26
+ 3. What to do and what to expect. One step per item, one action per step (rules.md rule 9),
27
+ each one something they can actually do or check. "Open the campaign and click Draw" is
28
+ two steps. Every step starts with the click path from where they already are (Admin,
28
29
  Connections, then the store; Shopify admin, Apps, then Drawbridge). "Open the app" is not a
29
30
  path.
30
31
  4. Anything that would otherwise look wrong to them. Numbered too.
@@ -30,6 +30,17 @@ check refuses a touched story that fails it. The QA agent in step 5 checks exact
30
30
  reads the mailbox for `@inbox`, and fails an untagged scenario that names nothing visible. Get
31
31
  Darren's yes on the story before step 2.
32
32
 
33
+ **List every way the change is undone, before the questions.** Name each state the change
34
+ writes (a row, a status, a vendor record, a counter) and each way a person or the system
35
+ reverses it: resubscribe, START, disconnect, unpublish, a row deleted, a segment left. For
36
+ each one, write down what else has to follow the reversal, such as connected apps or
37
+ counters, and give it a scenario. If nothing can reverse it, tag the scenario `@one-way`.
38
+ This list goes at the top of the numbered list Darren reads. `check-stories` refuses a When
39
+ whose undo has no scenario in the domain folder. `check-streams` refuses a sync stream that
40
+ handles a write and says nothing about a delete. Darren, 2026-09-24: the opt-out fix carried
41
+ an unsubscribe to Mailchimp and never asked what a Resubscribe does, so people who opted
42
+ back in stayed unsubscribed.
43
+
33
44
  **Ask for the access QA will need, in the same numbered list.** Walk the scenarios you just
34
45
  wrote and name what the QA agent must reach to check them: a screen on dev (nothing to ask),
35
46
  an inbox (`@inbox`: the connected Gmail mailbox with a `+<task id>` tag, or a shared box such
@@ -600,8 +600,8 @@ api's identity/organization routes); the merged keys keep the old names. Reading
600
600
  - **No Shopify webhook reaches the embedded app.** Every `[[webhooks.subscriptions]]` in
601
601
  drawbridge-shopify-app's `shopify.app.*.toml` — `app/uninstalled` included — is delivered to
602
602
  drawbridge-webhooks (`route/shopify.js`, topic-agnostic → `buffer` doc), which drawbridge-sync
603
- consumes (`lib/buffer.js` `shopify.app/uninstalled` → delete connections, hard-delete the `shop`
604
- record). So `app/routes/webhooks.app.uninstalled.tsx` is unreachable and **nothing in the
603
+ consumes (`lib/buffer.js` `shopify.app/uninstalled` → delete connections, hard-delete the store's
604
+ `install` record). So `app/routes/webhooks.app.uninstalled.tsx` is unreachable and **nothing in the
605
605
  embedded app may hold per-shop state that needs invalidating on uninstall** — no memo, no cache,
606
606
  no "already done" set. Violated once: `ensureChannel` cached "this shop is bound to our channel
607
607
  spec" in a process-local Set cleared only by that dead route, so uninstall → reinstall skipped
@@ -623,7 +623,8 @@ api's identity/organization routes); the merged keys keep the old names. Reading
623
623
  `extend.organization({ connections : true })` (org GET + org menu opt in via
624
624
  `hasOrganizationAccess`); app-web's `hasConnection` helper (`lib/helpers.js`) and api's
625
625
  `lib/tasks.js connectionErrors` read it. Two invariants: the lookup's `$project`
626
- (id/slug/status/title/shop/errors) is a **security boundary** — the org response spreads to the
626
+ (id/slug/status/install/source/errors; `group` and `title` are added after from the manifest
627
+ registry, not the row) is a **security boundary** — the org response spreads to the
627
628
  browser, so adding a credential field ships connection tokens to every dashboard user; and api
628
629
  deploys before app-web — the gate treats a missing field as "no connection" and silently hides
629
630
  connection-gated UI (e.g. the campaign Shop product columns row).
@@ -7,7 +7,7 @@ Branches use the **full gitflow prefixes, always spelled out**. Never use abbrev
7
7
  | `feature/<name>` | New features / changes (branched from `develop`) |
8
8
  | `bugfix/<name>` | Non-urgent bug fixes |
9
9
  | `hotfix/<name>` | Urgent production fixes (branched from `main`) |
10
- | `release/<version>` | Release preparation |
10
+ | `release/<asana id>` | A release (since 2026-10-21): the Releases project task id, the same id as `drawbridge-api/scripts/migration-<id>.js` and `drawbridge-docs/releases/<id>.md`; one branch per app the release touches, off `develop`, built against the Local database, merged by a developer through a pull request |
11
11
  | `chore/<name>` | Maintenance — dependency bumps, config, cleanup |
12
12
  | `support/<name>` | Long-lived support branches |
13
13
 
@@ -29,11 +29,50 @@ quietly that dev served stale code the whole time.
29
29
 
30
30
  ## How
31
31
 
32
- - Add the function to `drawbridge-api/scripts.js`, alongside the others.
33
- - Add a commented invocation to the numbered run-list at the bottom. Nothing runs
34
- until somebody uncomments a line and deploys.
35
- - Take `dryRun` and report what it would do. The first run of a migration is a
36
- read.
32
+ - One release has at most one migration file,
33
+ `drawbridge-api/scripts/migration-<release asana id>.js`, named for the
34
+ RELEASE task (the same id as the `release/<id>` branches and
35
+ `drawbridge-docs/releases/<id>.md`), holding every data step the release
36
+ needs and exporting
37
+ `({ controller }) => async ({ dryRun, organization, ...args }) => counts`.
38
+ Nothing at boot requires `scripts/`; `test/migrations-folder.test.js` refuses
39
+ any other file there. **The folder only ever holds what production has not
40
+ run**: once the production run is recorded, `git rm` the file (the promote
41
+ step will do this), so two releases in flight are two files and no spent code
42
+ lingers. (Until 2026-10-21 these were functions in one
43
+ `scripts.js` that boot required and that kept its fifteen spent functions for
44
+ a month; Darren, 2026-10-21: "avoids old code kicking around".)
45
+ - Take `{ dryRun, organization }` and report what it would do as the object you
46
+ return (`{ found, updated }` or whatever the counts are). The first run of a
47
+ migration is a read.
48
+ - Run it only through the runner. On a laptop, from the api checkout:
49
+ `npm run migrate -- <asana id> --dry --env dev`, then
50
+ `--organization <id> --env dev --release <slug>`, then `--env dev --release <slug>`.
51
+ No env file holds a value: the runner re-runs itself through `scripts/op.sh`
52
+ with the file `--env` names (`.env.local` / `.env.development`, two lines naming
53
+ a 1Password Environment), so the Environment is injected the way the servers
54
+ get it. It refuses the wrong cluster, hands a dry run a controller that throws
55
+ on any write, and writes the result to `runs/<env>-<fn>-<time>.json` (read the
56
+ file, never the console: `op run` conceals anything that looks like a secret).
57
+ - **Production runs inside the deployed api container**, never from a laptop,
58
+ so no laptop ever holds a production file: `doctl apps console <prod api app
59
+ id> drawbridge-api`, then `op run --environment "$OP_ENVIRONMENT_ID" -- node
60
+ migrate.js <asana id> --env prod --dry --sha <promoted commit>`, then the same
61
+ without `--dry` and with `--release <slug>`. The image carries `migrate.js`
62
+ and `scripts/`. There is no git there, so the runner proves `--sha`
63
+ against `APP_COMMIT_HASH` (the deployed commit, injected by the app spec); in
64
+ a checkout it still refuses production off `main`, at the wrong commit, or with
65
+ `scripts/`, `lib/` or `migrate.js` modified. `cat` the counts file from the
66
+ console shell afterwards, outside `op run`, to read it unconcealed.
67
+ - A real fleet run records itself when drawbridge-docs sits beside the api:
68
+ `drawbridge-docs/releases/<slug>.json` gets
69
+ `migrations.<asana id>.<development|production> = true`, and the release
70
+ document `releases/<slug>.md` gets a row in its Migrations table. That is how
71
+ "has it run on prod?" is answered. In the container there is no docs checkout:
72
+ the runner prints exactly which flag and row to write, and you commit them
73
+ from a laptop from the counts file. After a production run the promote step
74
+ deletes `scripts/migration-<asana id>.js`, so the folder only ever holds what
75
+ has not run.
37
76
  - Add it to the release document in `drawbridge-docs/releases/` for the launch it
38
77
  belongs to, with the ordering constraint stated: what has to be deployed before
39
78
  it can run, and what breaks if it runs early.
@@ -36,3 +36,7 @@
36
36
  8. Never make it up. A statement of fact in anything you write to a person is backed by something you read in this session: a file you opened whole, a doc in `drawbridge-docs`, the knowledge graph, a vendor's documentation you fetched, a value you measured. If none of those backs it, it is not a fact yet: say "I do not know" or put it to Darren as a numbered question. Never write a step in the past tense for something you have not done ("Updated the step in the task" when nothing was edited). Never describe a thing as absent because you have not found it yet; rule 6 says how to search first.
37
37
 
38
38
  Why: a confident sentence with nothing behind it reads exactly like a verified one, and the reader builds on it. It has sent a tester hunting for a badge that did not exist, described a repo as missing code that was in another package, and promised an edit that had not happened. Every one of those was cheaper to ask about than to correct afterwards.
39
+
40
+ 9. Write in the spirit of ASD-STE100. One idea per sentence, with an active verb, in about 20 words. A procedural step is one action: "Open the campaign. Click Draw." never "Open the campaign and click Draw". A condition or warning comes before the step it applies to, never after. One word has one meaning for the whole reply: do not swap in synonyms for variety, and write "must" for a requirement and "we recommend" for advice, never "should". The STE dictionary is not in force; use the words the team uses (rule 7).
41
+
42
+ Why: STE exists so that a reader who skims under pressure cannot misread a step. That is Jaime reading a retest list and Darren reading a question. Darren, 2026-10-21, asked for the spirit of STE and not the letter.
@@ -17,6 +17,14 @@ Full strategy, conventions, and per-repo coverage map live in
17
17
  export is a silent-undefined production throw — this has happened); every
18
18
  fixed production incident gets a same-day regression test naming the
19
19
  incident.
20
+ - **A test per state, not per ticket.** A handler that branches on a status
21
+ field or on a row's presence gets a case per value it can meet: each status,
22
+ the row absent, and the other side's timing (a cascade that runs before the
23
+ worker, a job that runs after the delete). Darren, 2026-09-24, after a fix
24
+ tested only the ticket's case and left the pending-submission case wrong.
25
+ - **A changed handler ships with a changed test.** The end-of-turn check
26
+ refuses a turn that changed `stream/`, `queue/`, `route/` or `lib/` source
27
+ with no `test/*.test.js` changed alongside it in that repo.
20
28
  - **Next.js apps get pure-logic tests only** — no jsdom/component testing; UI
21
29
  correctness is the smoke pack's job. Extract a pure seam into `lib/` rather
22
30
  than importing a react/next module into a test.
@@ -49,6 +49,18 @@ const changedFiles = (cwd) => {
49
49
  }
50
50
  }
51
51
 
52
+ // A CHANGED HANDLER SHIPS WITH A CHANGED TEST. The four directories are the
53
+ // ones a unit test can pin (conventions/testing.md); a Next.js page has none by
54
+ // convention. Darren, 2026-09-24: "every surface has required tests."
55
+ const TESTED_DIRS = /\/(stream|queue|route|lib)\/[^/]+\.(js|mjs|cjs|ts)$/
56
+ const TEST_FILE = /\/test\/.+\.test\.(js|mjs|cjs|ts)$/
57
+
58
+ const untestedSource = (changed) => {
59
+ const files = changed.map((file) => file.split(path.sep).join('/'))
60
+ if (files.some((file) => TEST_FILE.test(file))) return []
61
+ return files.filter((file) => TESTED_DIRS.test(file) && !file.split('/').some((part) => part === '.claude' || part === 'node_modules'))
62
+ }
63
+
52
64
  const run = () => {
53
65
  const input = readStdin()
54
66
 
@@ -98,6 +110,25 @@ const run = () => {
98
110
  }
99
111
  }
100
112
 
113
+ // 1c. The reverse direction (blocking) — a sync stream changed this turn that
114
+ // reacts to a write must say what a delete of that row does. The opt-out
115
+ // resync handled the insert and never the Resubscribe that deletes it.
116
+ const checkStreams = path.join(resolvePackageRoot(cwd), 'scripts', 'check-streams.js')
117
+ if (fs.existsSync(checkStreams)) {
118
+ try {
119
+ execFileSync('node', [ checkStreams, ...changedSource ], { cwd, encoding: 'utf8', stdio: 'pipe' })
120
+ } catch (error) {
121
+ const detail = ((error.stdout || '') + (error.stderr || '')).trim()
122
+ block(`A sync stream changed this turn reacts to a write and never says what happens when that row is deleted.\n${ detail }\nAdd the delete handler (and the story scenario for the undo), or a // ONE-WAY: comment saying why a delete needs nothing, before finishing.`)
123
+ }
124
+ }
125
+
126
+ // 1d. A changed handler ships with a changed test (blocking). See untestedSource.
127
+ const untested = untestedSource(changed)
128
+ if (untested.length) {
129
+ block(`Source changed with no test changed alongside it:\n${ untested.map((file) => ' ' + path.relative(cwd, file)).join('\n') }\nAdd or extend a test/*.test.js in this repo, one case per state the change can meet (each status value, the row absent, the other side's timing), before finishing.`)
130
+ }
131
+
101
132
  const skipGraph = process.env.DRAWBRIDGE_SKIP_GRAPHIFY === '1'
102
133
 
103
134
  // 2. Graph freshness (non-blocking nudge) — a full re-extract is expensive, so alert rather
@@ -145,9 +176,13 @@ const run = () => {
145
176
  process.exit(0)
146
177
  }
147
178
 
148
- try {
149
- run()
150
- } catch (error) {
151
- // Never fail a user's turn because of a hook bug.
152
- process.exit(0)
179
+ module.exports = { untestedSource }
180
+
181
+ if (require.main === module) {
182
+ try {
183
+ run()
184
+ } catch (error) {
185
+ // Never fail a user's turn because of a hook bug.
186
+ process.exit(0)
187
+ }
153
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawbridge/drawbridge-agents",
3
- "version": "0.1.72",
3
+ "version": "0.1.76",
4
4
  "description": "Shared agent-instruction content (rules, code style, conventions) for the drawbridge-* monorepo.",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -15,7 +15,8 @@
15
15
  "drawbridge-agents-sync": "bin/sync-claude.js",
16
16
  "drawbridge-agents-graph": "bin/graph.js",
17
17
  "drawbridge-agents-check-docs": "scripts/check-doc-links.js",
18
- "drawbridge-agents-check-stories": "scripts/check-stories.js"
18
+ "drawbridge-agents-check-stories": "scripts/check-stories.js",
19
+ "drawbridge-agents-check-streams": "scripts/check-streams.js"
19
20
  },
20
21
  "files": [
21
22
  "claude",
@@ -21,6 +21,23 @@ const path = require('path')
21
21
  const TAGS = new Set([ '@inbox', '@sms', '@offscreen' ])
22
22
  const VISIBLE = /\*\*[^*]+\*\*|"[^"]+"|“[^”]+”/
23
23
 
24
+ // EVERY ACTION THAT CAN BE UNDONE HAS ITS UNDO IN THE STORY. A scenario whose
25
+ // When does one side of a pair below needs a scenario in the same domain folder
26
+ // whose When does the other side, or the tag @one-way on the line above it (the
27
+ // action genuinely cannot be undone). Darren, 2026-09-24: the opt-out story
28
+ // said connected apps learn about an unsubscribe and never asked what a
29
+ // Resubscribe does, so Mailchimp kept people unsubscribed after they opted
30
+ // back in. Matched on When lines only, whole words, both directions.
31
+ const UNDO = [
32
+ [ /\bunsubscrib/i, /\bresubscrib/i, 'unsubscribe', 'resubscribe' ],
33
+ [ /\bconnect(s|ed|ing)?\b/i, /\bdisconnect/i, 'connect', 'disconnect' ],
34
+ [ /\bSTOP\b/, /\bSTART\b/, 'STOP', 'START' ],
35
+ [ /\bpublish(es|ed|ing)?\b/i, /\bunpublish/i, 'publish', 'unpublish' ],
36
+ [ /\bpaus(e|es|ed|ing)\b/i, /\bresum(e|es|ed|ing)\b/i, 'pause', 'resume' ],
37
+ [ /\benabl(e|es|ed|ing)\b/i, /\bdisabl(e|es|ed|ing)\b/i, 'enable', 'disable' ],
38
+ [ /\barchiv(e|es|ed|ing)\b|\bwrit(e|es|ing)\s+(it\s+)?off\b/i, /\b(unarchiv|restor)/i, 'archive or write off', 'restore' ]
39
+ ]
40
+
24
41
  const findFamilyRoot = () => {
25
42
  let dir = process.env.INIT_CWD || process.cwd()
26
43
  for (let i = 0; i < 8; i++) {
@@ -41,8 +58,10 @@ const walk = (dir, out = []) => {
41
58
  return out
42
59
  }
43
60
 
44
- // Returns [{ line, name, reason }] for one story file.
45
- const checkStory = (text) => {
61
+ // Returns [{ line, name, reason }] for one story file. `siblings` are the other
62
+ // story texts in the same domain folder: an undo written up in a sibling story
63
+ // (connect here, disconnect in the lifecycle story) counts.
64
+ const checkStory = (text, siblings = []) => {
46
65
  const problems = []
47
66
  const lines = text.split('\n')
48
67
  let scenario = null
@@ -55,7 +74,9 @@ const checkStory = (text) => {
55
74
  scenario = null
56
75
  }
57
76
 
77
+ const scenarios = []
58
78
  let pendingTag = false
79
+ let pendingOneWay = false
59
80
  lines.forEach((raw, index) => {
60
81
  const line = raw.trim()
61
82
  if (line.startsWith('```')) {
@@ -64,23 +85,41 @@ const checkStory = (text) => {
64
85
  if (!inFence) flush()
65
86
  return
66
87
  }
67
- if (/^#/.test(line)) { flush(); pendingTag = false; return }
88
+ if (/^#/.test(line)) { flush(); pendingTag = false; pendingOneWay = false; return }
68
89
  if (/^@\w/.test(line)) {
69
- pendingTag = line.split(/\s+/).some((tag) => TAGS.has(tag))
90
+ const tags = line.split(/\s+/)
91
+ pendingTag = tags.some((tag) => TAGS.has(tag))
92
+ pendingOneWay = tags.includes('@one-way')
70
93
  return
71
94
  }
72
95
  if (/^Scenario( Outline)?:/.test(line)) {
73
96
  flush()
74
- scenario = { line: index + 1, name: line, hasThen: false, visible: false, tagged: pendingTag }
97
+ scenario = { line: index + 1, name: line, hasThen: false, visible: false, tagged: pendingTag, oneWay: pendingOneWay, when: '' }
98
+ scenarios.push(scenario)
75
99
  pendingTag = false
100
+ pendingOneWay = false
76
101
  return
77
102
  }
78
103
  if (!scenario) return
104
+ if (/^When\b/.test(line)) scenario.when += ' ' + line
79
105
  if (/^Then\b/.test(line)) scenario.hasThen = true
80
106
  if (/^(Then|And|But)\b/.test(line) && VISIBLE.test(line)) scenario.visible = true
81
107
  })
82
108
  flush()
83
- return problems
109
+
110
+ const elsewhere = siblings.join('\n').split('\n').filter((line) => /^\s*When\b/.test(line)).join('\n')
111
+ for (const each of scenarios) {
112
+ if (each.oneWay) continue
113
+ for (const [ left, right, leftName, rightName ] of UNDO) {
114
+ for (const [ does, undoes, undoName ] of [ [ left, right, rightName ], [ right, left, leftName ] ]) {
115
+ if (!does.test(each.when)) continue
116
+ if (scenarios.some((other) => undoes.test(other.when)) || undoes.test(elsewhere)) continue
117
+ problems.push({ line: each.line, name: each.name, reason: `can be undone (${ undoName }) and no scenario's When does that; add the scenario, including what else must follow the undo, or tag this one @one-way` })
118
+ }
119
+ }
120
+ }
121
+
122
+ return problems.sort((a, b) => a.line - b.line)
84
123
  }
85
124
 
86
125
  const main = () => {
@@ -97,7 +136,9 @@ const main = () => {
97
136
  let count = 0
98
137
  for (const file of files) {
99
138
  if (!fs.existsSync(file)) continue
100
- for (const problem of checkStory(fs.readFileSync(file, 'utf8'))) {
139
+ const folder = path.dirname(file)
140
+ const siblings = fs.readdirSync(folder).filter((name) => name.endsWith('.md') && path.join(folder, name) !== file).map((name) => fs.readFileSync(path.join(folder, name), 'utf8'))
141
+ for (const problem of checkStory(fs.readFileSync(file, 'utf8'), siblings)) {
101
142
  count += 1
102
143
  console.error(` ${ path.relative(familyRoot, file) }:${ problem.line } ${ problem.name } — ${ problem.reason }`)
103
144
  }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // A sync stream that reacts to a row being written must say what happens when
3
+ // that row is deleted: a `delete` handler, or a comment starting `ONE-WAY:`
4
+ // that says why a delete needs nothing. Darren, 2026-09-24: stream/suppression.js
5
+ // carried an opt-out to Mailchimp on insert and had no delete, so a person who
6
+ // pressed Resubscribe (which deletes the row) stayed unsubscribed there.
7
+ //
8
+ // Usage: node scripts/check-streams.js <file ...> Only drawbridge-sync
9
+ // stream/*.js files are checked; stream/index.js is the router, not a handler.
10
+ // Exits 1 naming each file that fails.
11
+ const fs = require('fs')
12
+
13
+ const STREAM = /\/drawbridge-sync[^/]*\/stream\/(?!index\.js$)[^/]+\.js$/
14
+
15
+ // Returns a reason string, or null when the file passes.
16
+ const checkStream = (text) => {
17
+ const writes = /^\s*(insert|update)\s*:/m.test(text)
18
+ if (!writes) return null
19
+ if (/^\s*delete\s*:/m.test(text)) return null
20
+ if (/\/\/\s*ONE-WAY:/.test(text)) return null
21
+ return 'handles a write but not a delete; add a `delete` handler, or a `// ONE-WAY:` comment saying why a deleted row needs nothing'
22
+ }
23
+
24
+ const main = () => {
25
+ const files = process.argv.slice(2).filter((file) => STREAM.test(file.split('\\').join('/')) && fs.existsSync(file))
26
+ let count = 0
27
+ for (const file of files) {
28
+ const reason = checkStream(fs.readFileSync(file, 'utf8'))
29
+ if (reason) {
30
+ count += 1
31
+ console.error(` ${ file } — ${ reason }`)
32
+ }
33
+ }
34
+ if (count) {
35
+ console.error(`drawbridge-agents-check-streams: ${ count } stream(s) never say what a delete does`)
36
+ process.exit(1)
37
+ }
38
+ console.log(`drawbridge-agents-check-streams: every stream says what a delete does (${ files.length } file(s))`)
39
+ }
40
+
41
+ module.exports = { STREAM, checkStream }
42
+
43
+ if (require.main === module) main()