@lcvbeek/patina 0.1.0 → 0.2.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -77
  3. package/dist/commands/apply.d.ts +16 -0
  4. package/dist/commands/apply.d.ts.map +1 -1
  5. package/dist/commands/apply.js +102 -42
  6. package/dist/commands/apply.js.map +1 -1
  7. package/dist/commands/capture.d.ts +2 -0
  8. package/dist/commands/capture.d.ts.map +1 -1
  9. package/dist/commands/capture.js +2 -2
  10. package/dist/commands/capture.js.map +1 -1
  11. package/dist/commands/diff.d.ts.map +1 -1
  12. package/dist/commands/diff.js +6 -5
  13. package/dist/commands/diff.js.map +1 -1
  14. package/dist/commands/init.d.ts.map +1 -1
  15. package/dist/commands/init.js +37 -88
  16. package/dist/commands/init.js.map +1 -1
  17. package/dist/commands/layers.d.ts +13 -0
  18. package/dist/commands/layers.d.ts.map +1 -1
  19. package/dist/commands/layers.js +4 -4
  20. package/dist/commands/layers.js.map +1 -1
  21. package/dist/commands/migrate.d.ts +2 -0
  22. package/dist/commands/migrate.d.ts.map +1 -0
  23. package/dist/commands/migrate.js +188 -0
  24. package/dist/commands/migrate.js.map +1 -0
  25. package/dist/commands/onboard.d.ts +21 -0
  26. package/dist/commands/onboard.d.ts.map +1 -1
  27. package/dist/commands/onboard.js +29 -28
  28. package/dist/commands/onboard.js.map +1 -1
  29. package/dist/commands/run.d.ts +51 -0
  30. package/dist/commands/run.d.ts.map +1 -1
  31. package/dist/commands/run.js +36 -11
  32. package/dist/commands/run.js.map +1 -1
  33. package/dist/index.js +10 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/lint.d.ts +21 -0
  36. package/dist/lib/lint.d.ts.map +1 -0
  37. package/dist/lib/lint.js +57 -0
  38. package/dist/lib/lint.js.map +1 -0
  39. package/dist/lib/parser.d.ts +3 -0
  40. package/dist/lib/parser.d.ts.map +1 -1
  41. package/dist/lib/parser.js +3 -3
  42. package/dist/lib/parser.js.map +1 -1
  43. package/dist/lib/storage.d.ts +36 -2
  44. package/dist/lib/storage.d.ts.map +1 -1
  45. package/dist/lib/storage.js +131 -0
  46. package/dist/lib/storage.js.map +1 -1
  47. package/package.json +10 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leo van Beek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,19 +2,20 @@
2
2
 
3
3
  # Patina
4
4
 
