@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/abm-strategist.md +67 -1
- package/agents/ads-meta.md +67 -1
- package/agents/analyst.md +67 -1
- package/agents/animator.md +108 -0
- package/agents/architect.md +269 -20
- package/agents/brand-guardian.md +67 -1
- package/agents/brand-strategist.md +67 -1
- package/agents/campaign-content.md +67 -1
- package/agents/campaign-email.md +67 -1
- package/agents/campaign-sms.md +67 -1
- package/agents/campaign-social.md +67 -1
- package/agents/cco.md +83 -2
- package/agents/ceo.md +108 -11
- package/agents/chairman.md +197 -0
- package/agents/cmo.md +82 -2
- package/agents/community-greeter.md +67 -1
- package/agents/community-moderator.md +67 -1
- package/agents/compliance.md +67 -1
- package/agents/copywriter.md +67 -1
- package/agents/creative-strategist.md +67 -1
- package/agents/cro.md +81 -1
- package/agents/cto.md +266 -28
- package/agents/customer-interviewer.md +67 -1
- package/agents/customer-researcher.md +67 -1
- package/agents/customer-success-manager.md +67 -1
- package/agents/customer-trainer.md +67 -1
- package/agents/cxo.md +82 -1
- package/agents/demand-creator.md +67 -1
- package/agents/demo-mover.md +67 -1
- package/agents/demo-specialist.md +67 -1
- package/agents/demo-thai-family-law.md +67 -1
- package/agents/designer.md +67 -1
- package/agents/discovery-caller.md +67 -1
- package/agents/doctor.md +269 -0
- package/agents/educate-coach.md +67 -1
- package/agents/elevate-tutor.md +67 -1
- package/agents/email-lifecycle-marketer.md +67 -1
- package/agents/engage-specialist.md +67 -1
- package/agents/events-coordinator.md +67 -1
- package/agents/foundation-builder.md +67 -1
- package/agents/funnel-architect.md +67 -1
- package/agents/gift-creator.md +67 -1
- package/agents/google-ads.md +67 -1
- package/agents/guide.md +67 -1
- package/agents/helpdesk-dispatcher.md +67 -1
- package/agents/hook-specialist.md +67 -1
- package/agents/identify-optimizer.md +67 -1
- package/agents/implementer.md +313 -45
- package/agents/incident-commander.md +67 -1
- package/agents/insights-lead.md +87 -1
- package/agents/journey-runner.md +67 -1
- package/agents/linkedin-ads.md +67 -1
- package/agents/live-sales-chat.md +67 -1
- package/agents/market-researcher.md +67 -1
- package/agents/media-buyer.md +67 -1
- package/agents/memory-keeper.md +195 -0
- package/agents/movers-customer-researcher.md +67 -1
- package/agents/movers-foundation-builder.md +67 -1
- package/agents/movers-market-researcher.md +67 -1
- package/agents/movers-pricing-strategist.md +67 -1
- package/agents/nurture-architect.md +67 -1
- package/agents/offer-architect.md +67 -1
- package/agents/onboarder.md +67 -1
- package/agents/onboarding-specialist.md +67 -1
- package/agents/operations-dashboard.md +87 -1
- package/agents/perf-engineer.md +333 -37
- package/agents/playbook-writer.md +67 -1
- package/agents/plg-strategist.md +67 -1
- package/agents/positioning-architect.md +67 -1
- package/agents/press-officer.md +67 -1
- package/agents/pricing-strategist.md +67 -1
- package/agents/privacy-officer.md +67 -1
- package/agents/referral-manager.md +67 -1
- package/agents/refine-analyst.md +67 -1
- package/agents/release-manager.md +446 -39
- package/agents/renewals-upsell-rep.md +67 -1
- package/agents/review-engineer.md +319 -45
- package/agents/rewards-steward.md +67 -1
- package/agents/sales-call-coach.md +67 -1
- package/agents/sales-closer.md +67 -1
- package/agents/security-auditor.md +343 -48
- package/agents/sell-closer.md +67 -1
- package/agents/share-amplifier.md +67 -1
- package/agents/social-media-manager.md +67 -1
- package/agents/storyteller.md +301 -0
- package/agents/strategist.md +67 -1
- package/agents/strategy-aligner.md +67 -1
- package/agents/support-agent.md +67 -1
- package/agents/tagger.md +327 -0
- package/agents/tech-writer.md +195 -22
- package/agents/test-engineer.md +398 -29
- package/agents/tiktok-ads.md +67 -1
- package/agents/tracking-engineer.md +67 -1
- package/agents/trailkeeper.md +181 -0
- package/agents/upsell-strategist.md +67 -1
- package/agents/voice.md +67 -1
- package/agents/w1-recon.md +1 -1
- package/agents/w2-decide.md +1 -1
- package/agents/w3-edit.md +8 -2
- package/agents/w4-verify.md +13 -0
- package/agents/workflow-optimiser.md +81 -1
- package/commands/close.md +916 -160
- package/commands/deploy.md +102 -724
- package/commands/do.md +58 -2
- package/commands/sweep.md +159 -0
- package/commands/tasks.md +222 -0
- package/hooks/scripts/dev-only.sh +135 -0
- package/hooks/scripts/git-add-guard.sh +37 -2
- package/hooks/scripts/session-start.sh +32 -4
- package/package.json +1 -1
- package/rules/scripts.md +85 -0
- package/scripts/CLAUDE.md +315 -0
- package/scripts/ad-copy-lint.sh +656 -0
- package/scripts/agent-actor-parity.sh +129 -0
- package/scripts/blocks-manifest-cached.sh +100 -0
- package/scripts/chat-context-check.sh +89 -0
- package/scripts/chrome.mjs +18 -0
- package/scripts/close-metrics.sh +587 -0
- package/scripts/close-owner.sh +326 -0
- package/scripts/db-sync-lock-check.sh +116 -0
- package/scripts/deploy-emit.sh +311 -0
- package/scripts/deploy-gate-check.sh +155 -0
- package/scripts/deploy-ready.sh +78 -0
- package/scripts/deploy-record.sh +605 -0
- package/scripts/deploy-schema-check.sh +58 -0
- package/scripts/deploy.sh +393 -243
- package/scripts/do-auto.sh +127 -26
- package/scripts/do-board.sh +429 -0
- package/scripts/do-close.sh +1184 -0
- package/scripts/do-consumer-sweep.sh +18 -1
- package/scripts/do-decide.sh +476 -0
- package/scripts/do-fleet.sh +8 -2
- package/scripts/do-plan-json.mjs +110 -12
- package/scripts/do-prove-selftest.sh +108 -0
- package/scripts/do-prove.sh +86 -10
- package/scripts/do-rank.py +200 -3
- package/scripts/do-reconcile.sh +73 -12
- package/scripts/do-signal.sh +101 -23
- package/scripts/do-smoke.sh +18 -1
- package/scripts/do-w4-gates.sh +11 -1
- package/scripts/do-world-check.sh +153 -0
- package/scripts/download-stats.sh +172 -0
- package/scripts/factory-brief-check.sh +330 -0
- package/scripts/factory-check.sh +18 -1
- package/scripts/factory-close-check.sh +257 -0
- package/scripts/factory-emit.sh +211 -0
- package/scripts/factory-executor-check.mjs +353 -0
- package/scripts/factory-peak.sh +301 -0
- package/scripts/factory-repo.sh +71 -0
- package/scripts/factory-review-check.mjs +61 -0
- package/scripts/factory-tasks-check.sh +18 -1
- package/scripts/fixtures/factory-brief-real.md +44 -0
- package/scripts/flywheel-outcome.sh +63 -0
- package/scripts/gate-reaper-check.sh +98 -0
- package/scripts/gate-reaper.sh +9 -0
- package/scripts/gate-watchdog.sh +619 -0
- package/scripts/gc-content-check.sh +142 -0
- package/scripts/gh-traffic-capture.sh +153 -0
- package/scripts/govern-order-check.sh +202 -0
- package/scripts/governor-doors-check.sh +86 -5
- package/scripts/health.sh +448 -0
- package/scripts/id-inventory.mjs +418 -0
- package/scripts/incident.sh +212 -0
- package/scripts/land.sh +755 -45
- package/scripts/lib/gc-finished.sh +77 -0
- package/scripts/livekit-ratchet.sh +18 -1
- package/scripts/machine-check.sh +1 -1
- package/scripts/memory-index-budget.sh +79 -0
- package/scripts/npm-downloads.sh +109 -0
- package/scripts/one-agents.mjs +204 -8
- package/scripts/one-resume.sh +31 -3
- package/scripts/pr-body.sh +335 -0
- package/scripts/preview-fd-check.sh +289 -0
- package/scripts/redirect-lint.sh +169 -0
- package/scripts/release.sh +40 -6
- package/scripts/resume-lost-sessions.sh +68 -0
- package/scripts/shoot-pages.mjs +140 -0
- package/scripts/signal-meta-backfill.ts +451 -0
- package/scripts/signal-watch.sh +63 -6
- package/scripts/speed-cache-check.sh +12 -2
- package/scripts/sweep.sh +426 -0
- package/scripts/task-titles-dump.ts +101 -0
- package/scripts/test-cached.sh +47 -10
- package/scripts/test-lanes.sh +14 -0
- package/scripts/thread-name-backfill.ts +215 -0
- package/scripts/triage-shape-check.sh +149 -0
- package/scripts/tsc-cached.sh +155 -8
- package/scripts/typedb-flake-check.sh +3 -1
- package/scripts/urls-lint.sh +8 -0
- package/scripts/verify-board-doors.sh +80 -0
- package/scripts/verify-fast.sh +159 -6
- package/scripts/worktree-up.sh +21 -3
- package/skills/astro/SKILL.md +9 -3
- package/skills/astro/optimize-performance.md +3 -2
- package/skills/cloudflare/SKILL.md +3 -2
- package/skills/cloudflare-security-audit/AI-AND-LLM.md +83 -0
- package/skills/cloudflare-security-audit/ATTACK-CLASSES.md +130 -0
- package/skills/cloudflare-security-audit/CLIENT-SIDE.md +83 -0
- package/skills/cloudflare-security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
- package/skills/cloudflare-security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
- package/skills/cloudflare-security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
- package/skills/cloudflare-security-audit/HUNTING.md +251 -0
- package/skills/cloudflare-security-audit/LICENSE +21 -0
- package/skills/cloudflare-security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
- package/skills/cloudflare-security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
- package/skills/cloudflare-security-audit/PROVENANCE.md +78 -0
- package/skills/cloudflare-security-audit/RECONNAISSANCE.md +156 -0
- package/skills/cloudflare-security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
- package/skills/cloudflare-security-audit/SKILL.md +192 -0
- package/skills/cloudflare-security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
- package/skills/cloudflare-security-audit/VALIDATION-AND-REPORTING.md +186 -0
- package/skills/cloudflare-security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
- package/skills/cloudflare-security-audit/report-schema.json +461 -0
- package/skills/cloudflare-security-audit/validate-coverage-ledger.cjs +872 -0
- package/skills/cloudflare-security-audit/validate-coverage-ledger.test.cjs +740 -0
- package/skills/cloudflare-security-audit/validate-findings.cjs +773 -0
- package/skills/cloudflare-security-audit/validate-findings.test.cjs +652 -0
- package/skills/deploy/REFERENCE.md +713 -0
- package/skills/deploy/SKILL.md +140 -0
- package/skills/fleet-audit/SKILL.md +58 -0
- package/skills/meeting/SKILL.md +220 -0
- package/skills/planning/SKILL.md +256 -0
- package/skills/shadcn/SKILL.md +1 -1
- package/skills/typedb/SKILL.md +7 -0
- package/skills/voice/SKILL.md +94 -6
- package/skills/voice/corpus-check.sh +87 -0
- package/templates/template-agent.md +7 -1
- package/templates/template-feature.md +9 -0
- 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}`)
|