Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
5a78199
fix: a malformed request no longer reaches the socket
Harted Sep 1, 2026
7e7fcb9
refactor: shared no longer reaches into main for a type
Harted Sep 1, 2026
e0ce773
refactor: one side per IPC call, where it was actually two
Harted Sep 1, 2026
94d9304
refactor: five components move into their own folder
Harted Sep 1, 2026
58f1cff
refactor: AddRegister splits into its four subjects
Harted Sep 1, 2026
2ea0b9e
refactor: submitRegister finds its home
Harted Sep 1, 2026
aa734de
fix: the loose ends this branch left behind
Harted Sep 1, 2026
a9f0439
chore: a CLAUDE.md and the prose skill
Harted Sep 1, 2026
736fbd6
chore: fire /prose from a write
Harted Sep 1, 2026
9bca511
chore: the precommit checklist, with this project's commands
Harted Sep 1, 2026
18ec984
chore: the test skill, and what it caught
Harted Sep 1, 2026
d3bbe42
chore: ignore TODO.md
Harted Sep 1, 2026
918afdb
chore: the prose rule fires every time, and reaches a commit message
Harted Sep 1, 2026
cb93ed2
chore: the todo skill, and where a thing lands here
Harted Sep 1, 2026
5e7db13
refactor: every component is wrapped in meme
Harted Sep 1, 2026
509da5d
refactor: the local stores answer to one name
Harted Sep 1, 2026
5eb0b6e
refactor: MUI comes in by the door it lives behind
Harted Sep 1, 2026
ed8200c
chore: the paper cuts
Harted Sep 1, 2026
4891e2f
chore: scanningUniId is scanningUnitIds
Harted Sep 1, 2026
51f6573
test: the conventions, as assertions
Harted Sep 1, 2026
8f3cebf
docs: the conventions, in the file a contributor opens
Harted Sep 1, 2026
24285f8
feat: every channel that carries an object is guarded
Harted Sep 1, 2026
3e42b3f
fix: the conventions doc said things the suite did not
Harted Sep 1, 2026
38a6b80
refactor: the app version leaves the client store
Harted Sep 2, 2026
57ffee9
refactor: the root store is the client store
Harted Sep 2, 2026
41b9b69
refactor: the key carry belongs with the migrations
Harted Sep 2, 2026
9ee17c1
refactor: a store in a variable says which store
Harted Sep 2, 2026
a4c901a
chore: the audit skill, and what it is allowed to claim
Harted Sep 2, 2026
2135d36
chore: the audit skill learns to group
Harted Sep 2, 2026
02712a0
fix: a corrupt config no longer takes the window with it
Harted Sep 2, 2026
edd8ed7
chore: the audit skill pins the heading, not just the fields
Harted Sep 2, 2026
c9ed18c
chore: the handover skill, in this repo's own terms
Harted Sep 2, 2026
b4e6b22
docs: the audit reference names areas, not the files they wrote
Harted Sep 2, 2026
e8f0bc3
test: a store selector may not hand back an action
Harted Sep 2, 2026
456297d
refactor: fetch store actions where they run
Harted Sep 2, 2026
f5d1558
test: the same rule from the other side, and it is red
Harted Sep 2, 2026
508b221
refactor: a prop takes the handler's name, not the call
Harted Sep 2, 2026
d4d3476
fix: answer only for the unit ids the server hosts
Harted Sep 2, 2026
c90ba80
refactor: a pass-through prop takes the action, not a wrapper around it
Harted Sep 2, 2026
1014a10
docs: an unreleased section for what the server answers
Harted Sep 2, 2026
4f55ae6
fix: one answer for how wide a register is
Harted Sep 2, 2026
c6340dc
chore: the hooks fire on the write, not on the tool it went through
Harted Sep 2, 2026
dba1285
docs: cut the narration out of CONTRIBUTING
Harted Sep 2, 2026
23b1b63
test: a hook exits 0 or it is broken, for every one the settings wire
Harted Sep 2, 2026
8ca2b10
fix: a config that does not parse loses only the field that failed
Harted Sep 3, 2026
f339d93
fix: keep the listener the server already has
Harted Sep 3, 2026
4281bf3
fix: the coil dialog opens with what the device answered
Harted Sep 3, 2026
e7bf6dc
fix: no write leaves the dialog with an empty value field
Harted Sep 3, 2026
895f112
fix: the write dialog reads the data type off the address it opened
Harted Sep 3, 2026
1c04e18
fix: clear the read error between address groups
Harted Sep 3, 2026
13718eb
fix: re-register the client handlers on the client that replaces a hu…
Harted Sep 3, 2026
373442c
fix: remove only the logged transaction, not the whole table
Harted Sep 3, 2026
6478553
fix: an undefined field no longer overwrites the stored config
Harted Sep 3, 2026
51bd5a6
refactor: drop the two ipc channels nothing calls
Harted Sep 3, 2026
996a880
test: an eleventh conformance rule, every channel has a caller
Harted Sep 3, 2026
1df004e
fix: a register value for an address that is gone is dropped
Harted Sep 3, 2026
caa9695
chore: turn on noUncheckedIndexedAccess
Harted Sep 3, 2026
3d328da
refactor: no non-null assertions, and names that say what they hold
Harted Sep 3, 2026
630c11c
fix: report the serial port closing under the RTU server
Harted Sep 3, 2026
e8004b4
docs: repair the conformance sentence in CLAUDE.md
Harted Sep 3, 2026
bede464
docs: take the counts out of the headings
Harted Sep 3, 2026
3ab1f11
fix: ask about port 502 in the window showing the server
Harted Sep 3, 2026
782850c
fix: let the edit dialog offer one thing at a time
Harted Sep 3, 2026
a954087
fix: open the edit dialog with its fields marked valid
Harted Sep 3, 2026
35ce1a8
test: assert the bits the bitmap panel actually shows
Harted Sep 3, 2026
b3716a4
fix: offer only the parity the serial binding accepts
Harted Sep 3, 2026
5fc739d
fix: put a server register only where it can be read back
Harted Sep 4, 2026
13c6684
fix: rename the keys a config carries, not the words in it
Harted Sep 4, 2026
3a59baf
fix: let go of a window handle when the window is destroyed
Harted Sep 4, 2026
2cc3613
fix: put the client's unit id and port on the ranges the protocol fixes
Harted Sep 4, 2026
3902235
fix: tell a new window what the client is already doing
Harted Sep 4, 2026
407f9dc
test: arm the window listener before the click that opens it
Harted Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions .claude/hooks/__tests__/guards.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* The three Bash guards, both directions.
*
* A matcher narrowed to kill a false positive is how the false negatives get
* made, so every case names what must fire and what must not.
*/
import { describe, it, expect } from 'vitest'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'

