grounder 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +21 -20
  2. package/dist/agents/claude.d.ts +4 -4
  3. package/dist/agents/claude.d.ts.map +1 -1
  4. package/dist/agents/claude.js +158 -179
  5. package/dist/agents/claude.js.map +1 -1
  6. package/dist/agents/cursor.d.ts +3 -3
  7. package/dist/agents/cursor.d.ts.map +1 -1
  8. package/dist/agents/cursor.js +97 -144
  9. package/dist/agents/cursor.js.map +1 -1
  10. package/dist/agents/home-skills.d.ts +46 -0
  11. package/dist/agents/home-skills.d.ts.map +1 -0
  12. package/dist/agents/home-skills.js +83 -0
  13. package/dist/agents/home-skills.js.map +1 -0
  14. package/dist/agents/hook-fragment.d.ts +52 -0
  15. package/dist/agents/hook-fragment.d.ts.map +1 -0
  16. package/dist/agents/hook-fragment.js +76 -0
  17. package/dist/agents/hook-fragment.js.map +1 -0
  18. package/dist/agents/hook-install.d.ts +62 -0
  19. package/dist/agents/hook-install.d.ts.map +1 -0
  20. package/dist/agents/hook-install.js +74 -0
  21. package/dist/agents/hook-install.js.map +1 -0
  22. package/dist/agents/hook-runtime.d.ts +77 -10
  23. package/dist/agents/hook-runtime.d.ts.map +1 -1
  24. package/dist/agents/hook-runtime.js +274 -70
  25. package/dist/agents/hook-runtime.js.map +1 -1
  26. package/dist/agents/index.d.ts +8 -14
  27. package/dist/agents/index.d.ts.map +1 -1
  28. package/dist/agents/index.js +13 -19
  29. package/dist/agents/index.js.map +1 -1
  30. package/dist/agents/types.d.ts +56 -16
  31. package/dist/agents/types.d.ts.map +1 -1
  32. package/dist/cli.js +4 -0
  33. package/dist/cli.js.map +1 -1
  34. package/dist/commands/apply.d.ts +69 -0
  35. package/dist/commands/apply.d.ts.map +1 -0
  36. package/dist/commands/apply.js +238 -0
  37. package/dist/commands/apply.js.map +1 -0
  38. package/dist/commands/doctor.d.ts.map +1 -1
  39. package/dist/commands/doctor.js +190 -97
  40. package/dist/commands/doctor.js.map +1 -1
  41. package/dist/commands/handoff/peek.d.ts +1 -1
  42. package/dist/commands/handoff/peek.d.ts.map +1 -1
  43. package/dist/commands/handoff/peek.js +28 -12
  44. package/dist/commands/handoff/peek.js.map +1 -1
  45. package/dist/commands/install-drift.d.ts +34 -0
  46. package/dist/commands/install-drift.d.ts.map +1 -0
  47. package/dist/commands/install-drift.js +72 -0
  48. package/dist/commands/install-drift.js.map +1 -0
  49. package/dist/commands/migrate.d.ts +11 -3
  50. package/dist/commands/migrate.d.ts.map +1 -1
  51. package/dist/commands/migrate.js +67 -33
  52. package/dist/commands/migrate.js.map +1 -1
  53. package/dist/commands/overview.d.ts +33 -0
  54. package/dist/commands/overview.d.ts.map +1 -0
  55. package/dist/commands/overview.js +150 -0
  56. package/dist/commands/overview.js.map +1 -0
  57. package/dist/commands/render-artifact-table.d.ts +85 -0
  58. package/dist/commands/render-artifact-table.d.ts.map +1 -0
  59. package/dist/commands/render-artifact-table.js +232 -0
  60. package/dist/commands/render-artifact-table.js.map +1 -0
  61. package/dist/commands/search.d.ts.map +1 -1
  62. package/dist/commands/search.js +15 -4
  63. package/dist/commands/search.js.map +1 -1
  64. package/dist/commands/setup.d.ts.map +1 -1
  65. package/dist/commands/setup.js +86 -13
  66. package/dist/commands/setup.js.map +1 -1
  67. package/dist/commands/status.d.ts +7 -0
  68. package/dist/commands/status.d.ts.map +1 -1
  69. package/dist/commands/status.js +269 -88
  70. package/dist/commands/status.js.map +1 -1
  71. package/dist/connector/ledger-migrations.d.ts +55 -0
  72. package/dist/connector/ledger-migrations.d.ts.map +1 -0
  73. package/dist/connector/ledger-migrations.js +94 -0
  74. package/dist/connector/ledger-migrations.js.map +1 -0
  75. package/dist/connector/state.d.ts +96 -59
  76. package/dist/connector/state.d.ts.map +1 -1
  77. package/dist/connector/state.js +165 -107
  78. package/dist/connector/state.js.map +1 -1
  79. package/dist/help.d.ts +1 -1
  80. package/dist/help.d.ts.map +1 -1
  81. package/dist/help.js +27 -5
  82. package/dist/help.js.map +1 -1
  83. package/dist/reconcile/apply.d.ts +31 -0
  84. package/dist/reconcile/apply.d.ts.map +1 -0
  85. package/dist/reconcile/apply.js +97 -0
  86. package/dist/reconcile/apply.js.map +1 -0
  87. package/dist/reconcile/core.d.ts +82 -0
  88. package/dist/reconcile/core.d.ts.map +1 -0
  89. package/dist/reconcile/core.js +163 -0
  90. package/dist/reconcile/core.js.map +1 -0
  91. package/dist/reconcile/disk.d.ts +13 -0
  92. package/dist/reconcile/disk.d.ts.map +1 -0
  93. package/dist/reconcile/disk.js +28 -0
  94. package/dist/reconcile/disk.js.map +1 -0
  95. package/dist/util/fs.d.ts +14 -0
  96. package/dist/util/fs.d.ts.map +1 -1
  97. package/dist/util/fs.js +27 -1
  98. package/dist/util/fs.js.map +1 -1
  99. package/dist/util/merge-json.d.ts +13 -2
  100. package/dist/util/merge-json.d.ts.map +1 -1
  101. package/dist/util/merge-json.js +24 -10
  102. package/dist/util/merge-json.js.map +1 -1
  103. package/dist/vault/list-handoffs.d.ts +13 -3
  104. package/dist/vault/list-handoffs.d.ts.map +1 -1
  105. package/dist/vault/list-handoffs.js +28 -13
  106. package/dist/vault/list-handoffs.js.map +1 -1
  107. package/dist/vault/list-notes.d.ts +12 -3
  108. package/dist/vault/list-notes.d.ts.map +1 -1
  109. package/dist/vault/list-notes.js +13 -7
  110. package/dist/vault/list-notes.js.map +1 -1
  111. package/dist/vault/list-plans.d.ts +12 -3
  112. package/dist/vault/list-plans.d.ts.map +1 -1
  113. package/dist/vault/list-plans.js +13 -7
  114. package/dist/vault/list-plans.js.map +1 -1
  115. package/dist/vault/search.d.ts.map +1 -1
  116. package/dist/vault/search.js +128 -24
  117. package/dist/vault/search.js.map +1 -1
  118. package/package.json +2 -2
  119. package/templates/agents/claude/skills/grounder-handoff/SKILL.md +73 -0
  120. package/templates/agents/claude/{commands/grounder-note.md → skills/grounder-note/SKILL.md} +6 -0
  121. package/templates/agents/claude/skills/grounder-overview/SKILL.md +21 -0
  122. package/templates/agents/claude/{commands/grounder-plan.md → skills/grounder-plan/SKILL.md} +6 -0
  123. package/templates/agents/claude/skills/grounder-recall/SKILL.md +34 -0
  124. package/templates/agents/claude/{commands/grounder-search.md → skills/grounder-search/SKILL.md} +16 -8
  125. package/templates/agents/cursor/skills/grounder-handoff/SKILL.md +73 -0
  126. package/templates/agents/cursor/{commands/grounder-note.md → skills/grounder-note/SKILL.md} +6 -0
  127. package/templates/agents/cursor/skills/grounder-overview/SKILL.md +21 -0
  128. package/templates/agents/cursor/{commands/grounder-plan.md → skills/grounder-plan/SKILL.md} +6 -0
  129. package/templates/agents/cursor/skills/grounder-recall/SKILL.md +34 -0
  130. package/templates/agents/cursor/{commands/grounder-search.md → skills/grounder-search/SKILL.md} +16 -8
  131. package/templates/vault/plan.md +1 -1
  132. package/templates/vault/session-handoff.md +2 -1
  133. package/dist/agents/install-command.d.ts +0 -35
  134. package/dist/agents/install-command.d.ts.map +0 -1
  135. package/dist/agents/install-command.js +0 -65
  136. package/dist/agents/install-command.js.map +0 -1
  137. package/dist/commands/apply-agent-installs.d.ts +0 -34
  138. package/dist/commands/apply-agent-installs.d.ts.map +0 -1
  139. package/dist/commands/apply-agent-installs.js +0 -137
  140. package/dist/commands/apply-agent-installs.js.map +0 -1
  141. package/templates/agents/claude/commands/grounder-task-handoff.md +0 -58
  142. package/templates/agents/claude/commands/grounder-task.md +0 -22
  143. package/templates/agents/cursor/commands/grounder-task-handoff.md +0 -58
  144. package/templates/agents/cursor/commands/grounder-task.md +0 -22
