@polderlabs/bizar 10.7.1 → 10.7.2
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/agents/_shared/AGENT_BASELINE.md +266 -0
- package/.claude/agents/_shared/CLAUDE_TOOLS.md +412 -0
- package/.claude/agents/_shared/SKILLS.md +109 -0
- package/.claude/agents/brand-designer.md +55 -0
- package/.claude/agents/exec-assistant.md +34 -0
- package/.claude/agents/help-desk.md +44 -0
- package/.claude/agents/it-lead.md +53 -0
- package/.claude/agents/knowledge-manager.md +49 -0
- package/.claude/agents/office-coordinator.md +39 -0
- package/.claude/agents/office-greeter.md +53 -0
- package/.claude/agents/office-manager.md +287 -0
- package/.claude/agents/principal-engineer.md +58 -0
- package/.claude/agents/qa-reviewer.md +51 -0
- package/.claude/agents/research-analyst.md +53 -0
- package/.claude/agents/senior-engineer.md +55 -0
- package/.claude/agents/support-tech.md +87 -0
- package/.claude/agents/vp-engineering.md +54 -0
- package/.claude/commands/audit.md +48 -0
- package/.claude/commands/bizar.md +22 -0
- package/.claude/commands/cron.md +36 -0
- package/.claude/commands/explain.md +17 -0
- package/.claude/commands/goal.md +99 -0
- package/.claude/commands/init.md +15 -0
- package/.claude/commands/learn.md +46 -0
- package/.claude/commands/plan.md +35 -0
- package/.claude/commands/plow-through.md +50 -0
- package/.claude/commands/pr-review.md +49 -0
- package/.claude/commands/setup-provider.md +96 -0
- package/.claude/commands/spec.md +47 -0
- package/.claude/commands/sprint.md +43 -0
- package/.claude/commands/tailscale-serve.md +100 -0
- package/.claude/commands/team.md +132 -0
- package/.claude/commands/test.md +62 -0
- package/.claude/commands/validate.md +68 -0
- package/.claude/commands/visual-plan.md +24 -0
- package/.claude/hooks/README.md +108 -0
- package/.claude/hooks/__tests__/pretooluse-editwrite.test.mjs +146 -0
- package/.claude/hooks/__tests__/sessionend-recall.test.mjs +256 -0
- package/.claude/hooks/__tests__/sessionstart-prime.test.mjs +325 -0
- package/.claude/hooks/__tests__/thinking-route.test.mjs +319 -0
- package/.claude/hooks/auto-instinct.sh +81 -0
- package/.claude/hooks/learning-extract.mjs +92 -0
- package/.claude/hooks/post-merge-audit.sh +93 -0
- package/.claude/hooks/posttooluse-editwrite.mjs +91 -0
- package/.claude/hooks/pretooluse-bash.mjs +87 -0
- package/.claude/hooks/pretooluse-editwrite.mjs +117 -0
- package/.claude/hooks/sessionend-recall.mjs +384 -0
- package/.claude/hooks/sessionstart-prime.mjs +278 -0
- package/.claude/hooks/thinking-route.mjs +314 -0
- package/.claude/hooks/worker-suggest.mjs +110 -0
- package/.claude/settings.json +167 -0
- package/.claude/skills/9router/SKILL.md +80 -0
- package/.claude/skills/9router-chat/SKILL.md +73 -0
- package/.claude/skills/9router-embeddings/SKILL.md +69 -0
- package/.claude/skills/9router-image/SKILL.md +86 -0
- package/.claude/skills/9router-stt/SKILL.md +79 -0
- package/.claude/skills/9router-tts/SKILL.md +80 -0
- package/.claude/skills/9router-web-fetch/SKILL.md +99 -0
- package/.claude/skills/9router-web-search/SKILL.md +91 -0
- package/.claude/skills/agent-browser/SKILL.md +64 -0
- package/.claude/skills/bizar/README.md +9 -0
- package/.claude/skills/bizar/SKILL.md +447 -0
- package/.claude/skills/cpp-coding-standards/README.md +28 -0
- package/.claude/skills/cpp-coding-standards/SKILL.md +634 -0
- package/.claude/skills/cpp-coding-standards/references/concurrency.md +320 -0
- package/.claude/skills/cpp-coding-standards/references/error-handling.md +229 -0
- package/.claude/skills/cpp-coding-standards/references/memory-safety.md +216 -0
- package/.claude/skills/cpp-coding-standards/references/modern-idioms.md +282 -0
- package/.claude/skills/cpp-coding-standards/references/review-checklist.md +96 -0
- package/.claude/skills/cpp-testing/README.md +28 -0
- package/.claude/skills/cpp-testing/SKILL.md +304 -0
- package/.claude/skills/cpp-testing/references/coverage.md +370 -0
- package/.claude/skills/cpp-testing/references/framework-compare.md +175 -0
- package/.claude/skills/cpp-testing/references/host-test-for-embedded.md +499 -0
- package/.claude/skills/cpp-testing/references/mocking.md +364 -0
- package/.claude/skills/cpp-testing/references/tdd-workflow.md +308 -0
- package/.claude/skills/cubesandbox/SKILL.md +148 -0
- package/.claude/skills/de-sloppify/SKILL.md +38 -0
- package/.claude/skills/de-sloppify/cleanup.mjs +253 -0
- package/.claude/skills/de-sloppify/cleanup.test.mjs +189 -0
- package/.claude/skills/embedded-esp-idf/README.md +41 -0
- package/.claude/skills/embedded-esp-idf/SKILL.md +439 -0
- package/.claude/skills/embedded-esp-idf/references/freertos-patterns.md +214 -0
- package/.claude/skills/embedded-esp-idf/references/host-tests.md +164 -0
- package/.claude/skills/embedded-esp-idf/references/idf-py-commands.md +157 -0
- package/.claude/skills/embedded-esp-idf/references/kconfig.md +159 -0
- package/.claude/skills/embedded-esp-idf/references/logging-discipline.md +118 -0
- package/.claude/skills/embedded-esp-idf/references/memory-and-iram.md +137 -0
- package/.claude/skills/embedded-esp-idf/references/nvs.md +121 -0
- package/.claude/skills/embedded-esp-idf/references/packed-structs.md +192 -0
- package/.claude/skills/embedded-esp-idf/scripts/idf_env.sh +47 -0
- package/.claude/skills/embedded-esp-idf/scripts/size_check.sh +77 -0
- package/.claude/skills/glyph/SKILL.md +163 -0
- package/.claude/skills/harness-engineering/SKILL.md +142 -0
- package/.claude/skills/lightrag/SKILL.md +81 -0
- package/.claude/skills/memory-protocol/SKILL.md +105 -0
- package/.claude/skills/obsidian/SKILL.md +306 -0
- package/.claude/skills/read-the-damn-docs/SKILL.md +113 -0
- package/.claude/skills/self-improvement/SKILL.md +64 -0
- package/.claude/skills/skillopt/SKILL.md +129 -0
- package/.claude/skills/thinking-archetypes/SKILL.md +90 -0
- package/.claude/skills/thinking-bayesian/SKILL.md +267 -0
- package/.claude/skills/thinking-bounded-rationality/SKILL.md +406 -0
- package/.claude/skills/thinking-circle-of-competence/SKILL.md +216 -0
- package/.claude/skills/thinking-cynefin/SKILL.md +70 -0
- package/.claude/skills/thinking-debiasing/SKILL.md +192 -0
- package/.claude/skills/thinking-dual-process/SKILL.md +282 -0
- package/.claude/skills/thinking-effectuation/SKILL.md +366 -0
- package/.claude/skills/thinking-feedback-loops/SKILL.md +464 -0
- package/.claude/skills/thinking-fermi-estimation/SKILL.md +263 -0
- package/.claude/skills/thinking-first-principles/SKILL.md +167 -0
- package/.claude/skills/thinking-five-whys-plus/SKILL.md +139 -0
- package/.claude/skills/thinking-inversion/SKILL.md +195 -0
- package/.claude/skills/thinking-jobs-to-be-done/SKILL.md +363 -0
- package/.claude/skills/thinking-kepner-tregoe/SKILL.md +154 -0
- package/.claude/skills/thinking-leverage-points/SKILL.md +390 -0
- package/.claude/skills/thinking-lindy-effect/SKILL.md +331 -0
- package/.claude/skills/thinking-map-territory/SKILL.md +111 -0
- package/.claude/skills/thinking-margin-of-safety/SKILL.md +330 -0
- package/.claude/skills/thinking-model-combination/SKILL.md +406 -0
- package/.claude/skills/thinking-model-router/SKILL.md +360 -0
- package/.claude/skills/thinking-model-selection/SKILL.md +341 -0
- package/.claude/skills/thinking-occams-razor/SKILL.md +129 -0
- package/.claude/skills/thinking-ooda/SKILL.md +127 -0
- package/.claude/skills/thinking-opportunity-cost/SKILL.md +360 -0
- package/.claude/skills/thinking-pre-mortem/SKILL.md +170 -0
- package/.claude/skills/thinking-probabilistic/SKILL.md +324 -0
- package/.claude/skills/thinking-red-team/SKILL.md +142 -0
- package/.claude/skills/thinking-regret-minimization/SKILL.md +335 -0
- package/.claude/skills/thinking-reversibility/SKILL.md +326 -0
- package/.claude/skills/thinking-scientific-method/SKILL.md +162 -0
- package/.claude/skills/thinking-second-order/SKILL.md +184 -0
- package/.claude/skills/thinking-socratic/SKILL.md +198 -0
- package/.claude/skills/thinking-steel-manning/SKILL.md +332 -0
- package/.claude/skills/thinking-systems/SKILL.md +238 -0
- package/.claude/skills/thinking-theory-of-constraints/SKILL.md +338 -0
- package/.claude/skills/thinking-thought-experiment/SKILL.md +354 -0
- package/.claude/skills/thinking-triz/SKILL.md +171 -0
- package/.claude/skills/thinking-via-negativa/SKILL.md +358 -0
- package/cli/install/postinstall.mjs +54 -28
- package/cli/install/postinstall.test.mjs +98 -0
- package/package.json +7 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* .claude/hooks/__tests__/sessionend-recall.test.mjs
|
|
4
|
+
*
|
|
5
|
+
* Unit tests for sessionend-recall.mjs — SessionEnd hook that writes
|
|
6
|
+
* .bizar/sessions/<date>-<id>.md and .bizar/session-state.json from a
|
|
7
|
+
* synthesized transcript.
|
|
8
|
+
*
|
|
9
|
+
* Strategy: spawn the hook binary with a synthetic transcript on disk;
|
|
10
|
+
* assert files are written correctly.
|
|
11
|
+
*/
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
import test from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const HOOK_PATH = join(__dirname, '..', 'sessionend-recall.mjs');
|
|
24
|
+
|
|
25
|
+
function runHook(inputJson, cwd) {
|
|
26
|
+
const r = spawnSync('node', [HOOK_PATH], {
|
|
27
|
+
input: JSON.stringify(inputJson),
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
cwd: cwd || process.cwd(),
|
|
30
|
+
timeout: 8000,
|
|
31
|
+
});
|
|
32
|
+
return { status: r.status, stdout: r.stdout.trim(), stderr: r.stderr.trim() };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeProject({ withActiveFeature = true } = {}) {
|
|
36
|
+
const dir = join(tmpdir(), `bh-recall-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
mkdirSync(join(dir, '.bizar'), { recursive: true });
|
|
39
|
+
if (withActiveFeature) {
|
|
40
|
+
writeFileSync(
|
|
41
|
+
join(dir, 'feature_list.json'),
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
features: [
|
|
44
|
+
{ id: 'F-103', state: 'active', behavior: 'rewrite sessionstart-prime' },
|
|
45
|
+
],
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return dir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeTranscript(events) {
|
|
53
|
+
const path = join(tmpdir(), `bh-transcript-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jsonl`);
|
|
54
|
+
writeFileSync(path, events.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
55
|
+
return path;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readSessionState(dir) {
|
|
59
|
+
const p = join(dir, '.bizar', 'session-state.json');
|
|
60
|
+
if (!existsSync(p)) return null;
|
|
61
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readSessionNote(dir) {
|
|
65
|
+
const p = join(dir, '.bizar', 'sessions');
|
|
66
|
+
if (!existsSync(p)) return null;
|
|
67
|
+
const files = readdirSync(p).filter((f) => f.endsWith('.md'));
|
|
68
|
+
if (files.length === 0) return null;
|
|
69
|
+
const newest = files.sort().slice(-1)[0];
|
|
70
|
+
return { name: newest, body: readFileSync(join(p, newest), 'utf8') };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
test('SessionEnd: writes session note + state from transcript', () => {
|
|
76
|
+
const dir = makeProject();
|
|
77
|
+
const transcript = makeTranscript([
|
|
78
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:00Z', message: { role: 'user', content: 'implement F-103 hook overhaul' } },
|
|
79
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:05Z', message: { role: 'assistant', content: [
|
|
80
|
+
{ type: 'tool_use', name: 'Write', input: { file_path: '/home/drb0rk/.claude/hooks/sessionstart-prime.mjs', content: 'x' } },
|
|
81
|
+
]}},
|
|
82
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:10Z', message: { role: 'user', content: 'continue' } },
|
|
83
|
+
]);
|
|
84
|
+
try {
|
|
85
|
+
const { status } = runHook({
|
|
86
|
+
session_id: 'abc123def456789',
|
|
87
|
+
reason: 'exit',
|
|
88
|
+
cwd: dir,
|
|
89
|
+
transcript_path: transcript,
|
|
90
|
+
});
|
|
91
|
+
assert.equal(status, 0);
|
|
92
|
+
const state = readSessionState(dir);
|
|
93
|
+
assert.ok(state);
|
|
94
|
+
assert.equal(state.lastSessionId, 'abc123def456789');
|
|
95
|
+
assert.equal(state.reason, 'exit');
|
|
96
|
+
assert.equal(state.activeFeature, 'F-103');
|
|
97
|
+
assert.match(state.nextStep, /implement F-103 hook overhaul/);
|
|
98
|
+
const note = readSessionNote(dir);
|
|
99
|
+
assert.ok(note);
|
|
100
|
+
assert.match(note.name, /\d{4}-\d{2}-\d{2}-abc123de\.md/);
|
|
101
|
+
assert.match(note.body, /activeFeature: F-103/);
|
|
102
|
+
assert.match(note.body, /implement F-103 hook overhaul/);
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('SessionEnd: extracts bash commands and tools used', () => {
|
|
109
|
+
const dir = makeProject();
|
|
110
|
+
const transcript = makeTranscript([
|
|
111
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:00Z', message: { role: 'user', content: 'fix the bug' } },
|
|
112
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:05Z', message: { role: 'assistant', content: [
|
|
113
|
+
{ type: 'tool_use', name: 'Bash', input: { command: 'ls /missing' } },
|
|
114
|
+
{ type: 'tool_use', name: 'Edit', input: { file_path: '/tmp/foo.mjs' } },
|
|
115
|
+
{ type: 'tool_use', name: 'Bash', input: { command: 'make check' } },
|
|
116
|
+
]}},
|
|
117
|
+
]);
|
|
118
|
+
try {
|
|
119
|
+
runHook({ session_id: 'xyz987', reason: 'exit', cwd: dir, transcript_path: transcript });
|
|
120
|
+
const state = readSessionState(dir);
|
|
121
|
+
assert.ok(state);
|
|
122
|
+
assert.equal(state.toolsUsed.Bash, 2);
|
|
123
|
+
assert.equal(state.toolsUsed.Edit, 1);
|
|
124
|
+
assert.ok(state.filesTouched.includes('/tmp/foo.mjs'));
|
|
125
|
+
} finally {
|
|
126
|
+
rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('SessionEnd: captures errors as blockers', () => {
|
|
131
|
+
const dir = makeProject();
|
|
132
|
+
const transcript = makeTranscript([
|
|
133
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:00Z', message: { role: 'user', content: 'fix the bug' } },
|
|
134
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:05Z', message: { role: 'assistant', content: [
|
|
135
|
+
{ type: 'tool_use', id: 'x1', name: 'Bash', input: { command: 'cat /missing' } },
|
|
136
|
+
]}},
|
|
137
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:06Z', message: { role: 'tool', content: [
|
|
138
|
+
{ type: 'tool_result', tool_use_id: 'x1', content: 'Error: ENOENT: no such file or directory' },
|
|
139
|
+
]}},
|
|
140
|
+
]);
|
|
141
|
+
try {
|
|
142
|
+
runHook({ session_id: 'err', reason: 'exit', cwd: dir, transcript_path: transcript });
|
|
143
|
+
const state = readSessionState(dir);
|
|
144
|
+
assert.ok(state);
|
|
145
|
+
assert.ok(state.blockers.some((b) => b.includes('ENOENT')));
|
|
146
|
+
assert.match(state.nextStep, /Resolve Error: ENOENT/);
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('SessionEnd: skips filler prompts when picking nextStep', () => {
|
|
153
|
+
const dir = makeProject();
|
|
154
|
+
const transcript = makeTranscript([
|
|
155
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:00Z', message: { role: 'user', content: 'rewire the SessionStart briefing' } },
|
|
156
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:05Z', message: { role: 'user', content: 'continue' } },
|
|
157
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:10Z', message: { role: 'user', content: 'yes' } },
|
|
158
|
+
{ type: 'message', timestamp: '2026-07-22T18:00:15Z', message: { role: 'user', content: 'go on' } },
|
|
159
|
+
]);
|
|
160
|
+
try {
|
|
161
|
+
runHook({ session_id: 'filer', reason: 'exit', cwd: dir, transcript_path: transcript });
|
|
162
|
+
const state = readSessionState(dir);
|
|
163
|
+
assert.ok(state);
|
|
164
|
+
assert.match(state.nextStep, /rewire the SessionStart briefing/);
|
|
165
|
+
assert.doesNotMatch(state.nextStep, /Resume: continue/);
|
|
166
|
+
assert.doesNotMatch(state.nextStep, /Resume: yes/);
|
|
167
|
+
} finally {
|
|
168
|
+
rmSync(dir, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('SessionEnd: zero active features surfaces "pick next"', () => {
|
|
173
|
+
const dir = makeProject({ withActiveFeature: false });
|
|
174
|
+
const transcript = makeTranscript([]); // empty transcript → no prompts
|
|
175
|
+
try {
|
|
176
|
+
runHook({ session_id: 'noact', reason: 'exit', cwd: dir, transcript_path: transcript });
|
|
177
|
+
const state = readSessionState(dir);
|
|
178
|
+
assert.ok(state);
|
|
179
|
+
assert.equal(state.activeFeature, null);
|
|
180
|
+
assert.match(state.nextStep, /Pick next feature/);
|
|
181
|
+
} finally {
|
|
182
|
+
rmSync(dir, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('SessionEnd: malformed JSONL lines are skipped, hook exits 0', () => {
|
|
187
|
+
const dir = makeProject();
|
|
188
|
+
const transcript = join(tmpdir(), `bh-bad-${Date.now()}.jsonl`);
|
|
189
|
+
writeFileSync(
|
|
190
|
+
transcript,
|
|
191
|
+
'not json\n{"type":"message","timestamp":"2026-07-22T18:00:00Z","message":{"role":"user","content":"real prompt"}}\nstill bad\n',
|
|
192
|
+
);
|
|
193
|
+
try {
|
|
194
|
+
const { status } = runHook({ session_id: 'junk', reason: 'exit', cwd: dir, transcript_path: transcript });
|
|
195
|
+
assert.equal(status, 0);
|
|
196
|
+
const state = readSessionState(dir);
|
|
197
|
+
assert.ok(state);
|
|
198
|
+
assert.match(state.nextStep, /real prompt/);
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(dir, { recursive: true, force: true });
|
|
201
|
+
19 }
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('SessionEnd: missing transcript_path is graceful, exits 0', () => {
|
|
205
|
+
const dir = makeProject();
|
|
206
|
+
try {
|
|
207
|
+
const { status } = runHook({ session_id: 'none', reason: 'exit', cwd: dir });
|
|
208
|
+
assert.equal(status, 0);
|
|
209
|
+
const state = readSessionState(dir);
|
|
210
|
+
assert.ok(state);
|
|
211
|
+
// No transcript means no user prompts, no errors.
|
|
212
|
+
assert.equal(state.filesTouched.length, 0);
|
|
213
|
+
assert.equal(state.blockers.length, 0);
|
|
214
|
+
// F-103 is active in the default fixture, so nextStep continues with it.
|
|
215
|
+
assert.match(state.nextStep, /Continue with F-103/);
|
|
216
|
+
} finally {
|
|
217
|
+
rmSync(dir, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('SessionEnd: invalid JSON on stdin exits 0 with empty state', () => {
|
|
222
|
+
const dir = makeProject();
|
|
223
|
+
try {
|
|
224
|
+
const r = spawnSync('node', [HOOK_PATH], {
|
|
225
|
+
input: 'not json',
|
|
226
|
+
encoding: 'utf8',
|
|
227
|
+
cwd: dir,
|
|
228
|
+
timeout: 8000,
|
|
229
|
+
});
|
|
230
|
+
assert.equal(r.status, 0);
|
|
231
|
+
} finally {
|
|
232
|
+
rmSync(dir, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('SessionEnd: handles transcript with 200+ lines (cap respected)', () => {
|
|
237
|
+
const dir = makeProject();
|
|
238
|
+
const events = [];
|
|
239
|
+
for (let i = 0; i < 250; i++) {
|
|
240
|
+
events.push({
|
|
241
|
+
type: 'message',
|
|
242
|
+
timestamp: `2026-07-22T18:${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}Z`,
|
|
243
|
+
message: { role: 'user', content: i === 249 ? 'final substantive prompt' : `prompt ${i}` },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const transcript = makeTranscript(events);
|
|
247
|
+
try {
|
|
248
|
+
const { status } = runHook({ session_id: 'long', reason: 'exit', cwd: dir, transcript_path: transcript });
|
|
249
|
+
assert.equal(status, 0);
|
|
250
|
+
const state = readSessionState(dir);
|
|
251
|
+
assert.ok(state);
|
|
252
|
+
assert.match(state.nextStep, /final substantive prompt|Resume: final substantive prompt/);
|
|
253
|
+
} finally {
|
|
254
|
+
rmSync(dir, { recursive: true, force: true });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* .claude/hooks/__tests__/sessionstart-prime.test.mjs
|
|
4
|
+
*
|
|
5
|
+
* Unit tests for sessionstart-prime.mjs — the SessionStart hook that reads
|
|
6
|
+
* PROGRESS.md + feature_list.json + git log + .bizar/PROJECT.md and emits a
|
|
7
|
+
* structured briefing.
|
|
8
|
+
*
|
|
9
|
+
* Strategy: spawn the hook binary as a subprocess, feed it stdin JSON, and
|
|
10
|
+
* assert on stdout JSON. This avoids drift between test and source — what
|
|
11
|
+
* the binary emits IS what the tests check.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
import test from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const HOOK_PATH = join(__dirname, '..', 'sessionstart-prime.mjs');
|
|
25
|
+
|
|
26
|
+
function runHook(inputJson, cwd) {
|
|
27
|
+
const r = spawnSync('node', [HOOK_PATH], {
|
|
28
|
+
input: JSON.stringify(inputJson),
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
cwd: cwd || process.cwd(),
|
|
31
|
+
timeout: 8000,
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
status: r.status,
|
|
35
|
+
stdout: r.stdout.trim(),
|
|
36
|
+
stderr: r.stderr.trim(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseStdout(stdout) {
|
|
41
|
+
if (!stdout) return null;
|
|
42
|
+
try { return JSON.parse(stdout); } catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function additionalContext(stdout) {
|
|
46
|
+
const obj = parseStdout(stdout);
|
|
47
|
+
if (!obj) return '';
|
|
48
|
+
return (obj.hookSpecificOutput && obj.hookSpecificOutput.additionalContext) || '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Fixtures ───────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function makeProject({ withProgress = true, withFeatureList = true, withProject = true } = {}) {
|
|
54
|
+
const dir = join(tmpdir(), `bh-prime-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
mkdirSync(join(dir, '.bizar'), { recursive: true });
|
|
57
|
+
if (withProject) {
|
|
58
|
+
writeFileSync(
|
|
59
|
+
join(dir, '.bizar', 'PROJECT.md'),
|
|
60
|
+
'# TestProject\nA test fixture for the SessionStart hook.\n',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (withProgress) {
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(dir, 'PROGRESS.md'),
|
|
66
|
+
[
|
|
67
|
+
'# PROGRESS',
|
|
68
|
+
'',
|
|
69
|
+
'## Current State',
|
|
70
|
+
'',
|
|
71
|
+
'Branch: master | Last: 3b4f6f7 | Status: green',
|
|
72
|
+
'',
|
|
73
|
+
'## In Progress — F-099 hook overhaul',
|
|
74
|
+
'',
|
|
75
|
+
'Sprint S46: rewriting sessionstart-prime.mjs to read project state.',
|
|
76
|
+
'',
|
|
77
|
+
'## Done',
|
|
78
|
+
'',
|
|
79
|
+
'- F-098..F-102 shipped.',
|
|
80
|
+
].join('\n'),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (withFeatureList) {
|
|
84
|
+
writeFileSync(
|
|
85
|
+
join(dir, 'feature_list.json'),
|
|
86
|
+
JSON.stringify({
|
|
87
|
+
features: [
|
|
88
|
+
{ id: 'F-100', state: 'passing', behavior: 'old feature' },
|
|
89
|
+
{ id: 'F-101', state: 'passing', behavior: 'old feature 2' },
|
|
90
|
+
{ id: 'F-103', state: 'active', behavior: 'rewrite sessionstart-prime' },
|
|
91
|
+
],
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// Initialize a git repo so `git log` works.
|
|
96
|
+
spawnSync('git', ['init', '-q'], { cwd: dir });
|
|
97
|
+
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
|
|
98
|
+
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
|
|
99
|
+
spawnSync('git', ['commit', '--allow-empty', '-q', '-m', 'init'], { cwd: dir });
|
|
100
|
+
return dir;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
test('SessionStart: startup source emits structured briefing', () => {
|
|
106
|
+
const dir = makeProject();
|
|
107
|
+
try {
|
|
108
|
+
const { stdout, status } = runHook({ source: 'startup', cwd: dir });
|
|
109
|
+
assert.equal(status, 0, `hook exit non-zero (stderr: see above)`);
|
|
110
|
+
const ctx = additionalContext(stdout);
|
|
111
|
+
assert.match(ctx, /Bizar SessionStart \(startup\)/);
|
|
112
|
+
assert.match(ctx, /A test fixture for the SessionStart hook/);
|
|
113
|
+
assert.match(ctx, /Active feature: F-103/);
|
|
114
|
+
assert.match(ctx, /rewrite sessionstart-prime/);
|
|
115
|
+
assert.match(ctx, /WIP=1 honored/);
|
|
116
|
+
assert.match(ctx, /First move:/);
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('SessionStart: clear source is lighter (no feature list, no git reread)', () => {
|
|
123
|
+
const dir = makeProject();
|
|
124
|
+
try {
|
|
125
|
+
const { stdout } = runHook({ source: 'clear', cwd: dir });
|
|
126
|
+
const ctx = additionalContext(stdout);
|
|
127
|
+
assert.match(ctx, /Bizar SessionStart \(clear\)/);
|
|
128
|
+
// clear branch doesn't include feature stats
|
|
129
|
+
assert.doesNotMatch(ctx, /Active feature:/);
|
|
130
|
+
// It DOES include PROGRESS.md current-state (last commit line + Progress: line)
|
|
131
|
+
assert.match(ctx, /Progress:|Last commit:/);
|
|
132
|
+
} finally {
|
|
133
|
+
rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('SessionStart: resume source reads session-state.json handoff', () => {
|
|
138
|
+
const dir = makeProject();
|
|
139
|
+
try {
|
|
140
|
+
const handoff = {
|
|
141
|
+
lastSessionId: 'prior-session-id',
|
|
142
|
+
lastSessionEnd: '2026-07-22T15:00:00Z',
|
|
143
|
+
reason: 'exit',
|
|
144
|
+
activeFeature: 'F-103',
|
|
145
|
+
nextStep: 'finish the briefing unit tests',
|
|
146
|
+
filesTouched: ['/foo/bar.mjs'],
|
|
147
|
+
blockers: ['Error: ENOENT'],
|
|
148
|
+
};
|
|
149
|
+
writeFileSync(join(dir, '.bizar', 'session-state.json'), JSON.stringify(handoff));
|
|
150
|
+
const { stdout } = runHook({ source: 'resume', cwd: dir });
|
|
151
|
+
const ctx = additionalContext(stdout);
|
|
152
|
+
assert.match(ctx, /Bizar SessionStart \(resume\)/);
|
|
153
|
+
assert.match(ctx, /Last active feature: F-103/);
|
|
154
|
+
assert.match(ctx, /finish the briefing unit tests/);
|
|
155
|
+
assert.match(ctx, /Open blockers: Error: ENOENT/);
|
|
156
|
+
assert.match(ctx, /WARNING: context may have been compacted/);
|
|
157
|
+
} finally {
|
|
158
|
+
rmSync(dir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('SessionStart: resume with no session-state.json is graceful', () => {
|
|
163
|
+
const dir = makeProject();
|
|
164
|
+
try {
|
|
165
|
+
const { stdout } = runHook({ source: 'resume', cwd: dir });
|
|
166
|
+
const ctx = additionalContext(stdout);
|
|
167
|
+
assert.match(ctx, /Bizar SessionStart \(resume\)/);
|
|
168
|
+
assert.match(ctx, /No prior session-state\.json found/);
|
|
169
|
+
} finally {
|
|
170
|
+
rmSync(dir, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('SessionStart: WIP>1 violation surfaces in briefing', () => {
|
|
175
|
+
const dir = makeProject();
|
|
176
|
+
try {
|
|
177
|
+
writeFileSync(
|
|
178
|
+
join(dir, 'feature_list.json'),
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
features: [
|
|
181
|
+
{ id: 'F-A', state: 'active', behavior: 'one' },
|
|
182
|
+
{ id: 'F-B', state: 'active', behavior: 'two' },
|
|
183
|
+
{ id: 'F-C', state: 'passing', behavior: 'three' },
|
|
184
|
+
],
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
const { stdout } = runHook({ source: 'startup', cwd: dir });
|
|
188
|
+
const ctx = additionalContext(stdout);
|
|
189
|
+
assert.match(ctx, /WIP VIOLATION/);
|
|
190
|
+
assert.match(ctx, /F-A/);
|
|
191
|
+
assert.match(ctx, /F-B/);
|
|
192
|
+
} finally {
|
|
193
|
+
rmSync(dir, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('SessionStart: zero active features surfaces "pick next"', () => {
|
|
198
|
+
const dir = makeProject();
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(
|
|
201
|
+
join(dir, 'feature_list.json'),
|
|
202
|
+
JSON.stringify({
|
|
203
|
+
features: [
|
|
204
|
+
{ id: 'F-100', state: 'passing', behavior: 'old' },
|
|
205
|
+
{ id: 'F-101', state: 'passing', behavior: 'old2' },
|
|
206
|
+
],
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
const { stdout } = runHook({ source: 'startup', cwd: dir });
|
|
210
|
+
const ctx = additionalContext(stdout);
|
|
211
|
+
assert.match(ctx, /2\/2 passing/);
|
|
212
|
+
assert.match(ctx, /No active feature/);
|
|
213
|
+
} finally {
|
|
214
|
+
rmSync(dir, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('SessionStart: missing PROGRESS.md is graceful', () => {
|
|
219
|
+
const dir = makeProject({ withProgress: false });
|
|
220
|
+
try {
|
|
221
|
+
const { stdout, status } = runHook({ source: 'startup', cwd: dir });
|
|
222
|
+
assert.equal(status, 0);
|
|
223
|
+
const ctx = additionalContext(stdout);
|
|
224
|
+
assert.match(ctx, /Bizar SessionStart \(startup\)/);
|
|
225
|
+
} finally {
|
|
226
|
+
rmSync(dir, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('SessionStart: missing feature_list.json is graceful', () => {
|
|
231
|
+
const dir = makeProject({ withFeatureList: false });
|
|
232
|
+
try {
|
|
233
|
+
const { stdout, status } = runHook({ source: 'startup', cwd: dir });
|
|
234
|
+
assert.equal(status, 0);
|
|
235
|
+
const ctx = additionalContext(stdout);
|
|
236
|
+
assert.match(ctx, /Bizar SessionStart \(startup\)/);
|
|
237
|
+
} finally {
|
|
238
|
+
rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('SessionStart: missing .bizar/PROJECT.md is graceful', () => {
|
|
243
|
+
const dir = makeProject({ withProject: false });
|
|
244
|
+
try {
|
|
245
|
+
const { stdout, status } = runHook({ source: 'startup', cwd: dir });
|
|
246
|
+
assert.equal(status, 0);
|
|
247
|
+
const ctx = additionalContext(stdout);
|
|
248
|
+
assert.match(ctx, /Bizar SessionStart \(startup\)/);
|
|
249
|
+
} finally {
|
|
250
|
+
rmSync(dir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('SessionStart: invalid JSON on stdin exits 0 with empty briefing', () => {
|
|
255
|
+
const dir = makeProject();
|
|
256
|
+
try {
|
|
257
|
+
const r = spawnSync('node', [HOOK_PATH], {
|
|
258
|
+
input: '{not valid json',
|
|
259
|
+
encoding: 'utf8',
|
|
260
|
+
cwd: dir,
|
|
261
|
+
timeout: 8000,
|
|
262
|
+
});
|
|
263
|
+
assert.equal(r.status, 0);
|
|
264
|
+
const obj = parseStdout(r.stdout);
|
|
265
|
+
assert.ok(obj);
|
|
266
|
+
assert.ok(obj.hookSpecificOutput);
|
|
267
|
+
} finally {
|
|
268
|
+
rmSync(dir, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('SessionStart: defaults source to startup when missing', () => {
|
|
273
|
+
const dir = makeProject();
|
|
274
|
+
try {
|
|
275
|
+
const { stdout } = runHook({ cwd: dir });
|
|
276
|
+
const ctx = additionalContext(stdout);
|
|
277
|
+
assert.match(ctx, /Bizar SessionStart \(startup\)/);
|
|
278
|
+
} finally {
|
|
279
|
+
rmSync(dir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('SessionStart: briefing is hard-capped at 800 characters', () => {
|
|
284
|
+
const dir = makeProject();
|
|
285
|
+
try {
|
|
286
|
+
// Bloat each component individually so the composed briefing exceeds 800 chars.
|
|
287
|
+
const giantProject = ['# BigProject', '', 'A '.repeat(200).trim(), ' that is the one-line summary.'].join('\n');
|
|
288
|
+
writeFileSync(join(dir, '.bizar', 'PROJECT.md'), giantProject);
|
|
289
|
+
const giantProgress = '## Current State\n\nBranch: master | ' + 'filler '.repeat(50) + '\n\n' +
|
|
290
|
+
'## In Progress — F-099\n\n' + 'body '.repeat(200);
|
|
291
|
+
writeFileSync(join(dir, 'PROGRESS.md'), giantProgress);
|
|
292
|
+
const manyActive = Array.from({ length: 50 }, (_, i) => ({
|
|
293
|
+
id: `F-${100 + i}`,
|
|
294
|
+
state: 'passing',
|
|
295
|
+
behavior: 'a passing feature with a long description that fills space',
|
|
296
|
+
}));
|
|
297
|
+
manyActive.push({ id: 'F-103', state: 'active', behavior: 'rewrite sessionstart-prime' });
|
|
298
|
+
writeFileSync(join(dir, 'feature_list.json'), JSON.stringify({ features: manyActive }));
|
|
299
|
+
// Make many commits to bloat `git log --oneline -10`.
|
|
300
|
+
for (let i = 0; i < 12; i++) {
|
|
301
|
+
spawnSync('git', ['commit', '--allow-empty', '-q', '-m', `commit ${'x'.repeat(60)} ${i}`], { cwd: dir });
|
|
302
|
+
}
|
|
303
|
+
const { stdout } = runHook({ source: 'startup', cwd: dir });
|
|
304
|
+
const ctx = additionalContext(stdout);
|
|
305
|
+
assert.ok(ctx.length <= 800, `briefing is ${ctx.length} chars, cap is 800`);
|
|
306
|
+
assert.match(ctx, /…$/);
|
|
307
|
+
} finally {
|
|
308
|
+
rmSync(dir, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('SessionStart: project_summary reads first non-heading line', () => {
|
|
313
|
+
const dir = makeProject();
|
|
314
|
+
try {
|
|
315
|
+
writeFileSync(
|
|
316
|
+
join(dir, '.bizar', 'PROJECT.md'),
|
|
317
|
+
'# MyProject\n\nThis is the one-line summary.\n\nOther stuff.\n',
|
|
318
|
+
);
|
|
319
|
+
const { stdout } = runHook({ source: 'startup', cwd: dir });
|
|
320
|
+
const ctx = additionalContext(stdout);
|
|
321
|
+
assert.match(ctx, /This is the one-line summary\./);
|
|
322
|
+
} finally {
|
|
323
|
+
rmSync(dir, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
});
|