@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.
- package/LICENSE +21 -0
- package/README.md +170 -77
- package/dist/commands/apply.d.ts +16 -0
- package/dist/commands/apply.d.ts.map +1 -1
- package/dist/commands/apply.js +102 -42
- package/dist/commands/apply.js.map +1 -1
- package/dist/commands/capture.d.ts +2 -0
- package/dist/commands/capture.d.ts.map +1 -1
- package/dist/commands/capture.js +2 -2
- package/dist/commands/capture.js.map +1 -1
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +6 -5
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +37 -88
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/layers.d.ts +13 -0
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +4 -4
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/migrate.d.ts +2 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +188 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/onboard.d.ts +21 -0
- package/dist/commands/onboard.d.ts.map +1 -1
- package/dist/commands/onboard.js +29 -28
- package/dist/commands/onboard.js.map +1 -1
- package/dist/commands/run.d.ts +51 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +36 -11
- package/dist/commands/run.js.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/lint.d.ts +21 -0
- package/dist/lib/lint.d.ts.map +1 -0
- package/dist/lib/lint.js +57 -0
- package/dist/lib/lint.js.map +1 -0
- package/dist/lib/parser.d.ts +3 -0
- package/dist/lib/parser.d.ts.map +1 -1
- package/dist/lib/parser.js +3 -3
- package/dist/lib/parser.js.map +1 -1
- package/dist/lib/storage.d.ts +36 -2
- package/dist/lib/storage.d.ts.map +1 -1
- package/dist/lib/storage.js +131 -0
- package/dist/lib/storage.js.map +1 -1
- 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
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@lcvbeek/patina)
|
|
6
|
+
[](./LICENSE)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
##
|
|
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
|
|
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` |
|
|
70
|
-
| `.patina/
|
|
71
|
-
| `.patina/
|
|
72
|
-
| `.patina/
|
|
73
|
-
| `.patina/
|
|
74
|
-
| `.patina/
|
|
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 files — extended 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.
|
|
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
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
##
|
|
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
|
-
##
|
|
171
|
+
## How is this different from Claude Code's `/insights`?
|
|
123
172
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
##
|
|
197
|
+
## Context architecture
|
|
136
198
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
alias patina="npx tsx /path/to/patina/src/index.ts"
|
|
153
|
-
```
|
|
225
|
+
### Why this matters
|
|
154
226
|
|
|
155
|
-
|
|
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
|
-
##
|
|
239
|
+
## Design decisions
|
|
160
240
|
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
+
<details>
|
|
253
|
+
<summary><b>Why hub+spoke instead of one file?</b></summary>
|
|
176
254
|
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
+
</details>
|
|
190
281
|
|
|
191
|
-
|
|
282
|
+
<details>
|
|
283
|
+
<summary><b>Why individual capture files instead of one file?</b></summary>
|
|
192
284
|
|
|
193
|
-
|
|
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
|
-
|
|
288
|
+
</details>
|
package/dist/commands/apply.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/commands/apply.js
CHANGED
|
@@ -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
|
|
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)}
|
|
94
|
+
const newRow = `| ${cycleCount} | ${today} | ${insight.slice(0, 60)}... | ${changeDesc.slice(0, 50)}... |`;
|
|
93
95
|
const placeholder = '| — | — | — | — |';
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
let updated = content;
|
|
97
|
+
if (updated.includes(placeholder)) {
|
|
98
|
+
updated = updated.replace(placeholder, newRow);
|
|
96
99
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
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 ${
|
|
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
|
}
|