@drawbridge/drawbridge-agents 0.1.66 → 0.1.71

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.
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: drawbridge-qa
3
+ description: Walks every scenario of a user story on dev with a clean slate and reports pass or fail per scenario with a screenshot. Reads the connected mailbox for @inbox scenarios. Use after a feature is deployed to dev and before any handoff to QA. Give it the story file path(s), the Asana task id, and the dev organization id.
4
+ tools: Read, Bash, Grep, Glob, Write, mcp__claude_ai_Gmail__search_threads, mcp__claude_ai_Gmail__get_thread, mcp__claude_ai_Gmail__get_message
5
+ model: inherit
6
+ ---
7
+
8
+ You are the QA pass that runs before a feature reaches a human tester. You did not write the
9
+ code and you do not read the diff. You have the user story, a browser and a mailbox, the same
10
+ as the tester will. Your report decides whether the handoff is allowed: the Asana comment is
11
+ refused while it shows a failed scenario.
12
+
13
+ ## What you are given
14
+
15
+ 1. One or more story files under `drawbridge-docs/user-stories/`. The **Scenarios** section
16
+ is the checklist; **Expectations** says how each thing should look.
17
+ 2. The Asana task id the feature belongs to.
18
+ 3. The dev organization id to walk under.
19
+
20
+ ## How to walk a screen
21
+
22
+ From `drawbridge-tests`, every page is opened with
23
+
24
+ ```sh
25
+ npm run walk -- /organizations/<org id>/<path> # dashboard
26
+ npm run walk -- <full url> # share page, website
27
+ ```
28
+
29
+ It signs in, saves a screenshot and prints the headings, buttons, links, inputs and table
30
+ headers on the page. **Read the screenshot file with the Read tool every time.** What you
31
+ report as seen is what is on that image and in that printed list, nothing else. Setup and
32
+ keys: `conventions/walk-the-app.md`.
33
+
34
+ Where a scenario needs a click, type or submit, write a short Playwright script in the
35
+ session scratch directory that reuses the saved session
36
+ (`drawbridge-tests/e2e/.auth/user.json`, `lib/basic-auth.js`), performs the steps, and
37
+ screenshots after each. `drawbridge-tests/e2e/walk.js` is the pattern to copy.
38
+
39
+ ## How to check an inbox (`@inbox` scenarios)
40
+
41
+ The connected Gmail account is the QA mailbox. Trigger the email with a recipient of the form
42
+ `<connected address with a +tag>` where the tag is the task id (`darren+1218721236864155@…`),
43
+ so the search is exact. Then poll `mcp__claude_ai_Gmail__search_threads` with
44
+ `to:<that address> newer_than:1h` every 20 seconds for up to 3 minutes, open the thread with
45
+ `get_thread`, and check the subject, the body copy and every link the story names. A link the
46
+ story says leads somewhere is walked like any page. Nothing after 3 minutes is a **fail** with
47
+ the search you ran as the seen line.
48
+
49
+ ## What you check
50
+
51
+ One entry per scenario in the story, in the story's order. For each:
52
+
53
+ 1. **expected**: the story's Then, in its own words.
54
+ 2. **seen**: what the page or the email showed, quoting labels from the walk output or the
55
+ message.
56
+ 3. **status**:
57
+ - `pass` when seen matches expected.
58
+ - `fail` when it does not, when a label the story names does not exist on the page, when
59
+ the page never finished loading, or when an untagged scenario's Then names nothing a
60
+ person can see (that is a story defect; the fix is the story, and it is still a fail).
61
+ - `blocked` only for `@sms` and `@offscreen` scenarios, and for anything that needs
62
+ production or a third-party admin you cannot reach. Say why in `seen`.
63
+
64
+ A `blocked` scenario is not a pass. It is listed so the human tester knows it is theirs.
65
+
66
+ Never soften a fail. Never mark something pass because the code "should" do it.
67
+
68
+ ## The report
69
+
70
+ Write `drawbridge-tests/results/qa/<task id>.json`:
71
+
72
+ ```json
73
+ {
74
+ "task": "<asana task id>",
75
+ "story": [ "user-stories/17-draws/06-send-notification.md" ],
76
+ "organization": "<org id>",
77
+ "walkedAt": "<ISO timestamp>",
78
+ "scenarios": [
79
+ { "name": "…", "expected": "…", "seen": "…", "screenshot": "…png", "status": "pass" }
80
+ ],
81
+ "fails": 0,
82
+ "blocked": 0
83
+ }
84
+ ```
85
+
86
+ `fails` and `blocked` are counts over `scenarios`. Then reply to the caller with one numbered
87
+ list: every scenario, its status, and for a fail the expected and seen lines side by side.
88
+ Nothing else in the reply.
@@ -61,6 +61,30 @@
61
61
  {
62
62
  "type": "command",
63
63
  "command": "node ./.claude/hooks/guard-superpowers-docs.js"
64
+ },
65
+ {
66
+ "type": "command",
67
+ "command": "node ./.claude/hooks/consult-docs-gate.js"
68
+ }
69
+ ]
70
+ },
71
+ {
72
+ "matcher": "mcp__claude_ai_Asana__add_comment",
73
+ "hooks": [
74
+ {
75
+ "type": "command",
76
+ "command": "node ./.claude/hooks/qa-gate.js"
77
+ }
78
+ ]
79
+ }
80
+ ],
81
+ "PostToolUse": [
82
+ {
83
+ "matcher": "Read|Bash|Write|Edit|MultiEdit|NotebookEdit",
84
+ "hooks": [
85
+ {
86
+ "type": "command",
87
+ "command": "node ./.claude/hooks/consult-docs-record.js"
64
88
  }
65
89
  ]
66
90
  }
@@ -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
 
@@ -24,7 +24,9 @@ see what you said they would see.
24
24
  about ten minutes after ACTIVE, so wait that out before testing anything that runs a job. A
25
25
  comment posted mid-deploy sends them to test the old code.
26
26
  2. **Walk it in a browser yourself, on dev**, the way they will: same click path, same record,
27
- same screens. Only what you saw goes in the comment.
27
+ same screens. `npm run walk -- <path or url>` in drawbridge-tests signs in, screenshots the
28
+ page and prints every label on it (`conventions/walk-the-app.md`). Read the screenshot.
29
+ Only what you saw goes in the comment, and every quoted label comes from that printed list.
28
30
  3. **Gather all of it now.** Every scenario to retest, every prerequisite (a permission to
29
31
  grant, a setting to switch on, a record that has to exist), and anything on screen that will
30
32
  look wrong without warning. Go back a second time to complete or correct the first comment
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: drawbridge-feature
3
+ description: Use for ANY feature or change that will be handed to a tester — the only way a feature ships. Story first (one numbered list of questions), then code, code review, QA on dev by the drawbridge-qa agent, and only then the handoff. The handoff comment is refused without a clean QA report.
4
+ ---
5
+
6
+ # Build a feature
7
+
8
+ Darren, 2026-09-23: features and updates reach Jaime "99.99% completed, so she has less BS to
9
+ sift through." This is the order that gets there. Each step has a check that refuses the next
10
+ one when it was skipped, so do them in order and do not improvise around a refusal.
11
+
12
+ ## 1. Story first
13
+
14
+ Before any code. Find the domain folder in `drawbridge-docs/user-stories/` (the index table in
15
+ each `README.md` maps story IDs to files). Then:
16
+
17
+ - A new feature: invoke `write-user-stories` and scaffold the folder.
18
+ - A change to an existing behaviour: edit the story file directly, following
19
+ `user-stories/01-conventions.md`.
20
+
21
+ Everything you do not know goes to Darren as **one numbered list of questions**, each one three
22
+ plain sentences: what is true today, what would change as he would see it, yes or no
23
+ (`conventions/rules.md` rule 7). Never a question at a time, never prose around the list.
24
+
25
+ Every scenario's Then names what the person **sees**, as a **bold label** or "quoted copy" the
26
+ way the screen shows it; a scenario no screen shows is tagged `@inbox`, `@sms` or `@offscreen`
27
+ on the line above (`user-stories/01-conventions.md`, "Every scenario is checkable"). Run
28
+ `npx drawbridge-agents-check-stories <story file>` before asking for the yes; the end-of-turn
29
+ check refuses a touched story that fails it. The QA agent in step 5 checks exactly those lines,
30
+ reads the mailbox for `@inbox`, and fails an untagged scenario that names nothing visible. Get
31
+ Darren's yes on the story before step 2.
32
+
33
+ **Ask for the access QA will need, in the same numbered list.** Walk the scenarios you just
34
+ wrote and name what the QA agent must reach to check them: a screen on dev (nothing to ask),
35
+ an inbox (`@inbox`: the connected Gmail mailbox with a `+<task id>` tag, or a shared box such
36
+ as hello@ or info@ if the feature is customer-facing mail), a text message (`@sms`: a Twilio
37
+ number we own and can read, which does not exist yet), a third-party admin (Shopify, Klaviyo,
38
+ Stripe test mode), or a production-only surface. Each one that is not already connected is one
39
+ question: what is true today, what would let QA check it, yes or no. Darren, 2026-09-23:
40
+ "include connections or MCP access when creating new features so it's raised during the user
41
+ story prompts."
42
+
43
+ ## 2. Read before you write
44
+
45
+ Open the story you just wrote and the reference page it touches, and run one graph query for
46
+ the code you will change. The read-before-write hook refuses the first source edit until both
47
+ happened (`conventions/docs-linkage.md`).
48
+
49
+ ## 3. Code
50
+
51
+ In a worktree (`using-git-worktrees`), on a `feature/<name>` branch off `develop`. Where the
52
+ repo has a test suite, write the failing test first (`test-driven-development`). Keep to the
53
+ ladder: reuse before writing, one line before fifty.
54
+
55
+ ## 4. Code review
56
+
57
+ Two passes before merge, both on the diff:
58
+
59
+ 1. The `superpowers:code-reviewer` agent, against the story and the plan. Fix what it finds
60
+ or say in one line why not.
61
+ 2. `ponytail-review` for anything that should not exist.
62
+
63
+ ## 5. Deploy to dev and QA it
64
+
65
+ Merge to `develop` (that is the dev deploy) and wait until every touched app reports ACTIVE;
66
+ sync keeps the old build for about ten minutes after that. Then dispatch the **`drawbridge-qa`**
67
+ agent with the story file path(s), the Asana task id and the dev organization id. It walks
68
+ every scenario with a clean slate and writes `drawbridge-tests/results/qa/<task id>.json`.
69
+
70
+ Read its numbered list. Fix every `fail`, redeploy, dispatch it again. Repeat until `fails` is
71
+ 0. A `blocked` scenario stays in the report and goes into the handoff as the tester's line.
72
+
73
+ ## 6. Ship
74
+
75
+ Invoke `drawbridge-ship-feature`: docs reconciled, anchors checked, graph refreshed, handoff
76
+ posted. The handoff comment is refused by the QA gate while this session changed code and
77
+ `results/qa/<task id>.json` is missing, older than a day, or shows a fail.
78
+
79
+ ## What each check refuses
80
+
81
+ | Skipped | Refused by |
82
+ | --- | --- |
83
+ | Reading the story and graph before coding | `hooks/consult-docs-gate.js` on the first source edit |
84
+ | Updating the story when anchored code changed | `hooks/drift-check.js` at turn end |
85
+ | QA on dev | `hooks/qa-gate.js` on the Asana comment |
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 |
@@ -37,9 +37,14 @@ knowledge graph, and the Asana handoff so nothing drifts at ship time.
37
37
  knowledge graph (<feature>)" && git push origin main`. If it reports "graphify runtime not
38
38
  found", the `uv`/`graphify` binaries are in `~/.local/bin` — `export PATH="$HOME/.local/bin:$PATH"`
39
39
  and retry (do NOT run `graphify install`; it rewrites `CLAUDE.md`).
40
- 6. **Hand off the task.** Invoke `drawbridge-asana-ship-handoff` with the Asana task to reassign
41
- to the last commenter, post the change summary + retest steps, and set the due date.
42
- 7. **Clear the worktree.** If the feature was built in a git worktree (a sibling directory or a
40
+ 6. **Confirm the QA report is clean.** `drawbridge-tests/results/qa/<task id>.json`, written by
41
+ the `drawbridge-qa` agent (`drawbridge-feature` step 5), must exist, be from today, and show
42
+ `fails: 0`. If it is missing or stale, dispatch the agent now; if it shows a fail, fix and
43
+ redeploy first. The handoff comment in the next step is refused otherwise (`hooks/qa-gate.js`).
44
+ 7. **Hand off the task.** Invoke `drawbridge-asana-ship-handoff` with the Asana task to reassign
45
+ to the last commenter, post the change summary + retest steps, and set the due date. Any
46
+ `blocked` scenario in the QA report becomes a numbered line the tester runs themselves.
47
+ 8. **Clear the worktree.** If the feature was built in a git worktree (a sibling directory or a
43
48
  `.worktrees/` folder), remove it now that the branch is merged: from the main checkout,
44
49
  `git worktree remove <path>` then `git worktree prune`. If the removal complains about
45
50
  uncommitted changes, inspect them first — add `--force` only once you've confirmed the dirt
package/README.md CHANGED
@@ -43,6 +43,10 @@ scripts/
43
43
 
44
44
  hooks/
45
45
  drift-check.js ← Stop hook (wired via .claude-template/settings.json)
46
+ consult-docs-record.js ← PostToolUse hook: remembers a docs page read / graph query per session
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: every "quoted label" must have been walked or exist in source; a session that changed code needs a clean QA report
49
+ guard-superpowers-docs.js ← PreToolUse hook: plans/specs go to drawbridge-docs
46
50
  ```
47
51
 
48
52
  ## Consuming from a drawbridge-* repo
@@ -105,8 +109,13 @@ identical copy via the sync mirror (see "Delivery model").
105
109
  against `drawbridge-docs`.
106
110
  - **Drift-check Stop hook.** `hooks/drift-check.js` runs at each task-turn end, wired via the
107
111
  drift-check `Stop` hook in the mirrored `.claude/settings.json`. It **blocks** on an unresolved
108
- doc anchor, and **nudges** (non-blocking) when the graph looks stale or docs may need updating.
109
- It skips plan-mode and turns with no source changes.
112
+ doc anchor and on `@story`-anchored code changed without a drawbridge-docs change (once per
113
+ turn; the second attempt passes so the agent must answer), and **nudges** (non-blocking) when
114
+ the graph looks stale. It skips plan-mode and turns with no source changes.
115
+ - **Read-before-write hooks.** `hooks/consult-docs-record.js` (PostToolUse on Read/Bash) remembers
116
+ that a drawbridge-docs page was read and a graph query ran in this session;
117
+ `hooks/consult-docs-gate.js` (PreToolUse on Write/Edit) refuses the first source edit until
118
+ both happened. See `conventions/docs-linkage.md`, "Enforced, not just asked".
110
119
  - **Shared skills.** Mirrored into every repo's `.claude/skills/` (from `.claude-template/skills/`):
111
120
  `drawbridge-ship-feature` (verify → docs → graph → handoff), `drawbridge-asana-ship-handoff`,
112
121
  `drawbridge-explore-ecosystem`, `drawbridge-record-contract`.
package/claude/CLAUDE.md CHANGED
@@ -21,4 +21,6 @@
21
21
  @../conventions/git-branching.md
22
22
  @../conventions/graphify.md
23
23
  @../conventions/testing.md
24
+ @../conventions/walk-the-app.md
25
+ @../conventions/feature-pipeline.md
24
26
  @../conventions/workflow-step-naming.md
@@ -5,6 +5,19 @@
5
5
  developer lookups live in `reference/<file>.md` keyed by stable filename + heading anchor.
6
6
  Story IDs never change; only the `NN-`/`01-` file-name numbers shift, so never cite those.
7
7
 
8
+ ## Enforced, not just asked
9
+
10
+ Two hooks in the shared `.claude/settings.json` hold this line (since 0.1.67, 2026-09-23):
11
+
12
+ - **Read before you write.** The first edit to a source file in a session is refused until a
13
+ markdown page under `drawbridge-docs` has been read and a graph query has been run in that
14
+ session (`hooks/consult-docs-record.js` remembers, `hooks/consult-docs-gate.js` refuses).
15
+ Docs, `.claude/`, non-source files and growth are not gated; the graph half is skipped when
16
+ graphify is not installed or `DRAWBRIDGE_SKIP_GRAPHIFY=1`.
17
+ - **Docs move with the code.** Ending a turn after changing `@story`-anchored code without
18
+ touching drawbridge-docs is refused once (`hooks/drift-check.js`): update the story, or say
19
+ which story was read and why it still matches, then end the turn again.
20
+
8
21
  ## Before you change a documented behaviour
9
22
 
10
23
  1. Find the matching story by its **ID** (the story-index table in each
@@ -0,0 +1,18 @@
1
+ # How a feature ships
2
+
3
+ Every feature or change that a tester will see goes through the `drawbridge-feature` skill, in
4
+ this order, and no other way:
5
+
6
+ 1. Story first, in `drawbridge-docs/user-stories/`, with every scenario saying what the person
7
+ sees. Unknowns go to Darren as one numbered list of questions.
8
+ 2. Read the story and the reference page, and query the graph, before the first edit.
9
+ 3. Code, in a worktree, tests first where a suite exists.
10
+ 4. Code review on the diff: the reviewer agent and `ponytail-review`.
11
+ 5. Deploy to dev and dispatch the `drawbridge-qa` agent. It walks every scenario with a clean
12
+ slate and writes `drawbridge-tests/results/qa/<task id>.json`. Fix until `fails` is 0.
13
+ 6. `drawbridge-ship-feature`: docs, anchors, graph, handoff.
14
+
15
+ Each skipped step is refused by a hook, not by a reminder: the first source edit
16
+ (`consult-docs-gate.js`), the turn end (`drift-check.js`), and the Asana comment
17
+ (`qa-gate.js`, which only fires in a session that changed source code). Darren, 2026-09-23:
18
+ the point is that what reaches Jaime is "99.99% completed."
@@ -17,6 +17,9 @@ graphify explain "closeDraw" --graph ../drawbridge-docs/knowledge/graphs/global.
17
17
  graphify affected "closeDraw" --graph ../drawbridge-docs/knowledge/graphs/global.json
18
18
  ```
19
19
 
20
+ This is enforced: the first source edit of a session is refused until a graph query has run
21
+ (see `docs-linkage.md`, "Enforced, not just asked").
22
+
20
23
  `git pull` in drawbridge-docs gets you the current shared graph. It spans all sibling repos
21
24
  (code, via local AST); a semantic build additionally pulls in doc prose and richer INFERRED
22
25
  edges — see "Refreshing it".
@@ -23,6 +23,14 @@
23
23
 
24
24
  Why: three drafts on 2026-09-23 went out as bullets with paragraphs around them, then a partly numbered list with prose before and after. Each one had to be sent back. An ask that cannot be answered by number costs a round trip every time.
25
25
 
26
+ Each question is three plain sentences, in this order, and nothing else:
27
+
28
+ 1. What is true today. ("Today, nothing stops me from editing code before I have read the docs page.")
29
+ 2. What I would change, described as what the reader would see happen. ("I can add a check that refuses my first code edit until I have opened the docs page.")
30
+ 3. The ask. ("Do you want that? Yes or no.")
31
+
32
+ Use the words the team uses. Never a name I made up for the thing ("record hook", "gate hook", "marker", "nudge", "semantic pass") and never a term that lives only in our code or docs. If a sentence needs a word nobody on the team says out loud, rewrite the sentence. Test: could Jaime answer it without opening a repo? Darren, 2026-09-23: "the entire team hates the way you ask questions, they are always convoluted and use words that no one says." The same list of four questions had to be sent twice that day; the second version, in this shape, was answered in one line.
33
+
26
34
  This rule wins over two things that load into every session and say otherwise. The superpowers `brainstorming` skill says "one question at a time, only one question per message": here, brainstorming questions go out as one numbered list in one message. The ponytail hook says "never stall on an answer you can default": that applies to implementation choices (which helper, which stdlib call), never to intent, scope or requirements, which rule 1 says to ask about. Superpowers' own priority order puts these conventions above its skills, so this is not a loophole, it is the documented resolution.
27
35
 
28
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.
@@ -0,0 +1,94 @@
1
+ # Walk the app before you describe it
2
+
3
+ Every button, screen, label or flow named in a comment, a handoff or a reply was seen in a
4
+ browser in that session. Not inferred from the code, not remembered from last week. A tester
5
+ sent to a button that does not exist costs a full retest; this page is what makes the walk
6
+ possible from any developer's session.
7
+
8
+ Two ways to drive a browser. The first works from every session, headless, with no extra
9
+ setup beyond the `.env`; the second is optional and only works in an interactive session.
10
+
11
+ ## 1. Playwright in drawbridge-tests (always available)
12
+
13
+ Verified 2026-09-23: sign-in 2.5 s, screenshot and labels of a dev page in one command.
14
+
15
+ ### One-time setup, per machine
16
+
17
+ ```sh
18
+ cd drawbridge-tests
19
+ nvm use # Node 22 — the family node; v12 breaks on node: imports
20
+ npm run sync
21
+ npx playwright install chromium webkit
22
+ ```
23
+
24
+ Create `drawbridge-tests/.env` (gitignored; wrap values in single quotes). The keys the walk
25
+ needs, and where each value comes from:
26
+
27
+ | Key | Value | Where it comes from |
28
+ | --- | --- | --- |
29
+ | `APP_CLIENT_DASHBOARD_URI` | `https://dev.dashboard.trydrawbridge.com` | dev dashboard |
30
+ | `APP_CLIENT_FORM_URI` | dev share base | dev share app |
31
+ | `APP_CLIENT_WEBSITE_URI` | `https://dev.trydrawbridge.com` | dev website |
32
+ | `APP_TEST_ORG_ID` | the e2e organization id | `fixtures/e2e-org.md` |
33
+ | `USER_AUTH_EMAIL` | the account that OWNS the e2e organization | `fixtures/e2e-org.md`; a different address fails with "Invalid or expired code" |
34
+ | `USER_AUTH_OTC` | the fixed testing code the setup seeds and confirms with | `fixtures/e2e-org.md`; any value works, it is hashed at seed time |
35
+ | `BASIC_AUTH_USERNAME`, `BASIC_AUTH_PASSWORD` | the dev staging gate pair | the dev app spec, or 1Password (same pair gates dashboard and website) |
36
+ | `ENCRYPT_OTC_SECRET` | the DEPLOYED dev value, exactly | 1Password vault "Drawbridge - Environments", item "1. Environment Variables - Development", API section, via `op environment read` |
37
+ | `MONGODB_DATABASE_URI` | the dev database | same 1Password item |
38
+
39
+ `ENCRYPT_OTC_SECRET` must be the value the deployed dev API is running with. When it drifts,
40
+ sign-in fails with "Invalid or expired code" and nothing else in the error says why; re-copy
41
+ it from 1Password. `op whoami` reports "not signed in" even when the desktop-app integration
42
+ works, so probe with `op vault list`.
43
+
44
+ ### How the sign-in override works (so you can fix it when it breaks)
45
+
46
+ The dashboard's sign-in is reCAPTCHA-gated and reCAPTCHA scores an automated browser as a
47
+ bot, on purpose, with no test bypass. So the test does not use the sign-in form. It writes a
48
+ pending sign-in code straight into the dev database, hashed with `ENCRYPT_OTC_SECRET` the way
49
+ the API hashes it, then confirms that code through the dashboard's own confirm route, which
50
+ is not reCAPTCHA-gated. The session cookie is saved to `e2e/.auth/user.json` and reused by
51
+ every later walk until it expires (`e2e/auth.setup.js`, `lib/otc.js`).
52
+
53
+ ### Walking a page
54
+
55
+ ```sh
56
+ npm run walk -- /organizations/<org id>/suppressions # a dashboard path
57
+ npm run walk -- https://<slug>.dev.drwbrdg.com/<campaign> # any full URL (share, website)
58
+ npm run walk -- /organizations/<org id>/campaigns --out shot.png
59
+ ```
60
+
61
+ It signs in if the saved session is missing or stale, opens the page, saves a screenshot
62
+ (default `results/walk/<timestamp>.png`) and prints the headings, buttons, links and table
63
+ headers a person would see. **Read the screenshot** (the Read tool renders images), then
64
+ write the comment from what is on it. Quote labels from the printed list, never from memory.
65
+
66
+ The full smoke pack (`npm test`) and the dashboard functional run (`npm run dashboard`)
67
+ use the same session; see `drawbridge-tests/readme.md`.
68
+
69
+ ## 2. Claude in Chrome (optional, interactive sessions only)
70
+
71
+ Claude Code can drive the developer's own Chrome through the
72
+ [Claude in Chrome extension](https://chromewebstore.google.com/detail/claude/fcoeoabgfenejglbffodgkkbkcdhcgfn).
73
+ It shares the browser's login state, so once you are signed in to the dev dashboard in Chrome,
74
+ Claude can walk it as you. Vendor reference: https://code.claude.com/docs/en/chrome (read
75
+ 2026-09-23). What it needs:
76
+
77
+ - Google Chrome or Microsoft Edge (other Chromium browsers work; not WSL).
78
+ - The extension, version 1.0.36 or later.
79
+ - Claude Code signed in with `/login` on a direct Anthropic plan. An API key or a
80
+ `claude setup-token` token keeps the integration off even with `--chrome`.
81
+ - Start with `claude --chrome`, or run `/chrome` once and choose "Enabled by default". In the
82
+ VS Code extension it is on whenever the browser extension is installed. `/chrome` shows
83
+ "Status: Enabled" and "Extension: Installed" when it works.
84
+ - Sign in to `dev.dashboard.trydrawbridge.com` in that Chrome first; the basic-auth gate
85
+ prompts once. Claude pauses on any login page or CAPTCHA and asks you to handle it.
86
+
87
+ This path is not available to a headless or SDK session, which is why the Playwright walk
88
+ above is the one every handoff relies on.
89
+
90
+ ## What is enforced
91
+
92
+ - The `drawbridge-asana-ship-handoff` skill requires the walk before a handoff comment.
93
+ - `conventions/rules.md` rule 8: a fact in anything written to a person is backed by something
94
+ read this session. A label is a fact.
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook (Write/Edit/MultiEdit/NotebookEdit): the docs and the knowledge
3
+ // graph are the source of truth, and both were being skipped (Darren, 2026-09-23).
4
+ // conventions/docs-linkage.md and graphify.md already say "read the story first"
5
+ // and "query the graph before grepping"; prose asks, this refuses. The first
6
+ // source edit of a session is denied until consult-docs-record.js has seen a
7
+ // drawbridge-docs page read and a graph query in this session.
8
+ //
9
+ // What is NOT gated: anything under drawbridge-docs itself, anything under a
10
+ // .claude directory (the sync mirror), non-source files (json, md, css, ...), and
11
+ // growth, whose docs live in its own repo. The graph half is skipped when
12
+ // DRAWBRIDGE_SKIP_GRAPHIFY=1 or when no graphify binary is on this machine, so a
13
+ // missing tool never locks every edit.
14
+ const fs = require('fs')
15
+ const path = require('path')
16
+ const { spawnSync } = require('child_process')
17
+ const { markerPath } = require('./consult-docs-record.js')
18
+
19
+ const SOURCE_EXTENSIONS = new Set([ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs' ])
20
+
21
+ const readStdin = () => {
22
+ try {
23
+ return JSON.parse(fs.readFileSync(0, 'utf8') || '{}')
24
+ } catch (error) {
25
+ return {}
26
+ }
27
+ }
28
+
29
+ const deny = (reason) => {
30
+ process.stdout.write(JSON.stringify({
31
+ hookSpecificOutput: {
32
+ hookEventName: 'PreToolUse',
33
+ permissionDecision: 'deny',
34
+ permissionDecisionReason: reason
35
+ }
36
+ }))
37
+ process.exit(0)
38
+ }
39
+
40
+ const readMarker = (file) => {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(file, 'utf8') || '{}')
43
+ } catch (error) {
44
+ return {}
45
+ }
46
+ }
47
+
48
+ const graphifyInstalled = () => {
49
+ if (process.env.DRAWBRIDGE_SKIP_GRAPHIFY === '1') return false
50
+ const result = spawnSync('graphify', [ '--version' ], { encoding: 'utf8', stdio: 'pipe' })
51
+ return !result.error && result.status === 0
52
+ }
53
+
54
+ const run = () => {
55
+ const input = readStdin()
56
+
57
+ const tool = input.tool_name || ''
58
+ if (!/^(Write|Edit|MultiEdit|NotebookEdit)$/.test(tool)) process.exit(0)
59
+ if (!input.session_id) process.exit(0)
60
+
61
+ const toolInput = input.tool_input || {}
62
+ const filePath = toolInput.file_path || toolInput.notebook_path
63
+ if (!filePath) process.exit(0)
64
+
65
+ const resolved = path.resolve(filePath).split(path.sep).join('/')
66
+ const segments = resolved.split('/')
67
+
68
+ if (!SOURCE_EXTENSIONS.has(path.extname(resolved).toLowerCase())) process.exit(0)
69
+ if (segments.includes('drawbridge-docs')) process.exit(0)
70
+ if (segments.includes('.claude')) process.exit(0)
71
+ if (/\/drawbridge-growth[^/]*\//.test(resolved + '/')) process.exit(0)
72
+
73
+ const seen = readMarker(markerPath(input.session_id))
74
+ const needGraph = graphifyInstalled()
75
+
76
+ const missing = []
77
+ if (!seen.docs) missing.push('open the user story or reference page in drawbridge-docs that covers this behaviour (Read it; the story index is user-stories/<domain>/README.md, developer lookups are reference/<file>.md)')
78
+ if (needGraph && !seen.graph) missing.push('run a graph query for the code you are about to change: graphify query "<what you are changing>" --graph ../drawbridge-docs/knowledge/graphs/global.json')
79
+
80
+ if (missing.length === 0) process.exit(0)
81
+
82
+ deny(
83
+ 'Read before you write. This is the first source edit of the session and the docs and graph were not consulted yet. ' +
84
+ 'Do this first, then retry the edit: ' + missing.map((step, index) => (index + 1) + '. ' + step).join(' ') + '. ' +
85
+ 'See conventions/docs-linkage.md and conventions/graphify.md.'
86
+ )
87
+ }
88
+
89
+ try {
90
+ run()
91
+ } catch (error) {
92
+ // Never break a user's turn because of a hook bug — fail open.
93
+ process.exit(0)
94
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse hook (Read/Bash): remember, for this session, that the docs and the
3
+ // knowledge graph were consulted. consult-docs-gate.js reads the same file before
4
+ // the first source edit. Two halves because a hook only sees one tool call: this
5
+ // one watches what was read, the other decides what may be written.
6
+ //
7
+ // Counts as consulting the docs: a Read of any markdown file under drawbridge-docs.
8
+ // Counts as consulting the graph: a Bash command that runs a graphify query
9
+ // (query / explain / path / affected) or the family graph bin.
10
+ const fs = require('fs')
11
+ const os = require('os')
12
+ const path = require('path')
13
+
14
+ const readStdin = () => {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(0, 'utf8') || '{}')
17
+ } catch (error) {
18
+ return {}
19
+ }
20
+ }
21
+
22
+ // One file per session in the OS temp dir; nothing persists past it.
23
+ const markerPath = (sessionId) => path.join(os.tmpdir(), 'drawbridge-consulted-' + String(sessionId).replace(/[^\w.-]/g, '_') + '.json')
24
+
25
+ const readMarker = (file) => {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(file, 'utf8') || '{}')
28
+ } catch (error) {
29
+ return {}
30
+ }
31
+ }
32
+
33
+ const isDocsRead = (tool, toolInput) => {
34
+ if (tool !== 'Read') return false
35
+ const filePath = String(toolInput.file_path || '')
36
+ const resolved = path.resolve(filePath).split(path.sep).join('/')
37
+ return resolved.split('/').includes('drawbridge-docs') && /\.md$/i.test(resolved)
38
+ }
39
+
40
+ // A source edit outside .claude and drawbridge-docs: what qa-gate.js keys on to
41
+ // decide whether a session must show a QA report before it comments on Asana.
42
+ const SOURCE_EXTENSIONS = new Set([ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs' ])
43
+
44
+ const isSourceEdit = (tool, toolInput) => {
45
+ if (!/^(Write|Edit|MultiEdit|NotebookEdit)$/.test(tool)) return false
46
+ const filePath = String(toolInput.file_path || toolInput.notebook_path || '')
47
+ if (!filePath) return false
48
+ const resolved = path.resolve(filePath).split(path.sep).join('/')
49
+ const segments = resolved.split('/')
50
+ if (!SOURCE_EXTENSIONS.has(path.extname(resolved).toLowerCase())) return false
51
+ if (segments.includes('.claude') || segments.includes('drawbridge-docs')) return false
52
+ return true
53
+ }
54
+
55
+ const isGraphQuery = (tool, toolInput) => {
56
+ if (tool !== 'Bash') return false
57
+ const command = String(toolInput.command || '')
58
+ return /graphify\s+(query|explain|path|affected)\b/.test(command) || /drawbridge-agents-graph\b/.test(command)
59
+ }
60
+
61
+ const run = () => {
62
+ const input = readStdin()
63
+ if (!input.session_id) process.exit(0)
64
+
65
+ const tool = input.tool_name || ''
66
+ const toolInput = input.tool_input || {}
67
+
68
+ const docs = isDocsRead(tool, toolInput)
69
+ const graph = isGraphQuery(tool, toolInput)
70
+ const edited = isSourceEdit(tool, toolInput)
71
+ if (!docs && !graph && !edited) process.exit(0)
72
+
73
+ const file = markerPath(input.session_id)
74
+ const current = readMarker(file)
75
+ const next = {
76
+ docs: Boolean(current.docs) || docs,
77
+ edited: Boolean(current.edited) || edited,
78
+ graph: Boolean(current.graph) || graph
79
+ }
80
+ fs.writeFileSync(file, JSON.stringify(next))
81
+ process.exit(0)
82
+ }
83
+
84
+ module.exports = { markerPath }
85
+
86
+ // consult-docs-gate.js requires this file for markerPath; only the hook
87
+ // invocation itself may read stdin and exit.
88
+ if (require.main === module) {
89
+ try {
90
+ run()
91
+ } catch (error) {
92
+ // Never break a user's turn because of a hook bug — fail open.
93
+ process.exit(0)
94
+ }
95
+ }
@@ -83,6 +83,21 @@ const run = () => {
83
83
  }
84
84
  }
85
85
 
86
+ // 1b. Story checkability (blocking) — every scenario in a story touched this turn
87
+ // has a Then that names something a person sees, or a tag saying where else
88
+ // it shows up. Only the stories in the working tree are checked, so the
89
+ // backlog of older stories never blocks an unrelated turn.
90
+ const changedStories = changed.filter((file) => /\/drawbridge-docs\/user-stories\/.+\.md$/.test(file.split(path.sep).join('/')))
91
+ const checkStories = path.join(resolvePackageRoot(cwd), 'scripts', 'check-stories.js')
92
+ if (changedStories.length && fs.existsSync(checkStories)) {
93
+ try {
94
+ execFileSync('node', [ checkStories, ...changedStories ], { cwd, encoding: 'utf8', stdio: 'pipe' })
95
+ } catch (error) {
96
+ const detail = ((error.stdout || '') + (error.stderr || '')).trim()
97
+ block(`A story changed this turn has scenarios a tester could not check as written.\n${ detail }\nGive each Then a **label** or "copy" the person sees, or tag the scenario @inbox, @sms or @offscreen, before finishing.`)
98
+ }
99
+ }
100
+
86
101
  const skipGraph = process.env.DRAWBRIDGE_SKIP_GRAPHIFY === '1'
87
102
 
88
103
  // 2. Graph freshness (non-blocking nudge) — a full re-extract is expensive, so alert rather
@@ -108,8 +123,11 @@ const run = () => {
108
123
  }
109
124
  }