package/README.md CHANGED
@@ -7,7 +7,7 @@ Session summaries, plans, and notes in files you own.
7
7
 
8
8
  <picture>
9
9
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/andrej-kolic/grounder/main/docs/assets/what-dark.svg">
10
- <img alt="Grounder connects Cursor, Claude Code, and more agents to one markdown vault through slash commands and the CLI. The vault is a folder tree: 10-Projects/your-project/ containing plans (updated across sessions), notes (always a new file), and logs (saved session summaries). Caption: Any agent. One vault. Only when you ask." src="https://raw.githubusercontent.com/andrej-kolic/grounder/main/docs/assets/what.svg">
10
+ <img alt="Grounder connects Cursor, Claude Code, and more agents to one markdown vault through skills and the CLI. The vault is a folder tree: 10-Projects/your-project/ containing plans (updated across sessions), notes (always a new file), and logs (saved session summaries). Caption: Any agent. One vault. Only when you ask." src="https://raw.githubusercontent.com/andrej-kolic/grounder/main/docs/assets/what.svg">
11
11
  </picture>
12
12
 
13
13
  <div>&nbsp;</div>
@@ -21,7 +21,7 @@ Session summaries, plans, and notes in files you own.
21
21
  directory. Because it's files instead of chat history, work started in Cursor can be
22
22
  picked up in Claude Code, weeks later, on a different machine.
23
23
 
