@oneie/claude 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/abm-strategist.md +67 -1
  3. package/agents/ads-meta.md +67 -1
  4. package/agents/analyst.md +67 -1
  5. package/agents/animator.md +108 -0
  6. package/agents/architect.md +269 -20
  7. package/agents/brand-guardian.md +67 -1
  8. package/agents/brand-strategist.md +67 -1
  9. package/agents/campaign-content.md +67 -1
  10. package/agents/campaign-email.md +67 -1
  11. package/agents/campaign-sms.md +67 -1
  12. package/agents/campaign-social.md +67 -1
  13. package/agents/cco.md +83 -2
  14. package/agents/ceo.md +108 -11
  15. package/agents/chairman.md +197 -0
  16. package/agents/cmo.md +82 -2
  17. package/agents/community-greeter.md +67 -1
  18. package/agents/community-moderator.md +67 -1
  19. package/agents/compliance.md +67 -1
  20. package/agents/copywriter.md +67 -1
  21. package/agents/creative-strategist.md +67 -1
  22. package/agents/cro.md +81 -1
  23. package/agents/cto.md +266 -28
  24. package/agents/customer-interviewer.md +67 -1
  25. package/agents/customer-researcher.md +67 -1
  26. package/agents/customer-success-manager.md +67 -1
  27. package/agents/customer-trainer.md +67 -1
  28. package/agents/cxo.md +82 -1
  29. package/agents/demand-creator.md +67 -1
  30. package/agents/demo-mover.md +67 -1
  31. package/agents/demo-specialist.md +67 -1
  32. package/agents/demo-thai-family-law.md +67 -1
  33. package/agents/designer.md +67 -1
  34. package/agents/discovery-caller.md +67 -1
  35. package/agents/doctor.md +269 -0
  36. package/agents/educate-coach.md +67 -1
  37. package/agents/elevate-tutor.md +67 -1
  38. package/agents/email-lifecycle-marketer.md +67 -1
  39. package/agents/engage-specialist.md +67 -1
  40. package/agents/events-coordinator.md +67 -1
  41. package/agents/foundation-builder.md +67 -1
  42. package/agents/funnel-architect.md +67 -1
  43. package/agents/gift-creator.md +67 -1
  44. package/agents/google-ads.md +67 -1
  45. package/agents/guide.md +67 -1
  46. package/agents/helpdesk-dispatcher.md +67 -1
  47. package/agents/hook-specialist.md +67 -1
  48. package/agents/identify-optimizer.md +67 -1
  49. package/agents/implementer.md +313 -45
  50. package/agents/incident-commander.md +67 -1
  51. package/agents/insights-lead.md +87 -1
  52. package/agents/journey-runner.md +67 -1
  53. package/agents/linkedin-ads.md +67 -1
  54. package/agents/live-sales-chat.md +67 -1
  55. package/agents/market-researcher.md +67 -1
  56. package/agents/media-buyer.md +67 -1
  57. package/agents/memory-keeper.md +195 -0
  58. package/agents/movers-customer-researcher.md +67 -1
  59. package/agents/movers-foundation-builder.md +67 -1
  60. package/agents/movers-market-researcher.md +67 -1
  61. package/agents/movers-pricing-strategist.md +67 -1
  62. package/agents/nurture-architect.md +67 -1
  63. package/agents/offer-architect.md +67 -1
  64. package/agents/onboarder.md +67 -1
  65. package/agents/onboarding-specialist.md +67 -1
  66. package/agents/operations-dashboard.md +87 -1
  67. package/agents/perf-engineer.md +333 -37
  68. package/agents/playbook-writer.md +67 -1
  69. package/agents/plg-strategist.md +67 -1
  70. package/agents/positioning-architect.md +67 -1
  71. package/agents/press-officer.md +67 -1
  72. package/agents/pricing-strategist.md +67 -1
  73. package/agents/privacy-officer.md +67 -1
  74. package/agents/referral-manager.md +67 -1
  75. package/agents/refine-analyst.md +67 -1
  76. package/agents/release-manager.md +446 -39
  77. package/agents/renewals-upsell-rep.md +67 -1
  78. package/agents/review-engineer.md +319 -45
  79. package/agents/rewards-steward.md +67 -1
  80. package/agents/sales-call-coach.md +67 -1
  81. package/agents/sales-closer.md +67 -1
  82. package/agents/security-auditor.md +343 -48
  83. package/agents/sell-closer.md +67 -1
  84. package/agents/share-amplifier.md +67 -1
  85. package/agents/social-media-manager.md +67 -1
  86. package/agents/storyteller.md +301 -0
  87. package/agents/strategist.md +67 -1
  88. package/agents/strategy-aligner.md +67 -1
  89. package/agents/support-agent.md +67 -1
  90. package/agents/tagger.md +327 -0
  91. package/agents/tech-writer.md +195 -22
  92. package/agents/test-engineer.md +398 -29
  93. package/agents/tiktok-ads.md +67 -1
  94. package/agents/tracking-engineer.md +67 -1
  95. package/agents/trailkeeper.md +181 -0
  96. package/agents/upsell-strategist.md +67 -1
  97. package/agents/voice.md +67 -1
  98. package/agents/w1-recon.md +1 -1
  99. package/agents/w2-decide.md +1 -1
  100. package/agents/w3-edit.md +8 -2
  101. package/agents/w4-verify.md +13 -0
  102. package/agents/workflow-optimiser.md +81 -1
  103. package/commands/close.md +916 -160
  104. package/commands/deploy.md +102 -724
  105. package/commands/do.md +58 -2
  106. package/commands/sweep.md +159 -0
  107. package/commands/tasks.md +222 -0
  108. package/hooks/scripts/dev-only.sh +135 -0
  109. package/hooks/scripts/git-add-guard.sh +37 -2
  110. package/hooks/scripts/session-start.sh +32 -4
  111. package/package.json +1 -1
  112. package/rules/scripts.md +85 -0
  113. package/scripts/CLAUDE.md +315 -0
  114. package/scripts/ad-copy-lint.sh +656 -0
  115. package/scripts/agent-actor-parity.sh +129 -0
  116. package/scripts/blocks-manifest-cached.sh +100 -0
  117. package/scripts/chat-context-check.sh +89 -0
  118. package/scripts/chrome.mjs +18 -0
  119. package/scripts/close-metrics.sh +587 -0
  120. package/scripts/close-owner.sh +326 -0
  121. package/scripts/db-sync-lock-check.sh +116 -0
  122. package/scripts/deploy-emit.sh +311 -0
  123. package/scripts/deploy-gate-check.sh +155 -0
  124. package/scripts/deploy-ready.sh +78 -0
  125. package/scripts/deploy-record.sh +605 -0
  126. package/scripts/deploy-schema-check.sh +58 -0
  127. package/scripts/deploy.sh +393 -243
  128. package/scripts/do-auto.sh +127 -26
  129. package/scripts/do-board.sh +429 -0
  130. package/scripts/do-close.sh +1184 -0
  131. package/scripts/do-consumer-sweep.sh +18 -1
  132. package/scripts/do-decide.sh +476 -0
  133. package/scripts/do-fleet.sh +8 -2
  134. package/scripts/do-plan-json.mjs +110 -12
  135. package/scripts/do-prove-selftest.sh +108 -0
  136. package/scripts/do-prove.sh +86 -10
  137. package/scripts/do-rank.py +200 -3
  138. package/scripts/do-reconcile.sh +73 -12
  139. package/scripts/do-signal.sh +101 -23
  140. package/scripts/do-smoke.sh +18 -1
  141. package/scripts/do-w4-gates.sh +11 -1
  142. package/scripts/do-world-check.sh +153 -0
  143. package/scripts/download-stats.sh +172 -0
  144. package/scripts/factory-brief-check.sh +330 -0
  145. package/scripts/factory-check.sh +18 -1
  146. package/scripts/factory-close-check.sh +257 -0
  147. package/scripts/factory-emit.sh +211 -0
  148. package/scripts/factory-executor-check.mjs +353 -0
  149. package/scripts/factory-peak.sh +301 -0
  150. package/scripts/factory-repo.sh +71 -0
  151. package/scripts/factory-review-check.mjs +61 -0
  152. package/scripts/factory-tasks-check.sh +18 -1
  153. package/scripts/fixtures/factory-brief-real.md +44 -0
  154. package/scripts/flywheel-outcome.sh +63 -0
  155. package/scripts/gate-reaper-check.sh +98 -0
  156. package/scripts/gate-reaper.sh +9 -0
  157. package/scripts/gate-watchdog.sh +619 -0
  158. package/scripts/gc-content-check.sh +142 -0
  159. package/scripts/gh-traffic-capture.sh +153 -0
  160. package/scripts/govern-order-check.sh +202 -0
  161. package/scripts/governor-doors-check.sh +86 -5
  162. package/scripts/health.sh +448 -0
  163. package/scripts/id-inventory.mjs +418 -0
  164. package/scripts/incident.sh +212 -0
  165. package/scripts/land.sh +755 -45
  166. package/scripts/lib/gc-finished.sh +77 -0
  167. package/scripts/livekit-ratchet.sh +18 -1
  168. package/scripts/machine-check.sh +1 -1
  169. package/scripts/memory-index-budget.sh +79 -0
  170. package/scripts/npm-downloads.sh +109 -0
  171. package/scripts/one-agents.mjs +204 -8
  172. package/scripts/one-resume.sh +31 -3
  173. package/scripts/pr-body.sh +335 -0
  174. package/scripts/preview-fd-check.sh +289 -0
  175. package/scripts/redirect-lint.sh +169 -0
  176. package/scripts/release.sh +40 -6
  177. package/scripts/resume-lost-sessions.sh +68 -0
  178. package/scripts/shoot-pages.mjs +140 -0
  179. package/scripts/signal-meta-backfill.ts +451 -0
  180. package/scripts/signal-watch.sh +63 -6
  181. package/scripts/speed-cache-check.sh +12 -2
  182. package/scripts/sweep.sh +426 -0
  183. package/scripts/task-titles-dump.ts +101 -0
  184. package/scripts/test-cached.sh +47 -10
  185. package/scripts/test-lanes.sh +14 -0
  186. package/scripts/thread-name-backfill.ts +215 -0
  187. package/scripts/triage-shape-check.sh +149 -0
  188. package/scripts/tsc-cached.sh +155 -8
  189. package/scripts/typedb-flake-check.sh +3 -1
  190. package/scripts/urls-lint.sh +8 -0
  191. package/scripts/verify-board-doors.sh +80 -0
  192. package/scripts/verify-fast.sh +159 -6
  193. package/scripts/worktree-up.sh +21 -3
  194. package/skills/astro/SKILL.md +9 -3
  195. package/skills/astro/optimize-performance.md +3 -2
  196. package/skills/cloudflare/SKILL.md +3 -2
  197. package/skills/cloudflare-security-audit/AI-AND-LLM.md +83 -0
  198. package/skills/cloudflare-security-audit/ATTACK-CLASSES.md +130 -0
  199. package/skills/cloudflare-security-audit/CLIENT-SIDE.md +83 -0
  200. package/skills/cloudflare-security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
  201. package/skills/cloudflare-security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
  202. package/skills/cloudflare-security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
  203. package/skills/cloudflare-security-audit/HUNTING.md +251 -0
  204. package/skills/cloudflare-security-audit/LICENSE +21 -0
  205. package/skills/cloudflare-security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
  206. package/skills/cloudflare-security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
  207. package/skills/cloudflare-security-audit/PROVENANCE.md +78 -0
  208. package/skills/cloudflare-security-audit/RECONNAISSANCE.md +156 -0
  209. package/skills/cloudflare-security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
  210. package/skills/cloudflare-security-audit/SKILL.md +192 -0
  211. package/skills/cloudflare-security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
  212. package/skills/cloudflare-security-audit/VALIDATION-AND-REPORTING.md +186 -0
  213. package/skills/cloudflare-security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
  214. package/skills/cloudflare-security-audit/report-schema.json +461 -0
  215. package/skills/cloudflare-security-audit/validate-coverage-ledger.cjs +872 -0
  216. package/skills/cloudflare-security-audit/validate-coverage-ledger.test.cjs +740 -0
  217. package/skills/cloudflare-security-audit/validate-findings.cjs +773 -0
  218. package/skills/cloudflare-security-audit/validate-findings.test.cjs +652 -0
  219. package/skills/deploy/REFERENCE.md +713 -0
  220. package/skills/deploy/SKILL.md +140 -0
  221. package/skills/fleet-audit/SKILL.md +58 -0
  222. package/skills/meeting/SKILL.md +220 -0
  223. package/skills/planning/SKILL.md +256 -0
  224. package/skills/shadcn/SKILL.md +1 -1
  225. package/skills/typedb/SKILL.md +7 -0
  226. package/skills/voice/SKILL.md +94 -6
  227. package/skills/voice/corpus-check.sh +87 -0
  228. package/templates/template-agent.md +7 -1
  229. package/templates/template-feature.md +9 -0
  230. package/templates/template-todo.md +29 -0
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * shoot-pages.mjs — full-page screenshots of local pages, for the dev loop.
4
+ *
5
+ * WHY THIS EXISTS. `/api/og/screenshot.png` captures by asking Cloudflare's
6
+ * browser-rendering API to fetch a URL. No Cloudflare browser can reach a
7
+ * laptop's localhost, so in dev that door can only ever fall back to the
8
+ * generated SVG card — which makes the screenshot card (RecordGrid's
9
+ * `cardShape="screenshot"`) undesignable locally: no real capture, nothing
10
+ * taller than the window, and therefore no hover scroll to look at.
11
+ *
12
+ * So dev captures locally. This writes PNGs to
13
+ * one.ie/web/public/og-screenshots/full/<path>.png
14
+ * which Astro serves statically, and which the route redirects to on localhost.
15
+ * A path with no file 404s and the card swaps to its own fallback — the capture
16
+ * being absent must look like an absent capture, never like a broken page.
17
+ *
18
+ * node .claude/scripts/shoot-pages.mjs # every published + draft page
19
+ * node .claude/scripts/shoot-pages.mjs /p/my-page /about # just these paths
20
+ * node .claude/scripts/shoot-pages.mjs --base http://localhost:4321
21
+ *
22
+ * Captures at 1200 wide, fullPage, which is the same shape cfScreenshot asks
23
+ * Cloudflare for — so what you tune against locally is what ships.
24
+ *
25
+ * manifest: monorepo-only
26
+ */
27
+
28
+ import { mkdirSync, existsSync, readFileSync } from 'node:fs'
29
+ import { dirname, join, resolve } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+ import { createRequire } from 'node:module'
32
+
33
+ const HERE = dirname(fileURLToPath(import.meta.url))
34
+ const ROOT = resolve(HERE, '../..')
35
+ const WEB = join(ROOT, 'one.ie/web')
36
+ const OUT = join(WEB, 'public/og-screenshots/full')
37
+
38
+ const args = process.argv.slice(2)
39
+ const baseIdx = args.indexOf('--base')
40
+ const BASE = baseIdx >= 0 ? args[baseIdx + 1] : 'http://localhost:4321'
41
+ const WIDTH = 1200
42
+ // Matches SCREENSHOT_MAX_FULL_PX in lib/render.ts — a local capture that is
43
+ // taller than production would ever produce is a preview of something that
44
+ // cannot ship.
45
+ const MAX_H = 5000
46
+
47
+ const explicit = args.filter((a) => a.startsWith('/'))
48
+
49
+ /** Playwright lives in the web package; find it from there, not from cwd. */
50
+ function loadChromium() {
51
+ const req = createRequire(join(WEB, 'package.json'))
52
+ try {
53
+ return req('playwright-core').chromium
54
+ } catch {
55
+ try {
56
+ return req('playwright').chromium
57
+ } catch {
58
+ console.error('shoot-pages: playwright not found under one.ie/web. Run `bun install` there.')
59
+ process.exit(1)
60
+ }
61
+ }
62
+ }
63
+
64
+ /** Every page path this workspace serves, read from the same door the UI uses. */
65
+ async function listPagePaths() {
66
+ try {
67
+ const r = await fetch(`${BASE}/api/ask/pages:list`, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body: JSON.stringify({ data: { slug: process.env.DEV_SLUG ?? 'one' } }),
71
+ signal: AbortSignal.timeout(15_000),
72
+ })
73
+ const json = await r.json()
74
+ const pages = json?.result?.pages ?? []
75
+ return pages.map((p) => `/p/${p.slug}`)
76
+ } catch (err) {
77
+ console.error(`shoot-pages: could not list pages (${err.message}). Pass paths explicitly.`)
78
+ return []
79
+ }
80
+ }
81
+
82
+ const paths = explicit.length ? explicit : await listPagePaths()
83
+ if (!paths.length) {
84
+ console.error('shoot-pages: nothing to capture.')
85
+ process.exit(1)
86
+ }
87
+
88
+ const chromium = loadChromium()
89
+ const browser = await chromium.launch()
90
+ const page = await browser.newPage({ viewport: { width: WIDTH, height: 630 } })
91
+
92
+ let ok = 0
93
+ let failed = 0
94
+ for (const p of paths) {
95
+ const url = `${BASE}${p}`
96
+ const leaf = p.endsWith('/') ? `${p}index` : p
97
+ const file = join(OUT, `${leaf}.png`)
98
+ mkdirSync(dirname(file), { recursive: true })
99
+ try {
100
+ // `load`, never `networkidle` — lib/render.ts records the measurement: a
101
+ // page carrying a chat island holds an open SSE connection, so the network
102
+ // never goes idle and the capture times out.
103
+ await page.goto(url, { waitUntil: 'load', timeout: 20_000 })
104
+ await page.waitForTimeout(3000)
105
+
106
+ // CAPTURE BY RESIZING THE VIEWPORT, NOT BY `clip`.
107
+ // `screenshot({ clip })` without `fullPage` is clipped to the VIEWPORT, so
108
+ // asking for a 5000px region of a 630px viewport silently returns 630px.
109
+ // Measured the first time this ran: every one of 23 files was 1200x630
110
+ // while the log printed 3629, 5000, 2133 — because the log printed the
111
+ // height it had COMPUTED, never the height it had written. Playwright also
112
+ // rejects `clip` together with `fullPage`, so the way to get a capped
113
+ // full-page shot is to make the viewport the height you want and take a
114
+ // plain viewport capture.
115
+ const h0 = await page.evaluate(() => document.documentElement.scrollHeight)
116
+ const height = Math.max(630, Math.min(h0 || 630, MAX_H))
117
+ await page.setViewportSize({ width: WIDTH, height })
118
+ // Re-settle: a taller viewport loads lazy content and can reflow.
119
+ await page.waitForTimeout(600)
120
+ await page.screenshot({ path: file })
121
+ await page.setViewportSize({ width: WIDTH, height: 630 })
122
+
123
+ // READ THE FILE BACK. The whole reason this script was wrong once is that
124
+ // it reported an intention rather than a result.
125
+ const png = readFileSync(file)
126
+ const realW = png.readUInt32BE(16)
127
+ const realH = png.readUInt32BE(20)
128
+ const flag = realH < 700 && h0 > 900 ? ' <-- SHORT, page is taller than this' : ''
129
+ console.log(` ok ${p} ${realW}x${realH} (page ${h0})${flag}`)
130
+ ok++
131
+ } catch (err) {
132
+ console.log(` FAIL ${p} ${err.message.split('\n')[0]}`)
133
+ failed++
134
+ }
135
+ }
136
+
137
+ await browser.close()
138
+ console.log(`shoot-pages: ${ok} captured, ${failed} failed → ${OUT.replace(ROOT + '/', '')}`)
139
+ if (!existsSync(OUT)) process.exit(1)
140
+ process.exit(failed && !ok ? 1 : 0)
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env bun
2
+ // signal-meta-backfill.ts — stamp the envelope onto the rows written before there
3
+ // was an envelope to stamp.
4
+ //
5
+ // manifest: monorepo-only (binds the `one-owners` D1 by name through wrangler and
6
+ // imports the shipped parsers out of one.ie/web/src)
7
+ //
8
+ // WHY THIS EXISTS. `lib/in/speaker.ts`'s `looksSessionPosted` decides whether a
9
+ // `staff` row was TYPED by the operator or POSTED by a session through his key, and
10
+ // it decides it from prose. Measured 2026-09-13 on the two production dumps: 367
11
+ // such rows, 345 machine / 22 human, and the tuned rule still reads 12 of his own 22
12
+ // messages as "Claude session". `SignalMeta.machine` states the fact at write time
13
+ // and `MessageList.tsx`'s `drawnOf` asks the envelope FIRST — so every row that
14
+ // carries one takes the heuristic out of the loop. This is the other half: the rows
15
+ // already written. Stamp enough of them and the heuristic becomes scaffolding
16
+ // somebody can delete, instead of a permanent component of who-said-what.
17
+ //
18
+ // THE ONE RULE THAT SHAPES EVERYTHING BELOW: NEVER GUESS A HUMAN.
19
+ // A row that matches no machine shape gets NO meta — never `machine:false`. The two
20
+ // errors are not equal (speaker.ts says so at length): a machine row left to the
21
+ // heuristic is the status quo, while a HUMAN row stamped `machine:true` is the
22
+ // operator's own words filed under a bot's name, permanently, in the one surface he
23
+ // reads most — and `drawnOf` would then refuse to let any later tuning reach it.
24
+ // Abstention is always available and always cheap.
25
+ //
26
+ // IT REUSES THE SHIPPED PARSERS, it does not re-implement them:
27
+ // `parseTaskAnnounce` (one.ie/web/src/lib/chat/task-refs.ts)
28
+ // `classifyMessage` (one.ie/web/src/lib/chat/message-shapes.ts)
29
+ // `readSignalMeta` (one.ie/web/src/lib/signal-meta.ts) — the real read gate
30
+ // A third copy of "what does this body look like" is exactly the drift this repo
31
+ // keeps paying for. The four residual shapes the two parsers do not cover live in
32
+ // RULES below, in this file, because they are backfill evidence and not a rendering
33
+ // rule — nothing in `src/` should grow a branch for them.
34
+ //
35
+ // WHAT A STAMPED ROW LOOKS LIKE, and how it is told apart from a live one:
36
+ // every envelope this script writes carries `backfill: "signal-meta-backfill@1"`.
37
+ // `SignalMeta` does not declare that key and every reader is an allow-list
38
+ // extraction, so it is inert to the UI — and it is the exact predicate a rollback
39
+ // needs. `source` names the receiver ONLY where the body states it unambiguously
40
+ // (a bare announce IS `tasks:announce`'s own output); every other row's source is
41
+ // the backfill itself, because inferring a write door from a body shape is a guess
42
+ // and this file does not make guesses it cannot show.
43
+ //
44
+ // THE WRITE IS ONE BATCHED FILE, NOT 567 SPAWNS. The first cut ran one
45
+ // `wrangler d1 execute --command` per row: ~15-30 minutes of sequential remote calls
46
+ // against a live table, and every one of those minutes is a window in which a
47
+ // concurrent writer changes a row that was read in a paged SELECT. The apply now
48
+ // GENERATES SQL to disk (`--out`, chunked by `--chunk`) and runs each chunk with
49
+ // `--file`, so the whole pass is a handful of round trips and the operator can read
50
+ // the statements before any of them run. The generation happens on a DRY RUN too —
51
+ // that is the point of it.
52
+ //
53
+ // AND THE STATEMENT PATCHES, IT DOES NOT REPLACE. `json_set(content_json, '$.meta',
54
+ // json('…'))` adds one key to whatever the row holds AT THE MOMENT THE WRITE RUNS, so
55
+ // a message edited between the read and the apply keeps its edit — where a statement
56
+ // carrying the whole re-serialised body would silently overwrite it. The cost is that
57
+ // SQLite re-serialises the JSON (compact, key order preserved); the values do not
58
+ // change, the bytes may.
59
+ //
60
+ // SAFETIES, each one a thing that cannot be undone twice:
61
+ // 1. DRY RUN IS THE DEFAULT. `--apply` is required to run the generated SQL.
62
+ // 2. THE GUARDS ARE IN THE WHERE CLAUSE, not in this process's memory:
63
+ // `json_type(content_json) = 'object' AND json_extract(content_json,'$.meta') IS
64
+ // NULL`. Re-running the same file is a no-op; a row a live writer stamped first
65
+ // is skipped, never clobbered.
66
+ // 3. A row that already carries a readable envelope is never even planned — the
67
+ // live writer's fact always outranks a derived one.
68
+ // 4. Every derived envelope is round-tripped through the REAL `readSignalMeta`
69
+ // before it is counted. A derivation the reader would discard is reported as
70
+ // discarded, never as a row this script "would fix".
71
+ // 5. Every stamped row carries `meta.backfill`, and the ids are written beside the
72
+ // SQL, so the rollback names exactly the rows this pass touched.
73
+ //
74
+ // USAGE
75
+ // bun .claude/scripts/signal-meta-backfill.ts # dry run, every workspace
76
+ // bun .claude/scripts/signal-meta-backfill.ts --slug one # one workspace
77
+ // bun .claude/scripts/signal-meta-backfill.ts --limit 500 --max 2000
78
+ // bun .claude/scripts/signal-meta-backfill.ts --out .backfill --chunk 400
79
+ // bun .claude/scripts/signal-meta-backfill.ts --slug one --apply # runs the generated SQL
80
+ // …add --local to work against the miniflare copy instead of production.
81
+
82
+ import { spawnSync } from 'node:child_process'
83
+ import { mkdirSync, writeFileSync } from 'node:fs'
84
+ import { resolve } from 'node:path'
85
+ import { normalizeTid, parseTaskAnnounce } from '../../one.ie/web/src/lib/chat/task-refs.ts'
86
+ import { classifyMessage } from '../../one.ie/web/src/lib/chat/message-shapes.ts'
87
+ import { readSignalMeta, type SignalMeta } from '../../one.ie/web/src/lib/signal-meta.ts'
88
+ import { looksSessionPosted } from '../../one.ie/web/src/lib/in/speaker.ts'
89
+ import { taskIdOf } from '../../one.ie/web/src/lib/in/thread-name.ts'
90
+
91
+ const DB = 'one-owners'
92
+ const WEB = new URL('../../one.ie/web', import.meta.url).pathname
93
+ const STAMP = 'signal-meta-backfill@1'
94
+
95
+ const argv = process.argv.slice(2)
96
+ const flag = (name: string, fallback = ''): string => {
97
+ const i = argv.indexOf(`--${name}`)
98
+ return i >= 0 && argv[i + 1] && !argv[i + 1]!.startsWith('--') ? argv[i + 1]! : fallback
99
+ }
100
+ const has = (name: string) => argv.includes(`--${name}`)
101
+
102
+ const slug = flag('slug')
103
+ const limit = Number(flag('limit', '500'))
104
+ const max = Number(flag('max', '0'))
105
+ const chunk = Number(flag('chunk', '400'))
106
+ const outDir = resolve(new URL('../..', import.meta.url).pathname, flag('out', '.backfill'))
107
+ const apply = has('apply')
108
+ const remote = !has('local')
109
+
110
+ if (!Number.isFinite(limit) || limit <= 0 || !Number.isFinite(max) || max < 0 || !Number.isFinite(chunk) || chunk <= 0) {
111
+ console.error('usage: bun .claude/scripts/signal-meta-backfill.ts [--slug <workspace>] [--limit N] [--max N] [--chunk N] [--out DIR] [--local] [--apply]')
112
+ process.exit(2)
113
+ }
114
+
115
+ const sq = (s: string) => `'${s.replace(/'/g, "''")}'`
116
+
117
+ function wrangler(mode: '--command' | '--file', value: string): Array<Record<string, unknown>> {
118
+ const args = ['wrangler', 'd1', 'execute', DB, remote ? '--remote' : '--local', '--json', mode, value]
119
+ const r = spawnSync('bunx', args, { cwd: WEB, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 })
120
+ if (r.status !== 0) {
121
+ // A failed READ must never read as "no rows to stamp" — that is the shape that
122
+ // turns an outage into a clean-looking pass. Stop instead.
123
+ console.error(r.stderr || r.stdout)
124
+ throw new Error(`d1 command failed (exit ${r.status})`)
125
+ }
126
+ const start = r.stdout.indexOf('[')
127
+ if (start < 0) return []
128
+ const parsed = JSON.parse(r.stdout.slice(start)) as Array<{ results?: Array<Record<string, unknown>> }>
129
+ return parsed[0]?.results ?? []
130
+ }
131
+
132
+ // ── the evidence ─────────────────────────────────────────────────────────────
133
+ // Each rule answers one question: does this row carry a structure that only a
134
+ // machine writes? Ordered most-specific first; the first match wins and the rest
135
+ // are not consulted, so a bare announce is never also counted as prose.
136
+
137
+ interface Row {
138
+ id: string
139
+ ws: string
140
+ key: string | null
141
+ content: string
142
+ }
143
+
144
+ interface Stored {
145
+ text: string
146
+ tags?: string[]
147
+ sender?: string
148
+ authorKind?: string
149
+ meta?: unknown
150
+ }
151
+
152
+ interface Verdict {
153
+ rule: string
154
+ meta: SignalMeta & { backfill: string }
155
+ }
156
+
157
+ /** The dotted kind vocabulary `notifyActor` already writes — one word per shape. */
158
+ function envelope(kind: string, source: string, rest: Partial<SignalMeta> = {}): SignalMeta & { backfill: string } {
159
+ return { v: 1, kind, source, machine: true, ...rest, backfill: STAMP }
160
+ }
161
+
162
+ /**
163
+ * `[<kind>] <text>` — `notifyActor` (resolvers/messaging.ts:411) prefixes every
164
+ * notify whose kind is not the bare word `message`, and it is reachable ONLY from a
165
+ * server call site, never from a composer. The kind is dotted there
166
+ * (`kind.replace(/[:\-]+/g, '.')`) and dotted here, so a backfilled notify and a
167
+ * live one land in one vocabulary. The bracket must open the body and hold one
168
+ * lowercase word — a prose sentence that happens to contain a bracket is not this.
169
+ */
170
+ const NOTIFY_LEAD = /^\[([a-z][a-z0-9:_-]{1,40})\]\s/
171
+
172
+ /** `/do close — <slug>` — written by do-signal.sh:348 through the comment door. */
173
+ const DO_CLOSE = /^\/do\s+(close|cycle|wave)\b/
174
+
175
+ /** Bare words only — a namespaced tag matches zero subscribers, so it is dropped. */
176
+ const bare = (tags: readonly string[]) => tags.filter((t) => !t.includes(':'))
177
+
178
+ function verdictFor(row: Row, stored: Stored): Verdict | null {
179
+ const body = stored.text.trim()
180
+ // Both key shapes collapse to one tid, and the subject carries the SAME prefixed
181
+ // form `parseTaskAnnounce` produces — two rules naming one task two ways is the
182
+ // drift this file is trying not to add.
183
+ const tid = row.key && taskIdOf(row.key) ? normalizeTid(row.key) : ''
184
+
185
+ // The remote-origin marker sits on the ROW, not in the prose. It is the LAST rule
186
+ // rather than the first, and that ordering is the whole finding of the first dry
187
+ // run: it fired on 93 rows in `ehc` that are bare announces, and the envelope it
188
+ // wrote carried no subject, no title and no tags. A row's origin is the weakest
189
+ // thing you can say about it — say it only when nothing stronger is true.
190
+ const originOnly = stored.tags?.some((t) => t.startsWith('untrusted'))
191
+ ? { rule: 'untrusted-tag', meta: envelope('remote', `backfill:${STAMP}`, { ...(bare(stored.tags ?? []).length ? { tags: bare(stored.tags ?? []) } : {}) }) }
192
+ : null
193
+
194
+ if (!body) return originOnly
195
+
196
+ // The announce door's own output: a tag-path header and nothing but `k: v` lines.
197
+ // `parseTaskAnnounce` returns null for anything with a line of prose in it, so
198
+ // this cannot fire on a person quoting an announce.
199
+ const announce = parseTaskAnnounce(body)
200
+ if (announce) {
201
+ return {
202
+ rule: 'task-announce',
203
+ meta: envelope('task.announce', 'tasks:announce', {
204
+ subject: { type: 'task', id: announce.tid, ...(announce.title ? { title: announce.title } : {}) },
205
+ ...(bare(announce.tags).length ? { tags: bare(announce.tags) } : {}),
206
+ }),
207
+ }
208
+ }
209
+
210
+ const shape = classifyMessage(body)
211
+ if (shape.kind === 'triage') {
212
+ return {
213
+ rule: 'triage-receipt',
214
+ meta: envelope('triage', `backfill:${STAMP}`, {
215
+ ...(tid ? { subject: { type: 'task' as const, id: tid, ...(shape.to ? { title: shape.to } : {}) } } : {}),
216
+ }),
217
+ }
218
+ }
219
+ if (shape.kind === 'status') {
220
+ return {
221
+ rule: 'status-post',
222
+ meta: envelope('status', `backfill:${STAMP}`, {
223
+ actor: { id: shape.who, kind: 'agent' },
224
+ ...(tid ? { subject: { type: 'task' as const, id: tid } } : {}),
225
+ }),
226
+ }
227
+ }
228
+
229
+ const notify = body.match(NOTIFY_LEAD)
230
+ if (notify) {
231
+ return { rule: 'notify-line', meta: envelope(notify[1].replace(/[:_-]+/g, '.'), 'notify') }
232
+ }
233
+
234
+ if (DO_CLOSE.test(body)) {
235
+ return {
236
+ rule: 'do-close',
237
+ meta: envelope('close', `backfill:${STAMP}`, { ...(tid ? { subject: { type: 'task' as const, id: tid } } : {}) }),
238
+ }
239
+ }
240
+
241
+ // Nothing in the body matched. The row's own origin marker is all that is left,
242
+ // and where there is not one either this is the abstention the file exists for.
243
+ return originOnly
244
+ }
245
+
246
+ // ── the read ─────────────────────────────────────────────────────────────────
247
+
248
+ const where = slug ? `WHERE t.slug = ${sq(slug)}` : ''
249
+ const rows: Row[] = []
250
+ for (let offset = 0; ; offset += limit) {
251
+ const page = wrangler('--command',
252
+ `SELECT m.id AS id, t.slug AS ws, t.agent_id AS key, m.content_json AS content
253
+ FROM messages m JOIN threads t ON t.id = m.thread_id
254
+ ${where}
255
+ ORDER BY m.ts DESC LIMIT ${limit} OFFSET ${offset}`,
256
+ )
257
+ for (const r of page) {
258
+ rows.push({
259
+ id: String(r['id'] ?? ''),
260
+ ws: String(r['ws'] ?? ''),
261
+ key: r['key'] === null || r['key'] === undefined ? null : String(r['key']),
262
+ content: String(r['content'] ?? ''),
263
+ })
264
+ }
265
+ if (page.length < limit) break
266
+ if (max && rows.length >= max) break
267
+ }
268
+
269
+ // ── the classification ───────────────────────────────────────────────────────
270
+
271
+ interface Planned extends Verdict {
272
+ row: Row
273
+ body: string
274
+ }
275
+
276
+ const planned: Planned[] = []
277
+ const byRule = new Map<string, number>()
278
+ const byWorkspace = new Map<string, { read: number; stamped: number }>()
279
+ let alreadyMeta = 0
280
+ let notAnObject = 0
281
+ let discardedByReader = 0
282
+ let abstained = 0
283
+ // Comparison only, never a decision: how the prose heuristic would have read the
284
+ // same rows. Reported so the gap between "stated" and "guessed" is a number.
285
+ let heuristicSaysMachine = 0
286
+ let contestedCohort = 0
287
+ /** The only cohort worth reading by hand: rows this script claims and the prose rule does not. */
288
+ const disputed: Array<{ id: string; ws: string; rule: string; body: string }> = []
289
+
290
+ for (const row of rows) {
291
+ const ws = byWorkspace.get(row.ws) ?? { read: 0, stamped: 0 }
292
+ ws.read++
293
+ byWorkspace.set(row.ws, ws)
294
+
295
+ let stored: Stored | null = null
296
+ try {
297
+ const parsed = JSON.parse(row.content) as unknown
298
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'text' in (parsed as Record<string, unknown>)) {
299
+ const o = parsed as Record<string, unknown>
300
+ stored = {
301
+ text: String(o['text'] ?? ''),
302
+ ...(Array.isArray(o['tags']) ? { tags: (o['tags'] as unknown[]).map(String) } : {}),
303
+ ...(typeof o['sender'] === 'string' ? { sender: o['sender'] } : {}),
304
+ ...(typeof o['authorKind'] === 'string' ? { authorKind: o['authorKind'] } : {}),
305
+ meta: o['meta'],
306
+ }
307
+ }
308
+ } catch {
309
+ stored = null
310
+ }
311
+ // An array-of-parts or a bare string row has no object to put `meta` on — the
312
+ // reader looks for `content_json.meta` and nowhere else, so writing one here
313
+ // would change the row's shape as well as its content. Left alone.
314
+ if (!stored) { notAnObject++; continue }
315
+ if (readSignalMeta(stored.meta)) { alreadyMeta++; continue }
316
+
317
+ const contested = stored.authorKind === 'staff'
318
+ if (contested) {
319
+ contestedCohort++
320
+ if (looksSessionPosted(stored.text)) heuristicSaysMachine++
321
+ }
322
+
323
+ const verdict = verdictFor(row, stored)
324
+ if (!verdict) { abstained++; continue }
325
+ // THE READER IS THE JUDGE. A derivation `readSignalMeta` discards is not a row
326
+ // this script would fix — it is a row it would write meta into that nothing reads.
327
+ if (!readSignalMeta(verdict.meta)) { discardedByReader++; continue }
328
+
329
+ planned.push({ ...verdict, row, body: stored.text })
330
+ byRule.set(verdict.rule, (byRule.get(verdict.rule) ?? 0) + 1)
331
+ ws.stamped++
332
+ if (contested && !looksSessionPosted(stored.text)) disputed.push({ id: row.id, ws: row.ws, rule: verdict.rule, body: stored.text })
333
+ }
334
+
335
+ // ── report ───────────────────────────────────────────────────────────────────
336
+
337
+ const one = (s: string, n = 84) => {
338
+ const t = s.replace(/\s+/g, ' ').trim()
339
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t
340
+ }
341
+
342
+ console.log(`${apply ? 'APPLY' : 'DRY RUN'} · ${slug ? `workspace ${slug}` : 'every workspace'} · ${remote ? 'remote' : 'local'} · page ${limit}${max ? ` · max ${max}` : ''}`)
343
+ console.log(`rows read: ${rows.length}`)
344
+ console.log(` already carry a readable envelope: ${alreadyMeta} (never touched)`)
345
+ console.log(` content_json is not an object: ${notAnObject} (no place to put one)`)
346
+ console.log(` matched no machine shape: ${abstained} (ABSTAINED — no meta, never machine:false)`)
347
+ console.log(` derived but the reader discards: ${discardedByReader}`)
348
+ console.log(` WOULD STAMP: ${planned.length}`)
349
+
350
+ console.log('\n── by rule ──')
351
+ for (const [rule, n] of [...byRule.entries()].sort((a, b) => b[1] - a[1])) console.log(` ${rule.padEnd(18)} ${n}`)
352
+
353
+ console.log('\n── by workspace ──')
354
+ for (const [ws, n] of [...byWorkspace.entries()].sort((a, b) => b[1].stamped - a[1].stamped)) {
355
+ if (!n.stamped && !slug) continue
356
+ console.log(` ${ws.padEnd(16)} read ${String(n.read).padStart(5)} would stamp ${String(n.stamped).padStart(5)}`)
357
+ }
358
+
359
+ console.log('\n── against the heuristic this replaces (comparison only — it decides nothing) ──')
360
+ console.log(` authorKind staff rows read: ${contestedCohort}`)
361
+ console.log(` …looksSessionPosted would call MACHINE: ${heuristicSaysMachine}`)
362
+ console.log(` …this script states machine:true: ${[...byWorkspace.values()].reduce((a, b) => a + b.stamped, 0)} (all rows, not only staff)`)
363
+ console.log(` staff rows stamped where the heuristic says HUMAN: ${disputed.length}`)
364
+ console.log(' (the last line is the only risky cohort: a row this script claims and the prose rule does not.')
365
+ console.log(' every one of them is printed below, in full-ish, so the decision is made on the rows and not on a count.)')
366
+ for (const d of disputed) console.log(` ${d.id} ${d.ws.padEnd(12)} [${d.rule}] ${d.body.replace(/\s+/g, ' ').trim().slice(0, 180)}`)
367
+
368
+ console.log('\n── sample: before → after (5 per rule) ──')
369
+ for (const rule of byRule.keys()) {
370
+ const sample = planned.filter((p) => p.rule === rule).slice(0, 5)
371
+ console.log(`\n [${rule}]`)
372
+ for (const p of sample) {
373
+ console.log(` ${p.row.id} ${p.row.ws}`)
374
+ console.log(` body : ${one(p.body)}`)
375
+ console.log(` meta : ${one(JSON.stringify(p.meta), 160)}`)
376
+ }
377
+ }
378
+
379
+ // ── generate the SQL ─────────────────────────────────────────────────────────
380
+ // ALWAYS, dry run included. The operator decides by reading statements, not a
381
+ // description of statements.
382
+
383
+ /**
384
+ * One statement per row. `json_set` PATCHES the row as it stands at write time —
385
+ * it never carries the body back, so a message edited between the read and the
386
+ * apply keeps its edit. The two guards live in the WHERE clause, which is what
387
+ * makes re-running the file a no-op and a row a live writer stamped first untouched.
388
+ */
389
+ const statementFor = (p: Planned) =>
390
+ `UPDATE messages SET content_json = json_set(content_json, '$.meta', json(${sq(JSON.stringify(p.meta))}))\n` +
391
+ ` WHERE id = ${sq(p.row.id)} AND json_type(content_json) = 'object' AND json_extract(content_json, '$.meta') IS NULL;`
392
+
393
+ const scope = slug || 'all'
394
+ mkdirSync(outDir, { recursive: true })
395
+ const files: string[] = []
396
+ for (let i = 0; i < planned.length; i += chunk) {
397
+ const slice = planned.slice(i, i + chunk)
398
+ const path = `${outDir}/signal-meta-${scope}-${String(i / chunk + 1).padStart(3, '0')}.sql`
399
+ writeFileSync(
400
+ path,
401
+ `-- signal-meta-backfill ${STAMP} · ${scope} · rows ${i + 1}-${i + slice.length} of ${planned.length}\n` +
402
+ `-- Re-runnable: every statement carries its own guard. Rollback predicate:\n` +
403
+ `-- json_extract(content_json, '$.meta.backfill') = '${STAMP}'\n\n` +
404
+ slice.map(statementFor).join('\n'),
405
+ 'utf8',
406
+ )
407
+ files.push(path)
408
+ }
409
+ // The id list beside the SQL, so a rollback names the rows this pass touched
410
+ // instead of re-deriving them from a rule that may have moved since.
411
+ //
412
+ // A RUN THAT PLANS NOTHING WRITES NOTHING HERE. Measured 2026-09-13, minutes after
413
+ // the real apply: re-running the same command to PROVE idempotence planned 0 rows,
414
+ // and this line truncated the rollback list of the 195 rows the first run had just
415
+ // stamped — the no-op proof destroyed the undo for the work it was proving. The
416
+ // guards held in the database and the evidence died on disk, which is the worse half
417
+ // to lose. An empty plan now leaves the file exactly as it is.
418
+ const idsPath = `${outDir}/signal-meta-${scope}.ids.txt`
419
+ if (planned.length) writeFileSync(idsPath, `${planned.map((p) => `${p.row.id}\t${p.row.ws}\t${p.rule}`).join('\n')}\n`, 'utf8')
420
+
421
+ console.log(`\n── generated SQL (${planned.length} statements, ${files.length} file(s) of ≤${chunk}) ──`)
422
+ for (const f of files) console.log(` ${f}`)
423
+ console.log(
424
+ planned.length
425
+ ? ` ${idsPath} (id, workspace, rule — the rollback's row list)`
426
+ : ` nothing to do — ${idsPath} and any SQL already in ${outDir} were LEFT AS THEY WERE (an earlier pass's rollback list is not this run's to overwrite)`,
427
+ )
428
+ if (planned.length) {
429
+ console.log('\n first statement:')
430
+ for (const line of statementFor(planned[0]).split('\n')) console.log(` ${one(line, 200)}`)
431
+ }
432
+
433
+ if (!apply) {
434
+ console.log('\nDRY RUN — nothing was written to the database. Read the SQL above, then re-run with --apply.')
435
+ console.log(`Rollback after an apply: the rows are listed in ${idsPath}, and every one carries`)
436
+ console.log(` json_extract(content_json, '$.meta.backfill') = '${STAMP}' — remove with`)
437
+ console.log(` UPDATE messages SET content_json = json_remove(content_json, '$.meta')`)
438
+ console.log(` WHERE json_extract(content_json, '$.meta.backfill') = '${STAMP}';`)
439
+ process.exit(0)
440
+ }
441
+
442
+ // ── write ────────────────────────────────────────────────────────────────────
443
+ // One round trip per FILE, not per row. The per-row form measured ~15-30 minutes
444
+ // of sequential remote calls for this corpus, and every minute of it was a window
445
+ // in which a concurrent writer could change a row read in an earlier page.
446
+
447
+ for (const f of files) {
448
+ wrangler('--file', f)
449
+ console.log(` ran ${f}`)
450
+ }
451
+ console.log(`\nran ${files.length} file(s), ${planned.length} guarded statement(s). Rollback list: ${idsPath}`)