const HOOKS = dirname(fileURLToPath(import.meta.url))
const hook = (name) => join(HOOKS, '..', `${name}.mjs`)

const fire = (name, toolInput, extra = {}) => {
const payload = JSON.stringify({
session_id: `test-${Math.random()}`,
...extra,
...(toolInput ? { tool_input: toolInput } : {})
})
const out = execFileSync('node', [hook(name)], { input: payload, encoding: 'utf8' })
return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : ''
}

describe('precommit-trigger', () => {
it('fires on the whole-project commands the checklist names', () => {
for (const command of [
'yarn lint',
'yarn typecheck',
'yarn test',
'yarn verify',
'yarn test:e2e',
'yarn test:all:mac'
]) {
expect(fire('precommit-trigger', { command }), command).not.toBe('')
}
})

it('fires when one follows another command', () =>
expect(fire('precommit-trigger', { command: 'yarn lint && yarn typecheck' })).not.toBe(''))
it('fires with no whitespace before the separator', () =>
expect(fire('precommit-trigger', { command: 'yarn lint; echo done' })).not.toBe(''))

it('stays quiet on watch mode, which is how you check one change', () =>
expect(fire('precommit-trigger', { command: 'yarn test:watch' })).toBe(''))
it('stays quiet on a measurement rather than a check', () =>
expect(fire('precommit-trigger', { command: 'yarn test:e2e:scan-perf' })).toBe(''))
it('stays quiet on a single spec or file', () => {
expect(fire('precommit-trigger', { command: 'npx vitest run src/shared/a.test.ts' })).toBe('')
expect(fire('precommit-trigger', { command: 'npx playwright test e2e/a.spec.ts' })).toBe('')
})
it('stays quiet on prose quoting the command', () =>
expect(fire('precommit-trigger', { command: "grep -rn 'yarn lint' CONTRIBUTING.md" })).toBe(''))

it('states the rule once, then asks the question', () => {
const session = `same-${Math.random()}`
const again = (command) => {
const payload = JSON.stringify({ session_id: session, tool_input: { command } })
const out = execFileSync('node', [hook('precommit-trigger')], {
input: payload,
encoding: 'utf8'
})
return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : ''
}
expect(again('yarn lint')).toContain('start at step 1')
expect(again('yarn test')).toBe('precommit: finishing work, or checking one change?')
})
})