24
- The daily loop is five slash commands — [examples](#examples) show what you type.
24
+ The daily loop is five skills, typed as `/name` — [examples](#examples) show what you type.
25
25
 
26
26
  ## The problem
27
27
 
@@ -36,9 +36,9 @@ The daily loop is five slash commands — [examples](#examples) show what you ty
36
36
  - **Plans live, sessions accumulate** — a plan updates in place across sessions; notes and handoffs are dated files, kept to go back to. `search` ranks across all of them.
37
37
  - **Deliberate** — nothing is read or written until you run a command. No auto-capture, no RAG, no tokens spent otherwise.
38
38
  - **Follows you across machines** — the vault is just a folder, so git (or Syncthing, or Dropbox) is all the sync you need.
39
- - **Cursor and Claude Code today** — slash commands for both; more agents on the [roadmap](#roadmap).
39
+ - **Cursor and Claude Code today** — skills for both; more agents on the [roadmap](#roadmap).
40
40
 
41
- **Requirements:** Node.js 18+ and a folder to keep the files in — an existing Obsidian
41
+ **Requirements:** Node.js 22+ and a folder to keep the files in — an existing Obsidian
42
42
  vault, or an empty directory Grounder fills over time. Git is optional.
43
43
 
44
44
  ## Install
@@ -63,7 +63,7 @@ run it (e.g. "set up grounder"). Skip the global install with
63
63
  ## Quickstart
64
64
 
65
65
  ```bash
66
- # Once per machine — connect to a vault + install agent slash commands
66
+ # Once per machine — connect to a vault + install agent skills
67
67
  grounder setup <path-to-your-vault> --hooks
68
68
 
69
69
  # Once per project folder
@@ -78,12 +78,12 @@ one-line reminder at session start when a saved session exists — see
78
78
 
79
79
  ## Daily use
80
80
 
81
- Work from the agent's chat. A typical loop starts by resuming the last saved session and
81
+ Work from the agent's chat. A typical loop starts by recalling the last saved session and
82
82
  ends by saving a short summary.
83
83
 
84
- Anything after a slash command is an instruction, not file content. The agent follows
85
- it — writes a plan, saves a note, or searches the vault. `/grounder-task` and
86
- `/grounder-task-handoff` don't need text at all.
84
+ Anything after the skill name is an instruction, not file content. The agent follows
85
+ it — writes a plan, saves a note, or searches the vault. `/grounder-recall` and
86
+ `/grounder-handoff` don't need text at all.
87
87
 
88
88
  ### Examples
89
89
 
@@ -91,22 +91,22 @@ A typical session:
91
91
 
92
92
  | You type | What it does |
93
93
  | -------- | ------------ |
94
- | `/grounder-task` | Resume the latest saved session |
94
+ | `/grounder-recall` | Recall the latest saved session |
95
95
  | `/grounder-plan save insights from this session as an implementation plan with steps` | Write a named plan |
96
96
  | `/grounder-search decisions and discussions on token refresh` | Search the vault |
97
97
  | `/grounder-note explain why we rejected cookie sessions` | Save a new note |
98
- | `/grounder-task-handoff` | Save a short session summary |
98
+ | `/grounder-handoff` | Save a short session summary |
99
99
 
100
- Later, update the living plan or resume a named session:
100
+ Later, update the living plan or recall a named session:
101
101
 
102
102
  | You type | What it does |
103
103
  | -------- | ------------ |
104
104
  | `/grounder-plan update the auth rewrite plan — jwt validator is done` | Update an existing plan |
105
- | `/grounder-task resume the auth-middleware session` | Resume a specific saved session, not the latest |
105
+ | `/grounder-recall the auth-middleware session` | Recall a specific saved session, not the latest |
106
106
 
107
107
  ### Demo
108
108
 
109
- ![Daily-use session loop across the five slash commands](https://raw.githubusercontent.com/andrej-kolic/grounder/main/packages/demo-casts/out/readme.gif)
109
+ ![Daily-use session loop across the five skills](https://raw.githubusercontent.com/andrej-kolic/grounder/main/packages/demo-casts/out/readme.gif)
110
110
 
111
111
  ## How it works
112
112
 
@@ -133,7 +133,7 @@ Three things, and that's the whole model:
133
133
  `10-Projects/` is a common Obsidian vault convention, so Grounder slots into an existing vault instead
134
134
  of fighting it. A **handoff** is a saved session summary — what got done, what's next,
135
135
  what's blocked — and they live under `logs/` because they accumulate one per session.
136
- `/grounder-task` resumes the latest saved session by default, or an earlier one by name.
136
+ `/grounder-recall` recalls the latest saved session by default, or an earlier one by name.
137
137
 
138
138
  Here's the living plan, `plans/auth-rewrite.md`:
139
139
 
@@ -163,7 +163,7 @@ Machine config, the link marker, and how commands find the project:
163
163
 
164
164
  ## Commands
165
165
 
166
- No agent, or want to write by hand? Each slash command has a matching CLI command.
166
+ No agent, or want to write by hand? Each skill has a matching CLI command.
167
167
  Pass the file text (or search query) — not an instruction for the agent.
168
168
 
169
169
  ```bash
@@ -172,6 +172,7 @@ grounder note "Investigate auth middleware" # always a new note
172
172
  grounder handoff $'# Handoff: ...\n\n## Next\n1. ...' # saved session summary
173
173
  grounder search "token refresh" # find earlier documents on token refresh
174
174
  grounder plan list # also: note list, handoff list
175
+ grounder overview # counts + recent titles, all three buckets
175
176
  grounder status # am I wired up?
176
177
  grounder doctor # why isn't this working?
177
178
  ```
@@ -184,11 +185,11 @@ Full flags and behavior: **[CLI reference](https://github.com/andrej-kolic/groun
184
185
 
185
186
  Those are instructions written once: stable rules about the project. Grounder stores
186
187
  what accumulates: what happened last session, what's next, the plan currently in flight.
187
- They're complements — `/grounder-task` reads the latest saved session *and* `AGENTS.md`.
188
+ They're complements — `/grounder-recall` reads the latest saved session *and* `AGENTS.md`.
188
189
 
189
190
  ### Is this an MCP server?
190
191
 
191
- No. It's a CLI plus slash command files. Nothing is registered with the agent, nothing
192
+ No. It's a CLI plus skill files. Nothing is registered with the agent, nothing
192
193
  runs in the background, and no tokens are spent until you type a command.
193
194
 
194
195
  ### Do I need Obsidian?
@@ -204,7 +205,7 @@ with the project.
204
205
 
205
206
  ### What does it put in my repo?
206
207
 
207
- Only `.grounder.json` — two lines, safe to commit. Slash commands go under `~/.cursor` and
208
+ Only `.grounder.json` — two lines, safe to commit. Skill files go under `~/.cursor` and
208
209
  `~/.claude`; vault content stays outside the project tree entirely.
209
210
 
210
211
  ### Does it capture my conversations automatically?
@@ -222,7 +223,7 @@ No. Nothing is written or loaded unless you ask. That's the point.
222
223
  ## Roadmap
223
224
 
224
225
  - **Support for Copilot, Codex, and other popular agents** — expand beyond Cursor and Claude Code so more agent tools can use the same vault memory.
225
- - **Auto-draft handoff on session end** (under consideration) — a hook that has the agent write the same structured Done/Next/Blockers checkpoint automatically, instead of requiring `/grounder-task-handoff`.
226
+ - **Auto-draft handoff on session end** (under consideration) — a hook that has the agent write the same structured Done/Next/Blockers checkpoint automatically, instead of requiring `/grounder-handoff`.
226
227
 
227
228
  ## Development
228
229
 
@@ -1,7 +1,7 @@
1
1
  import type { AgentAdapter } from "./types.js";
2
2
  /**
3
3
  * Canonical SessionStart command for Claude Code (home-local runtime, not `npx`).
4
- * @see {@link peekHookCommand} — REVERT: restore `"npx grounder handoff peek"` and drop runtime.
4
+ * @see {@link peekHookCommand}
5
5
  */
6
6
  export declare function claudePeekHookCommand(homeDir?: string): string;
7
7
  /**
@@ -9,13 +9,13 @@ export declare function claudePeekHookCommand(homeDir?: string): string;
9
9
  * Excludes `resume` and `fork` — those sessions already carry prior context.
10
10
  */
11
11
  export declare const CLAUDE_SESSION_START_MATCHER = "startup|clear|compact";
12
- export declare function claudeCommandsDir(homeDir?: string): string;
12
+ export declare function claudeSkillsDir(homeDir?: string): string;
13
13
  /** Absolute path to Claude Code's shared settings file (`~/.claude/settings.json`). */
14
14
  export declare function claudeSettingsJsonPath(homeDir?: string): string;
15
15
  export declare function grounderNoteCommandPath(homeDir?: string): string;
16
16
  export declare function grounderPlanCommandPath(homeDir?: string): string;
17
- export declare function grounderTaskHandoffCommandPath(homeDir?: string): string;
18
- export declare function grounderTaskCommandPath(homeDir?: string): string;
17
+ export declare function grounderHandoffCommandPath(homeDir?: string): string;
18
+ export declare function grounderRecallCommandPath(homeDir?: string): string;
19
19
  export declare function expectedArtifacts(homeDir?: string): string[];
20
20
  /** Paths of hook config this adapter touches — currently just `settings.json`. */
21
21
  export declare function expectedHookArtifacts(homeDir?: string): string[];
@@ -1 +1 @@
1
- {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/agents/claude.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EACV,YAAY,EAIb,MAAM,YAAY,CAAC;AAapB;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B,0BAA0B,CAAC;AAEpE,wBAAgB,iBAAiB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,uFAAuF;AACvF,wBAAgB,sBAAsB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED,wBAAgB,uBAAuB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED,wBAAgB,8BAA8B,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAEvE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED,wBAAgB,iBAAiB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAE5D;AAED,kFAAkF;AAClF,wBAAgB,qBAAqB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAEhE;AA+ND,eAAO,MAAM,MAAM,EAAE,YAwCpB,CAAC"}
1
+ {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/agents/claude.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,YAAY,EAA2C,MAAM,YAAY,CAAC;AAQxF;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B,0BAA0B,CAAC;AAEpE,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,uFAAuF;AACvF,wBAAgB,sBAAsB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED,wBAAgB,uBAAuB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED,wBAAgB,uBAAuB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED,wBAAgB,0BAA0B,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAEnE;AAED,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAElE;AAED,wBAAgB,iBAAiB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAE5D;AAED,kFAAkF;AAClF,wBAAgB,qBAAqB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAEhE;AAyPD,eAAO,MAAM,MAAM,EAAE,YAapB,CAAC"}
@@ -1,23 +1,18 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import { resolveHomeDir } from "../connector/home.js";
5
4
  import { fileExists } from "../util/fs.js";
6
- import { mergeJsonFile } from "../util/merge-json.js";
7
- import { installHookRuntime, isGrounderPeekHookCommand, isHookRuntimeStale, peekHookCommand, } from "./hook-runtime.js";
8
- import { installCommandFile, recordCommandFileHashes } from "./install-command.js";
9
- const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
10
- const templateDir = path.join(pkgRoot, "templates", "agents", "claude", "commands");
11
- const COMMANDS = [
12
- "grounder-note.md",
13
- "grounder-search.md",
14
- "grounder-plan.md",
15
- "grounder-task-handoff.md",
16
- "grounder-task.md",
17
- ];
5
+ import { homeSkillsLayout } from "./home-skills.js";
6
+ import { readEventEntries, readHooksObject, removeMatchingEntries } from "./hook-fragment.js";
7
+ import { installHookFragment, removeHookFragment } from "./hook-install.js";
8
+ import { isGrounderPeekHookCommand, isHookRuntimeStale, peekHookCommand } from "./hook-runtime.js";
9
+ /** Whole-file artifacts: `~/.claude/skills/` plus the retired `~/.claude/commands/`. */
10
+ const layout = homeSkillsLayout({ id: "claude", agentDir: ".claude" });
11
+ /** Hook event Grounder owns in `settings.json`. */
12
+ const SESSION_START_EVENT = "SessionStart";
18
13
  /**
19
14
  * Canonical SessionStart command for Claude Code (home-local runtime, not `npx`).
20
- * @see {@link peekHookCommand} — REVERT: restore `"npx grounder handoff peek"` and drop runtime.
15
+ * @see {@link peekHookCommand}
21
16
  */
22
17
  export function claudePeekHookCommand(homeDir) {
23
18
  return peekHookCommand(homeDir);
@@ -27,27 +22,27 @@ export function claudePeekHookCommand(homeDir) {
27
22
  * Excludes `resume` and `fork` — those sessions already carry prior context.
28
23
  */
29
24
  export const CLAUDE_SESSION_START_MATCHER = "startup|clear|compact";
30
- export function claudeCommandsDir(homeDir) {
31
- return path.join(resolveHomeDir(homeDir), ".claude", "commands");
25
+ export function claudeSkillsDir(homeDir) {
26
+ return layout.skillsDir(homeDir);
32
27
  }
33
28
  /** Absolute path to Claude Code's shared settings file (`~/.claude/settings.json`). */
34
29
  export function claudeSettingsJsonPath(homeDir) {
35
30
  return path.join(resolveHomeDir(homeDir), ".claude", "settings.json");
36
31
  }
37
32
  export function grounderNoteCommandPath(homeDir) {
38
- return path.join(claudeCommandsDir(homeDir), "grounder-note.md");
33
+ return layout.skillPath("grounder-note", homeDir);
39
34
  }
40
35
  export function grounderPlanCommandPath(homeDir) {
41
- return path.join(claudeCommandsDir(homeDir), "grounder-plan.md");
36
+ return layout.skillPath("grounder-plan", homeDir);
42
37
  }
43
- export function grounderTaskHandoffCommandPath(homeDir) {
44
- return path.join(claudeCommandsDir(homeDir), "grounder-task-handoff.md");
38
+ export function grounderHandoffCommandPath(homeDir) {
39
+ return layout.skillPath("grounder-handoff", homeDir);
45
40
  }
46
- export function grounderTaskCommandPath(homeDir) {
47
- return path.join(claudeCommandsDir(homeDir), "grounder-task.md");
41
+ export function grounderRecallCommandPath(homeDir) {
42
+ return layout.skillPath("grounder-recall", homeDir);
48
43
  }
49
44
  export function expectedArtifacts(homeDir) {
50
- return COMMANDS.map((filename) => path.join(claudeCommandsDir(homeDir), filename));
45
+ return layout.expectedArtifacts(homeDir);
51
46
  }
52
47
  /** Paths of hook config this adapter touches — currently just `settings.json`. */
53
48
  export function expectedHookArtifacts(homeDir) {
@@ -83,62 +78,86 @@ export function expectedHookArtifacts(homeDir) {
83
78
  // - matcher group → { matcher, hooks: Hook[] }
84
79
  // - hook entry → { type: "command", command: string }
85
80
  //
86
- // Idempotency: {@link isGrounderPeekHookCommand} (runtime path or legacy npx).
81
+ // Idempotency / recognizer: {@link isGrounderPeekHookCommand} (runtime path
82
+ // or legacy npx). Always-converge (Ansible `blockinfile` / Kubernetes
83
+ // Server-Side Apply sole-owner model): every recognizer match — however many,
84
+ // wherever they live across matcher groups — is removed and replaced with
85
+ // exactly one canonical entry. No conflict / `--force` gate.
87
86
  // ---------------------------------------------------------------------------
88
87
  function peekHookEntry(homeDir) {
89
88
  return { type: "command", command: claudePeekHookCommand(homeDir) };
90
89
  }
91
- /**
92
- * Locate Grounder's peek command inside a `hooks.SessionStart` array.
93
- *
94
- * @param sessionStart - `settings.hooks.SessionStart` — array of matcher groups
95
- * @returns Indices into that array / the group's `hooks` array, or `null` if absent
96
- */
97
- function findPeekHook(sessionStart) {
98
- for (let groupIdx = 0; groupIdx < sessionStart.length; groupIdx++) {
99
- const group = sessionStart[groupIdx];
100
- if (!group || typeof group !== "object" || Array.isArray(group)) {
101
- continue;
102
- }
103
- const hooks = group.hooks;
104
- if (!Array.isArray(hooks)) {
90
+ function isClaudeHookEntry(hook) {
91
+ return (hook !== null &&
92
+ typeof hook === "object" &&
93
+ !Array.isArray(hook) &&
94
+ hook.type === "command" &&
95
+ isGrounderPeekHookCommand(hook.command));
96
+ }
97
+ function isMatcherGroup(group) {
98
+ return group !== null && typeof group === "object" && !Array.isArray(group);
99
+ }
100
+ /** Every Grounder hook entry across every matcher group, tagged with its group's matcher. */
101
+ function findAllPeekHooksPlaced(sessionStart) {
102
+ const found = [];
103
+ for (const group of sessionStart) {
104
+ if (!isMatcherGroup(group) || !Array.isArray(group.hooks)) {
105
105
  continue;
106
106
  }
107
- for (let hookIdx = 0; hookIdx < hooks.length; hookIdx++) {
108
- const hook = hooks[hookIdx];
109
- if (hook &&
110
- typeof hook === "object" &&
111
- !Array.isArray(hook) &&
112
- isGrounderPeekHookCommand(hook.command)) {
113
- return { groupIdx, hookIdx };
114
- }
107
+ for (const hook of group.hooks.filter(isClaudeHookEntry)) {
108
+ found.push({ matcher: group.matcher, hook });
115
109
  }
116
110
  }
117
- return null;
111
+ return found;
118
112
  }
119
- /** Whether `settings.json` already lists any Grounder peek command (for status labeling). */
120
- async function peekHookHadGrounderEntry(filePath) {
121
- try {
122
- const parsed = JSON.parse(await readFile(filePath, "utf8"));
123
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
124
- return false;
125
- }
126
- const hooks = parsed.hooks;
127
- if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) {
128
- return false;
113
+ /** Every Grounder hook entry across every matcher group, flattened. */
114
+ function findAllPeekHooks(sessionStart) {
115
+ return findAllPeekHooksPlaced(sessionStart).map((placed) => placed.hook);
116
+ }
117
+ /** Remove every Grounder hook entry from every matcher group's `hooks` array. */
118
+ function removeAllPeekHooks(sessionStart) {
119
+ return sessionStart.map((group) => {
120
+ if (!isMatcherGroup(group) || !Array.isArray(group.hooks)) {
121
+ return group;
129
122
  }
130
- const sessionStart = hooks.SessionStart;
131
- return Array.isArray(sessionStart) && findPeekHook(sessionStart) !== null;
123
+ return { ...group, hooks: removeMatchingEntries(group.hooks, isClaudeHookEntry) };
124
+ });
125
+ }
126
+ function hasHooks(group) {
127
+ return isMatcherGroup(group) && Array.isArray(group.hooks) && group.hooks.length > 0;
128
+ }
129
+ /**
130
+ * Drop matcher groups that removal itself emptied — i.e. `next[i]` has no
131
+ * hooks left but `before[i]` (same index, pre-removal) did. A group that was
132
+ * already empty for reasons of its own (not something Grounder touched) is
133
+ * left alone: it isn't clutter Grounder created room for, so it isn't
134
+ * Grounder's call to delete it. `next` may be longer than `before` (a newly
135
+ * appended canonical group) — those extra entries always have hooks, so the
136
+ * length mismatch never reaches the `before[i]` lookup.
137
+ */
138
+ function dropGroupsEmptiedByRemoval(before, next) {
139
+ return next.filter((group, i) => hasHooks(group) || !hasHooks(before[i]));
140
+ }
141
+ /** `hooks.SessionStart` from a settings.json on disk; `null` if absent or unreadable. */
142
+ async function readSessionStart(filePath) {
143
+ try {
144
+ return readEventEntries(JSON.parse(await readFile(filePath, "utf8")), SESSION_START_EVENT);
132
145
  }
133
146
  catch {
134
- return false;
147
+ return null;
135
148
  }
136
149
  }
150
+ /** Whether `settings.json` already lists any Grounder peek command (for status labeling). */
151
+ async function peekHookHadGrounderEntry(filePath) {
152
+ return findAllPeekHooks((await readSessionStart(filePath)) ?? []).length > 0;
153
+ }
137
154
  /**
138
- * Skip only when the canonical command is already present *and* the runtime is
139
- * current for the running grounder version/source. Legacy `npx` entries or a
140
- * stale runtime (missing, or symlinked/copied from a different source) always
141
- * refresh — no `--force` required to migrate or to pick up an upgrade.
155
+ * Skip only when exactly one canonical entry is already present *under the
156
+ * canonical matcher group* ({@link CLAUDE_SESSION_START_MATCHER}) *and* the
157
+ * runtime is current for the running grounder version/source. Anything else
158
+ * — no entry, a legacy `npx` form, a drifted command, more than one match
159
+ * (however scattered across matcher groups), or the one match sitting under
160
+ * the wrong matcher — always converges.
142
161
  */
143
162
  async function peekHookUpToDate(filePath, homeDir) {
144
163
  if (!(await fileExists(filePath))) {
@@ -147,149 +166,109 @@ async function peekHookUpToDate(filePath, homeDir) {
147
166
  if (await isHookRuntimeStale(homeDir)) {
148
167
  return false;
149
168
  }
150
- try {
151
- const parsed = JSON.parse(await readFile(filePath, "utf8"));
152
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
153
- return false;
154
- }
155
- const hooks = parsed.hooks;
156
- if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) {
157
- return false;
158
- }
159
- const sessionStart = hooks.SessionStart;
160
- if (!Array.isArray(sessionStart)) {
161
- return false;
162
- }
163
- const found = findPeekHook(sessionStart);
164
- if (!found) {
165
- return false;
166
- }
167
- const group = sessionStart[found.groupIdx];
168
- return group.hooks[found.hookIdx]?.command === claudePeekHookCommand(homeDir);
169
+ const sessionStart = await readSessionStart(filePath);
170
+ if (!sessionStart) {
171
+ return false;
169
172
  }
170
- catch {
173
+ const placed = findAllPeekHooksPlaced(sessionStart);
174
+ if (placed.length !== 1 || placed[0].matcher !== CLAUDE_SESSION_START_MATCHER) {
171
175
  return false;
172
176
  }
177
+ return JSON.stringify(placed[0].hook) === JSON.stringify(peekHookEntry(homeDir));
173
178
  }
174
179
  /**
175
- * Deep-merge Grounder's SessionStart hook into an existing settings object.
180
+ * Converge Grounder's SessionStart hook into an existing settings object:
181
+ * remove every recognizer match from every matcher group, then insert
182
+ * exactly one canonical entry.
183
+ *
184
+ * Preserves every key except the nested path it owns. Insertion strategy
185
+ * (after removal, so this always runs against a Grounder-free tree):
186
+ * 1. A matcher group with `matcher === CLAUDE_SESSION_START_MATCHER` exists
187
+ * (possibly the very one a match was just removed from) → append there.
188
+ * 2. Else → push a new matcher group with Grounder's hook.
176
189
  *
177
- * Preserves every key except the nested path it owns. Strategy:
178
- * 1. If a Grounder peek hook already exists (runtime or legacy npx) → replace in place.
179
- * 2. Else if a matcher group with `matcher === CLAUDE_SESSION_START_MATCHER` exists →
180
- * append Grounder's hook to that group's `hooks` array.
181
- * 3. Else → push a new matcher group with Grounder's hook.
190
+ * Any matcher group the removal step emptied — e.g. a Grounder-only hook
191
+ * that lived under a non-canonical matcher is dropped rather than left
192
+ * behind as clutter, matching {@link removeClaudeHooks}'s uninstall path.
182
193
  *
183
194
  * @param current - Parsed settings.json root (object). Other top-level keys untouched.
184
195
  * @param homeDir - Home override for the canonical command path
185
196
  * @returns New settings object with `hooks.SessionStart` updated
186
197
  */
187
198
  function mergeClaudeHooks(current, homeDir) {
188
- const hooks = current.hooks && typeof current.hooks === "object" && !Array.isArray(current.hooks)
189
- ? { ...current.hooks }
190
- : {};
191
- const sessionStart = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : [];
192
- const found = findPeekHook(sessionStart);
199
+ const hooks = readHooksObject(current, claudeSettingsJsonPath(homeDir));
200
+ const raw = hooks[SESSION_START_EVENT];
201
+ const sessionStart = Array.isArray(raw) ? raw : [];
202
+ const cleaned = removeAllPeekHooks(sessionStart);
193
203
  const entry = peekHookEntry(homeDir);
194
- if (found) {
195
- // Path 1: refresh existing Grounder hook entry in place
196
- const group = { ...sessionStart[found.groupIdx] };
204
+ const matcherIdx = cleaned.findIndex((group) => isMatcherGroup(group) && group.matcher === CLAUDE_SESSION_START_MATCHER);
205
+ let nextSessionStart;
206
+ if (matcherIdx >= 0) {
207
+ const group = { ...cleaned[matcherIdx] };
197
208
  const groupHooks = Array.isArray(group.hooks) ? [...group.hooks] : [];
198
- groupHooks[found.hookIdx] = entry;
209
+ groupHooks.push(entry);
199
210
  group.hooks = groupHooks;
200
- sessionStart[found.groupIdx] = group;
211
+ nextSessionStart = [...cleaned];
212
+ nextSessionStart[matcherIdx] = group;
201
213
  }
202
214
  else {
203
- const matcherIdx = sessionStart.findIndex((group) => group &&
204
- typeof group === "object" &&
205
- !Array.isArray(group) &&
206
- group.matcher === CLAUDE_SESSION_START_MATCHER);
207
- if (matcherIdx >= 0) {
208
- // Path 2: same matcher group already exists (e.g. user hooks) — append ours
209
- const group = { ...sessionStart[matcherIdx] };
210
- const groupHooks = Array.isArray(group.hooks) ? [...group.hooks] : [];
211
- groupHooks.push(entry);
212
- group.hooks = groupHooks;
213
- sessionStart[matcherIdx] = group;
214
- }
215
- else {
216
- // Path 3: no matching group — create the canonical SessionStart entry
217
- sessionStart.push({
218
- matcher: CLAUDE_SESSION_START_MATCHER,
219
- hooks: [entry],
220
- });
221
- }
215
+ nextSessionStart = [...cleaned, { matcher: CLAUDE_SESSION_START_MATCHER, hooks: [entry] }];
222
216
  }
223
- return { ...current, hooks: { ...hooks, SessionStart: sessionStart } };
217
+ nextSessionStart = dropGroupsEmptiedByRemoval(sessionStart, nextSessionStart);
218
+ return { ...current, hooks: { ...hooks, [SESSION_START_EVENT]: nextSessionStart } };
224
219
  }
225
220
  /**
226
- * Install (or refresh) Grounder's SessionStart teaser hook into `~/.claude/settings.json`.
227
- *
228
- * Also materializes `~/.grounder/runtime` (see {@link installHookRuntime}).
229
- *
230
- * Force semantics:
231
- * - Up-to-date canonical entry + fresh runtime and `force` false → skip
232
- * - Otherwise refresh runtime + merge host config
233
- *
234
- * Unparseable settings.json: {@link mergeJsonFile} backs off and this throws (never clobbers).
221
+ * Remove every Grounder hook entry from every matcher group, touching
222
+ * nothing else, and drop a matcher group only when this removal is what left
223
+ * its `hooks` array empty (see {@link dropGroupsEmptiedByRemoval}) — clutter
224
+ * Grounder itself created room for, not a group that started empty for
225
+ * reasons of its own. Returns `current` verbatim when there's no Grounder
226
+ * entry to remove, so `mergeJsonFile` sees no change and leaves an unrelated
227
+ * `settings.json` untouched instead of reformatting it.
235
228
  */
236
- async function installHooks(opts) {
237
- const dest = claudeSettingsJsonPath(opts.homeDir);
238
- const upToDate = await peekHookUpToDate(dest, opts.homeDir);
239
- if (upToDate && !opts.force) {
240
- return { artifacts: { [dest]: "skipped" } };
241
- }
242
- if (opts.dryRun) {
243
- const fileExisted = await fileExists(dest);
244
- const hadGrounderEntry = fileExisted && (await peekHookHadGrounderEntry(dest));
245
- const status = hadGrounderEntry ? "overwritten" : "created";
246
- return { artifacts: { [dest]: status } };
247
- }
248
- await installHookRuntime({ homeDir: opts.homeDir });
249
- const fileExisted = await fileExists(dest);
250
- const hadGrounderEntry = fileExisted && (await peekHookHadGrounderEntry(dest));
251
- const result = await mergeJsonFile(dest, (current) => mergeClaudeHooks(current, opts.homeDir));
252
- if (!result.ok) {
253
- throw new Error(result.message);
229
+ function removeClaudeHooks(current) {
230
+ const hooks = current.hooks && typeof current.hooks === "object" && !Array.isArray(current.hooks)
231
+ ? current.hooks
232
+ : undefined;
233
+ const raw = hooks?.[SESSION_START_EVENT];
234
+ const sessionStart = Array.isArray(raw) ? raw : [];
235
+ if (findAllPeekHooks(sessionStart).length === 0) {
236
+ return current;
254
237
  }
255
- const status = hadGrounderEntry ? "overwritten" : "created";
256
- return { artifacts: { [dest]: status } };
238
+ const cleaned = dropGroupsEmptiedByRemoval(sessionStart, removeAllPeekHooks(sessionStart));
239
+ return { ...current, hooks: { ...hooks, [SESSION_START_EVENT]: cleaned } };
240
+ }
241
+ /**
242
+ * Install (or converge) Grounder's SessionStart teaser hook into `~/.claude/settings.json`.
243
+ * @see {@link installHookFragment} for the shared install/report scaffolding.
244
+ */
245
+ async function installHooks(opts) {
246
+ return installHookFragment({
247
+ dest: claudeSettingsJsonPath(opts.homeDir),
248
+ isUpToDate: (filePath) => peekHookUpToDate(filePath, opts.homeDir),
249
+ hasGrounderEntry: peekHookHadGrounderEntry,
250
+ merge: (current) => mergeClaudeHooks(current, opts.homeDir),
251
+ }, opts);
252
+ }
253
+ /**
254
+ * Remove Grounder's SessionStart hook entry entirely (`--no-hooks`) — the
255
+ * opt-out must also remove the fragment, not just flip `hooksEnabled` false,
256
+ * or the session hook keeps firing and the tri-state's stickiness against
257
+ * the next plain `migrate` would have nothing to rest on.
258
+ */
259
+ async function removeHooks(opts) {
260
+ return removeHookFragment(claudeSettingsJsonPath(opts.homeDir), removeClaudeHooks, opts);
257
261
  }
258
262
  export const claude = {
259
263
  id: "claude",
260
264
  name: "Claude Code",
261
- commandsSchema: 3,
262
- hooksSchema: 1,
263
- async isInstalled() {
264
- return fileExists(path.join(resolveHomeDir(), ".claude"));
265
- },
265
+ isInstalled: layout.isInstalled,
266
266
  expectedArtifacts,
267
267
  expectedHookArtifacts,
268
- async install(opts) {
269
- const artifacts = {};
270
- const files = {};
271
- for (const filename of COMMANDS) {
272
- const { dest, status, hash } = await installCommandFile({
273
- ...opts,
274
- agentId: claude.id,
275
- templateDir,
276
- commandsDir: claudeCommandsDir(opts.homeDir),
277
- filename,
278
- });
279
- artifacts[dest] = status;
280
- if (hash) {
281
- files[dest] = { hash };
282
- }
283
- }
284
- await recordCommandFileHashes({
285
- agentId: claude.id,
286
- commandsSchema: claude.commandsSchema,
287
- files,
288
- homeDir: opts.homeDir,
289
- dryRun: opts.dryRun,
290
- });
291
- return { artifacts };
292
- },
268
+ desiredArtifacts: layout.desiredArtifacts,
269
+ tombstones: layout.tombstones,
270
+ ownedPrefixes: layout.ownedPrefixes,
293
271
  installHooks,
272
+ removeHooks,
294
273
  };
295
274
  //# sourceMappingURL=claude.js.map