5
- Patina is what forms naturally when you keep working with AI. Each retro cycle deposits a thin layer — captured moments, reflection answers, Claude's synthesis, a proposed instruction change. Over time, `patina.md` builds up into something with real depth: a working record of how your team uses AI, versioned in git, shared by everyone including new hires.
5
+ [![npm version](https://img.shields.io/npm/v/@lcvbeek/patina.svg)](https://www.npmjs.com/package/@lcvbeek/patina)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
6
7
 
7
- ---
8
-
9
- ## How is this different from Claude Code's built-in `/insights`?
10
-
11
- `/insights` produces a personal HTML report in `~/.claude/` — useful analysis, but it belongs to one person and doesn't persist between sessions. Patina produces `patina.md`, a structured document that lives in your repo, is versioned with git, and accumulates layers across cycles. The goal isn't better analysis — it's a shared artifact your team actually owns and maintains together.
8
+ Patina is what forms naturally when you keep working with AI. Each retro
9
+ cycle deposits a thin layer — captured moments, reflection answers, Claude's
10
+ synthesis, a proposed instruction change. Over time, `patina.md` builds up
11
+ into something with real depth: a working record of how your team uses AI,
12
+ versioned in git, shared by everyone including new hires.
12
13
 
13
14
  ---
14
15
 
15
- ## The loop
16
+ ## How it works
16
17
 
17
- ```
18
+ ```text
18
19
  patina capture # anyone on the team, anytime
19
20
  → records a notable moment to .patina/captures/
20
21
  → committed to the repo, visible to everyone
@@ -34,6 +35,47 @@ Next session, the whole team works from an updated set of shared instructions.
34
35
 
35
36
  ---
36
37
 
38
+ ## Requirements
39
+
40
+ - Node.js 18+
41
+ - Access to Claude via one of:
42
+ - **Claude Code CLI** (recommended) — install at
43
+ [claude.ai/code](https://claude.ai/code), authenticate once, and Patina
44
+ uses it automatically. Respects your existing plan including Claude Max.
45
+ - **Anthropic API key** — set `ANTHROPIC_API_KEY` in your environment.
46
+ Patina falls back to this if the CLI isn't found.
47
+ Billed separately per token.
48
+
49
+ ```bash
50
+ export ANTHROPIC_API_KEY=sk-ant-...
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ npm install -g @lcvbeek/patina
59
+ ```
60
+
61
+ ---
62
+
63
+ ## First run
64
+
65
+ ```bash
66
+ cd your-project
67
+ patina init # creates .patina/ and patina.md
68
+ patina run # first cycle — answer 9 onboarding questions (~10 min)
69
+ patina diff # review what Claude proposed
70
+ patina buff # apply the change
71
+ git add .patina/patina.md .patina/cycles/ .patina/captures/
72
+ git commit -m "First patina layer"
73
+ ```
74
+
75
+ If you have no prior Claude Code session data, `patina run` will still work — your answers are the primary input for the first cycle.
76
+
77
+ ---
78
+
37
79
  ## Commands
38
80
 
39
81
  | Command | What it does |
@@ -42,8 +84,10 @@ Next session, the whole team works from an updated set of shared instructions.
42
84
  | `patina capture [text]` | Capture a notable moment while it's fresh — feeds into the next retro |
43
85
  | `patina run` | Full retro session — auto-ingests logs, loads captures, asks reflection questions, calls Claude for synthesis |
44
86
  | `patina diff` | Review the proposed instruction change from the last `patina run` |
45
- | `patina buff` | Apply the pending diff to `patina.md` (`patina apply` also works) |
87
+ | `patina buff` | Apply the pending diff to `patina.md` or spoke file (`patina apply` also works) |
88
+ | `patina migrate` | Split a monolithic `patina.md` into slim core + spoke files |
46
89
  | `patina status` | Show metrics: token spend, rework rate, tool usage, trends across cycles |
90
+ | `patina layers` | Visualise the patina you've built — one ASCII layer per retro cycle |
47
91
  | `patina ingest` | Manually parse Claude Code logs (optional — `patina run` does this automatically) |
48
92
 
49
93
  ### patina capture
@@ -56,7 +100,9 @@ patina capture # interactive mode
56
100
 
57
101
  Tags: `near-miss` / `went-well` / `frustration` / `pattern` / `other`
58
102
 
59
- Captures are written to `.patina/captures/` as individual JSON files (one per capture, to avoid merge conflicts) and committed to the repo. Author is read from `git config user.name`.
103
+ Captures are written to `.patina/captures/` as individual JSON files
104
+ (one per capture, to avoid merge conflicts) and committed to the repo.
105
+ Author is read from `git config user.name`.
60
106
 
61
107
  ---
62
108
 
@@ -66,130 +112,177 @@ Captures are written to `.patina/captures/` as individual JSON files (one per ca
66
112
 
67
113
  | Path | Committed | Why |
68
114
  |---|---|---|
69
- | `.patina/patina.md` | | The shared AI operating document |
70
- | `.patina/cycles/` | | Each layerfull cycle reports, the team's accumulated record |
71
- | `.patina/captures/` | | In-the-moment observations from anyone on the team |
72
- | `.patina/sessions/` | | Personal session data, machine-specific |
73
- | `.patina/metrics.json` | | Derived from sessions, not a source of truth |
74
- | `.patina/pending-diff.json` | | Ephemeral consumed by `patina buff` |
75
-
76
- ---
77
-
78
- ## What `patina run` produces
79
-
80
- - `.patina/cycles/YYYY-MM-DD.md` — full cycle report: metrics snapshot, identified patterns, coaching insight, proposed instruction diff, reflection answers
81
- - `.patina/pending-diff.json` — the diff staged for `patina buff`
82
- - An updated `.patina/patina.md` once you run `patina buff`
115
+ | `.patina/patina.md` | Yes | The shared AI operating document (slim core) |
116
+ | `.patina/context/` | Yes | Spoke filesextended context loaded on demand |
117
+ | `.patina/cycles/` | Yes | Each layer full cycle reports |
118
+ | `.patina/captures/` | Yes | In-the-moment observations from anyone on the team |
119
+ | `.patina/sessions/` | No | Personal session data, machine-specific |
120
+ | `.patina/metrics.json` | No | Derived from sessions, not a source of truth |
121
+ | `.patina/pending-diff.json` | No | Ephemeral — consumed by `patina buff` |
83
122
 
84
123
  ---
85
124
 
86
125
  ## What `patina.md` is
87
126
 
88
- Your team's AI operating constitution. It has sections for working agreements, agent profiles, delegation patterns, an incident log, eval criteria, and an opportunity backlog. `patina buff` appends a new entry to the cycle history and inserts the proposed instruction into the right section.
127
+ Your team's AI operating constitution. The slim core (~50 lines) has
128
+ sections for working agreements, agent profiles, and hard guardrails —
129
+ always loaded into every Claude Code session. Extended sections (autonomy
130
+ map, incident log, eval framework, cycle history) live in
131
+ `.patina/context/` as spoke files loaded on demand.
89
132
 
90
- The file is yours edit it directly whenever you want. Patina treats it as the source of truth for how your team works with AI and passes it to Claude during synthesis.
133
+ `patina buff` routes proposed changes to the correct file.
134
+ The file is yours — edit it directly whenever you want. Patina treats it
135
+ as the source of truth for how your team works with AI and passes it to
136
+ Claude during synthesis.
91
137
 
92
138
  ### How agents read it
93
139
 
94
140
  `patina init` adds the following line to your project's `CLAUDE.md` (creating it if it doesn't exist):
95
141
 
96
- ```
142
+ ```text
97
143
  @.patina/patina.md
98
144
  ```
99
145
 
100
- Claude Code's `@filename` import syntax means every Claude Code session in the project automatically gets the contents of `patina.md` — no manual copying needed. When `patina buff` updates `patina.md`, Claude picks up the change in the next session.
101
-
102
- If a `CLAUDE.md` already exists, `init` appends the import line without touching anything else.
146
+ Claude Code's `@filename` import syntax means every Claude Code session in
147
+ the project automatically gets the contents of `patina.md` — no manual
148
+ copying needed. When `patina buff` updates `patina.md`, Claude picks up the
149
+ change in the next session.
103
150
 
104
151
  ---
105
152
 
106
- ## What gets tracked
153
+ ## Privacy
107
154
 
108
155
  Everything stays local. No data leaves your machine except what you choose to send to Claude via the `claude` CLI during `patina run`.
109
156
 
110
157
  What gets ingested from your Claude Code logs:
158
+
111
159
  - Session timestamps and project names
112
160
  - Estimated token counts
113
161
  - Tool call names and frequencies
114
162
  - Whether a session contained rework (detected heuristically from the JSONL)
115
163
 
116
164
  What is never sent to Claude:
165
+
117
166
  - Raw session content or conversation transcripts
118
167
  - Anything outside `.patina/`
119
168
 
120
169
  ---
121
170
 
122
- ## Requirements
171
+ ## How is this different from Claude Code's `/insights`?
123
172
 
124
- - Node.js 18+
125
- - Access to Claude via one of:
126
- - **Claude Code CLI** (recommended) — install at [claude.ai/code](https://claude.ai/code), authenticate once, and Patina uses it automatically. Respects your existing plan including Claude Max.
127
- - **Anthropic API key** set `ANTHROPIC_API_KEY` in your environment. Patina falls back to this if the CLI isn't found. Billed separately per token.
173
+ `/insights` produces a personal HTML report in `~/.claude/` — useful
174
+ analysis, but it belongs to one person and doesn't persist between sessions.
175
+ Patina produces `patina.md`, a structured document that lives in your repo,
176
+ is versioned with git, and accumulates layers across cycles. The goal isn't
177
+ better analysis — it's a shared artifact your team actually owns and
178
+ maintains together.
128
179
 
129
- ```bash
130
- export ANTHROPIC_API_KEY=sk-ant-...
131
- ```
180
+ ---
181
+
182
+ ## Early software
183
+
184
+ This is v0.1.0. It works, but expect rough edges:
185
+
186
+ - The `claude` CLI call in `patina run` has a 120-second timeout; if Claude
187
+ is slow the command will fail (your reflection answers are saved to
188
+ `.patina/pending-reflection.json` so you can retry without re-answering)
189
+ - Session ingestion parses Claude Code's JSONL format — if Anthropic changes
190
+ that format, ingestion will break
191
+ - Token estimates are heuristic, not exact
192
+
193
+ If something breaks or the instruction diff Claude produces is bad, that's useful signal. Open an issue or message me directly.
132
194
 
133
195
  ---
134
196
 
135
- ## Install
197
+ ## Context architecture
136
198
 
137
- ```bash
138
- git clone https://github.com/lcvbeek/patina.git
139
- cd patina
140
- npm install
199
+ Patina uses a **hub+spoke** model to keep agent context lean:
200
+
201
+ ```text
202
+ .patina/
203
+ patina.md ← slim core (~50 lines, ~500 tokens). Always loaded.
204
+ context/
205
+ autonomy-detail.md ← full autonomy map with routine scenarios
206
+ incident-log.md ← past agent incidents
207
+ eval-framework.md ← eval criteria and pass thresholds
208
+ cycle-history.md ← retro cycle history
209
+ opportunity-backlog.md ← improvement ideas
141
210
  ```
142
211
 
143
- Run via:
212
+ The **core** (`patina.md`) contains only the highest-value content: working
213
+ agreements, agent behavior contracts, and hard guardrails. It's loaded into
214
+ every Claude Code session via `@.patina/patina.md` in `CLAUDE.md`.
144
215
 
145
- ```bash
146
- npx tsx src/index.ts <command>
147
- ```
216
+ **Spoke files** hold content that's useful during specific activities
217
+ (debugging, testing, retro reviews) but would waste tokens if loaded every
218
+ session. Agents can read them on demand when relevant — the core includes a
219
+ comment index pointing to each spoke file.
148
220
 
149
- Or add an alias:
221
+ `patina buff` automatically routes proposed changes to the correct file
222
+ based on section number. `patina migrate` splits an existing monolithic
223
+ `patina.md` into the hub+spoke layout.
150
224
 
151
- ```bash
152
- alias patina="npx tsx /path/to/patina/src/index.ts"
153
- ```
225
+ ### Why this matters
154
226
 
155
- Global install via `npm install -g` is coming once the CLI is stable.
227
+ Context pollution reduces model precision. Anthropic's research shows that
228
+ the smallest high-signal token set produces the best results. By keeping the
229
+ always-loaded core under 80 lines / 3,200 chars, Patina ensures the
230
+ constitution never becomes a tax on your agent's performance — even after
231
+ dozens of retro cycles.
232
+
233
+ The synthesis prompt enforces this: proposed instructions must be imperative,
234
+ apply to >50% of sessions, and not duplicate existing entries. Stale entries
235
+ are flagged for removal each cycle.
156
236
 
157
237
  ---
158
238
 
159
- ## First run
239
+ ## Design decisions
160
240
 
161
- ```bash
162
- cd your-project
163
- patina init # creates .patina/ and patina.md
164
- patina run # first cycle — answer the reflection questions
165
- patina diff # review what Claude proposed
166
- patina buff # apply the change
167
- git add .patina/patina.md .patina/cycles/ .patina/captures/
168
- git commit -m "First patina cycle"
169
- ```
241
+ <details>
242
+ <summary><b>Why <code>patina.md</code> instead of editing CLAUDE.md directly?</b></summary>
170
243
 
171
- If you have no prior Claude Code session data, `patina run` will still work your reflection answers are the primary input for the first cycle.
244
+ `patina.md` is a structured format Patina can reliably parse, section-match,
245
+ and append to. `patina init` wires it into your `CLAUDE.md` via
246
+ `@.patina/patina.md`, so agents always get the latest version. Keeping it
247
+ separate means Patina never risks corrupting your hand-written `CLAUDE.md`
248
+ content.
172
249
 
173
- ---
250
+ </details>
174
251
 
175
- ## Early software
252
+ <details>
253
+ <summary><b>Why hub+spoke instead of one file?</b></summary>
176
254
 
177
- This is v0.1.0. It works, but expect rough edges:
255
+ A monolithic `patina.md` grows unboundedly as cycles accumulate. After 10+
256
+ cycles, sections like incident log and cycle history add hundreds of tokens
257
+ that are rarely relevant. The hub+spoke model keeps always-loaded context at
258
+ ~500 tokens while preserving all data in spoke files for when it's needed.
259
+ See [Context architecture](#context-architecture) for details.
178
260
 
179
- - The `claude` CLI call in `patina run` has a 120-second timeout; if Claude is slow the command will fail (your reflection answers are saved to `.patina/pending-reflection.json` so you can retry without re-answering)
180
- - Session ingestion parses Claude Code's JSONL format — if Anthropic changes that format, ingestion will break
181
- - Token estimates are heuristic, not exact
261
+ </details>
182
262
 
183
- If something breaks or the instruction diff Claude produces is bad, that's useful signal. Open an issue or message me directly.
263
+ <details>
264
+ <summary><b>Why the <code>claude</code> CLI instead of the API directly?</b></summary>
184
265
 
185
- ---
266
+ No separate API key needed — it respects your existing Claude Code
267
+ authentication and model access. If you don't have the CLI, set
268
+ `ANTHROPIC_API_KEY` and Patina falls back to the SDK.
269
+
270
+ </details>
271
+
272
+ <details>
273
+ <summary><b>Why six reflection questions?</b></summary>
186
274
 
187
- ## Design decisions worth knowing
275
+ The reflection questions supplement sparse log data and give Claude
276
+ qualitative signal the JSONL doesn't contain — what felt frustrating, what
277
+ nearly went wrong. Both matter for the synthesis. The first cycle asks 9
278
+ onboarding questions instead, to establish your baseline agreements.
188
279
 
189
- **Why a living doc instead of CLAUDE.md?** `patina.md` is a structured format Patina can reliably read and append to. You can copy entries into your `CLAUDE.md` or `AGENTS.md` manually — that handoff is intentional for now.
280
+ </details>
190
281
 
191
- **Why does it use the `claude` CLI instead of the API directly?** No separate API key, and it respects your existing Claude Code authentication and model access.
282
+ <details>
283
+ <summary><b>Why individual capture files instead of one file?</b></summary>
192
284
 
193
- **Why six questions?** The reflection questions supplement sparse log data and give Claude qualitative signal the JSONL doesn't contain what felt frustrating, what nearly went wrong. Both matter for the synthesis.
285
+ Multiple teammates capturing on the same day would produce merge conflicts
286
+ in a single file. One JSON file per capture means clean parallel commits.
194
287
 
195
- **Why individual capture files instead of appending to one file?** Multiple teammates capturing on the same day would produce merge conflicts in a single file. One file per capture means clean parallel commits.
288
+ </details>
@@ -1,2 +1,18 @@
1
+ /**
2
+ * Find the section header in patina.md and insert the diff text after it.
3
+ * Falls back to appending at the end of the section if the header isn't found exactly.
4
+ */
5
+ export declare function applyDiffToDoc(content: string, section: string, diffText: string): string;
6
+ /**
7
+ * Update the Retro Cycle History table.
8
+ * Operates on the cycle-history spoke file content (not the core patina.md).
9
+ * Keeps at most CYCLE_HISTORY_CAP rows — oldest rows are dropped when the cap
10
+ * is exceeded. Full cycle detail is preserved in .patina/cycles/.
11
+ */
12
+ export declare function updateCycleHistory(content: string, insight: string, changeDesc: string): string;
13
+ /**
14
+ * Update the cycle history spoke file on disk.
15
+ */
16
+ export declare function updateCycleHistoryFile(cwd: string, insight: string, changeDesc: string): void;
1
17
  export declare function applyCommand(): Promise<void>;
2
18
  //# sourceMappingURL=apply.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/commands/apply.ts"],"names":[],"mappings":"AAkJA,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CA8ElD"}
1
+ {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/commands/apply.ts"],"names":[],"mappings":"AAoCA;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CA+DzF;AAID;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CA6C/F;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAc7F;AAED,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAoHlD"}
@@ -1,7 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import readline from 'readline';
4
- import { assertInitialised, readPendingDiff, LIVING_DOC_FILE, PENDING_DIFF_FILE, METRICS_FILE, } from '../lib/storage.js';
4
+ import { assertInitialised, readPendingDiff, LIVING_DOC_FILE, PENDING_DIFF_FILE, METRICS_FILE, SPOKE_FILES, CORE_MAX_LINES, CORE_MAX_CHARS, resolveTargetFile, ensureSpokeFiles, } from '../lib/storage.js';
5
+ import { lintMarkdown, fixMarkdown } from '../lib/lint.js';
5
6
  const isTTY = process.stdout.isTTY;
6
7
  function bold(s) { return isTTY ? `\x1b[1m${s}\x1b[0m` : s; }
7
8
  function dim(s) { return isTTY ? `\x1b[2m${s}\x1b[0m` : s; }
@@ -23,7 +24,7 @@ function confirm(question) {
23
24
  * Find the section header in patina.md and insert the diff text after it.
24
25
  * Falls back to appending at the end of the section if the header isn't found exactly.
25
26
  */
26
- function applyDiffToDoc(content, section, diffText) {
27
+ export function applyDiffToDoc(content, section, diffText) {
27
28
  const lines = content.split('\n');
28
29
  // Try to find the matching section header (## 1. Working Agreements, etc.)
29
30
  // Match by number prefix or by full title (case-insensitive)
@@ -56,10 +57,10 @@ function applyDiffToDoc(content, section, diffText) {
56
57
  ];
57
58
  return newLines.join('\n');
58
59
  }
59
- // Find the end of this section (next ## heading or end of file)
60
+ // Find the end of this section (next ## heading, --- separator, or end of file)
60
61
  let sectionEnd = lines.length;
61
62
  for (let i = sectionIdx + 1; i < lines.length; i++) {
62
- if (lines[i].startsWith('## ')) {
63
+ if (lines[i].startsWith('## ') || lines[i].trim() === '---') {
63
64
  sectionEnd = i;
64
65
  break;
65
66
  }
@@ -82,33 +83,43 @@ function applyDiffToDoc(content, section, diffText) {
82
83
  }
83
84
  const CYCLE_HISTORY_CAP = 5;
84
85
  /**
85
- * Update the Retro Cycle History table in patina.md.
86
+ * Update the Retro Cycle History table.
87
+ * Operates on the cycle-history spoke file content (not the core patina.md).
86
88
  * Keeps at most CYCLE_HISTORY_CAP rows — oldest rows are dropped when the cap
87
89
  * is exceeded. Full cycle detail is preserved in .patina/cycles/.
88
90
  */
89
- function updateCycleHistory(content, insight, changeDesc) {
91
+ export function updateCycleHistory(content, insight, changeDesc) {
90
92
  const today = new Date().toISOString().slice(0, 10);
91
93
  const cycleCount = (content.match(/^\| \d+/gm) || []).length + 1;
92
- const newRow = `| ${cycleCount} | ${today} | ${insight.slice(0, 60)} | ${changeDesc.slice(0, 50)} |`;
94
+ const newRow = `| ${cycleCount} | ${today} | ${insight.slice(0, 60)}... | ${changeDesc.slice(0, 50)}... |`;
93
95
  const placeholder = '| — | — | — | — |';
94
- if (content.includes(placeholder)) {
95
- return content.replace(placeholder, newRow);
96
+ let updated = content;
97
+ if (updated.includes(placeholder)) {
98
+ updated = updated.replace(placeholder, newRow);
96
99
  }
97
- // Append a new row before the end of the history table
98
- const historyHeader = '## 7. Retro Cycle History';
99
- const historyIdx = content.indexOf(historyHeader);
100
- if (historyIdx === -1)
101
- return content;
102
- const after = content.slice(historyIdx);
103
- const lastRowMatch = after.match(/(\| .+ \|\n?)(?!.*\| .+ \|)/s);
104
- if (!lastRowMatch)
105
- return content;
106
- const insertPos = historyIdx + after.indexOf(lastRowMatch[0]) + lastRowMatch[0].length;
107
- let updated = content.slice(0, insertPos) + newRow + '\n' + content.slice(insertPos);
108
- // Trim oldest rows if over cap — find all data rows in Section 7 and drop from the top
109
- const headerMatch = updated.match(/## 7\. Retro Cycle History[\s\S]*?\| Cycle \| Date \| Key Insight \| Change Made \|\n\|[-| ]+\|\n/);
110
- if (headerMatch) {
111
- const tableStart = updated.indexOf(headerMatch[0]) + headerMatch[0].length;
100
+ else {
101
+ // Append a new row at the end of the table
102
+ const tableHeaderPattern = /\| Cycle \| Date \| Key Insight \| Change Made \|\n\|[-| ]+\|/;
103
+ const headerMatch = updated.match(tableHeaderPattern);
104
+ if (!headerMatch) {
105
+ // No table found — append one
106
+ updated = updated.trimEnd() + '\n' + newRow + '\n';
107
+ }
108
+ else {
109
+ // Find the last table row and append after it
110
+ const after = updated.slice(headerMatch.index);
111
+ const lastRowMatch = after.match(/(\| .+ \|\n?)(?!.*\| .+ \|)/s);
112
+ if (lastRowMatch) {
113
+ const insertPos = headerMatch.index + after.indexOf(lastRowMatch[0]) + lastRowMatch[0].length;
114
+ updated = updated.slice(0, insertPos) + newRow + '\n' + updated.slice(insertPos);
115
+ }
116
+ }
117
+ }
118
+ // Trim oldest rows if over cap
119
+ const tableHeaderPattern2 = /\| Cycle \| Date \| Key Insight \| Change Made \|\n\|[-| ]+\|\n/;
120
+ const headerMatch2 = updated.match(tableHeaderPattern2);
121
+ if (headerMatch2) {
122
+ const tableStart = headerMatch2.index + headerMatch2[0].length;
112
123
  const afterTable = updated.slice(tableStart);
113
124
  const rowRegex = /^\| \d+ \|.+\|\n?/gm;
114
125
  const rows = [...afterTable.matchAll(rowRegex)];
@@ -120,6 +131,23 @@ function updateCycleHistory(content, insight, changeDesc) {
120
131
  }
121
132
  return updated;
122
133
  }
134
+ /**
135
+ * Update the cycle history spoke file on disk.
136
+ */
137
+ export function updateCycleHistoryFile(cwd, insight, changeDesc) {
138
+ const spokeFile = path.join(cwd, SPOKE_FILES['cycle-history']);
139
+ let content = '';
140
+ if (fs.existsSync(spokeFile)) {
141
+ content = fs.readFileSync(spokeFile, 'utf-8');
142
+ }
143
+ else {
144
+ // Create with default template if missing
145
+ ensureSpokeFiles(cwd);
146
+ content = fs.readFileSync(spokeFile, 'utf-8');
147
+ }
148
+ const updated = updateCycleHistory(content, insight, changeDesc);
149
+ fs.writeFileSync(spokeFile, updated, 'utf-8');
150
+ }
123
151
  export async function applyCommand() {
124
152
  assertInitialised();
125
153
  const cwd = process.cwd();
@@ -129,35 +157,67 @@ export async function applyCommand() {
129
157
  console.log(dim('Run `patina run` first, then `patina diff` to review.'));
130
158
  return;
131
159
  }
132
- const livingDocPath = path.join(cwd, LIVING_DOC_FILE);
160
+ // Route diff to the correct file (core patina.md or a spoke file)
161
+ const targetFilePath = resolveTargetFile(pending.section, cwd);
162
+ const targetRelPath = path.relative(cwd, targetFilePath);
163
+ const isCore = targetFilePath === path.join(cwd, LIVING_DOC_FILE);
133
164
  console.log(`\n${bold('patina apply')} — applying instruction change`);
134
165
  console.log(hr());
166
+ console.log(` Target : ${cyan(targetRelPath)}`);
135
167
  console.log(` Section : ${cyan(pending.section)}`);
136
168
  console.log(` Adding :\n`);
137
169
  pending.diff.split('\n').forEach(line => {
138
170
  console.log(` ${green('+ ' + line)}`);
139
171
  });
140
172
  console.log();
141
- const ok = await confirm(`Apply this change to ${LIVING_DOC_FILE}? [y/N] `);
173
+ const ok = await confirm(`Apply this change to ${targetRelPath}? [y/N] `);
142
174
  if (!ok) {
143
175
  console.log(dim('Aborted. Pending diff unchanged.'));
144
176
  return;
145
177
  }
146
- // Read or create living doc
147
- let content = fs.existsSync(livingDocPath)
148
- ? fs.readFileSync(livingDocPath, 'utf-8')
149
- : '# AI Operating Constitution\n\n## 1. Working Agreements\n\n';
150
- // Apply the diff
178
+ // Ensure spoke files exist before applying
179
+ ensureSpokeFiles(cwd);
180
+ // Read or create target file
181
+ let content = fs.existsSync(targetFilePath)
182
+ ? fs.readFileSync(targetFilePath, 'utf-8')
183
+ : isCore
184
+ ? '# AI Operating Constitution\n\n## 1. Working Agreements\n\n'
185
+ : '';
186
+ // Apply the diff and auto-fix common lint issues
151
187
  content = applyDiffToDoc(content, pending.section, pending.diff);
152
- // Update cycle history
153
- content = updateCycleHistory(content, pending.rationale, pending.diff.slice(0, 50));
154
- // Update the "last updated" timestamp
155
- content = content.replace(/> Maintained by `patina`\. Last updated: .+/, `> Maintained by \`patina\`. Last updated: ${new Date().toISOString().slice(0, 10)}`);
156
- fs.writeFileSync(livingDocPath, content, 'utf-8');
157
- // Warn if patina.md is getting large
158
- const SIZE_WARN_BYTES = 5 * 1024; // 5KB
159
- if (Buffer.byteLength(content, 'utf-8') > SIZE_WARN_BYTES) {
160
- console.log(yellow('⚠') + ` patina.md is over 5KB — consider reviewing sections for outdated entries.`);
188
+ content = fixMarkdown(content);
189
+ fs.writeFileSync(targetFilePath, content, 'utf-8');
190
+ // Warn about any remaining lint issues
191
+ const warnings = lintMarkdown(content);
192
+ if (warnings.length > 0) {
193
+ console.log(yellow('!') + ` ${warnings.length} markdown lint warning(s) in ${targetRelPath}:`);
194
+ for (const w of warnings.slice(0, 5)) {
195
+ console.log(dim(` line ${w.line}: ${w.rule} ${w.message}`));
196
+ }
197
+ if (warnings.length > 5) {
198
+ console.log(dim(` ... and ${warnings.length - 5} more. Run \`npm run lint:md\` for full report.`));
199
+ }
200
+ }
201
+ // Update cycle history in the spoke file
202
+ updateCycleHistoryFile(cwd, pending.rationale, pending.diff.slice(0, 50));
203
+ // Update the "last updated" timestamp in core patina.md
204
+ const livingDocPath = path.join(cwd, LIVING_DOC_FILE);
205
+ if (fs.existsSync(livingDocPath)) {
206
+ let coreContent = fs.readFileSync(livingDocPath, 'utf-8');
207
+ const today = new Date().toISOString().slice(0, 10);
208
+ coreContent = coreContent.replace(/> Last updated: .+/, `> Last updated: ${today}`);
209
+ // Also match the old format
210
+ coreContent = coreContent.replace(/> Maintained by `patina`\. Last updated: .+/, `> Last updated: ${today}`);
211
+ fs.writeFileSync(livingDocPath, coreContent, 'utf-8');
212
+ // Enforce hard cap on core patina.md
213
+ const coreLines = coreContent.split('\n');
214
+ const coreChars = coreContent.length;
215
+ if (coreLines.length > CORE_MAX_LINES || coreChars > CORE_MAX_CHARS) {
216
+ console.log(yellow('!') +
217
+ ` Core patina.md exceeds limits (${coreLines.length} lines / ${coreChars} chars).` +
218
+ ` Cap: ${CORE_MAX_LINES} lines / ${CORE_MAX_CHARS} chars.` +
219
+ ` Consider pruning stale entries or moving detail to spoke files.`);
220
+ }
161
221
  }
162
222
  // Clear pending diff
163
223
  const pendingDiffPath = path.join(cwd, PENDING_DIFF_FILE);
@@ -175,11 +235,11 @@ export async function applyCommand() {
175
235
  catch { /* non-fatal */ }
176
236
  }
177
237
  console.log();
178
- console.log(green('✓') + ` Applied to ${bold(LIVING_DOC_FILE)}`);
238
+ console.log(green('✓') + ` Applied to ${bold(targetRelPath)}`);
179
239
  console.log(green('✓') + ' Cycle history updated');
180
240
  console.log(green('✓') + ' Pending diff cleared');
181
241
  console.log();
182
- console.log(dim(`Next: review ${LIVING_DOC_FILE} to confirm the change looks right.`));
242
+ console.log(dim(`Next: review ${targetRelPath} to confirm the change looks right.`));
183
243
  console.log(dim('Run `patina ingest` at the start of your next cycle to continue tracking.'));
184
244
  console.log();
185
245
  }