describe('bulk-edit-guard', () => {
it('fires on an in-place sed', () =>
expect(fire('bulk-edit-guard', { command: "sed -i '' 's/a/b/' src/a.ts" })).not.toBe(''))
it('fires on an in-place perl', () =>
expect(fire('bulk-edit-guard', { command: "perl -i -pe 's/a/b/' src/a.ts" })).not.toBe(''))
it('fires after a separator', () =>
expect(fire('bulk-edit-guard', { command: "yarn lint && sed -i.bak 's/a/b/' a.ts" })).not.toBe(
''
))
it('stays quiet on a sed that only reads', () =>
expect(fire('bulk-edit-guard', { command: "sed -n '1,20p' src/a.ts" })).toBe(''))
it('stays quiet on a heredoc, which the prose trigger owns', () =>
expect(fire('bulk-edit-guard', { command: "python3 - <<'PY'\nprint(1)\nPY" })).toBe(''))
})

describe('git-restore-guard', () => {
/** A repo with one unstaged change, so the guard has something to warn about. */
const dirtyRepo = () => {
const dir = mkdtempSync(join(tmpdir(), 'guard-'))
const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
git('init', '-q')
git('config', 'user.email', 'a@b.c')
git('config', 'user.name', 'test')
writeFileSync(join(dir, 'a.ts'), 'const x = 1\n')
git('add', 'a.ts')
git('commit', '-qm', 'first')
writeFileSync(join(dir, 'a.ts'), 'const x = 2\n')
return dir
}

it('fires on a checkout naming a path that exists', () => {
const cwd = dirtyRepo()
expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd })).toContain('a.ts')
})
it('fires on git restore', () => {
const cwd = dirtyRepo()
expect(fire('git-restore-guard', { command: 'git restore a.ts' }, { cwd })).toContain(
'git restore'
)
})
it('fires on any git stash', () => {
const cwd = dirtyRepo()
expect(fire('git-restore-guard', { command: 'git stash' }, { cwd })).toContain('stash pop')
})
it('stays quiet on a branch switch, which carries the work along', () => {
const cwd = dirtyRepo()
expect(fire('git-restore-guard', { command: 'git checkout main' }, { cwd })).toBe('')
})
it('stays quiet on checkout -b', () => {
const cwd = dirtyRepo()
expect(fire('git-restore-guard', { command: 'git checkout -b feature/x' }, { cwd })).toBe('')
})
it('stays quiet on checkout -b whose branch name is also a file', () => {
// Only the -b exclusion separates this from a restore: the path check sees
// a name that exists and would say yes.
const cwd = dirtyRepo()
expect(fire('git-restore-guard', { command: 'git checkout -b a.ts' }, { cwd })).toBe('')
})
it('stays quiet on --staged alone, the undo of git add', () => {
const cwd = dirtyRepo()
expect(fire('git-restore-guard', { command: 'git restore --staged a.ts' }, { cwd })).toBe('')
})
it('stays quiet when nothing is unstaged', () => {
const dir = mkdtempSync(join(tmpdir(), 'guard-clean-'))
const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
git('init', '-q')
git('config', 'user.email', 'a@b.c')
git('config', 'user.name', 'test')
writeFileSync(join(dir, 'a.ts'), 'const x = 1\n')
git('add', 'a.ts')
git('commit', '-qm', 'first')
expect(fire('git-restore-guard', { command: 'git checkout -- a.ts' }, { cwd: dir })).toBe('')
})
})
117 changes: 117 additions & 0 deletions .claude/hooks/__tests__/prose-trigger.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Both directions, in one run.
*
* A matcher narrowed to kill a false positive is how the false negatives get
* made, so every case here names what must fire and what must not. The payloads
* are built rather than typed: a hand-escaped one in a shell went through as
* unparseable, and the hook read that as nothing to say — which looks exactly
* like a matcher declining.
*/
import { describe, it, expect } from 'vitest'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'

const HOOK = join(dirname(fileURLToPath(import.meta.url)), '..', 'prose-trigger.mjs')

