@drawbridge/drawbridge-agents 0.1.70 → 0.1.72
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/.claude-template/agents/drawbridge-qa.md +20 -0
- package/.claude-template/skills/drawbridge-asana-comment/SKILL.md +4 -0
- package/.claude-template/skills/drawbridge-feature/SKILL.md +1 -1
- package/README.md +1 -1
- package/conventions/cross-repo-contracts.md +7 -0
- package/conventions/walk-the-app.md +20 -0
- package/hooks/qa-gate.js +83 -6
- package/package.json +1 -1
- package/scripts/check-stories.js +0 -0
|
@@ -36,6 +36,26 @@ session scratch directory that reuses the saved session
|
|
|
36
36
|
(`drawbridge-tests/e2e/.auth/user.json`, `lib/basic-auth.js`), performs the steps, and
|
|
37
37
|
screenshots after each. `drawbridge-tests/e2e/walk.js` is the pattern to copy.
|
|
38
38
|
|
|
39
|
+
## How to seed an entrant (a scenario that needs a lead, an entry or a winner)
|
|
40
|
+
|
|
41
|
+
Never insert a lead by hand. A hand-written lead carries subtotals the product never
|
|
42
|
+
counted, and removing it afterwards drives the campaign's Entries and Submissions below zero
|
|
43
|
+
(Asana 1218811920761541). Seed the way the product does, through
|
|
44
|
+
`drawbridge-tests/lib/seed.js`:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
const { removeEntrant, seedEntrant } = require( './lib/seed' );
|
|
48
|
+
|
|
49
|
+
const seeded = await seedEntrant({ campaign : '<campaign id>', email : 'qa+<task id>@…', entries : 1 });
|
|
50
|
+
// … walk the scenario; seeded.lead, seeded.entry, seeded.submission are the ids …
|
|
51
|
+
await removeEntrant( seeded );
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It writes a zero-count lead plus one pending submission and waits for the sync worker to
|
|
55
|
+
process it, exactly as a share-page submission does; removing it nets every counter back to
|
|
56
|
+
zero. If a scenario needs something the helper cannot seed, the scenario is `blocked` with
|
|
57
|
+
that as the reason, never seeded by hand.
|
|
58
|
+
|
|
39
59
|
## How to check an inbox (`@inbox` scenarios)
|
|
40
60
|
|
|
41
61
|
The connected Gmail account is the QA mailbox. Trigger the email with a recipient of the form
|
|
@@ -64,6 +64,10 @@ Write for the person reading it, not for the repo.
|
|
|
64
64
|
the interface labels them. Not your paraphrase, not the variable name. If the button says
|
|
65
65
|
Send, write Send.
|
|
66
66
|
- **Quote user-facing copy exactly**, so they can match it against what is on screen.
|
|
67
|
+
Double quotes mean "one of our labels", and the comment tool refuses the post if a
|
|
68
|
+
double-quoted label was not seen by a walk in the last day (`npm run walk` in
|
|
69
|
+
drawbridge-tests) and exists nowhere in the family's source. A third-party label, a mail
|
|
70
|
+
client's button, a Shopify admin page, goes in 'single quotes' and is not checked.
|
|
67
71
|
- Use their words for their bug. If they called it a prize notification, it is a prize
|
|
68
72
|
notification, not a lead-facing transactional send.
|
|
69
73
|
|
|
@@ -83,4 +83,4 @@ posted. The handoff comment is refused by the QA gate while this session changed
|
|
|
83
83
|
| Reading the story and graph before coding | `hooks/consult-docs-gate.js` on the first source edit |
|
|
84
84
|
| Updating the story when anchored code changed | `hooks/drift-check.js` at turn end |
|
|
85
85
|
| QA on dev | `hooks/qa-gate.js` on the Asana comment |
|
|
86
|
-
| Walking before naming a button | `
|
|
86
|
+
| Walking before naming a button | `hooks/qa-gate.js` on every Asana comment: a "double-quoted" label must appear in a walk record from the last day or in family source |
|
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ hooks/
|
|
|
45
45
|
drift-check.js ← Stop hook (wired via .claude-template/settings.json)
|
|
46
46
|
consult-docs-record.js ← PostToolUse hook: remembers a docs page read / graph query per session
|
|
47
47
|
consult-docs-gate.js ← PreToolUse hook: refuses the first source edit until both happened
|
|
48
|
-
qa-gate.js ← PreToolUse hook on the Asana comment tool: a session that changed code needs a clean QA report
|
|
48
|
+
qa-gate.js ← PreToolUse hook on the Asana comment tool: every "quoted label" must have been walked or exist in source; a session that changed code needs a clean QA report
|
|
49
49
|
guard-superpowers-docs.js ← PreToolUse hook: plans/specs go to drawbridge-docs
|
|
50
50
|
```
|
|
51
51
|
|
|
@@ -242,6 +242,13 @@ skill) so it stops living only in tribal memory.
|
|
|
242
242
|
breaks the audit. Detail: drawbridge-docs `reference/billing.md`.
|
|
243
243
|
- Every `$inc` on a `totals.*` counter needs a matching entry in `drawbridge-sync/lib/totals.js`
|
|
244
244
|
`SOURCES` — that map is the only drift backstop.
|
|
245
|
+
- **A count never reads below zero.** Every decrement of a `totals.*` counter goes through
|
|
246
|
+
`drawbridge-sync/lib/totals.js` `adjustTotals` / `flooredTotals`, a pipeline update that clamps
|
|
247
|
+
each field at zero in the same write. A bare `$inc` with a negative delta, or a `{ $gt : 0 }`
|
|
248
|
+
query guard that skips the whole write, is the shape that drove a campaign to Entries -5
|
|
249
|
+
(Asana 1218811920761541). Test data is seeded through `drawbridge-tests/lib/seed.js` so the
|
|
250
|
+
worker does the counting; a lead inserted by hand with subtotals was never counted and its
|
|
251
|
+
delete decrements what was never added.
|
|
245
252
|
- The `invoice` collection's **unique partial index on `stripeInvoiceId`** (drawbridge-api
|
|
246
253
|
`schema/invoice.js`) and sync's `upsertInvoice` E11000 catch (`lib/buffer.js`) are one contract:
|
|
247
254
|
sync upserts the same invoice from three concurrent webhooks (finalized/payment_succeeded/voided)
|
|
@@ -66,6 +66,26 @@ write the comment from what is on it. Quote labels from the printed list, never
|
|
|
66
66
|
The full smoke pack (`npm test`) and the dashboard functional run (`npm run dashboard`)
|
|
67
67
|
use the same session; see `drawbridge-tests/readme.md`.
|
|
68
68
|
|
|
69
|
+
### Seeding an entrant for a walk
|
|
70
|
+
|
|
71
|
+
A walk that needs a lead, an entry or a winner seeds it through
|
|
72
|
+
`drawbridge-tests/lib/seed.js`, never with a hand-written `insertOne`:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
const { removeEntrant, seedEntrant } = require( './lib/seed' );
|
|
76
|
+
|
|
77
|
+
const seeded = await seedEntrant({ campaign : '<campaign id>', email : 'walk+<date>@…', entries : 1 });
|
|
78
|
+
await removeEntrant( seeded ); // when the walk is done
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`seedEntrant` writes what the api's thin ingest writes — a zero-count lead and one submission
|
|
82
|
+
flagged pending — and waits for the sync worker to materialize the entries and every counter.
|
|
83
|
+
`removeEntrant` deletes the draws that picked the lead and then the lead; sync's cascade does
|
|
84
|
+
the rest and each counter nets back to zero. A lead inserted with its subtotals filled in was
|
|
85
|
+
never counted on its campaign, so deleting it decremented Entries and Submissions below zero
|
|
86
|
+
(Asana 1218811920761541, 2026-09-23). Counters now floor at zero in sync, but the seed still
|
|
87
|
+
has to go through the worker or the counts on the walked screen are wrong.
|
|
88
|
+
|
|
69
89
|
## 2. Claude in Chrome (optional, interactive sessions only)
|
|
70
90
|
|
|
71
91
|
Claude Code can drive the developer's own Chrome through the
|
package/hooks/qa-gate.js
CHANGED
|
@@ -41,11 +41,10 @@ const readJson = (file) => {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const
|
|
44
|
+
const findFamilyRoot = (cwd) => {
|
|
45
45
|
let dir = cwd
|
|
46
46
|
for (let depth = 0; depth < 4; depth += 1) {
|
|
47
|
-
|
|
48
|
-
if (fs.existsSync(candidate)) return candidate
|
|
47
|
+
if (fs.existsSync(path.join(dir, 'drawbridge-tests'))) return dir
|
|
49
48
|
const parent = path.dirname(dir)
|
|
50
49
|
if (parent === dir) break
|
|
51
50
|
dir = parent
|
|
@@ -53,19 +52,97 @@ const findReport = (cwd, task) => {
|
|
|
53
52
|
return null
|
|
54
53
|
}
|
|
55
54
|
|
|
55
|
+
const findReport = (cwd, task) => {
|
|
56
|
+
const root = findFamilyRoot(cwd)
|
|
57
|
+
if (!root) return null
|
|
58
|
+
const candidate = path.join(root, 'drawbridge-tests', 'results', 'qa', task + '.json')
|
|
59
|
+
return fs.existsSync(candidate) ? candidate : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Quoted labels must have been seen -------------------------------------
|
|
63
|
+
//
|
|
64
|
+
// A "double-quoted" string in a comment is a label on OUR screens and is checked:
|
|
65
|
+
// it must appear in a walk record from the last day (drawbridge-tests/results/walk/
|
|
66
|
+
// *.json, written by npm run walk) or, failing that, in the family's source. A
|
|
67
|
+
// 'single-quoted' string is a third-party label (Outlook's buttons, a Shopify
|
|
68
|
+
// admin page) or spoken words, and is not checked. Darren, 2026-09-23: no more
|
|
69
|
+
// "buttons or functions that don't exist".
|
|
70
|
+
|
|
71
|
+
const SOURCE_REPOS = [ 'drawbridge-app-web', 'drawbridge-components', 'drawbridge-share', 'drawbridge-api', 'drawbridge-utils', 'drawbridge-shopify-app', 'drawbridge-website', 'drawbridge-emails' ]
|
|
72
|
+
|
|
73
|
+
const quotedLabels = (text) => {
|
|
74
|
+
const found = new Set()
|
|
75
|
+
const re = /"([^"\n]{1,120})"|“([^”\n]{1,120})”/g
|
|
76
|
+
let match
|
|
77
|
+
while ((match = re.exec(text)) !== null) found.add((match[1] || match[2]).trim())
|
|
78
|
+
return Array.from(found).filter(Boolean)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const walkedLabels = (root) => {
|
|
82
|
+
const dir = path.join(root, 'drawbridge-tests', 'results', 'walk')
|
|
83
|
+
const seen = []
|
|
84
|
+
if (!fs.existsSync(dir)) return seen
|
|
85
|
+
for (const name of fs.readdirSync(dir)) {
|
|
86
|
+
if (!name.endsWith('.json')) continue
|
|
87
|
+
const file = path.join(dir, name)
|
|
88
|
+
try {
|
|
89
|
+
if (Date.now() - fs.statSync(file).mtimeMs > MAX_AGE_MS) continue
|
|
90
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
91
|
+
for (const key of [ 'buttons', 'headings', 'inputs', 'links', 'tableHeaders', 'title' ]) {
|
|
92
|
+
const value = record[key]
|
|
93
|
+
if (Array.isArray(value)) seen.push(...value.map(String))
|
|
94
|
+
else if (typeof value === 'string') seen.push(value)
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// an unreadable record proves nothing
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return seen
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const inSource = (root, label) => {
|
|
104
|
+
const { spawnSync } = require('child_process')
|
|
105
|
+
const dirs = SOURCE_REPOS.map((name) => path.join(root, name)).filter((dir) => fs.existsSync(dir))
|
|
106
|
+
if (!dirs.length) return false
|
|
107
|
+
const result = spawnSync('grep', [
|
|
108
|
+
'-rFq', '--include=*.js', '--include=*.jsx', '--include=*.ts', '--include=*.tsx', '--include=*.json', '--include=*.hbs', '--include=*.md',
|
|
109
|
+
'--exclude-dir=node_modules', '--exclude-dir=.next', '--exclude-dir=dist', '--exclude-dir=.git', '--exclude-dir=.claude',
|
|
110
|
+
'--', label, ...dirs
|
|
111
|
+
], { encoding: 'utf8' })
|
|
112
|
+
return result.status === 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const unseenLabels = (cwd, text) => {
|
|
116
|
+
const root = findFamilyRoot(cwd)
|
|
117
|
+
if (!root) return []
|
|
118
|
+
const labels = quotedLabels(text)
|
|
119
|
+
if (!labels.length) return []
|
|
120
|
+
const walked = walkedLabels(root).map((value) => value.toLowerCase())
|
|
121
|
+
return labels.filter((label) => {
|
|
122
|
+
const needle = label.toLowerCase()
|
|
123
|
+
if (walked.some((value) => value.includes(needle))) return false
|
|
124
|
+
return !inSource(root, label)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
56
128
|
const run = () => {
|
|
57
129
|
const input = readStdin()
|
|
58
130
|
|
|
59
131
|
if (input.tool_name !== 'mcp__claude_ai_Asana__add_comment') process.exit(0)
|
|
60
132
|
if (!input.session_id) process.exit(0)
|
|
61
133
|
|
|
134
|
+
const cwd = input.cwd || process.cwd()
|
|
135
|
+
const toolInput = input.tool_input || {}
|
|
136
|
+
|
|
137
|
+
// Every comment, code changed or not: a quoted label has to exist.
|
|
138
|
+
const unseen = unseenLabels(cwd, String(toolInput.html_text || toolInput.text || ''))
|
|
139
|
+
if (unseen.length) deny('A label in this comment was never seen: ' + unseen.map((label) => '"' + label + '"').join(', ') + '. Walk the page (npm run walk in drawbridge-tests) and quote the label as it prints, or use \'single quotes\' for a third-party label such as a mail client\'s button.')
|
|
140
|
+
|
|
62
141
|
const seen = readJson(markerPath(input.session_id)) || {}
|
|
63
142
|
if (!seen.edited) process.exit(0)
|
|
64
143
|
|
|
65
|
-
const task = String(
|
|
144
|
+
const task = String(toolInput.task_id || '')
|
|
66
145
|
if (!task) process.exit(0)
|
|
67
|
-
|
|
68
|
-
const cwd = input.cwd || process.cwd()
|
|
69
146
|
const file = findReport(cwd, task)
|
|
70
147
|
const how = 'Dispatch the drawbridge-qa agent with the story file(s), task ' + task + ' and the dev organization id; it writes drawbridge-tests/results/qa/' + task + '.json. Fix every fail and run it again before commenting.'
|
|
71
148
|
|
package/package.json
CHANGED
package/scripts/check-stories.js
CHANGED
|
File without changes
|