110
125
 
111
- // 3. Docs staleness (non-blocking heuristic) — code under a @story-anchored area changed but no
112
- // drawbridge-docs change is in the working tree.
126
+ // 3. Docs staleness (BLOCKING since 2026-09-23, Darren: the docs are the source of truth and
127
+ // the nudge was being ignored) — code under a @story-anchored area changed but no
128
+ // drawbridge-docs change is in the working tree. The stop_hook_active escape at the top
129
+ // lets the turn end on the second attempt, so the block costs exactly one explicit
130
+ // answer: update the story, or say which story was checked and why it still matches.
113
131
  const touchedAnchoredCode = changedSource.some((file) => {
114
132
  try {
115
133
  return /@story\s+[a-z0-9]/.test(fs.readFileSync(file, 'utf8'))
@@ -121,7 +139,7 @@ const run = () => {
121
139
  if (touchedAnchoredCode && !touchedDocs) {
122
140
  // `@story` is backticked so doc-link scanners (including stale node_modules
123
141
  // copies that don't skip .claude/) never read this sentence as a real tag.
124
- nudge('You changed code that implements a documented behaviour (carries an `@story` anchor) without touching drawbridge-docs. Confirm the matching user story still matches, or update it in this change.')
142
+ block('You changed code that implements a documented behaviour (carries an `@story` anchor) and did not touch drawbridge-docs. Before ending the turn: update the matching user story or reference page in this change, or state in your reply which story you read and why it still matches as written.')
125
143
  }
126
144
 
127
145
  process.exit(0)
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook on the Asana comment tool: a session that changed source code may
3
+ // not post to a task until the drawbridge-qa agent's report for that task exists,
4
+ // is from the last day, and shows no failed scenario. A session that changed
5
+ // nothing (an answer, a question) posts freely. Darren, 2026-09-23: features reach
6
+ // Jaime "99.99% completed". See the drawbridge-feature skill.
7
+ //
8
+ // "Changed source code" is what consult-docs-record.js remembered for this session.
9
+ // The report is drawbridge-tests/results/qa/<task id>.json, found by walking up from
10
+ // cwd to the folder that holds the family (a worktree sits one level deeper).
11
+ const fs = require('fs')
12
+ const path = require('path')
13
+ const { markerPath } = require('./consult-docs-record.js')
14
+
15
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000
16
+
17
+ const readStdin = () => {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(0, 'utf8') || '{}')
20
+ } catch (error) {
21
+ return {}
22
+ }
23
+ }
24
+
25
+ const deny = (reason) => {
26
+ process.stdout.write(JSON.stringify({
27
+ hookSpecificOutput: {
28
+ hookEventName: 'PreToolUse',
29
+ permissionDecision: 'deny',
30
+ permissionDecisionReason: reason
31
+ }
32
+ }))
33
+ process.exit(0)
34
+ }
35
+
36
+ const readJson = (file) => {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(file, 'utf8') || '{}')
39
+ } catch (error) {
40
+ return null
41
+ }
42
+ }
43
+
44
+ const findFamilyRoot = (cwd) => {
45
+ let dir = cwd
46
+ for (let depth = 0; depth < 4; depth += 1) {
47
+ if (fs.existsSync(path.join(dir, 'drawbridge-tests'))) return dir
48
+ const parent = path.dirname(dir)
49
+ if (parent === dir) break
50
+ dir = parent
51
+ }
52
+ return null
53
+ }
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
+
128
+ const run = () => {
129
+ const input = readStdin()
130
+
131
+ if (input.tool_name !== 'mcp__claude_ai_Asana__add_comment') process.exit(0)
132
+ if (!input.session_id) process.exit(0)
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
+
141
+ const seen = readJson(markerPath(input.session_id)) || {}
142
+ if (!seen.edited) process.exit(0)
143
+
144
+ const task = String(toolInput.task_id || '')
145
+ if (!task) process.exit(0)
146
+ const file = findReport(cwd, task)
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.'
148
+
149
+ if (!file) deny('QA first. This session changed source code and there is no QA report for task ' + task + '. ' + how)
150
+
151
+ const report = readJson(file)
152
+ if (!report || !Array.isArray(report.scenarios)) deny('QA report for task ' + task + ' is not readable (' + file + '). ' + how)
153
+
154
+ const age = Date.now() - Date.parse(report.walkedAt || 0)
155
+ if (!(age >= 0 && age <= MAX_AGE_MS)) deny('QA report for task ' + task + ' is older than a day (' + (report.walkedAt || 'no walkedAt') + '). ' + how)
156
+
157
+ const failed = report.scenarios.filter((scenario) => scenario && scenario.status === 'fail').map((scenario) => scenario.name || 'unnamed')
158
+ if (failed.length) deny('QA report for task ' + task + ' has ' + failed.length + ' failed scenario(s): ' + failed.join('; ') + '. Fix, redeploy, and run drawbridge-qa again before commenting.')
159
+
160
+ process.exit(0)
161
+ }
162
+
163
+ try {
164
+ run()
165
+ } catch (error) {
166
+ // Never break a user's turn because of a hook bug — fail open.
167
+ process.exit(0)
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawbridge/drawbridge-agents",
3
- "version": "0.1.66",
3
+ "version": "0.1.71",
4
4
  "description": "Shared agent-instruction content (rules, code style, conventions) for the drawbridge-* monorepo.",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -14,7 +14,8 @@
14
14
  "bin": {
15
15
  "drawbridge-agents-sync": "bin/sync-claude.js",
16
16
  "drawbridge-agents-graph": "bin/graph.js",
17
- "drawbridge-agents-check-docs": "scripts/check-doc-links.js"
17
+ "drawbridge-agents-check-docs": "scripts/check-doc-links.js",
18
+ "drawbridge-agents-check-stories": "scripts/check-stories.js"
18
19
  },
19
20
  "files": [
20
21
  "claude",
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // Every scenario in a user story must be checkable by someone with a browser.
3
+ // Rule: each `Scenario:` has a Then, and its Then / And / But lines name at least
4
+ // one thing the person SEES, written as **a bold label** or "quoted copy". A
5
+ // scenario that lives somewhere a browser cannot look is tagged on the line
6
+ // above it, and the tag says where:
7
+ //
8
+ // @inbox the outcome is an email (the QA agent reads it through Gmail)
9
+ // @sms the outcome is a text message
10
+ // @offscreen no screen shows it (a charge, a log line, a later job)
11
+ //
12
+ // Tagged scenarios are exempt from the visible-thing rule, not from the Then.
13
+ //
14
+ // Usage: node scripts/check-stories.js [story.md ...] (no args = every story)
15
+ // Exits 1 naming file:line for each scenario that fails. Darren, 2026-09-23:
16
+ // "fix this so that there are no gaps" — the QA agent can only check what a
17
+ // story says a person sees.
18
+ const fs = require('fs')
19
+ const path = require('path')
20
+
21
+ const TAGS = new Set([ '@inbox', '@sms', '@offscreen' ])
22
+ const VISIBLE = /\*\*[^*]+\*\*|"[^"]+"|“[^”]+”/
23
+
24
+ const findFamilyRoot = () => {
25
+ let dir = process.env.INIT_CWD || process.cwd()
26
+ for (let i = 0; i < 8; i++) {
27
+ if (fs.existsSync(path.join(dir, 'drawbridge-docs', 'user-stories'))) return dir
28
+ const parent = path.dirname(dir)
29
+ if (parent === dir) break
30
+ dir = parent
31
+ }
32
+ return null
33
+ }
34
+
35
+ const walk = (dir, out = []) => {
36
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
37
+ const full = path.join(dir, entry.name)
38
+ if (entry.isDirectory()) walk(full, out)
39
+ else if (entry.name.endsWith('.md')) out.push(full)
40
+ }
41
+ return out
42
+ }
43
+
44
+ // Returns [{ line, name, reason }] for one story file.
45
+ const checkStory = (text) => {
46
+ const problems = []
47
+ const lines = text.split('\n')
48
+ let scenario = null
49
+ let inFence = false
50
+
51
+ const flush = () => {
52
+ if (!scenario) return
53
+ if (!scenario.hasThen) problems.push({ line: scenario.line, name: scenario.name, reason: 'no Then line' })
54
+ else if (!scenario.tagged && !scenario.visible) problems.push({ line: scenario.line, name: scenario.name, reason: 'Then names nothing a person sees (no **label** or "copy"); tag it @inbox, @sms or @offscreen if no screen shows it' })
55
+ scenario = null
56
+ }
57
+
58
+ let pendingTag = false
59
+ lines.forEach((raw, index) => {
60
+ const line = raw.trim()
61
+ if (line.startsWith('```')) {
62
+ inFence = !inFence
63
+ // A scenario block inside a fence is still a scenario; a fence that closes ends it.
64
+ if (!inFence) flush()
65
+ return
66
+ }
67
+ if (/^#/.test(line)) { flush(); pendingTag = false; return }
68
+ if (/^@\w/.test(line)) {
69
+ pendingTag = line.split(/\s+/).some((tag) => TAGS.has(tag))
70
+ return
71
+ }
72
+ if (/^Scenario( Outline)?:/.test(line)) {
73
+ flush()
74
+ scenario = { line: index + 1, name: line, hasThen: false, visible: false, tagged: pendingTag }
75
+ pendingTag = false
76
+ return
77
+ }
78
+ if (!scenario) return
79
+ if (/^Then\b/.test(line)) scenario.hasThen = true
80
+ if (/^(Then|And|But)\b/.test(line) && VISIBLE.test(line)) scenario.visible = true
81
+ })
82
+ flush()
83
+ return problems
84
+ }
85
+
86
+ const main = () => {
87
+ const familyRoot = findFamilyRoot()
88
+ if (!familyRoot) {
89
+ console.error('drawbridge-agents-check-stories: could not locate drawbridge-docs; run from within the drawbridge-* family')
90
+ process.exit(1)
91
+ }
92
+
93
+ const storiesRoot = path.join(familyRoot, 'drawbridge-docs', 'user-stories')
94
+ const requested = process.argv.slice(2).map((file) => path.resolve(file))
95
+ const files = requested.length ? requested.filter((file) => file.startsWith(storiesRoot) && file.endsWith('.md')) : walk(storiesRoot)
96
+
97
+ let count = 0
98
+ for (const file of files) {
99
+ if (!fs.existsSync(file)) continue
100
+ for (const problem of checkStory(fs.readFileSync(file, 'utf8'))) {
101
+ count += 1
102
+ console.error(` ${ path.relative(familyRoot, file) }:${ problem.line } ${ problem.name } — ${ problem.reason }`)
103
+ }
104
+ }
105
+
106
+ if (count) {
107
+ console.error(`drawbridge-agents-check-stories: ${ count } scenario(s) a tester could not check as written (${ files.length } file(s) scanned)`)
108
+ process.exit(1)
109
+ }
110
+ console.log(`drawbridge-agents-check-stories: every scenario names what the person sees (${ files.length } file(s))`)
111
+ }
112
+
113
+ module.exports = { checkStory }
114
+
115
+ if (require.main === module) main()