@skitterbyte/skitterspec 16.2.0 → 16.3.2
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/MIGRATION.md +292 -0
- package/README.md +19 -0
- package/package.json +3 -2
- package/src/cli.js +6 -2
- package/src/init.js +45 -11
- package/src/lines-diff.js +114 -0
package/MIGRATION.md
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# Migration guide
|
|
2
|
+
|
|
3
|
+
## `@skitterbyte/skitterspec-linear` v9 → v10 (`push` validates your issue states)
|
|
4
|
+
|
|
5
|
+
**`spec-sync push` now refuses to run until the configured `states` names have
|
|
6
|
+
been checked against your Linear workspace.** The check itself is not new — it
|
|
7
|
+
already existed on `spec-sync status --workspace-states` — but it was advisory,
|
|
8
|
+
and skipping it sent a state name Linear **silently ignores**: the description
|
|
9
|
+
lands, the issue never moves, and nothing errors. The base
|
|
10
|
+
`@skitterbyte/skitterspec` is unaffected.
|
|
11
|
+
|
|
12
|
+
### Breaking change
|
|
13
|
+
|
|
14
|
+
| | v9 | v10 |
|
|
15
|
+
|---|-----|-----|
|
|
16
|
+
| `spec-sync push <spec>` | runs | **exits 1** unless `--workspace-states <file>` or `--skip-state-check` is passed |
|
|
17
|
+
| A configured state absent from the workspace | pushed, silently no-op | **exits 1**, naming the workspace's real states |
|
|
18
|
+
|
|
19
|
+
`/spec-push` handles this for you — it fetches the workspace's issue
|
|
20
|
+
workflow-state names over MCP and passes them on. **Nothing changes if you drive
|
|
21
|
+
sync through the skill.** Only a direct CLI caller needs updating.
|
|
22
|
+
|
|
23
|
+
### What to do
|
|
24
|
+
|
|
25
|
+
1. **Using `/spec-push`?** Nothing. Run `update` and carry on.
|
|
26
|
+
2. **Calling `spec-sync push` from CI or a script?** Supply the workspace's issue
|
|
27
|
+
workflow-state names as a JSON array and pass the file:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
# names come from your Linear workspace, e.g. via the MCP server or the API
|
|
31
|
+
echo '["Backlog","Todo","In Progress","Done","Canceled"]' > states.json
|
|
32
|
+
skitterspec spec-sync push my-spec --workspace-states states.json --json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or opt out deliberately with `--skip-state-check`. Don't reach for it to get
|
|
36
|
+
past a *failing* check — that check is the only thing standing between you and
|
|
37
|
+
a push that moves nothing.
|
|
38
|
+
3. **If the check refuses,** it tells you what your workspace actually has and,
|
|
39
|
+
where the lifecycle bucket makes it unambiguous, which name to use:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
spec-sync push: refusing — configured state name(s) not in the workspace
|
|
43
|
+
|
|
44
|
+
states.complete: "Done" is not an issue state in this workspace
|
|
45
|
+
use "Completed" instead
|
|
46
|
+
|
|
47
|
+
available: Backlog, Todo, In Progress, Completed, Canceled
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Fix `specs/.core/linear.config.json` → `states`. `/spec-push` will offer to
|
|
51
|
+
apply the fix for you.
|
|
52
|
+
|
|
53
|
+
### Also in v10 (not breaking)
|
|
54
|
+
|
|
55
|
+
- **A pre-9.0 mirror is detected before it can be orphaned.** A spec still
|
|
56
|
+
carrying `linear_project_id` / `linear_milestone_id` reads as unlinked to v9+,
|
|
57
|
+
so `push` would emit an all-creates plan and abandon the live mirror. The plan
|
|
58
|
+
now carries a `legacy` field naming how many objects that would strand, and
|
|
59
|
+
`/spec-push` stops. **If you skipped the v8 → v9 migration below, read it now** —
|
|
60
|
+
this is the guard that catches you, not a substitute for it.
|
|
61
|
+
- **`update` says what it skipped** — each `customized (kept)` file now reports
|
|
62
|
+
`+added −removed`, with `--diff` to see the upstream changes you declined.
|
|
63
|
+
- **This guide now ships inside the package** (it wasn't in the published tarball
|
|
64
|
+
before v10 — `files` listed only `bin`/`src`/`assets`).
|
|
65
|
+
|
|
66
|
+
## `@skitterbyte/skitterspec-linear` v8 → v9 (a spec is an Issue, phases are sub-issues)
|
|
67
|
+
|
|
68
|
+
**v9 remaps the Linear mirror.** A spec is now a Linear **issue** (not a Project),
|
|
69
|
+
each phase a **sub-issue** (not a Milestone), and **tasks are no longer synced**
|
|
70
|
+
(they stay in the repo phase files). This collapses a large spec from ~1 project +
|
|
71
|
+
N milestones + dozens of task-issues down to **one issue + one sub-issue per
|
|
72
|
+
phase**. The base `@skitterbyte/skitterspec` is unaffected (still v15).
|
|
73
|
+
|
|
74
|
+
### Breaking changes
|
|
75
|
+
|
|
76
|
+
| Area | v8 | v9 |
|
|
77
|
+
|------|-----|-----|
|
|
78
|
+
| `linear.config.json` → `mapping` | `{specFolder:"project", phases:"milestone", tasks:"issue"}` | `{specFolder:"issue", phases:"subissue", tasks:"none"}` |
|
|
79
|
+
| `linear.config.json` → `linear` | `initiativeId` | `projectId` (the project picker's default) |
|
|
80
|
+
| `linear.config.json` → `states` | Linear **Project** statuses (e.g. `Completed`) | Linear **issue** workflow states (e.g. `Done`) |
|
|
81
|
+
| `linear.config.json` → `sync.fieldOwnership` | `{description, milestones, tasks, workflowState}` | `{description, subIssues, workflowState}` |
|
|
82
|
+
| Phase frontmatter | `linear_milestone_id` | `linear_issue_id` (the sub-issue id) |
|
|
83
|
+
| Overview frontmatter | `linear_project_id` + `linear_identifier` | `linear_identifier` (the spec issue) |
|
|
84
|
+
| Last-pushed snapshot | `{project, milestones, issues}` | `{issue, subIssues}` |
|
|
85
|
+
|
|
86
|
+
### What to do
|
|
87
|
+
|
|
88
|
+
1. **Upgrade and re-run `update`:** `npx @skitterbyte/skitterspec-linear update`.
|
|
89
|
+
It refreshes the skills, the `linear.config.md` / `SETUP.md` docs, and the
|
|
90
|
+
config example.
|
|
91
|
+
2. **Edit `specs/.core/linear.config.json`** to the new keys above (or delete it
|
|
92
|
+
and re-copy `linear.config.json.example`). Point `states` at your workspace's
|
|
93
|
+
**issue** states; set `linear.projectId` if most specs belong to one Project —
|
|
94
|
+
it pre-selects the picker's default rather than fixing every spec there.
|
|
95
|
+
3. **Optionally add `intake`** to start specs from issues someone else filed:
|
|
96
|
+
|
|
97
|
+
```jsonc
|
|
98
|
+
"intake": {
|
|
99
|
+
"label": "web-app", // the inbox `/spec --from-issue` browses
|
|
100
|
+
"bugLabels": ["bug"] // issues with these route to /spec-bug
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Without it, `/spec SKI-123` still adopts an issue by id; only the browsable
|
|
105
|
+
inbox and the bug routing need the labels.
|
|
106
|
+
4. **Existing pushed specs:** the snapshot format changed, so the first
|
|
107
|
+
`/spec-push` after upgrading **re-creates** the mirror (a fresh issue +
|
|
108
|
+
sub-issues). Delete any stale `specs/.core/linear-base/*.base.json` and the old
|
|
109
|
+
`linear_project_id` / `linear_milestone_id` frontmatter first. If you were
|
|
110
|
+
pre-first-push, there's nothing to reconcile.
|
|
111
|
+
5. **Task-level issues** created under v8 are no longer managed by the sync —
|
|
112
|
+
close or repurpose them in Linear by hand.
|
|
113
|
+
|
|
114
|
+
## `@skitterbyte/skitterspec-linear` v7 → v8 (sync goes one-way; `/spec-pull` removed)
|
|
115
|
+
|
|
116
|
+
**v8 made sync one-way.** The repo became the sole source of truth and Linear a
|
|
117
|
+
**generated mirror**: content is pushed up, never read back or merged. The
|
|
118
|
+
three-way merge engine and everything that fed it were retired.
|
|
119
|
+
|
|
120
|
+
### Breaking changes
|
|
121
|
+
|
|
122
|
+
| Area | v7 | v8 |
|
|
123
|
+
|------|-----|-----|
|
|
124
|
+
| Skills | `/spec-status`, `/spec-push`, **`/spec-pull`** | `/spec-status`, `/spec-push` |
|
|
125
|
+
| `spec-sync` subcommands | `normalize`, `push`, `status`, **`pull`** | `normalize`, `push`, `status`, **`record`** |
|
|
126
|
+
| Sidecar | a three-way merge base | the **last-pushed snapshot** (`record` writes it) |
|
|
127
|
+
| `sync.fieldOwnership` | `pull` / `both` values were load-bearing | still parsed; nothing is pulled |
|
|
128
|
+
|
|
129
|
+
### What to do
|
|
130
|
+
|
|
131
|
+
1. **Drop `/spec-pull` from any workflow that calls it.** There is no
|
|
132
|
+
replacement: editing the mirror in Linear is no longer an input. A person
|
|
133
|
+
editing the issue will see it overwritten by the next push.
|
|
134
|
+
2. **Replace `spec-sync pull` in scripts with `spec-sync record`** — it writes the
|
|
135
|
+
snapshot from the current repo files after a push is applied.
|
|
136
|
+
3. **Delete stale merge-base sidecars** under `sync.baseDir`; the first push after
|
|
137
|
+
upgrading writes the new snapshot format.
|
|
138
|
+
|
|
139
|
+
## `@skitterbyte/skitterspec-linear` v1 → v7 (no breaking changes)
|
|
140
|
+
|
|
141
|
+
Every major in this range was a **routine version bump**, not a breaking
|
|
142
|
+
contract. The skills (`/spec-status`, `/spec-push`, `/spec-pull`) and the
|
|
143
|
+
`spec-sync` subcommands (`normalize`, `push`, `pull`, `status`) were identical at
|
|
144
|
+
v1 and at v7. Upgrading anywhere inside this range needs **no action** beyond
|
|
145
|
+
re-running `update`.
|
|
146
|
+
|
|
147
|
+
One thing did change quietly, at **v4**: `sync.fieldOwnership` lost its
|
|
148
|
+
`milestones`, `phaseBodies`, `acceptanceCriteria` and `taskBreakdown` entries when
|
|
149
|
+
that detail moved inside `description`. A config still listing them does not
|
|
150
|
+
error — unknown keys merge in and *join the compared set* — so remove them if
|
|
151
|
+
you have them, or they will be compared against fields that no longer exist.
|
|
152
|
+
|
|
153
|
+
## `@skitterbyte/skitterspec` v3 → v16 (no breaking changes)
|
|
154
|
+
|
|
155
|
+
**Nothing in this range requires action.** Thirteen majors sounds like thirteen
|
|
156
|
+
migrations; it was one habit. Every release in this period was cut as a major
|
|
157
|
+
bump regardless of size (see `RELEASING.md`), and the base package's contract
|
|
158
|
+
never broke: **no skill was ever removed and no CLI flag was ever removed** — the
|
|
159
|
+
surface only grew. The spec folder layout
|
|
160
|
+
(`.core`/`backlog`/`in-progress`/`complete`/`cancelled`) is unchanged throughout.
|
|
161
|
+
|
|
162
|
+
The `feat(sync)!` commits that appear in this window changed
|
|
163
|
+
`@skitterbyte/skitterspec-linear`, which ships separately; the base was bumped
|
|
164
|
+
alongside it in lockstep. If you are on the **superset**, read the provider
|
|
165
|
+
entries above — those are the ones with work in them.
|
|
166
|
+
|
|
167
|
+
What each major actually added, so you can see what you gain by upgrading:
|
|
168
|
+
|
|
169
|
+
| Major | What landed |
|
|
170
|
+
|-------|-------------|
|
|
171
|
+
| v4 | `setup` commands bootstrap a fresh worktree's dependencies |
|
|
172
|
+
| v5, v6 | version bumps only |
|
|
173
|
+
| v7 | release docs refreshed; stale scripts dropped |
|
|
174
|
+
| v8 | version bump only |
|
|
175
|
+
| v9 | released alongside the provider's Linear body round-trip |
|
|
176
|
+
| v10, v11 | version bumps only |
|
|
177
|
+
| v12 | install manifest + `update --resync` / `--reset`; **`/spec-live`** overlay |
|
|
178
|
+
| v13 | **`/spec-hotfix`**, **`/spec-to-main`**, `spec-env prune` for orphaned test DBs |
|
|
179
|
+
| v14 | **Impact map** in the spec templates; live-aware `/spec-go`; the docs site |
|
|
180
|
+
| v15 | released alongside the provider's one-way sync switch |
|
|
181
|
+
| v16 | spec `Name:` handle; `/spec-complete` · `/spec-cancel` commit their own edits |
|
|
182
|
+
|
|
183
|
+
**Spec files written under an older version still read.** The template grew
|
|
184
|
+
(the Impact map at v14, the `Name:` header at v16) but the lifecycle skills treat
|
|
185
|
+
both as optional — `/spec-review` adds them if you want them.
|
|
186
|
+
|
|
187
|
+
## `@skitterbyte/skitterspec` v2 → v3 (slimmer surface + local traffic diversion)
|
|
188
|
+
|
|
189
|
+
**v3 shrinks the everyday command surface to five verbs — `spec → go → connect →
|
|
190
|
+
commit → complete` — by folding provisioning, teardown, and grooming into the
|
|
191
|
+
lifecycle skills, and adds `/spec-connect` for testing a worktree at your normal
|
|
192
|
+
`localhost` URL.** (`@skitterbyte/skitterspec-linear` moves to v2.0.0 in lockstep.)
|
|
193
|
+
|
|
194
|
+
### Removed skills (breaking) → where they went
|
|
195
|
+
|
|
196
|
+
| Removed skill | Replaced by |
|
|
197
|
+
|---------------|-------------|
|
|
198
|
+
| `/spec-env` | **Automatic in `/spec-go`** — it provisions the worktree and (with your OK) starts the spec's dev servers. Escalate Docker later with the CLI: `skitterspec spec-env up <name>`. |
|
|
199
|
+
| `/spec-env-down` | **Folded into `/spec-complete` and `/spec-cancel`** — they tear the environment down (dev servers, worktree, stack, slot) as part of finishing/abandoning a spec. |
|
|
200
|
+
| `/spec-ready` | **Folded into `/spec`** — grilling now writes a `Ready` spec directly (or `Draft` if you deliberately leave open questions). Go straight to `/spec-go`. |
|
|
201
|
+
|
|
202
|
+
The **`skitterspec spec-env` CLI engine stays** (`up`, `down`, `dev`, `connect`,
|
|
203
|
+
`integrate`, `status`, `resolve`) — only the three *skills* were removed. Anything
|
|
204
|
+
that scripted those CLI verbs keeps working.
|
|
205
|
+
|
|
206
|
+
### New — `/spec-connect` and two config blocks
|
|
207
|
+
|
|
208
|
+
- **`/spec-connect <name>`** points your canonical `localhost` ports at a spec's
|
|
209
|
+
running dev servers (so you can test a worktree's UI/API at the normal URL);
|
|
210
|
+
`/spec-connect main` hands the ports back. It's a small bundled Node reverse
|
|
211
|
+
proxy — no external install. Exclusive: one spec exposed at a time.
|
|
212
|
+
- **`env.config.json` gains `dev` and `proxy` blocks.** `dev` lists the host dev
|
|
213
|
+
servers `/spec-go` starts (`{ name, command, portVar, health?, frontPort? }`);
|
|
214
|
+
`proxy` configures the front-door proxy (`{ enabled, host }`). Both default to
|
|
215
|
+
off/empty, so existing projects are unaffected until you fill `dev` in.
|
|
216
|
+
|
|
217
|
+
### What to do
|
|
218
|
+
|
|
219
|
+
1. **Upgrade and re-run `init`** (or `update`): `npx @skitterbyte/skitterspec
|
|
220
|
+
update`. It stops installing the three removed skills, installs `/spec-connect`,
|
|
221
|
+
and refreshes the CLAUDE.md section + `spec-planning` rule. Your specs and
|
|
222
|
+
`env.config.json` are untouched.
|
|
223
|
+
2. **Remove muscle memory for the old commands** — use `/spec-go` to bring a spec
|
|
224
|
+
up, `/spec-complete`/`/spec-cancel` to tear it down, and `/spec` (no separate
|
|
225
|
+
`/spec-ready`) to reach a Ready spec.
|
|
226
|
+
3. **To test UI/API worktrees:** add a `dev` block to `env.config.json` (see
|
|
227
|
+
`specs/.core/env.config.md`), then `/spec-go` → `/spec-connect <name>`.
|
|
228
|
+
|
|
229
|
+
## `@skitterbyte/skitterspec` v1 → v2 (tracker-free base)
|
|
230
|
+
|
|
231
|
+
**v2 of the base package is tracker-free.** The Linear sync feature — the
|
|
232
|
+
`/spec-status`, `/spec-push` skills, the `spec-sync` CLI, the
|
|
233
|
+
Linear-aware steps of `/spec` and `/spec-go`, and the `linear.config.*`
|
|
234
|
+
templates — moved out of `@skitterbyte/skitterspec` into a separate **superset**
|
|
235
|
+
distribution, `@skitterbyte/skitterspec-linear`. You now install exactly one:
|
|
236
|
+
|
|
237
|
+
| If you… | Install |
|
|
238
|
+
|---------|---------|
|
|
239
|
+
| don't sync specs to a tracker | `@skitterbyte/skitterspec` (v2) |
|
|
240
|
+
| use (or want) Linear sync | `@skitterbyte/skitterspec-linear` |
|
|
241
|
+
|
|
242
|
+
Everything else — the spec lifecycle and per-spec isolation — is unchanged and
|
|
243
|
+
present in **both**.
|
|
244
|
+
|
|
245
|
+
### If you did NOT use Linear sync
|
|
246
|
+
|
|
247
|
+
Nothing to do. Upgrade to v2 and re-run `init` (or `update`) as usual. The base
|
|
248
|
+
never installed the Linear skills for you, so there's nothing to remove.
|
|
249
|
+
|
|
250
|
+
### If you DID use Linear sync
|
|
251
|
+
|
|
252
|
+
Switching is one install plus a re-`init`:
|
|
253
|
+
|
|
254
|
+
1. **Install the superset** (in place of the base):
|
|
255
|
+
|
|
256
|
+
```sh
|
|
257
|
+
npm rm @skitterbyte/skitterspec # if it was a dependency
|
|
258
|
+
npx @skitterbyte/skitterspec-linear init
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
2. **Re-run `init`.** It re-installs the shared skills (now composed with the
|
|
262
|
+
Linear steps) and the three sync skills, and re-scaffolds the config
|
|
263
|
+
templates. Your existing files are preserved — `init` never overwrites without
|
|
264
|
+
`--force`.
|
|
265
|
+
|
|
266
|
+
3. **Your config is unchanged.** The live config path is still
|
|
267
|
+
`specs/.core/linear.config.json`, and the committed base sidecars under
|
|
268
|
+
`specs/.core/linear-base/` are read as-is. No re-linking, no re-sync.
|
|
269
|
+
|
|
270
|
+
That's it — `/spec-status`, `/spec-push`, and `skitterspec-linear
|
|
271
|
+
spec-sync …` work exactly as before.
|
|
272
|
+
|
|
273
|
+
### One config note — branch naming
|
|
274
|
+
|
|
275
|
+
Embedding the Linear identifier in a worktree branch name is now configured in the
|
|
276
|
+
**isolation** config, not the Linear config. In `specs/.core/env.config.json` set:
|
|
277
|
+
|
|
278
|
+
```jsonc
|
|
279
|
+
"branch": { "pattern": "{identifier}-{slug}", "identifierField": "linear_identifier" }
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
If you don't need the id in branch names, leave the default `{type}/{slug}` — the
|
|
283
|
+
old implicit Linear-branch behaviour is off unless you opt in this way. (This is
|
|
284
|
+
the only behavioural change beyond the package split.)
|
|
285
|
+
|
|
286
|
+
## Why the split
|
|
287
|
+
|
|
288
|
+
The base couldn't ship without a specific tracker's fingerprints baked into shared
|
|
289
|
+
skills and a `src/sync/` engine. Extracting the provider makes the base a clean,
|
|
290
|
+
tracker-free workflow and lets a new provider (e.g. Jira) ship as another superset
|
|
291
|
+
over the same base — without re-patching the base. See
|
|
292
|
+
`specs/complete/feat-extract-ticketing-provider/` for the full rationale.
|
package/README.md
CHANGED
|
@@ -22,6 +22,25 @@ This installs the skills + rules into `.claude/`, scaffolds `specs/`, and patche
|
|
|
22
22
|
`CLAUDE.md`. See `.claude/rules/spec-planning.md` after install to set your
|
|
23
23
|
project's typecheck/test/lint commands.
|
|
24
24
|
|
|
25
|
+
## Upgrading
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx @skitterbyte/skitterspec update
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`update` refreshes the files it manages (skills, rules, `specs/.core` docs) and
|
|
32
|
+
**keeps anything you edited**. A file it kept is listed under
|
|
33
|
+
`customized (kept)` with the change it declined summarised as `+added −removed`:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
customized (kept):
|
|
37
|
+
.claude/rules/spec-planning.md +34 −13
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Add `--diff` to see those changes as a unified diff before deciding whether to
|
|
41
|
+
re-apply your edits on top, or `--force` to take the package version and lose
|
|
42
|
+
them. Your `specs/` content and live `.core` config are never touched.
|
|
43
|
+
|
|
25
44
|
## Pick one distribution
|
|
26
45
|
|
|
27
46
|
Ticketing sync is a **separate superset you install instead of this one**:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec",
|
|
3
|
-
"version": "16.2
|
|
3
|
+
"version": "16.3.2",
|
|
4
4
|
"description": "Spec-driven development for Claude Code — a tracker-free filesystem workflow: lifecycle skills and per-spec isolation. For Linear sync, install @skitterbyte/skitterspec-linear instead.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"bin",
|
|
21
21
|
"src",
|
|
22
|
-
"assets"
|
|
22
|
+
"assets",
|
|
23
|
+
"MIGRATION.md"
|
|
23
24
|
],
|
|
24
25
|
"engines": {
|
|
25
26
|
"node": ">=18"
|
package/src/cli.js
CHANGED
|
@@ -79,6 +79,8 @@ Options (init / update):
|
|
|
79
79
|
--reset (init) Start again: reset managed scaffolding fresh
|
|
80
80
|
(needs --yes; never touches your specs or config)
|
|
81
81
|
--force Overwrite skill/rule/script files that already exist
|
|
82
|
+
--diff (update) Show the upstream changes each customized
|
|
83
|
+
file declined, as a unified diff
|
|
82
84
|
--dir <path> Target project dir (default: positional arg or cwd)
|
|
83
85
|
--no-claude-md Skip creating/patching CLAUDE.md
|
|
84
86
|
--yes, -y Accept defaults; skip the interactive setup prompts
|
|
@@ -104,6 +106,7 @@ function parse(argv) {
|
|
|
104
106
|
removeReleaseTooling: false,
|
|
105
107
|
resync: false,
|
|
106
108
|
reset: false,
|
|
109
|
+
diff: false,
|
|
107
110
|
}
|
|
108
111
|
const positional = []
|
|
109
112
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -116,6 +119,7 @@ function parse(argv) {
|
|
|
116
119
|
else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
|
|
117
120
|
else if (a === '--resync') opts.resync = true
|
|
118
121
|
else if (a === '--reset') opts.reset = true
|
|
122
|
+
else if (a === '--diff') opts.diff = true
|
|
119
123
|
else if (a === '--dir') opts.dir = argv[++i]
|
|
120
124
|
else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
|
|
121
125
|
else positional.push(a)
|
|
@@ -1346,7 +1350,7 @@ async function run(argv) {
|
|
|
1346
1350
|
break
|
|
1347
1351
|
}
|
|
1348
1352
|
if (action === 'resync') {
|
|
1349
|
-
resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
|
|
1353
|
+
resync(dir, { claudeMd: opts.claudeMd, force: opts.force, diff: opts.diff })
|
|
1350
1354
|
break
|
|
1351
1355
|
}
|
|
1352
1356
|
// action === 'create-missing' → fall through to a normal (skip-existing) init.
|
|
@@ -1365,7 +1369,7 @@ async function run(argv) {
|
|
|
1365
1369
|
case 'update':
|
|
1366
1370
|
// `update` is a resync — refresh managed files, keep customized ones
|
|
1367
1371
|
// (--force to overwrite). Leaves specs/ and live .core config alone.
|
|
1368
|
-
resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
|
|
1372
|
+
resync(dir, { claudeMd: opts.claudeMd, force: opts.force, diff: opts.diff })
|
|
1369
1373
|
await cleanupReleaseTooling(dir, opts)
|
|
1370
1374
|
break
|
|
1371
1375
|
default:
|
package/src/init.js
CHANGED
|
@@ -53,7 +53,7 @@ const CORE_FILES = listCoreTemplates()
|
|
|
53
53
|
const SPEC_MARKER_START = '<!-- skitterspec:start -->'
|
|
54
54
|
const SPEC_MARKER_END = '<!-- skitterspec:end -->'
|
|
55
55
|
|
|
56
|
-
const report = { created: [], updated: [], skipped: [], removed: [], customized: [], warnings: [] }
|
|
56
|
+
const report = { created: [], updated: [], skipped: [], removed: [], customized: [], healed: [], warnings: [] }
|
|
57
57
|
|
|
58
58
|
function resetReport() {
|
|
59
59
|
for (const k of Object.keys(report)) report[k].length = 0
|
|
@@ -82,6 +82,8 @@ function ensureDir(p) {
|
|
|
82
82
|
// old version we own" (safe to update) from "a file the user edited" (keep). It
|
|
83
83
|
// lists only managed FILES (skills, rules, .core templates) — never user content.
|
|
84
84
|
|
|
85
|
+
const { linesDiff } = require('./lines-diff.js')
|
|
86
|
+
|
|
85
87
|
const MANIFEST_FILE = path.join('specs', '.core', '.skitterspec-manifest.json')
|
|
86
88
|
const MANIFEST_VERSION = 1
|
|
87
89
|
|
|
@@ -132,13 +134,24 @@ function writeManifest(dir, files) {
|
|
|
132
134
|
|
|
133
135
|
// Classify a managed file against the manifest baseline.
|
|
134
136
|
// missing — not on disk
|
|
135
|
-
// pristine —
|
|
136
|
-
// customized — on disk but differs
|
|
137
|
-
|
|
137
|
+
// pristine — ours to update: it matches the package asset, or the hash we recorded
|
|
138
|
+
// customized — on disk but differs from both — a user edit; keep it
|
|
139
|
+
//
|
|
140
|
+
// `bundled` (the current package asset) is optional but decisive: a file whose
|
|
141
|
+
// CONTENT equals what we ship is not customized, whatever the manifest says.
|
|
142
|
+
// Without that check a stale hash pinned the file out of updates permanently —
|
|
143
|
+
// anything that changed it out-of-band (an errant tool, a partial restore, a
|
|
144
|
+
// manifest lost and re-seeded at the wrong version) froze it for good, silently.
|
|
145
|
+
// Comparing content first makes the tool self-healing after any restore.
|
|
146
|
+
// `pruneRetiredManaged` passes no `bundled` on purpose: the package no longer
|
|
147
|
+
// ships that file, so there is nothing to compare it against.
|
|
148
|
+
function managedState(dir, relPath, manifest, bundled) {
|
|
138
149
|
const abs = path.join(dir, relPath)
|
|
139
150
|
if (!fs.existsSync(abs)) return 'missing'
|
|
151
|
+
const onDisk = fs.readFileSync(abs, 'utf8')
|
|
152
|
+
if (bundled !== undefined && onDisk === bundled) return 'pristine'
|
|
140
153
|
const known = manifest.files[relPath]
|
|
141
|
-
return known && sha1(
|
|
154
|
+
return known && sha1(onDisk) === known ? 'pristine' : 'customized'
|
|
142
155
|
}
|
|
143
156
|
|
|
144
157
|
// Reconcile and persist the manifest after an install/resync run: keep prior
|
|
@@ -405,7 +418,7 @@ function isExistingSetup(dir) {
|
|
|
405
418
|
// update; customized (edited) → keep + report, unless `force`.
|
|
406
419
|
function resyncManagedFile(dir, target, manifest, force) {
|
|
407
420
|
const { relPath, abs, bundled } = target
|
|
408
|
-
const state = managedState(dir, relPath, manifest)
|
|
421
|
+
const state = managedState(dir, relPath, manifest, bundled)
|
|
409
422
|
const write = (bucket) => {
|
|
410
423
|
ensureDir(path.dirname(abs))
|
|
411
424
|
fs.writeFileSync(abs, bundled)
|
|
@@ -416,17 +429,25 @@ function resyncManagedFile(dir, target, manifest, force) {
|
|
|
416
429
|
if (state === 'customized') {
|
|
417
430
|
if (force) return write('updated')
|
|
418
431
|
writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
|
|
419
|
-
|
|
432
|
+
// Carry the change the user just DECLINED. A bare filename tells them a
|
|
433
|
+
// decision was made on their behalf but not what it was, which leaves
|
|
434
|
+
// "clobber and re-apply my edits by hand" as the only safe way to upgrade.
|
|
435
|
+
const { added, removed, hunks } = linesDiff(fs.readFileSync(abs, 'utf8'), bundled)
|
|
436
|
+
return report.customized.push({ relPath, added, removed, hunks })
|
|
420
437
|
}
|
|
421
438
|
// pristine — update only if the bundled content actually changed
|
|
422
439
|
if (fs.readFileSync(abs, 'utf8') === bundled) {
|
|
440
|
+
// The file is ours and current, but the manifest disagreed — record the
|
|
441
|
+
// repair rather than healing in silence: a file that quietly starts
|
|
442
|
+
// updating again is as opaque as one that quietly stopped.
|
|
443
|
+
if (manifest.files[relPath] !== sha1(bundled)) report.healed.push(relPath)
|
|
423
444
|
writtenHashes[relPath] = sha1(bundled)
|
|
424
445
|
return report.skipped.push(relPath)
|
|
425
446
|
}
|
|
426
447
|
write('updated')
|
|
427
448
|
}
|
|
428
449
|
|
|
429
|
-
function resync(dir, { force = false, claudeMd = true } = {}) {
|
|
450
|
+
function resync(dir, { force = false, claudeMd = true, diff = false } = {}) {
|
|
430
451
|
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
431
452
|
resetReport()
|
|
432
453
|
const manifest = readManifest(dir)
|
|
@@ -436,7 +457,7 @@ function resync(dir, { force = false, claudeMd = true } = {}) {
|
|
|
436
457
|
pruneRetiredManaged(dir, manifest)
|
|
437
458
|
if (claudeMd) installClaudeMd(dir, { mode: 'update' })
|
|
438
459
|
flushManifest(dir)
|
|
439
|
-
printReport(dir, 'resync')
|
|
460
|
+
printReport(dir, 'resync', { diff })
|
|
440
461
|
}
|
|
441
462
|
|
|
442
463
|
// The never-touch set: START AGAIN may only delete a known managed file, and may
|
|
@@ -502,7 +523,7 @@ function reset(dir, { claudeMd = true } = {}) {
|
|
|
502
523
|
printReport(dir, 'reset')
|
|
503
524
|
}
|
|
504
525
|
|
|
505
|
-
function printReport(dir, mode) {
|
|
526
|
+
function printReport(dir, mode, { diff = false } = {}) {
|
|
506
527
|
const line = (label, items) => {
|
|
507
528
|
if (!items.length) return
|
|
508
529
|
process.stdout.write(`\n${label}:\n`)
|
|
@@ -512,12 +533,25 @@ function printReport(dir, mode) {
|
|
|
512
533
|
line('created', report.created)
|
|
513
534
|
line('updated', report.updated)
|
|
514
535
|
line('removed', report.removed)
|
|
515
|
-
line(
|
|
536
|
+
line(
|
|
537
|
+
'customized (kept)',
|
|
538
|
+
report.customized.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
|
|
539
|
+
)
|
|
540
|
+
line('manifest repaired', report.healed)
|
|
516
541
|
line('unchanged', report.skipped)
|
|
517
542
|
if (report.warnings.length) {
|
|
518
543
|
process.stdout.write('\nwarnings:\n')
|
|
519
544
|
for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
|
|
520
545
|
}
|
|
546
|
+
if (diff) {
|
|
547
|
+
for (const c of report.customized) {
|
|
548
|
+
if (!c.hunks.length) continue
|
|
549
|
+
process.stdout.write(`\n--- ${c.relPath} (kept — this is what you declined)\n`)
|
|
550
|
+
for (const h of c.hunks) process.stdout.write(`${h}\n`)
|
|
551
|
+
}
|
|
552
|
+
} else if (report.customized.length) {
|
|
553
|
+
process.stdout.write('\nRe-run with --diff to see the changes those files declined.\n')
|
|
554
|
+
}
|
|
521
555
|
const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
|
|
522
556
|
const isolationNote = isolationOn
|
|
523
557
|
? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A minimal line diff — just enough for `update` to say what it skipped.
|
|
5
|
+
*
|
|
6
|
+
* `update` reports a file it kept as `customized (kept)` and nothing else, so
|
|
7
|
+
* there is no way to learn WHICH upstream changes you declined without diffing
|
|
8
|
+
* against `node_modules` by hand. That is how a real behavioural change (the
|
|
9
|
+
* lifecycle skills learning to commit their own edits) went unnoticed through an
|
|
10
|
+
* upgrade in the field.
|
|
11
|
+
*
|
|
12
|
+
* Zero dependencies on purpose: this package ships with none, and `diff(1)` is
|
|
13
|
+
* not a portable guarantee. An LCS over lines is a few dozen lines of code and
|
|
14
|
+
* the inputs are markdown files of a few hundred lines.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Longest-common-subsequence walk over two line arrays, as a flat op list.
|
|
18
|
+
// `t` is ' ' (context), '-' (only in `a`) or '+' (only in `b`).
|
|
19
|
+
function diffOps(a, b) {
|
|
20
|
+
const n = a.length
|
|
21
|
+
const m = b.length
|
|
22
|
+
// dp[i][j] = LCS length of a[i..] and b[j..], flattened.
|
|
23
|
+
const dp = new Int32Array((n + 1) * (m + 1))
|
|
24
|
+
const at = (i, j) => i * (m + 1) + j
|
|
25
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
26
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
27
|
+
dp[at(i, j)] = a[i] === b[j] ? dp[at(i + 1, j + 1)] + 1 : Math.max(dp[at(i + 1, j)], dp[at(i, j + 1)])
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ops = []
|
|
32
|
+
let i = 0
|
|
33
|
+
let j = 0
|
|
34
|
+
while (i < n && j < m) {
|
|
35
|
+
if (a[i] === b[j]) {
|
|
36
|
+
ops.push({ t: ' ', line: a[i], a: i, b: j })
|
|
37
|
+
i++
|
|
38
|
+
j++
|
|
39
|
+
} else if (dp[at(i + 1, j)] >= dp[at(i, j + 1)]) {
|
|
40
|
+
ops.push({ t: '-', line: a[i], a: i, b: j })
|
|
41
|
+
i++
|
|
42
|
+
} else {
|
|
43
|
+
ops.push({ t: '+', line: b[j], a: i, b: j })
|
|
44
|
+
j++
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
while (i < n) {
|
|
48
|
+
ops.push({ t: '-', line: a[i], a: i, b: j })
|
|
49
|
+
i++
|
|
50
|
+
}
|
|
51
|
+
while (j < m) {
|
|
52
|
+
ops.push({ t: '+', line: b[j], a: i, b: j })
|
|
53
|
+
j++
|
|
54
|
+
}
|
|
55
|
+
return ops
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Group the ops into unified-diff hunks, each carrying `context` unchanged lines
|
|
59
|
+
// either side of a run of changes. Runs closer together than 2×context merge, as
|
|
60
|
+
// `diff -u` does, so a cluster of edits reads as one hunk.
|
|
61
|
+
function toHunks(ops, context) {
|
|
62
|
+
const changed = ops.map((o) => o.t !== ' ')
|
|
63
|
+
const hunks = []
|
|
64
|
+
let k = 0
|
|
65
|
+
while (k < ops.length) {
|
|
66
|
+
if (!changed[k]) {
|
|
67
|
+
k++
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
let start = Math.max(0, k - context)
|
|
71
|
+
let end = k
|
|
72
|
+
// Extend while the next change is near enough to keep in the same hunk.
|
|
73
|
+
for (let p = k; p < ops.length; p++) {
|
|
74
|
+
if (changed[p]) end = p
|
|
75
|
+
else if (p - end > context * 2) break
|
|
76
|
+
}
|
|
77
|
+
end = Math.min(ops.length - 1, end + context)
|
|
78
|
+
|
|
79
|
+
const body = ops.slice(start, end + 1)
|
|
80
|
+
const aStart = body[0].a + 1
|
|
81
|
+
const bStart = body[0].b + 1
|
|
82
|
+
const aLen = body.filter((o) => o.t !== '+').length
|
|
83
|
+
const bLen = body.filter((o) => o.t !== '-').length
|
|
84
|
+
hunks.push(
|
|
85
|
+
[`@@ -${aStart},${aLen} +${bStart},${bLen} @@`, ...body.map((o) => `${o.t}${o.line}`)].join('\n'),
|
|
86
|
+
)
|
|
87
|
+
k = end + 1
|
|
88
|
+
}
|
|
89
|
+
return hunks
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Diff `a` (what is on disk) against `b` (what the package ships).
|
|
94
|
+
*
|
|
95
|
+
* `added`/`removed` count the lines an update WOULD add and remove — the summary
|
|
96
|
+
* printed beside a kept file. `hunks` are unified-diff blocks for `--diff`.
|
|
97
|
+
*
|
|
98
|
+
* @param {string|string[]} a
|
|
99
|
+
* @param {string|string[]} b
|
|
100
|
+
* @param {{context?:number}} [opts]
|
|
101
|
+
* @returns {{added:number, removed:number, hunks:string[]}}
|
|
102
|
+
*/
|
|
103
|
+
function linesDiff(a, b, { context = 3 } = {}) {
|
|
104
|
+
const A = Array.isArray(a) ? a : String(a).split('\n')
|
|
105
|
+
const B = Array.isArray(b) ? b : String(b).split('\n')
|
|
106
|
+
const ops = diffOps(A, B)
|
|
107
|
+
return {
|
|
108
|
+
added: ops.filter((o) => o.t === '+').length,
|
|
109
|
+
removed: ops.filter((o) => o.t === '-').length,
|
|
110
|
+
hunks: toHunks(ops, context),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { linesDiff }
|