/** What the hook says, or '' when it declined. Throws when it exits non-zero. */
const fire = (toolInput, sessionId = `test-${Math.random()}`) => {
const payload = JSON.stringify({ session_id: sessionId, ...(toolInput ? { tool_input: toolInput } : {}) })
const out = execFileSync('node', [HOOK], { input: payload, encoding: 'utf8' })
return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : ''
}

describe('prose-trigger fires on every write and edit', () => {
it('a markdown write', () => expect(fire({ file_path: 'a.md', content: 'x' })).not.toBe(''))
it('an edit adding a line comment', () =>
expect(fire({ file_path: 'a.ts', new_string: ' // why\nconst x = 1' })).not.toBe(''))
it('an edit adding a block comment', () =>
expect(fire({ file_path: 'a.tsx', new_string: '/* why */\nconst y = 2' })).not.toBe(''))

// The classification these three used to fail is what let a refactor's worth
// of comments through: an edit that reads as code today carries a comment in
// the next call, and the hook has no way to know which is which.
it('code with no comment in it', () =>
expect(fire({ file_path: 'a.ts', new_string: 'const x = 1' })).not.toBe(''))
it('a file that is neither markdown nor source', () =>
expect(fire({ file_path: 'a.json', content: '{"a":1}' })).not.toBe(''))
it('a write that names a file and no content at all', () =>
expect(fire({ file_path: 'a.ts' })).not.toBe(''))
})

describe('prose-trigger stays quiet on', () => {
it('a payload with no tool_input', () => expect(fire(null)).toBe(''))
it('a tool that names no file and runs no command', () =>
expect(fire({ pattern: 'foo', path: 'src' })).toBe(''))
it('an empty file path', () => expect(fire({ file_path: '' })).toBe(''))
})

describe('prose-trigger reaches an edit made through Bash', () => {
// Every one of these wrote a TypeScript comment during the C1 refactor and
// the hook said nothing, because the text sat in the command rather than in
// content or new_string.
it('fires on a heredoc', () =>
expect(fire({ command: "python3 - <<'PYEOF'\nprint(1)\nPYEOF" })).not.toBe(''))
it('fires on an unquoted heredoc', () =>
expect(fire({ command: 'cat > a.ts <<EOF\nx\nEOF' })).not.toBe(''))
it('fires on an in-place sed', () =>
expect(fire({ command: "sed -i '' 's/a/b/' src/a.ts" })).not.toBe(''))
it('fires on tee', () => expect(fire({ command: 'echo x | tee src/a.ts' })).not.toBe(''))
it('fires on a redirect into a file', () =>
expect(fire({ command: 'echo x > src/a.ts' })).not.toBe(''))

it('stays quiet on a command that only reads', () => {
expect(fire({ command: 'yarn test' })).toBe('')
expect(fire({ command: "grep -rn 'utf8' src/" })).toBe('')
expect(fire({ command: 'git status --porcelain' })).toBe('')
})
it('stays quiet on output thrown away', () =>
expect(fire({ command: 'yarn lint > /dev/null 2>&1' })).toBe(''))
it('stays quiet on a `>` that is not a redirect', () => {
// The anchor before the `>` is what separates these from a write.
expect(fire({ command: "awk 'NF>4 { print }' src/a.ts" })).toBe('')
expect(fire({ command: "grep -n '\\-\\->' src/a.ts" })).toBe('')
})
it('stays quiet on a pipe, which writes no file', () =>
expect(fire({ command: 'yarn test 2>&1 | tail -5' })).toBe(''))
})

describe('prose-trigger says the whole rule', () => {
it('every time, in the same session', () => {
const session = `same-${Math.random()}`
const first = fire({ file_path: 'a.md', content: 'x' }, session)
const second = fire({ file_path: 'b.md', content: 'y' }, session)
expect(second).toBe(first)
expect(first).toContain('claim, an order, or a measurement')
})
})

describe('prose-trigger reaches a commit message', () => {
it('fires on git commit, which is written through Bash and not a Write', () =>
expect(fire({ command: 'git commit -F -' })).not.toBe(''))
it('fires on git merge for the same reason', () =>
expect(fire({ command: 'git merge --no-ff feature' })).not.toBe(''))
it('stays quiet on other git commands', () => {
expect(fire({ command: 'git status --porcelain' })).toBe('')
expect(fire({ command: 'git diff --staged' })).toBe('')
expect(fire({ command: 'git log --oneline -5' })).toBe('')
})
it('fires when a commit follows another command', () =>
expect(fire({ command: 'yarn test && git commit -F -' })).not.toBe(''))
// A command that mentions a commit and writes nothing is what keeps this
// matcher honest. A heredoc mentioning one used to be here too, and now
// fires as the write it is.
it('stays quiet on a command that merely mentions the word', () => {
expect(fire({ command: "grep -rn 'commit' docs/" })).toBe('')
expect(fire({ command: "rg 'git commit' .claude/" })).toBe('')
})
})

