@alecsibilia/luca 13.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +47 -0
  3. package/bin/luca.js +3 -0
  4. package/dist/chunks/branch.mjs +47 -0
  5. package/dist/chunks/bun-runtime.mjs +46 -0
  6. package/dist/chunks/checks.mjs +53 -0
  7. package/dist/chunks/claim-verify.mjs +465 -0
  8. package/dist/chunks/classify.mjs +105 -0
  9. package/dist/chunks/confidence.mjs +199 -0
  10. package/dist/chunks/doctor.mjs +158 -0
  11. package/dist/chunks/hook.mjs +696 -0
  12. package/dist/chunks/init.mjs +715 -0
  13. package/dist/chunks/muninndb-health.mjs +66 -0
  14. package/dist/chunks/phase.mjs +38 -0
  15. package/dist/chunks/pr-review.mjs +122 -0
  16. package/dist/chunks/preferences.mjs +61 -0
  17. package/dist/chunks/repair.mjs +111 -0
  18. package/dist/chunks/repo.mjs +58 -0
  19. package/dist/chunks/retro.mjs +86 -0
  20. package/dist/chunks/roadmap.mjs +58 -0
  21. package/dist/chunks/rules.mjs +527 -0
  22. package/dist/chunks/stale-mcp-server.mjs +90 -0
  23. package/dist/chunks/state.mjs +57 -0
  24. package/dist/chunks/stray-local-install.mjs +200 -0
  25. package/dist/chunks/telemetry.mjs +165 -0
  26. package/dist/chunks/todo.mjs +151 -0
  27. package/dist/chunks/vault-init.mjs +300 -0
  28. package/dist/chunks/verification.mjs +95 -0
  29. package/dist/chunks/version.mjs +70 -0
  30. package/dist/chunks/workflow.mjs +47 -0
  31. package/dist/claude/.claude/agents/architect.md +410 -0
  32. package/dist/claude/.claude/agents/build.md +111 -0
  33. package/dist/claude/.claude/agents/discuss.md +93 -0
  34. package/dist/claude/.claude/agents/discussion.md +149 -0
  35. package/dist/claude/.claude/agents/execute.md +416 -0
  36. package/dist/claude/.claude/agents/executor.md +161 -0
  37. package/dist/claude/.claude/agents/fast.md +84 -0
  38. package/dist/claude/.claude/agents/finalize.md +484 -0
  39. package/dist/claude/.claude/agents/learner.md +160 -0
  40. package/dist/claude/.claude/agents/plan-reviewer.md +129 -0
  41. package/dist/claude/.claude/agents/plan.md +96 -0
  42. package/dist/claude/.claude/agents/research.md +327 -0
  43. package/dist/claude/.claude/agents/researcher.md +78 -0
  44. package/dist/claude/.claude/agents/review.md +283 -0
  45. package/dist/claude/.claude/agents/reviewer.md +163 -0
  46. package/dist/claude/.claude/agents/shadow-scanner.md +257 -0
  47. package/dist/claude/.claude/agents/triage.md +230 -0
  48. package/dist/claude/.claude/agents/verifier.md +131 -0
  49. package/dist/claude/.claude/commands/bug-diagnose.md +12 -0
  50. package/dist/claude/.claude/commands/gh-issue-triage.md +14 -0
  51. package/dist/claude/.claude/commands/gh-pr-address.md +235 -0
  52. package/dist/claude/.claude/commands/gh-prepare.md +12 -0
  53. package/dist/claude/.claude/commands/grill-me.md +12 -0
  54. package/dist/claude/.claude/commands/lu-review.md +51 -0
  55. package/dist/claude/.claude/commands/lu.md +75 -0
  56. package/dist/claude/.claude/commands/luca-init.md +14 -0
  57. package/dist/claude/.claude/commands/luca-telemetry-report.md +12 -0
  58. package/dist/claude/.claude/commands/memory-audit.md +12 -0
  59. package/dist/claude/.claude/commands/milestone-new.md +122 -0
  60. package/dist/claude/.claude/commands/phase-discuss.md +45 -0
  61. package/dist/claude/.claude/commands/phase-execute.md +39 -0
  62. package/dist/claude/.claude/commands/phase-plan.md +53 -0
  63. package/dist/claude/.claude/commands/repo-cleanup.md +80 -0
  64. package/dist/claude/.claude/commands/todo-add.md +28 -0
  65. package/dist/claude/.claude/commands/todo-check.md +36 -0
  66. package/dist/claude/.claude/hooks/context-refresher.ts +285 -0
  67. package/dist/claude/.claude/hooks/continuation-messages.ts +215 -0
  68. package/dist/claude/.claude/hooks/pipeline-guard.ts +182 -0
  69. package/dist/claude/.claude/settings.json +41 -0
  70. package/dist/claude/skills/arch-audit/SKILL.md +161 -0
  71. package/dist/claude/skills/autopilot/SKILL.md +1299 -0
  72. package/dist/claude/skills/bug-diagnose/SKILL.md +102 -0
  73. package/dist/claude/skills/choose/SKILL.md +124 -0
  74. package/dist/claude/skills/gh-issue-triage/SKILL.md +97 -0
  75. package/dist/claude/skills/gh-pr-address/SKILL.md +235 -0
  76. package/dist/claude/skills/gh-prepare/SKILL.md +209 -0
  77. package/dist/claude/skills/grill-me/SKILL.md +46 -0
  78. package/dist/claude/skills/lu/SKILL.md +112 -0
  79. package/dist/claude/skills/lu-review/SKILL.md +51 -0
  80. package/dist/claude/skills/luca-init/SKILL.md +91 -0
  81. package/dist/claude/skills/luca-telemetry-report/SKILL.md +145 -0
  82. package/dist/claude/skills/luca-write-surface/SKILL.md +213 -0
  83. package/dist/claude/skills/memory-audit/SKILL.md +217 -0
  84. package/dist/claude/skills/milestone-audit/SKILL.md +545 -0
  85. package/dist/claude/skills/milestone-complete/SKILL.md +168 -0
  86. package/dist/claude/skills/milestone-gaps/SKILL.md +60 -0
  87. package/dist/claude/skills/milestone-new/SKILL.md +125 -0
  88. package/dist/claude/skills/note/SKILL.md +162 -0
  89. package/dist/claude/skills/phase-add/SKILL.md +91 -0
  90. package/dist/claude/skills/phase-assumptions/SKILL.md +92 -0
  91. package/dist/claude/skills/phase-discuss/SKILL.md +165 -0
  92. package/dist/claude/skills/phase-execute/SKILL.md +1786 -0
  93. package/dist/claude/skills/phase-insert/SKILL.md +100 -0
  94. package/dist/claude/skills/phase-plan/SKILL.md +461 -0
  95. package/dist/claude/skills/phase-remove/SKILL.md +113 -0
  96. package/dist/claude/skills/phase-research/SKILL.md +80 -0
  97. package/dist/claude/skills/post-init-tour/SKILL.md +58 -0
  98. package/dist/claude/skills/progress/SKILL.md +271 -0
  99. package/dist/claude/skills/project-new/SKILL.md +609 -0
  100. package/dist/claude/skills/quick/SKILL.md +256 -0
  101. package/dist/claude/skills/rename-audit/SKILL.md +52 -0
  102. package/dist/claude/skills/repo-audit/SKILL.md +88 -0
  103. package/dist/claude/skills/repo-cleanup/SKILL.md +80 -0
  104. package/dist/claude/skills/seed-memory/SKILL.md +235 -0
  105. package/dist/claude/skills/session-pause/SKILL.md +126 -0
  106. package/dist/claude/skills/session-plan/SKILL.md +112 -0
  107. package/dist/claude/skills/session-resume/SKILL.md +75 -0
  108. package/dist/claude/skills/todo-add/SKILL.md +85 -0
  109. package/dist/claude/skills/todo-check/SKILL.md +77 -0
  110. package/dist/claude/skills/workflow-save/SKILL.md +277 -0
  111. package/dist/index.d.mts +33 -0
  112. package/dist/index.d.ts +33 -0
  113. package/dist/index.mjs +69 -0
  114. package/dist/shared/luca.B3Mimc0P.mjs +52 -0
  115. package/dist/shared/luca.B3saVjJm.mjs +163 -0
  116. package/dist/shared/luca.BYdjkfnz.mjs +217 -0
  117. package/dist/shared/luca.BmhNkYe2.mjs +56 -0
  118. package/dist/shared/luca.C4gMUoBd.mjs +358 -0
  119. package/dist/shared/luca.CQ3g1xrD.mjs +19 -0
  120. package/dist/shared/luca.CRmaAfXR.mjs +713 -0
  121. package/dist/shared/luca.CrXzXueR.mjs +57 -0
  122. package/dist/shared/luca.DTomPq7I.mjs +91 -0
  123. package/dist/shared/luca.DjDTeDCi.mjs +1904 -0
  124. package/dist/shared/luca.HZxBTBgD.mjs +201 -0
  125. package/dist/shared/luca.TSMg1t7I.mjs +10 -0
  126. package/dist/shared/luca.dM-MKlNE.mjs +25 -0
  127. package/dist/shared/luca.naWEcQ4B.mjs +7 -0
  128. package/package.json +76 -0
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: phase-plan
3
+ description: "Drive the \"plan\" pipeline step — produce a phase plan grounded in the user decisions from /phase-discuss."
4
+ ---
5
+
6
+ # /phase-plan
7
+
8
+ You are running the **plan** step. Research is done, user decisions are captured in `context.md`. Your job is to produce a phase plan that downstream stages will execute.
9
+
10
+ ## Preconditions
11
+
12
+ 1. Run `luca state read`. The `pipelineStep` must be `architect` (entering plan) or `plan` (already advanced).
13
+ 2. If currently `architect`, run `luca state advance --to-step plan`.
14
+ 3. Run `luca phase current` to get the active slug and directory. If no active phase, abort.
15
+
16
+ ## Read inputs
17
+
18
+ Read these in order via the `Read` tool:
19
+ - `.luca/phases/<slug>/research.md` — research findings
20
+ - `.luca/phases/<slug>/context.md` — user decisions
21
+
22
+ If either is missing, abort with a clear error pointing at the missing step.
23
+
24
+ ## Produce the plan
25
+
26
+ The legacy v12 `luca-planner` subagent was dropped per plan §5.6 — planning work is done by the architect mode-agent or, when invoked from the `/phase-plan` command flow, inline by the orchestrator. Synthesize the plan from:
27
+ - The phase slug
28
+ - The current `pipelineStep` (always `plan` here)
29
+ - The research findings + user decisions read above
30
+ - The repo's coding patterns (from research) and acceptance criteria
31
+
32
+ The plan should be a markdown document with: objective, atomic tasks (waves), verification criteria per task, and success criteria for the phase.
33
+
34
+ ## Persist the plan
35
+
36
+ Write the plan with the `Write` tool to the canonical path. Use the `dir` field from `luca phase current`; the plan path is `<dir>/plan.md`:
37
+
38
+ ```
39
+ Write tool → <dir>/plan.md
40
+ content: "<plan markdown>"
41
+ ```
42
+
43
+ The stage-gate hook only permits this `Write` to `<dir>/plan.md` while `pipelineStep === "plan"` — any other path or step is blocked.
44
+
45
+ ## Advance
46
+
47
+ Run `luca state advance --to-step plan-review` to hand off to plan-review.
48
+
49
+ ## What you must NOT do
50
+
51
+ - Do NOT write code. Code writes are blocked in PLANNING.
52
+ - Do NOT skip the synthesis step — read research.md + context.md before drafting the plan. The plan must be grounded in those inputs.
53
+ - Do NOT write `plan.md` to any path other than `<dir>/plan.md`, or via `Edit` — the hook blocks every other `.luca/` write.
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: repo-cleanup
3
+ description: Scan the repository for AI-session debris and optionally clean it up.
4
+ ---
5
+
6
+ # /repo-cleanup
7
+
8
+ Scan the repository for AI-session debris — orphaned scripts, misplaced source files, tool artifacts, dead exports, `.luca/` contract violations, repo-root markdown debris — and optionally apply the remediations.
9
+
10
+ The scan is performed by the **`luca-shadow-scanner`** subagent (strictly read-only). This command drives that scan and applies fixes through the **`luca repo cleanup-apply`** CLI (the destructive write half).
11
+
12
+ ## Parse arguments
13
+
14
+ Parse `$ARGUMENTS` for flags:
15
+
16
+ - `--quick` — quick scan (categories 1 + 3 only)
17
+ - `--full` — full scan (all 7 categories, including dead exports)
18
+ - `--dry-run` — show findings without applying anything
19
+ - `--fix` — auto-apply every `auto_fixable` finding without prompting
20
+ - `--category=N` — restrict to a single detection category (1–7)
21
+
22
+ If no scan-mode flag is given, default to **`standard`** mode with interactive review.
23
+
24
+ Resolve `<repo_vault>` from `.luca/config.json` → `muninn.vault`, falling back to `"default"`.
25
+
26
+ ## Step 1 — Scan
27
+
28
+ Spawn the **`luca-shadow-scanner`** subagent via the `Agent` tool. The task prompt must include:
29
+
30
+ - The scan mode (`quick` | `standard` | `full`) resolved from the flags.
31
+ - Any `--category=N` filter — tell the scanner to report only that category.
32
+
33
+ The subagent ends its response with a single JSON block conforming to `ShadowScanReportSchema` (`scan_mode`, `categories_scanned`, `findings[]`, `summary`, `scanned_at`).
34
+
35
+ ## Step 2 — Parse the report
36
+
37
+ Take the **last JSON block** of the subagent's response as the `ShadowScanReport`. Each entry in `findings[]` has: `category`, `severity`, `file_path`, `description`, `recommendation`, `recommended_action` (`delete` | `move` | `gitignore`), `target_path?`, `auto_fixable`.
38
+
39
+ Display the findings banner: total count plus the per-severity breakdown from `summary`.
40
+
41
+ ## Step 3 — Handle the findings
42
+
43
+ - **No findings** (`summary.total === 0`) → report a clean scan and stop.
44
+
45
+ - **`--dry-run`** → display all findings grouped by severity (critical first) and stop. Apply nothing.
46
+
47
+ - **`--fix`** → for every finding where `auto_fixable === true`, stage that single finding object in a JSON file and run:
48
+
49
+ ```
50
+ # /tmp/luca-cleanup-finding.json holds the single finding object
51
+ luca repo cleanup-apply --file /tmp/luca-cleanup-finding.json --confirm
52
+ ```
53
+
54
+ Findings with `auto_fixable === false` (e.g. repo-root markdown, SUMMARY moves) are listed for the user but not auto-applied.
55
+
56
+ - **Interactive mode** (default) → present each finding sorted by severity (critical first). For each one, offer three choices:
57
+
58
+ - **Fix** → stage the single finding object in a JSON file and run `luca repo cleanup-apply --file <path> --confirm`. For a `move`, the finding must carry `target_path`; if it does not, ask the user where it should go and add `target_path` to the file before running.
59
+ - **Keep** → record the user's decision so the file is not re-flagged next scan:
60
+
61
+ First call `mcp__muninn__muninn_remember` with `vault: "<repo_vault>"`, `concept: "shadow-debt:kept:<file_path>"`, and content noting the user approved keeping `<file_path>` with an ISO timestamp. Then promote it: `mcp__muninn__muninn_trust({ id: <returned id>, trust: "verified", vault: "<repo_vault>" })` — this is a user-confirmed decision. The `luca-shadow-scanner` recalls `shadow-debt:kept` entries and excludes them from future scans.
62
+ - **Skip** → take no action; the file will be flagged again on the next scan.
63
+
64
+ ## Step 4 — Store the cleanup metric
65
+
66
+ After processing every finding, record what the cleanup did (`metric:*` routes to the repo vault per the vault-routing rule):
67
+
68
+ ```
69
+ mcp__muninn__muninn_remember({
70
+ vault: "<repo_vault>",
71
+ concept: "metric:shadow-debt-cleanup-<ISO timestamp>",
72
+ content: JSON.stringify({
73
+ scan_mode, total, fixed, kept, skipped, cleaned_at
74
+ })
75
+ })
76
+ ```
77
+
78
+ The `luca-shadow-scanner` already stores the raw scan counts under `metric:shadow-debt-scan-*`; this metric captures the action outcome (`fixed` / `kept` / `skipped`).
79
+
80
+ $ARGUMENTS
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: todo-add
3
+ description: Add a new item to the development backlog.
4
+ ---
5
+
6
+ # /todo-add
7
+
8
+ Add a new todo to the development backlog. Todos persist in **MuninnDB** (concept `todo:<id>`, repo vault) — there is no `.luca/todos/` directory.
9
+
10
+ ## Steps
11
+
12
+ 1. **Parse the title.** Treat `$ARGUMENTS` as the todo title — a short imperative description (e.g. "Add retry to the upload client"). If no arguments were provided, ask the user what they'd like to add.
13
+
14
+ 2. **Create the todo.** Run the `luca todo add` CLI with the title:
15
+
16
+ ```
17
+ luca todo add --title "<title>" --source manual
18
+ ```
19
+
20
+ - `--source manual` marks this as a hand-entered item (vs. `gh-issue-#N` or `phase-research`).
21
+ - If the user supplied acceptance criteria or extra context, pass it as `--body "<text>"`.
22
+ - The `id` is derived from the title automatically; only pass an explicit `--id` if the user asked for a specific slug.
23
+
24
+ 3. **Execute the returned instruction.** `luca todo add` validates the input and prints a `mcp__muninn__muninn_remember` instruction blob (delegation pattern — the `luca` CLI cannot call MuninnDB directly). Execute that instruction **exactly as returned** to persist the todo.
25
+
26
+ 4. **Confirm.** Report that the todo was created and show its `id` (the kebab-case slug derived from the title) and its status (`pending`).
27
+
28
+ $ARGUMENTS
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: todo-check
3
+ description: List all items in the development backlog.
4
+ ---
5
+
6
+ # /todo-check
7
+
8
+ List the development backlog. Todos live in **MuninnDB** (concept `todo:*`, repo vault).
9
+
10
+ ## Steps
11
+
12
+ 1. **Parse the filter.** Check `$ARGUMENTS` for an optional status filter — one of `pending`, `backlog`, or `done`.
13
+
14
+ 2. **List the todos.** Run the `luca todo list` CLI:
15
+
16
+ ```
17
+ luca todo list
18
+ ```
19
+
20
+ Pass `--status <filter>` if a filter was given.
21
+
22
+ 3. **Execute the returned instruction.** `luca todo list` prints a `mcp__muninn__muninn_recall` instruction blob (delegation pattern). Execute it **exactly as returned** to recall the todos.
23
+
24
+ 4. **Parse each entry.** Each recalled memory's `content` is JSON conforming to `TodoSchema` (`id`, `title`, `body?`, `status`, `source?`, `updatedAt`). Parse every entry. If a status filter was requested, keep only todos whose `content.status` matches it.
25
+
26
+ 5. **Display.** Render a numbered checklist grouped by status, in this order:
27
+
28
+ 1. ⬜ **Pending** — `status: "pending"`
29
+ 2. 📋 **Backlog** — `status: "backlog"`
30
+ 3. ✅ **Done** — `status: "done"`
31
+
32
+ For each todo, show its `id`, `title`, `source` (if set), and `updatedAt`.
33
+
34
+ 6. **Empty backlog.** If no todos came back, tell the user the backlog is empty and suggest `/todo-add` to start building it.
35
+
36
+ $ARGUMENTS
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * context-refresher handler — `PostToolUse` hook that surfaces a
4
+ * per-step `<luca-reminder>` to combat context rot.
5
+ *
6
+ * This is the Claude Code delivery vehicle for the pure
7
+ * `computeContextRefresher()` algorithm in
8
+ * `@alecsibilia/luca-core/orchestration`. The algorithm decides whether
9
+ * to emit and what to say; this handler is glue:
10
+ *
11
+ * 1. Read the Claude Code PostToolUse payload from stdin. We do NOT
12
+ * narrow on tool_name — the hook is registered with matcher `*`
13
+ * because every tool call is a context-growth tick.
14
+ * 2. Load (or initialize) the sidecar `.claude/cache/
15
+ * context-refresher-state.json`.
16
+ * 3. Increment the toolCallCount.
17
+ * 4. Read `.luca/state.json` for the current pipelineStep.
18
+ * 5. Call `computeContextRefresher()`.
19
+ * 6. If the verdict carries `nextState`, persist it back to the
20
+ * sidecar. If the verdict carries a `refresh-emitted` reason,
21
+ * emit the message via `additionalContext` in the PostToolUse
22
+ * hook output JSON shape.
23
+ * 7. Exit 0 always (informational hook — never blocks).
24
+ *
25
+ * The Claude Code hook contract (PostToolUse):
26
+ *
27
+ * stdin JSON shape (snake_case fields per the docs):
28
+ * {
29
+ * "session_id": "...",
30
+ * "hook_event_name": "PostToolUse",
31
+ * "tool_name": "<any>",
32
+ * "tool_input": { ... },
33
+ * "tool_response": { ... }
34
+ * }
35
+ *
36
+ * Exit codes:
37
+ * 0 → all signals come from stdout JSON
38
+ * 2 → block (not used here — this hook is informational)
39
+ * * → other non-zero → error (failure-open: we never use this)
40
+ *
41
+ * Stdout JSON for context injection:
42
+ * {
43
+ * "hookSpecificOutput": {
44
+ * "hookEventName": "PostToolUse",
45
+ * "additionalContext": "<luca-reminder>...</luca-reminder>"
46
+ * }
47
+ * }
48
+ *
49
+ * Sidecar location: `.claude/cache/context-refresher-state.json`. This
50
+ * lives under `.claude/` rather than `.luca/` because it is hook-
51
+ * managed bookkeeping (the cross-invocation counter), not pipeline
52
+ * state. The `.luca/` contract is a strict allowlist of pipeline-state
53
+ * artifacts; the sidecar would violate that contract. `.claude/cache/`
54
+ * is a fresh subdirectory (Claude Code's harness ignores unknown
55
+ * files under `.claude/`, so we own the namespace) — wipe it freely
56
+ * during Phase H cleanup if needed.
57
+ *
58
+ * Failure-open philosophy (per E-1 / E-2 / E-3 reasoning, intentionally
59
+ * reused): if ANYTHING unexpected happens (stdin parse error, state.json
60
+ * missing/malformed, sidecar read/write failure), exit 0 silently. A
61
+ * hook that mis-emits a reminder is mildly annoying; a hook that
62
+ * crashes the session is worse. Choose silent skip.
63
+ */
64
+ import { existsSync } from 'node:fs'
65
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
66
+ import { dirname, join } from 'node:path'
67
+
68
+ import { appendLedger } from '@alecsibilia/luca-core/ledger'
69
+ import {
70
+ computeContextRefresher,
71
+ type ContextRefresherCarryState,
72
+ type ContextRefresherInput,
73
+ } from '@alecsibilia/luca-core/orchestration'
74
+ import { loadCurrentState } from '@alecsibilia/luca-core/state'
75
+
76
+ /**
77
+ * Shape of the relevant slice of the PostToolUse stdin payload. We do
78
+ * not introspect the payload beyond reading it; the matcher (`*`) is
79
+ * the only gate on this hook. Defensive typing — the harness may add
80
+ * fields and the handler should not break on them.
81
+ */
82
+ interface PostToolUsePayload {
83
+ tool_name?: string
84
+ toolName?: string
85
+ }
86
+
87
+ /**
88
+ * On-disk sidecar shape. Identical fields to ContextRefresherCarryState
89
+ * with a `schemaVersion` for future migrations. The handler reads the
90
+ * sidecar with defensive defaults — a missing or malformed file
91
+ * collapses to "no prior state" (counter zero, no lastFiredStep).
92
+ */
93
+ interface SidecarFile {
94
+ schemaVersion: 1
95
+ toolCallCount: number
96
+ lastFiredStep?: string
97
+ lastFiredAt?: string
98
+ }
99
+
100
+ const SIDECAR_RELATIVE_PATH = '.claude/cache/context-refresher-state.json'
101
+
102
+ async function main(): Promise<number> {
103
+ const raw = await Bun.stdin.text()
104
+ if (!raw.trim()) {
105
+ // Empty stdin — nothing to do. Allow silently.
106
+ return 0
107
+ }
108
+
109
+ try {
110
+ // We don't need any field from the payload — the matcher (`*`)
111
+ // already filtered to "every tool call". We still parse to
112
+ // detect malformed payloads and fail open silently in that case.
113
+ JSON.parse(raw) as PostToolUsePayload
114
+ } catch {
115
+ // Malformed stdin is the harness's problem. Fail open silently.
116
+ return 0
117
+ }
118
+
119
+ const cwd = process.cwd()
120
+
121
+ // Load prior sidecar state (or default to a fresh counter).
122
+ const priorRaw = await readSidecar(cwd)
123
+ // Increment the counter to reflect THIS tool call BEFORE calling
124
+ // the algorithm — the algorithm's threshold check compares the
125
+ // post-increment value to toolCallsPerRefresh.
126
+ const priorState: ContextRefresherCarryState = {
127
+ toolCallCount: priorRaw.toolCallCount + 1,
128
+ ...(priorRaw.lastFiredStep !== undefined
129
+ ? { lastFiredStep: priorRaw.lastFiredStep }
130
+ : {}),
131
+ ...(priorRaw.lastFiredAt !== undefined
132
+ ? { lastFiredAt: priorRaw.lastFiredAt }
133
+ : {}),
134
+ }
135
+
136
+ const state = await loadCurrentState({ cwd })
137
+ const now = new Date().toISOString()
138
+
139
+ const input: ContextRefresherInput = {
140
+ currentStep: state.pipelineStep,
141
+ priorState,
142
+ now,
143
+ ...(state.complexity !== undefined
144
+ ? { complexity: state.complexity }
145
+ : {}),
146
+ ...(state.oversight !== undefined
147
+ ? { oversight: state.oversight }
148
+ : {}),
149
+ }
150
+
151
+ const verdict = computeContextRefresher(input)
152
+
153
+ // Persist nextState when present. We do this BEFORE writing to
154
+ // stdout so a downstream stdout error doesn't leave the counter
155
+ // un-incremented (which would let the refresher fire on every
156
+ // subsequent tool call until the counter caught up again).
157
+ if (verdict?.nextState !== undefined) {
158
+ await writeSidecar(cwd, verdict.nextState)
159
+ } else {
160
+ // No nextState in the verdict — either the algorithm returned
161
+ // null (idle step, no carry needed) or this is an
162
+ // unknown-current-step verdict. In either case persist a
163
+ // fresh counter from the increment so we don't lose ticks.
164
+ await writeSidecar(cwd, priorState)
165
+ }
166
+
167
+ // Emit a ledger event for the hook firing. Only log on decisive
168
+ // outcomes (emit vs skipped) — every tool call fires this hook, so
169
+ // logging every tick would flood the ledger. Failure-open.
170
+ if (verdict !== null && verdict.reason === 'refresh-emitted') {
171
+ try {
172
+ const runId = typeof (state as { sessionId?: unknown }).sessionId === 'string'
173
+ ? (state as { sessionId: string }).sessionId
174
+ : ''
175
+ appendLedger({
176
+ cwd,
177
+ runId,
178
+ event: 'hook.context-refresher.fired',
179
+ data: {
180
+ pipelineStep: state.pipelineStep,
181
+ decision: 'emitted',
182
+ toolCallCount: priorState.toolCallCount,
183
+ },
184
+ })
185
+ } catch {
186
+ // Failure-open.
187
+ }
188
+ }
189
+
190
+ if (verdict === null) {
191
+ // Idle step or quiet skip. No additionalContext emitted.
192
+ return 0
193
+ }
194
+
195
+ if (verdict.reason !== 'refresh-emitted') {
196
+ // No reminder for this tick (cooldown active, counter below
197
+ // threshold, or unknown step — we don't surface internal
198
+ // warnings to the model).
199
+ return 0
200
+ }
201
+
202
+ // Emit the additionalContext payload. Claude Code consumes the
203
+ // first valid JSON object on stdout for PostToolUse hooks.
204
+ const output = {
205
+ hookSpecificOutput: {
206
+ hookEventName: 'PostToolUse',
207
+ additionalContext: verdict.message,
208
+ },
209
+ }
210
+ process.stdout.write(JSON.stringify(output) + '\n')
211
+ return 0
212
+ }
213
+
214
+ /**
215
+ * Read the sidecar file, returning defaults on missing/malformed file.
216
+ * Never throws — every error path collapses to "no prior state".
217
+ */
218
+ async function readSidecar(cwd: string): Promise<SidecarFile> {
219
+ const fallback: SidecarFile = { schemaVersion: 1, toolCallCount: 0 }
220
+ const p = join(cwd, SIDECAR_RELATIVE_PATH)
221
+ if (!existsSync(p)) return fallback
222
+ try {
223
+ const raw = await readFile(p, 'utf-8')
224
+ if (!raw.trim()) return fallback
225
+ const parsed = JSON.parse(raw) as Partial<SidecarFile>
226
+ if (typeof parsed.toolCallCount !== 'number') return fallback
227
+ return {
228
+ schemaVersion: 1,
229
+ toolCallCount: parsed.toolCallCount,
230
+ ...(typeof parsed.lastFiredStep === 'string'
231
+ ? { lastFiredStep: parsed.lastFiredStep }
232
+ : {}),
233
+ ...(typeof parsed.lastFiredAt === 'string'
234
+ ? { lastFiredAt: parsed.lastFiredAt }
235
+ : {}),
236
+ }
237
+ } catch {
238
+ return fallback
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Write the sidecar file atomically (write-then-rename). On any error,
244
+ * swallow silently — we'd rather lose a counter tick than crash the
245
+ * session over a transient disk hiccup.
246
+ */
247
+ async function writeSidecar(
248
+ cwd: string,
249
+ state: ContextRefresherCarryState,
250
+ ): Promise<void> {
251
+ const p = join(cwd, SIDECAR_RELATIVE_PATH)
252
+ const dir = dirname(p)
253
+ const tmp = `${p}.tmp`
254
+ const payload: SidecarFile = {
255
+ schemaVersion: 1,
256
+ toolCallCount: state.toolCallCount,
257
+ ...(state.lastFiredStep !== undefined
258
+ ? { lastFiredStep: state.lastFiredStep }
259
+ : {}),
260
+ ...(state.lastFiredAt !== undefined
261
+ ? { lastFiredAt: state.lastFiredAt }
262
+ : {}),
263
+ }
264
+ try {
265
+ await mkdir(dir, { recursive: true })
266
+ await writeFile(tmp, JSON.stringify(payload) + '\n', 'utf-8')
267
+ // Bun-supported rename via fs/promises (atomic on POSIX).
268
+ const { rename } = await import('node:fs/promises')
269
+ await rename(tmp, p)
270
+ } catch {
271
+ // Sidecar persistence failure is non-fatal — next invocation
272
+ // will recompute from a fresh counter. Silently absorb.
273
+ }
274
+ }
275
+
276
+ main().then(
277
+ (code) => process.exit(code),
278
+ (err) => {
279
+ // Defensive: any unexpected throw means we fail open silently.
280
+ // No stderr — informational hook; users shouldn't see internal
281
+ // errors.
282
+ void err
283
+ process.exit(0)
284
+ },
285
+ )