@davvidbaker/flambe-cli 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -74,3 +74,56 @@ processes only need their normal local Flambe connection settings.
74
74
  In `agent` mode, your current work AI reads the review returned by `flambe message`
75
75
  and submits its decision with `flambe decide <review-id> --file <decision.json>`.
76
76
  No separate model or inference API key is required.
77
+
78
+ ## Local Codex / Single Burner hooks
79
+
80
+ With Single Burner running, put its printed connection values in the target
81
+ project's `.env`, including `FLAMBE_RUNTIME_MODE=local_self_contained`. From
82
+ that project root run:
83
+
84
+ ```sh
85
+ flambe setup codex
86
+ ```
87
+
88
+ This installs project-scoped Codex hooks and the bundled `flambe-cli` skill.
89
+ It preserves unrelated hooks, backs up an existing `hooks.json` once, and keeps
90
+ an existing custom skill rather than overwriting it. Restart local Codex, open
91
+ `/hooks`, and review/trust the new definitions. Hook loading remains subject to
92
+ project trust and workspace policy. No ChatGPT plugin, Platform organization,
93
+ tunnel, or model API key is needed for this local route.
94
+
95
+ Keep `flambe` available on PATH to the Codex process. Hooks use `flambe.cmd` on
96
+ Windows and `flambe` on macOS/Linux, with no machine-specific checkout paths.
97
+ If you installed the CLI while Codex was already open, restart the app so it can
98
+ see the updated PATH.
99
+
100
+ - Session start, resume, prompts, subagent start, and compaction restore context.
101
+ - Tool hooks use a small cache, refresh local status at most once per 30 seconds
102
+ during ordinary work, and request a fresh stack check every ten tool calls or
103
+ 90 seconds while tools continue. Explicit lifecycle checkpoints reset the
104
+ call counter; a timer does not run while the agent is idle.
105
+ - Stop requests at most one reconciliation continuation after substantial tool
106
+ work without a verified checkpoint. It does not automatically close parents.
107
+ - Interrupt records a pending local reconciliation reminder; it does not mutate
108
+ activities or prevent interruption.
109
+
110
+ The agent still names/nests activities, decides when work changed, and submits
111
+ requested reducer decisions. Hooks only send status requests to the configured
112
+ numeric-loopback server. They do not call a model, inspect transcripts, or
113
+ queue activity mutations. Missing configuration, an unavailable server, or an
114
+ unidentifiable agent produces bounded feedback and permits work to continue.
115
+ The local cache contains counters/fingerprints, not prompts, command text,
116
+ credentials, or activity names. See [ADR-023](../docs/ADR-023-portable-local-codex-hooks.md).
117
+
118
+ ```sh
119
+ flambe setup codex --check
120
+ flambe setup codex --remove
121
+ ```
122
+
123
+ `--check` inspects installed files and local connection configuration; it cannot
124
+ confirm a live server, Codex trust, or workspace policy.
125
+ `--remove` retains skills and unrelated hooks. For a live check, restart Codex,
126
+ confirm these hooks are trusted in `/hooks`, then ask it to track a short local
127
+ activity, request/submit an agent-mode review, and end or suspend the activity.
128
+ Verify that the chart and that agent's lane reflect the result. A fresh install
129
+ on the work laptop still needs this native Codex verification.
package/bin/flambe.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  import { run } from '../src/cli.mjs';
4
4
  import { loadProjectEnv } from '../src/env.mjs';
5
5
 
6
- loadProjectEnv();
6
+ // Hook input selects its installed project root; do not preload a nested .env.
7
+ if (process.argv[2] !== 'hook') loadProjectEnv();
7
8
 
8
9
  try {
9
10
  await run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davvidbaker/flambe-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "CLI for streaming coding-agent activity into Flambe",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,11 +9,14 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "src",
12
- "README.md"
12
+ "README.md",
13
+ "skills",
14
+ "scripts/prepare-skills.mjs"
13
15
  ],
14
16
  "scripts": {
15
17
  "test": "node --test",
16
- "prepublishOnly": "npm test"
18
+ "prepublishOnly": "npm test",
19
+ "prepack": "node scripts/prepare-skills.mjs"
17
20
  },