describe('prose-trigger never interrupts', () => {
it('exits 0 on unparseable stdin', () => {
expect(execFileSync('node', [HOOK], { input: 'not json', encoding: 'utf8' })).toBe('')
})
it('exits 0 on the JSON null that reaches the try and not the catch', () => {
expect(execFileSync('node', [HOOK], { input: 'null', encoding: 'utf8' })).toBe('')
})
})
74 changes: 74 additions & 0 deletions .claude/hooks/__tests__/test-trigger.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Both directions, in one run.
*
* A matcher narrowed to kill a false positive is how the false negatives get
* made, so every case here names what must fire and what must not.
*/
import { describe, it, expect } from 'vitest'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'

const HOOK = join(dirname(fileURLToPath(import.meta.url)), '..', 'test-trigger.mjs')

/** What the hook says, or '' when it declined. Throws when it exits non-zero. */
const fire = (toolInput, sessionId = `test-${Math.random()}`) => {
const payload = JSON.stringify({
session_id: sessionId,
...(toolInput ? { tool_input: toolInput } : {})
})
const out = execFileSync('node', [HOOK], { input: payload, encoding: 'utf8' })
return out.trim() ? JSON.parse(out).hookSpecificOutput.additionalContext : ''
}

describe('test-trigger fires on a test written through Write or Edit', () => {
it('a unit test by its path', () =>
expect(fire({ file_path: 'src/shared/__tests__/utils.test.ts', content: 'x' })).not.toBe(''))
it('an e2e spec by its path', () =>
expect(fire({ file_path: 'e2e/specs/01-main/01-home.spec.ts', content: 'x' })).not.toBe(''))
it('a source file that grows a describe', () =>
expect(fire({ file_path: 'src/a.ts', new_string: "describe('x', () => {})" })).not.toBe(''))
it('a source file that grows an it', () =>
expect(fire({ file_path: 'src/a.tsx', new_string: " it('does', async () => {})" })).not.toBe(
''
))
})

describe('test-trigger stays quiet on', () => {
it('source with no test call in it', () =>
expect(fire({ file_path: 'src/a.ts', new_string: 'const x = 1' })).toBe(''))
it('a markdown file that talks about tests', () =>
expect(fire({ file_path: 'CONTRIBUTING.md', content: "describe('x', () => {})" })).toBe(''))
it('a payload with no tool_input', () => expect(fire(null)).toBe(''))
it('a command that only runs the suite', () =>
expect(fire({ command: 'npx vitest run src/shared/__tests__/utils.test.ts' })).toBe(''))
it('a command that only reads a spec', () =>
expect(fire({ command: 'cat e2e/specs/01-main/01-home.spec.ts' })).toBe(''))
})

describe('test-trigger reaches a test written through Bash', () => {
it('fires on a heredoc naming a test path', () =>
expect(
fire({ command: "cat > src/shared/__tests__/a.test.ts <<'EOF'\nx\nEOF" })
).not.toBe(''))
it('fires on a heredoc carrying a test call', () =>
expect(fire({ command: "python3 - <<'PY'\ns = \"it('works', () => {})\"\nPY" })).not.toBe(''))
it('stays quiet on a heredoc that writes neither', () =>
expect(fire({ command: "python3 - <<'PY'\nprint(1)\nPY" })).toBe(''))
})

describe('test-trigger states the rule once, then asks', () => {
it('gives the whole rule first and the questions after', () => {
const session = `same-${Math.random()}`
const first = fire({ file_path: 'a.test.ts', content: 'x' }, session)
const second = fire({ file_path: 'b.test.ts', content: 'y' }, session)
expect(first).toContain('A test you have not seen fail proves nothing')
expect(second).toContain('seen it fail')
expect(second).not.toBe(first)
})
})

describe('test-trigger never interrupts', () => {
it('exits 0 on unparseable stdin', () =>
expect(execFileSync('node', [HOOK], { input: 'not json', encoding: 'utf8' })).toBe(''))
})
Loading