18
21
  "engines": {
19
22
  "node": ">=22"
@@ -0,0 +1,12 @@
1
+ import {copyFileSync, existsSync, mkdirSync} from 'node:fs';
2
+ import {fileURLToPath} from 'node:url';
3
+ import {dirname} from 'node:path';
4
+
5
+ const source = fileURLToPath(new URL('../../.codex/skills/flambe-cli/SKILL.md', import.meta.url));
6
+ const target = fileURLToPath(new URL('../skills/flambe-cli/SKILL.md', import.meta.url));
7
+ if (existsSync(source)) {
8
+ mkdirSync(dirname(target), {recursive: true});
9
+ copyFileSync(source, target);
10
+ } else if (!existsSync(target)) {
11
+ throw new Error('The CLI package is missing its canonical flambe-cli skill.');
12
+ }
@@ -0,0 +1,129 @@
1
+ ---
2
+ name: flambe-cli
3
+ description: Track coding-agent work in a Flambe trace with the flambe CLI. Use when logging substantial or multi-step work in a project configured with FLAMBE_URL, FLAMBE_API_TOKEN, and FLAMBE_TRACE_ID.
4
+ ---
5
+
6
+ # Flambe CLI
7
+
8
+ Log semantic work units as a live flame chart — not commands, file opens, or hidden reasoning. Skip trivial questions. If `FLAMBE_URL`, `FLAMBE_API_TOKEN`, or `FLAMBE_TRACE_ID` is set neither in `.env` nor in the shell environment (Cloud Agent secrets arrive as environment variables), skip silently.
9
+
10
+ Needs Node 22+ and `flambe` on `PATH`. If it is missing, `npm install -g @davvidbaker/flambe-cli`; in this repo without a global install, use `node cli/bin/flambe.mjs`. You do not need to pick a name: the reducer names each agent on its first request, the CLI remembers it (`~/.flambe/agent-names.json`) and prints `reducer named this agent "X"` on stderr once. `flambe whoami` shows your id, name, and platform. `FLAMBE_AGENT_PLATFORM` is the product you run on (`"Cursor Cloud"`, `"Codex"`; derived from the session when unset) and is shared by many agents; it is not your name. Set `FLAMBE_AGENT_NAME` only to override; never put `FLAMBE_AGENT_ID`, `FLAMBE_AGENT_NAME`, or `FLAMBE_AGENT_PLATFORM` in `.env`.
11
+
12
+ ## Think in a stack
13
+
14
+ Always treat attention as a **call stack**, not a flat to-do list.
15
+
16
+ You will often produce some progress on a parent goal, then go down a rabbit hole about one specific thing (a bug, a prerequisite, a design question, a test failure). That digression is a **push**: start a nested activity under the still-running parent. When the rabbit hole is done, **pop**: end the nested activity with the outcome, and continue the parent where you left off.
17
+
18
+ - Leave parents running while you dig. Ending the parent when you dive deeper flattens the chart and loses the “come back up” story.
19
+ - Nested steps belong under the workstream they serve — not as siblings of the top objective.
20
+ - When attention moves to a true sibling of the current leaf, end the leaf first, then start the sibling under the same parent.
21
+ - Use `--root` only for a genuinely new top-level workstream, not for every digression.
22
+ - Keep the active stack truthful as work changes. Do not plan a detailed chart up front or reconstruct one afterward.
23
+
24
+ ## Local Codex hooks
25
+
26
+ For a Single Burner project, run `flambe setup codex` from its root after setting
27
+ its local connection values in `.env`. Setup merges project hooks into
28
+ `.codex/hooks.json` and installs this skill if it is missing. Existing custom
29
+ skills and unrelated hooks are retained. Restart local Codex and review/trust
30
+ these hooks with `/hooks`; installed files alone do not prove they are running.
31
+ Use `flambe setup codex --check` to inspect the files, and `--remove` to remove
32
+ only Flambe's own hook handlers. Keep `flambe` on PATH; Windows hooks use
33
+ `flambe.cmd` so PowerShell script policy does not select `flambe.ps1`.
34
+
35
+ Hooks restore workflow context, request periodic stack checks, and can request
36
+ one reconciliation continuation before a substantive turn ends. They make no
37
+ inference calls or semantic activity changes. You still start/nest/end the real
38
+ work and complete requested work-AI reviews. A turn ending does not mean its
39
+ parent task ended. Missing servers or identity produce feedback without blocking
40
+ ordinary work. Hooks require `local_self_contained`; they do not route to Cloud.
41
+
42
+ ## Commands
43
+
44
+ ```sh
45
+ flambe start "Fix USERPROFILE resolution" # prints id; nests under the current active activity
46
+ flambe start "Add Windows support" --root # new top-level workstream
47
+ flambe end "$ID" "Resolved USERPROFILE on Windows"
48
+ flambe suspend "$ID" "Waiting for product decision" # only when explicitly tabling work
49
+ flambe resume "$ID" "Decision received"
50
+ flambe observe carbon 312.4 --unit gCO2eq/kWh --on 2026-09-04 --payload '{"source":"us-ba-mean"}'
51
+ flambe message "Auth fix needs the session module refactored too; widening scope" # ask the Reducer Agent
52
+ ```
53
+
54
+ `observe` writes a user overlay (carbon, mood, …), not an activity. Same `kind` + `--on` date upserts and merges payload keys. Omit `--on` to always insert.
55
+
56
+ **255-character hard limit on activity names and `--description`.** The column is `varchar(255)`. A longer value returns a 500 and can wedge the offline queue. Count before `start`. Prefer a short concrete name; put detail in the end/suspend/resume message (those are unbounded).
57
+
58
+ Reuse this conversation's activity id when you already have one. Run `flambe status --active --json` only to find a matching open activity from this session; ignore unrelated active work. Do not fetch threads or categories to decide placement; that is the reducer's job.
59
+
60
+ Every command is a proposal; the reducer owns the stack. Without `--parent`/`--root`, `start` nests under **your own** newest active activity (other agents' work is never your parent), puts a child on its parent's thread, and gives it the parent's categories; a new root gets its thread and categories chosen by the reducer a few seconds later. `FLAMBE_THREAD` (name, unique slug, or id) is sent as `--thread` when that flag is omitted; do not list threads and pick one by project name. Pass `--thread` / `--parent` / `--category` only when you are certain. Offline `offline-…` ids from `start` are valid for `end`. Do not retry queued operations. Offline results carry no immediate reducer output; nothing is dropped. Local `serve` always applies deterministic rules, including duplicate reuse, ancestor resumption, and descendant closure. Optional review is off by default; an explicitly configured server can ask the current work AI to review or call a separate model.
61
+
62
+ ## Read every result; the reducer may rewrite
63
+
64
+ Any command (`start`, `end`, `suspend`, `resume`, `message`) may come back with `direction`, `reply`, and `actions_applied`, not only `message`. The reducer records what you proposed and then corrects the structure around it when needed: re-parent, rename, treat your `start` as a resume of an already-open same-name activity, resume a suspended ancestor before nesting under it, or close open descendants when you end a parent (`--force`). The CLI prints each rewrite on stderr (`reducer nested 43 under 42`, `actions`). The returned state is the truth: keep using the ids it gives you, do not undo a rewrite, and do not re-issue the proposal.
65
+
66
+ The reducer may answer a proposal with a question in `reply`, usually when a name does not fit its ancestors. Answer before your next step: rename the activity, or reply with `flambe message`. The CLI reminds you on the next command if a question is still open; it never refuses.
67
+
68
+ A returned `direction` is binding: act on it before doing more work. On `pause`, `stop`, or `escalate`, stop and surface the reply to David rather than deciding yourself. `direction -` or none means continue as you were. A model-judged `start` can take a few seconds; wait for it.
69
+
70
+ ## Review with the AI doing the work
71
+
72
+ Review only at meaningful scope/approach decisions or when placement is uncertain.
73
+ Routine lifecycle tracking uses deterministic local rules. Do not review every
74
+ start/end, and do not request full history for routine bookkeeping. Work-AI
75
+ review context contains the current path and this agent's open work. Compact
76
+ lifecycle replies mark their state scope; omitted activities are not ended.
77
+
78
+ When a local result contains `review` and `model_review.status: awaiting_decision`,
79
+ you are the reviewer. This is the current work AI evaluating its own context,
80
+ not independent approval. Do not start another model, ask for provider keys, or
81
+ claim a separate reviewer approved your work.
82
+
83
+ Read `review.instructions`, `review.context`, and `review.decision_schema`.
84
+ Evaluate the user's intent, current path, and proposed scope. Choose concise
85
+ assessment, direction, reply, rationale, and at most one permitted action.
86
+ Write that decision as JSON and submit it with:
87
+
88
+ ```sh
89
+ flambe decide <review.review_id> --file <decision.json>
90
+ ```
91
+
92
+ `flambe message` returns the review JSON in this mode. Flagged starts normally
93
+ print only a small review-available notice on stderr; request review only when
94
+ scope or placement needs judgment. They include full review JSON only when the
95
+ server explicitly enables automatic start reviews. Stdout remains the recorded id. Keep the same local
96
+ connection, trace, and stable agent id for both steps. Ordinary
97
+ lifecycle tracking continues to use start/end/suspend/resume.
98
+
99
+ Complete a requested review before continuing substantive work. Follow the
100
+ returned direction after submission; escalate unresolved human judgment with no
101
+ actions and ask the user. Do not repeat the recorded start to request review.
102
+ On `REDUCER_STATE_CHANGED` or `REVIEW_EXPIRED`, request a fresh review with
103
+ `flambe message` for that activity, reassess it, and submit against the new id.
104
+ Do not resubmit the old decision blindly. Identical retries of a completed
105
+ submission return a receipt; `replayed: true` means no new action was applied.
106
+ If the server lacks work-AI review support, report that instead of changing instances.
107
+
108
+ ## End of work
109
+
110
+ When the work you were doing is finished, check limbo and scheduled activities before you stop. Run `flambe status --unstarted --json`. If something there is worth doing next, propose that candidate with `flambe message` and begin it only if the reply says continue. Do not begin it unasked, and do not ask David in chat.
111
+
112
+ ## Ask the reducer before you drift
113
+
114
+ `flambe message "<update>"` is your own channel: it sends your update plus the live flame (open and suspended work, the path to the current activity, and ended work from the last 21 days) and answers with `assessment`, `direction`, `reply`, and at most one stack change. Message when you are about to:
115
+
116
+ - widen scope beyond the activity you started
117
+ - change approach after the plan stalled
118
+ - end a root workstream
119
+ - continue while suspecting the work has drifted from what David asked
120
+ - answer a question the reducer asked
121
+
122
+ Do not message for routine progress, and never instead of `start`/`end`. Defaults to your newest active activity; pass `--activity <id>` to target another. Needs a reachable server with model review configured (not queued). Local self-contained `serve` has optional review, off by default, including an `agent` mode that returns review context for you to evaluate. On `REDUCER_NOT_CONFIGURED`, do not retry or switch to a remote server; surface unresolved judgment to the user and continue deterministic logging. A lifecycle result with `model_review.status: failed` still recorded the activity: keep its returned id/state, surface the review failure, and do not repeat the start or retarget another instance.
123
+
124
+ ## What to record
125
+
126
+ - Start a new activity when the goal or scope meaningfully changes (a specific implementation, a discovered prerequisite, a concrete bug, focused tests, verification) — that is a stack push.
127
+ - Do not start an activity for committing or pushing. Those are wrap-up of the current leaf; mention them in the end-message if useful. Exception: the assigned work *is* getting code onto a remote.
128
+ - Name the concrete action and object (`Fix USERPROFILE config resolution`), not `Investigate` / `Implement` / `Work on tests`. Stay well under 255 characters; do not compensate with a long name or `--description`.
129
+ - End-message: the outcome, not `done`. That records a resolution. Omitting the message is a plain end. No secrets, tokens, or transcripts. Ending the leaf is the pop; then resume the parent mentally and in the log.
package/src/cli.mjs CHANGED
@@ -28,6 +28,7 @@ const usage = `Usage:
28
28
  flambe whoami [--json]
29
29
  flambe message <text> [--activity <id>] [--json]
30
30
  flambe decide <review-id> --file <decision.json>
31
+ flambe setup codex [--check | --remove]
31
32
  flambe observe <kind> <value> [--unit <unit>] [--on <YYYY-MM-DD>] [--payload <json>] [--at <ISO-8601>]
32
33
  flambe serve [--port <n>] [--db <path>] [--static <dir>]
33
34
  flambe export [file] [--db <path>]
@@ -360,9 +361,43 @@ function formatReducerNote(note) {
360
361
  return `reducer: ${JSON.stringify(note)}\n`;
361
362
  }
362
363
 
363
- export async function run(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, client, questionsPath } = {}) {
364
+ export async function run(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, stdin = process.stdin, client, questionsPath } = {}) {
364
365
  const [command, ...args] = argv;
365
366
 
367
+ if (command === 'hook' && args.length === 1 && args[0] === 'codex') {
368
+ const {readHookInput, runCodexHook} = await import('./codex-hook.mjs');
369
+ let output;
370
+ try { output = await runCodexHook(await readHookInput(stdin), {env}); }
371
+ catch { output = {systemMessage: 'Single Burner hook input could not be read; work may continue.'}; }
372
+ stdout.write(`${JSON.stringify(output)}\n`);
373
+ return;
374
+ }
375
+
376
+ if (command === 'setup') {
377
+ if (args[0] !== 'codex' || args.length > 2
378
+ || (args[1] !== undefined && !['--check', '--remove'].includes(args[1]))) {
379
+ throw new Error('Usage: flambe setup codex [--check | --remove]');
380
+ }
381
+ const {setupCodex} = await import('./codex-setup.mjs');
382
+ const action = args[1] === '--check' ? 'check' : args[1] === '--remove' ? 'remove' : 'install';
383
+ const result = setupCodex({action});
384
+ let localConnectionConfigured = false;
385
+ try {
386
+ localConnectionConfigured = env.FLAMBE_RUNTIME_MODE === 'local_self_contained' && Boolean(clientFromEnv(env));
387
+ } catch { /* Only report whether the config is usable; never echo credentials. */ }
388
+ if (action === 'check') stdout.write(`${JSON.stringify({...result, localConnectionConfigured})}\n`);
389
+ else if (action === 'remove') stdout.write(`Single Burner Codex hooks ${result.removed ? 'removed' : 'were not installed'}. Existing skills and unrelated hooks were retained.\n`);
390
+ else {
391
+ stdout.write(`Single Burner Codex hooks installed in ${result.root}\n`);
392
+ stdout.write(result.skillInstalled ? 'Installed the flambe-cli skill.\n' : 'Kept the existing flambe-cli skill.\n');
393
+ if (!result.skillMatches) stdout.write('The existing skill differs from the bundled version; review it for current work-AI review instructions.\n');
394
+ if (!localConnectionConfigured) stdout.write('Copy Single Burner connection values into this project .env, including FLAMBE_RUNTIME_MODE=local_self_contained, before using the hooks.\n');
395
+ stdout.write('Restart the local Codex session. Review and trust these project hooks with /hooks before relying on them. Workspace policy can restrict hooks.\n');
396
+ stdout.write('Run flambe setup codex --check to inspect installed files and connection configuration. This does not confirm Codex trust or live hook execution.\n');
397
+ }
398
+ return;
399
+ }
400
+
366
401
  if (!command || command === 'help' || command === '--help' || command === '-h') {
367
402
  stdout.write(`${usage}\n`);
368
403
  return;
@@ -0,0 +1,237 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
3
+ import {homedir} from 'node:os';
4
+ import {dirname, join, resolve} from 'node:path';
5
+ import {setTimeout as delay} from 'node:timers/promises';
6
+ import {agentIdentityFromEnv, clientFromEnv} from './client.mjs';
7
+ import {loadProjectEnv} from './env.mjs';
8
+ import {atomicWrite, hasFlambeHooks} from './codex-setup.mjs';
9
+ import {assertRuntimeUrl} from './runtime.mjs';
10
+
11
+ const RESTORE = new Set(['SessionStart', 'SubagentStart', 'PostCompact', 'UserPromptSubmit']);
12
+ const EVENTS = new Set([...RESTORE, 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt']);
13
+ const hash = text => createHash('sha256').update(text).digest('hex');
14
+ const clean = value => String(value).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 160);
15
+ const emptyState = () => ({workInTurn: 0, workSinceTracked: 0, callsSinceReminder: 0});
16
+
17
+ function projectRoot(cwd) {
18
+ let directory = resolve(cwd);
19
+ for (;;) {
20
+ const path = join(directory, '.codex', 'hooks.json');
21
+ if (existsSync(path) && hasFlambeHooks(JSON.parse(readFileSync(path, 'utf8')))) return directory;
22
+ const parent = dirname(directory);
23
+ if (parent === directory || existsSync(join(directory, '.git'))) return null;
24
+ directory = parent;
25
+ }
26
+ }
27
+
28
+ function context(event, text) {
29
+ return {hookSpecificOutput: {hookEventName: event, additionalContext: text}};
30
+ }
31
+
32
+ function connectionWarning(event) {
33
+ const text = 'Single Burner hook could not verify the local connection. Check the running server and this project\'s .env, including FLAMBE_RUNTIME_MODE=local_self_contained. Work may continue; do not substitute Cloud or repeat queued activity commands.';
34
+ return event === 'Stop' || event === 'Interrupt' ? {systemMessage: text} : context(event, text);
35
+ }
36
+
37
+ // Only recognise a standalone CLI invocation. A mixed shell script still counts
38
+ // as work, even if it happens to contain a tracking command.
39
+ function trackingCommand(input) {
40
+ const command = input?.tool_input?.command ?? input?.tool_input?.cmd;
41
+ if (typeof command !== 'string' || /[\n;&|]/.test(command)) return null;
42
+ const match = command.trim().match(/^(?:(?:[A-Z_][A-Z_0-9]*=(?:"[^"]*"|'[^']*'|[^\s]+))\s+)*(?:flambe(?:\.cmd)?|(?:"[^"]*node(?:\.exe)?"|[^\s]*node(?:\.exe)?)\s+(?:"[^"]*flambe\.mjs"|[^\s]*flambe\.mjs))\s+(start|end|suspend|resume|plan|message|decide|status|ping|whoami)\b/);
43
+ return match?.[1] ?? null;
44
+ }
45
+
46
+ function succeeded(response) {
47
+ if (typeof response === 'string') {
48
+ try { return succeeded(JSON.parse(response)); } catch { return false; }
49
+ }
50
+ return response?.exit_code === 0 || response?.exitCode === 0;
51
+ }
52
+
53
+ function ownActivities(state, agentId) {
54
+ return (state.activities ?? []).filter(activity => activity.agentId === agentId
55
+ && activity.status === 'running');
56
+ }
57
+
58
+ function fingerprint(activities) {
59
+ return hash(JSON.stringify(activities.map(activity => [activity.id, activity.parentId,
60
+ activity.latestEvent?.id, activity.latestEvent?.timestamp]).sort((a, b) => a[0] - b[0])));
61
+ }
62
+
63
+ function guidance(root, activities, {restore = false, interrupted = false} = {}) {
64
+ const lines = [
65
+ 'Single Burner checkpoint: use this project\'s local flambe CLI; never substitute Cloud tools.',
66
+ `Run CLI commands from ${JSON.stringify(clean(root))} so they read the project .env.`,
67
+ ];
68
+ if (restore) lines.push('Read and follow .codex/skills/flambe-cli/SKILL.md. Track semantic work before beginning it, nest focused investigations and verification, and update the stack when focus changes. In agent mode, evaluate requested reviews yourself and submit with flambe decide; no other model is needed.');
69
+ lines.push(activities.length
70
+ ? `This agent's running activities (data): ${activities.slice(-5).map(activity => `${activity.id} ${JSON.stringify(clean(activity.name))}`).join('; ')}.`
71
+ : 'This agent has no running activity. Before substantial work, inspect its suspended work and start or resume an appropriate semantic activity. Trivial one-offs need no activity.');
72
+ if (interrupted) lines.push('The previous turn was interrupted. Reconcile its open work before continuing; an interruption does not mean the task finished.');
73
+ lines.push('Check whether the current activity still describes your actual focus. Keep parents open while working on children; pop completed children before moving on.');
74
+ return lines.join('\n');
75
+ }
76
+
77
+ async function locked(path, operation) {
78
+ mkdirSync(dirname(path), {recursive: true});
79
+ const lock = `${path}.lock`;
80
+ const started = Date.now();
81
+ for (;;) {
82
+ try {
83
+ mkdirSync(lock);
84
+ writeFileSync(join(lock, 'pid'), String(process.pid));
85
+ break;
86
+ }
87
+ catch (error) {
88
+ if (error.code !== 'EEXIST' || Date.now() - started > 2600) return {};
89
+ // A killed/timed-out hook must not disable future checkpoints forever.
90
+ try {
91
+ if (Date.now() - statSync(lock).mtimeMs > 30_000) {
92
+ rmSync(lock, {recursive: true, force: true});
93
+ continue;
94
+ }
95
+ const pid = Number(readFileSync(join(lock, 'pid'), 'utf8'));
96
+ if (Number.isSafeInteger(pid) && pid > 0) {
97
+ try { process.kill(pid, 0); }
98
+ catch (check) { if (check.code === 'ESRCH') rmSync(lock, {recursive: true, force: true}); }
99
+ }
100
+ } catch {
101
+ try { if (Date.now() - statSync(lock).mtimeMs > 10_000) rmSync(lock, {recursive: true, force: true}); }
102
+ catch { /* Another hook just released it. */ }
103
+ }
104
+ await delay(20);
105
+ }
106
+ }
107
+ try { return await operation(); }
108
+ finally { rmSync(lock, {recursive: true, force: true}); }
109
+ }
110
+
111
+ /** A checkpoint adapter, not an activity generator or model client. */
112
+ export async function runCodexHook(input, {
113
+ env: shellEnv = process.env, now = Date.now,
114
+ stateDir = shellEnv.FLAMBE_CODEX_HOOK_STATE_DIR?.trim() || join(homedir(), '.flambe', 'codex-hooks'),
115
+ fetchImpl = globalThis.fetch, checkEveryMs = 30_000,
116
+ remindEveryCalls = 10, remindEveryMs = 90_000,
117
+ } = {}) {
118
+ const event = input?.hook_event_name;
119
+ if (!EVENTS.has(event) || typeof input.cwd !== 'string' || !input.session_id) return {};
120
+ if (event === 'Stop' && input.stop_hook_active) return {};
121
+ let root;
122
+ let env;
123
+ let identity;
124
+ try {
125
+ root = projectRoot(input.cwd);
126
+ if (!root) return {};
127
+ env = {...shellEnv};
128
+ loadProjectEnv({cwd: root, env});
129
+ if (!['FLAMBE_URL', 'FLAMBE_API_TOKEN', 'FLAMBE_TRACE_ID'].some(key => env[key])) return {};
130
+ if (env.FLAMBE_RUNTIME_MODE !== 'local_self_contained') return connectionWarning(event);
131
+ assertRuntimeUrl(env.FLAMBE_URL, 'local_self_contained');
132
+ identity = agentIdentityFromEnv(env);
133
+ if (!identity.agentId) {
134
+ return RESTORE.has(event) ? context(event,
135
+ 'Single Burner: read .codex/skills/flambe-cli/SKILL.md. The hook could not derive this Codex agent\'s identity, so it did not inspect any activity stack. Check flambe whoami in the local Codex environment; do not reuse another agent\'s identity or put agent IDs in .env.') : {};
136
+ }
137
+ } catch { return connectionWarning(event); }
138
+ const key = hash(JSON.stringify([root, env.FLAMBE_URL, env.FLAMBE_TRACE_ID, identity.agentId, input.session_id]));
139
+ const path = join(stateDir, `${key}.json`);
140
+ try {
141
+ return await locked(path, async () => {
142
+ let state;
143
+ try { state = {...emptyState(), ...JSON.parse(readFileSync(path, 'utf8'))}; }
144
+ catch { state = emptyState(); }
145
+ const at = now();
146
+ const save = output => { atomicWrite(path, `${JSON.stringify(state)}\n`); return output; };
147
+ const turn = input.turn_id ?? state.turn ?? 'unknown';
148
+ if (turn !== state.turn || event === 'UserPromptSubmit') {
149
+ state.turn = turn;
150
+ delete state.stopTurn;
151
+ state.workInTurn = 0;
152
+ state.workSinceTracked = 0;
153
+ state.callsSinceReminder = 0;
154
+ state.missingWarned = false;
155
+ }
156
+ if (event === 'Interrupt') {
157
+ state.interrupted = true;
158
+ return save({});
159
+ }
160
+ const tracking = trackingCommand(input);
161
+ if (event === 'PostToolUse') {
162
+ if (tracking && !['status', 'ping', 'whoami'].includes(tracking) && succeeded(input.tool_response)) {
163
+ state.workSinceTracked = 0;
164
+ state.callsSinceReminder = 0;
165
+ state.remindedAt = at;
166
+ state.checkedAt = 0;
167
+ }
168
+ return save({});
169
+ }
170
+ if (event === 'PreToolUse') {
171
+ if (tracking) return save({});
172
+ const weight = /^(apply_patch|Edit|Write)$/.test(input.tool_name ?? '') ? 3 : 1;
173
+ state.workInTurn += weight;
174
+ state.workSinceTracked += weight;
175
+ state.callsSinceReminder++;
176
+ state.lastWorkAt = at;
177
+ }
178
+ if (event === 'Stop' && (state.workInTurn < 3 || state.workSinceTracked === 0 || state.stopTurn === turn)) return save({});
179
+ const restore = RESTORE.has(event);
180
+ const periodic = state.callsSinceReminder >= remindEveryCalls || at - (state.remindedAt ?? at) >= remindEveryMs;
181
+ const due = restore || periodic || event === 'Stop' || !state.checkedAt || at - state.checkedAt >= checkEveryMs;
182
+ let activities = [];
183
+ if (due) {
184
+ state.checkedAt = at;
185
+ try {
186
+ const client = clientFromEnv(env);
187
+ client.fetch = (url, options) => fetchImpl(url, {...options, signal: AbortSignal.timeout(1200)});
188
+ activities = ownActivities(await client.status({activeOnly: true}), identity.agentId);
189
+ const current = fingerprint(activities);
190
+ const latestChange = Math.max(0, ...activities.map(activity => Date.parse(activity.latestEvent?.timestamp) || 0));
191
+ if (state.fingerprint && state.fingerprint !== current
192
+ && ((!activities.length && state.hasActive) || latestChange >= (state.lastWorkAt ?? 0))) {
193
+ state.workSinceTracked = 0;
194
+ state.callsSinceReminder = 0;
195
+ state.remindedAt = at;
196
+ }
197
+ state.fingerprint = current;
198
+ state.available = true;
199
+ state.hasActive = activities.length > 0;
200
+ } catch {
201
+ state.available = false;
202
+ if (!state.warnedAt || at - state.warnedAt >= remindEveryMs || restore) {
203
+ state.warnedAt = at;
204
+ return save(connectionWarning(event));
205
+ }
206
+ return save({});
207
+ }
208
+ }
209
+ if (!state.available) return save({});
210
+ if (event === 'Stop') {
211
+ if (!state.workSinceTracked) return save({});
212
+ state.stopTurn = turn;
213
+ return save({decision: 'block', reason: guidance(root, activities)
214
+ + '\nBefore handing back, reconcile the work you actually did. End completed activities, suspend work waiting on the user, or explain why an activity remains running. Complete any requested reducer review. This is one checkpoint, not an instruction to invent work or end every open parent.'});
215
+ }
216
+ const firstUntracked = event === 'PreToolUse' && !state.missingWarned && !state.hasActive;
217
+ if (restore || firstUntracked || periodic) {
218
+ if (firstUntracked) state.missingWarned = true;
219
+ state.callsSinceReminder = 0;
220
+ state.remindedAt = at;
221
+ const interrupted = state.interrupted;
222
+ state.interrupted = false;
223
+ return save(context(event, guidance(root, activities, {restore, interrupted})));
224
+ }
225
+ return save({});
226
+ });
227
+ } catch { return connectionWarning(event); }
228
+ }
229
+
230
+ export async function readHookInput(stdin = process.stdin) {
231
+ let body = '';
232
+ for await (const chunk of stdin) {
233
+ body += chunk;
234
+ if (Buffer.byteLength(body) > 1024 * 1024) throw new Error('Hook input exceeds 1 MiB');
235
+ }
236
+ return JSON.parse(body);
237
+ }
@@ -0,0 +1,104 @@
1
+ import {existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync} from 'node:fs';
2
+ import {dirname, join, resolve} from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+ import {randomUUID} from 'node:crypto';
5
+
6
+ export const HOOK_COMMAND = 'flambe hook codex';
7
+ export const HOOK_COMMAND_WINDOWS = 'flambe.cmd hook codex';
8
+ export const HOOK_EVENTS = ['SessionStart', 'SubagentStart', 'UserPromptSubmit', 'PostCompact', 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt'];
9
+
10
+ export function managedHook(hook) {
11
+ return hook?.type === 'command' && hook.command === HOOK_COMMAND
12
+ && hook.commandWindows === HOOK_COMMAND_WINDOWS;
13
+ }
14
+
15
+ export function hasFlambeHooks(config) {
16
+ return Object.values(config?.hooks ?? {}).some(groups => Array.isArray(groups)
17
+ && groups.some(group => group.hooks?.some(managedHook)));
18
+ }
19
+
20
+ function readConfig(path) {
21
+ if (!existsSync(path)) return {hooks: {}};
22
+ let config;
23
+ try { config = JSON.parse(readFileSync(path, 'utf8')); }
24
+ catch { throw new Error('Cannot read .codex/hooks.json as JSON; existing hooks were not changed.'); }
25
+ if (!config || typeof config !== 'object' || Array.isArray(config)
26
+ || (config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)))
27
+ || Object.values(config.hooks ?? {}).some(groups => !Array.isArray(groups)
28
+ || groups.some(group => !group || !Array.isArray(group.hooks)))) {
29
+ throw new Error('Unsupported .codex/hooks.json structure; existing hooks were not changed.');
30
+ }
31
+ return config;
32
+ }
33
+
34
+ export function atomicWrite(path, text) {
35
+ mkdirSync(dirname(path), {recursive: true});
36
+ const temporary = `${path}.${randomUUID()}.tmp`;
37
+ try {
38
+ writeFileSync(temporary, text, {mode: 0o600, flag: 'wx'});
39
+ renameSync(temporary, path);
40
+ } finally {
41
+ if (existsSync(temporary)) unlinkSync(temporary);
42
+ }
43
+ }
44
+
45
+ export function bundledSkill() {
46
+ const bundled = fileURLToPath(new URL('../skills/flambe-cli/SKILL.md', import.meta.url));
47
+ const source = fileURLToPath(new URL('../../.codex/skills/flambe-cli/SKILL.md', import.meta.url));
48
+ return readFileSync(existsSync(bundled) ? bundled : source, 'utf8');
49
+ }
50
+
51
+ function withoutManagedHooks(config) {
52
+ const result = {...config, hooks: {...config.hooks}};
53
+ for (const [event, groups] of Object.entries(result.hooks)) {
54
+ result.hooks[event] = groups.flatMap(group => {
55
+ if (!group.hooks.some(managedHook)) return [group];
56
+ const hooks = group.hooks.filter(hook => !managedHook(hook));
57
+ return hooks.length ? [{...group, hooks}] : [];
58
+ });
59
+ if (!result.hooks[event].length) delete result.hooks[event];
60
+ }
61
+ return result;
62
+ }
63
+
64
+ export function setupCodex({cwd = process.cwd(), action = 'install', skill = bundledSkill()} = {}) {
65
+ const root = resolve(cwd);
66
+ const path = join(root, '.codex', 'hooks.json');
67
+ const skillPath = join(root, '.codex', 'skills', 'flambe-cli', 'SKILL.md');
68
+ const existing = readConfig(path);
69
+ const installedEvents = HOOK_EVENTS.filter(event => existing.hooks?.[event]?.some(group =>
70
+ group.hooks.some(managedHook)));
71
+ const skillExists = existsSync(skillPath);
72
+ if (action === 'check') return {
73
+ root, hooksPath: path, installed: installedEvents.length === HOOK_EVENTS.length,
74
+ installedEvents, skillExists,
75
+ skillMatches: skillExists && readFileSync(skillPath, 'utf8') === skill,
76
+ };
77
+ const config = withoutManagedHooks(existing);
78
+ if (action === 'remove') {
79
+ if (installedEvents.length) atomicWrite(path, `${JSON.stringify(config, null, 2)}\n`);
80
+ return {root, hooksPath: path, removed: installedEvents.length > 0, skillRetained: skillExists};
81
+ }
82
+ if (action !== 'install') throw new Error('Unknown Codex setup action');
83
+ for (const event of HOOK_EVENTS) {
84
+ config.hooks[event] ??= [];
85
+ config.hooks[event].push({hooks: [{
86
+ type: 'command', command: HOOK_COMMAND, commandWindows: HOOK_COMMAND_WINDOWS,
87
+ timeout: event === 'Interrupt' ? 3 : 5,
88
+ statusMessage: 'Single Burner tracking checkpoint', additionalContextLimit: 1800,
89
+ }]});
90
+ }
91
+ // Preserve custom/existing instructions. Never silently replace a skill.
92
+ if (!skillExists) atomicWrite(skillPath, skill);
93
+ const serialized = `${JSON.stringify(config, null, 2)}\n`;
94
+ const unchanged = existsSync(path) && readFileSync(path, 'utf8') === serialized;
95
+ if (!unchanged) {
96
+ if (existsSync(path)) {
97
+ const backup = `${path}.before-flambe`;
98
+ if (!existsSync(backup)) writeFileSync(backup, readFileSync(path), {flag: 'wx', mode: 0o600});
99
+ }
100
+ atomicWrite(path, serialized);
101
+ }
102
+ return {root, hooksPath: path, unchanged, skillInstalled: !skillExists,
103
+ skillMatches: !skillExists || readFileSync(skillPath, 'utf8') === skill};
104
+ }