@skitterbyte/skitterspec-linear 9.2.0 → 10.0.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/MIGRATION.md ADDED
@@ -0,0 +1,156 @@
1
+ # Migration guide
2
+
3
+ ## `@skitterbyte/skitterspec-linear` v8 → v9 (a spec is an Issue, phases are sub-issues)
4
+
5
+ **v9 remaps the Linear mirror.** A spec is now a Linear **issue** (not a Project),
6
+ each phase a **sub-issue** (not a Milestone), and **tasks are no longer synced**
7
+ (they stay in the repo phase files). This collapses a large spec from ~1 project +
8
+ N milestones + dozens of task-issues down to **one issue + one sub-issue per
9
+ phase**. The base `@skitterbyte/skitterspec` is unaffected (still v15).
10
+
11
+ ### Breaking changes
12
+
13
+ | Area | v8 | v9 |
14
+ |------|-----|-----|
15
+ | `linear.config.json` → `mapping` | `{specFolder:"project", phases:"milestone", tasks:"issue"}` | `{specFolder:"issue", phases:"subissue", tasks:"none"}` |
16
+ | `linear.config.json` → `linear` | `initiativeId` | `projectId` (the project picker's default) |
17
+ | `linear.config.json` → `states` | Linear **Project** statuses (e.g. `Completed`) | Linear **issue** workflow states (e.g. `Done`) |
18
+ | `linear.config.json` → `sync.fieldOwnership` | `{description, milestones, tasks, workflowState}` | `{description, subIssues, workflowState}` |
19
+ | Phase frontmatter | `linear_milestone_id` | `linear_issue_id` (the sub-issue id) |
20
+ | Overview frontmatter | `linear_project_id` + `linear_identifier` | `linear_identifier` (the spec issue) |
21
+ | Last-pushed snapshot | `{project, milestones, issues}` | `{issue, subIssues}` |
22
+
23
+ ### What to do
24
+
25
+ 1. **Upgrade and re-run `update`:** `npx @skitterbyte/skitterspec-linear update`.
26
+ It refreshes the skills, the `linear.config.md` / `SETUP.md` docs, and the
27
+ config example.
28
+ 2. **Edit `specs/.core/linear.config.json`** to the new keys above (or delete it
29
+ and re-copy `linear.config.json.example`). Point `states` at your workspace's
30
+ **issue** states; set `linear.projectId` if most specs belong to one Project —
31
+ it pre-selects the picker's default rather than fixing every spec there.
32
+ 3. **Optionally add `intake`** to start specs from issues someone else filed:
33
+
34
+ ```jsonc
35
+ "intake": {
36
+ "label": "web-app", // the inbox `/spec --from-issue` browses
37
+ "bugLabels": ["bug"] // issues with these route to /spec-bug
38
+ }
39
+ ```
40
+
41
+ Without it, `/spec SKI-123` still adopts an issue by id; only the browsable
42
+ inbox and the bug routing need the labels.
43
+ 4. **Existing pushed specs:** the snapshot format changed, so the first
44
+ `/spec-push` after upgrading **re-creates** the mirror (a fresh issue +
45
+ sub-issues). Delete any stale `specs/.core/linear-base/*.base.json` and the old
46
+ `linear_project_id` / `linear_milestone_id` frontmatter first. If you were
47
+ pre-first-push, there's nothing to reconcile.
48
+ 5. **Task-level issues** created under v8 are no longer managed by the sync —
49
+ close or repurpose them in Linear by hand.
50
+
51
+ ## `@skitterbyte/skitterspec` v2 → v3 (slimmer surface + local traffic diversion)
52
+
53
+ **v3 shrinks the everyday command surface to five verbs — `spec → go → connect →
54
+ commit → complete` — by folding provisioning, teardown, and grooming into the
55
+ lifecycle skills, and adds `/spec-connect` for testing a worktree at your normal
56
+ `localhost` URL.** (`@skitterbyte/skitterspec-linear` moves to v2.0.0 in lockstep.)
57
+
58
+ ### Removed skills (breaking) → where they went
59
+
60
+ | Removed skill | Replaced by |
61
+ |---------------|-------------|
62
+ | `/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>`. |
63
+ | `/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. |
64
+ | `/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`. |
65
+
66
+ The **`skitterspec spec-env` CLI engine stays** (`up`, `down`, `dev`, `connect`,
67
+ `integrate`, `status`, `resolve`) — only the three *skills* were removed. Anything
68
+ that scripted those CLI verbs keeps working.
69
+
70
+ ### New — `/spec-connect` and two config blocks
71
+
72
+ - **`/spec-connect <name>`** points your canonical `localhost` ports at a spec's
73
+ running dev servers (so you can test a worktree's UI/API at the normal URL);
74
+ `/spec-connect main` hands the ports back. It's a small bundled Node reverse
75
+ proxy — no external install. Exclusive: one spec exposed at a time.
76
+ - **`env.config.json` gains `dev` and `proxy` blocks.** `dev` lists the host dev
77
+ servers `/spec-go` starts (`{ name, command, portVar, health?, frontPort? }`);
78
+ `proxy` configures the front-door proxy (`{ enabled, host }`). Both default to
79
+ off/empty, so existing projects are unaffected until you fill `dev` in.
80
+
81
+ ### What to do
82
+
83
+ 1. **Upgrade and re-run `init`** (or `update`): `npx @skitterbyte/skitterspec
84
+ update`. It stops installing the three removed skills, installs `/spec-connect`,
85
+ and refreshes the CLAUDE.md section + `spec-planning` rule. Your specs and
86
+ `env.config.json` are untouched.
87
+ 2. **Remove muscle memory for the old commands** — use `/spec-go` to bring a spec
88
+ up, `/spec-complete`/`/spec-cancel` to tear it down, and `/spec` (no separate
89
+ `/spec-ready`) to reach a Ready spec.
90
+ 3. **To test UI/API worktrees:** add a `dev` block to `env.config.json` (see
91
+ `specs/.core/env.config.md`), then `/spec-go` → `/spec-connect <name>`.
92
+
93
+ ## `@skitterbyte/skitterspec` v1 → v2 (tracker-free base)
94
+
95
+ **v2 of the base package is tracker-free.** The Linear sync feature — the
96
+ `/spec-status`, `/spec-push` skills, the `spec-sync` CLI, the
97
+ Linear-aware steps of `/spec` and `/spec-go`, and the `linear.config.*`
98
+ templates — moved out of `@skitterbyte/skitterspec` into a separate **superset**
99
+ distribution, `@skitterbyte/skitterspec-linear`. You now install exactly one:
100
+
101
+ | If you… | Install |
102
+ |---------|---------|
103
+ | don't sync specs to a tracker | `@skitterbyte/skitterspec` (v2) |
104
+ | use (or want) Linear sync | `@skitterbyte/skitterspec-linear` |
105
+
106
+ Everything else — the spec lifecycle and per-spec isolation — is unchanged and
107
+ present in **both**.
108
+
109
+ ### If you did NOT use Linear sync
110
+
111
+ Nothing to do. Upgrade to v2 and re-run `init` (or `update`) as usual. The base
112
+ never installed the Linear skills for you, so there's nothing to remove.
113
+
114
+ ### If you DID use Linear sync
115
+
116
+ Switching is one install plus a re-`init`:
117
+
118
+ 1. **Install the superset** (in place of the base):
119
+
120
+ ```sh
121
+ npm rm @skitterbyte/skitterspec # if it was a dependency
122
+ npx @skitterbyte/skitterspec-linear init
123
+ ```
124
+
125
+ 2. **Re-run `init`.** It re-installs the shared skills (now composed with the
126
+ Linear steps) and the three sync skills, and re-scaffolds the config
127
+ templates. Your existing files are preserved — `init` never overwrites without
128
+ `--force`.
129
+
130
+ 3. **Your config is unchanged.** The live config path is still
131
+ `specs/.core/linear.config.json`, and the committed base sidecars under
132
+ `specs/.core/linear-base/` are read as-is. No re-linking, no re-sync.
133
+
134
+ That's it — `/spec-status`, `/spec-push`, and `skitterspec-linear
135
+ spec-sync …` work exactly as before.
136
+
137
+ ### One config note — branch naming
138
+
139
+ Embedding the Linear identifier in a worktree branch name is now configured in the
140
+ **isolation** config, not the Linear config. In `specs/.core/env.config.json` set:
141
+
142
+ ```jsonc
143
+ "branch": { "pattern": "{identifier}-{slug}", "identifierField": "linear_identifier" }
144
+ ```
145
+
146
+ If you don't need the id in branch names, leave the default `{type}/{slug}` — the
147
+ old implicit Linear-branch behaviour is off unless you opt in this way. (This is
148
+ the only behavioural change beyond the package split.)
149
+
150
+ ## Why the split
151
+
152
+ The base couldn't ship without a specific tracker's fingerprints baked into shared
153
+ skills and a `src/sync/` engine. Extracting the provider makes the base a clean,
154
+ tracker-free workflow and lets a new provider (e.g. Jira) ship as another superset
155
+ over the same base — without re-patching the base. See
156
+ `specs/complete/feat-extract-ticketing-provider/` for the full rationale.
package/README.md CHANGED
@@ -51,6 +51,25 @@ fuller guide):
51
51
  `/spec-status` reports what would push. Sync is **one-way**: the repo is the
52
52
  source of truth and Linear is a generated mirror.
53
53
 
54
+ ## Upgrading
55
+
56
+ ```sh
57
+ npx @skitterbyte/skitterspec-linear update
58
+ ```
59
+
60
+ `update` refreshes the files it manages (skills, rules, `specs/.core` docs) and
61
+ **keeps anything you edited**. A file it kept is listed under
62
+ `customized (kept)` with the change it declined summarised as `+added −removed`:
63
+
64
+ ```
65
+ customized (kept):
66
+ .claude/rules/spec-planning.md +34 −13
67
+ ```
68
+
69
+ Add `--diff` to see those changes as a unified diff before deciding whether to
70
+ re-apply your edits on top, or `--force` to take the package version and lose
71
+ them. Your `specs/` content and live `.core` config are never touched.
72
+
54
73
  ## What the superset adds
55
74
 
56
75
  On top of the base skills (`/spec`, `/spec-go`, isolation, …):
@@ -14,6 +14,17 @@ team) that the config reference (`linear.config.md`) assumes you already have.
14
14
 
15
15
  ---
16
16
 
17
+ ## Upgrading from 8.x
18
+
19
+ **v9 remapped the mirror.** A spec is now an **issue** (was a Project), a phase a
20
+ **sub-issue** (was a Milestone), and tasks are no longer objects. The frontmatter
21
+ keys moved with it, so a spec linked under 8.x reads as **unlinked** to v9 — the
22
+ next `/spec-push` would mint a fresh mirror and abandon the old one.
23
+
24
+ `spec-sync push` detects this and refuses to let the plan be applied blind, but
25
+ read **`MIGRATION.md`** ("v8 → v9", shipped with the package) before upgrading a
26
+ repo with a live mirror. Fresh installs can skip this section.
27
+
17
28
  ## 1. Install the package
18
29
 
19
30
  Install the Linear superset (or, if you already run the base, switch to it — it
@@ -173,10 +184,15 @@ With a linked spec, confirm push end-to-end:
173
184
  this session. Re-run step 2; remember a fresh add needs a Claude Code restart.
174
185
  - **"missing required tools: issueCreate"** — you're on the read-only endpoint
175
186
  (or a restricted API key). Use `https://mcp.linear.app/mcp` for push.
176
- - **A configured state name silently does nothing** — Linear ignores an unknown
177
- issue state. Run `/spec-status` (it validates the `states` names against the
178
- workspace) and fix `linear.config.json` to the real issue-state names
179
- (`Backlog / Todo / In Progress / Done / Canceled`).
187
+ - **"refusing — the configured issue states have not been validated"** — `push`
188
+ requires the workspace's issue-state names (`--workspace-states <file>`), which
189
+ `/spec-push` fetches for you. Run the skill rather than the CLI directly, or
190
+ pass the file yourself.
191
+ - **"refusing — configured state name(s) not in the workspace"** — Linear ignores
192
+ an unknown issue state, so this is caught before the push rather than after.
193
+ Fix `linear.config.json` to the real issue-state names
194
+ (`Backlog / Todo / In Progress / Done / Canceled`). Upgrading from 8.x, the
195
+ value inverts: project status `Completed` → issue state `Done`.
180
196
  - **Reconnecting doesn't switch workspace** — Linear ties the OAuth session to one
181
197
  workspace. Remove and re-add the server to authenticate against another.
182
198
  - **Bold around an inline code span renders oddly in Linear** — Linear moves the
@@ -54,7 +54,12 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
54
54
 
55
55
  // Map the spec's lifecycle bucket → the Linear ISSUE workflow-state name. Used
56
56
  // for the spec issue's state (from its folder) AND each sub-issue's state (from
57
- // the phase emoji). Names must match the workspace's issue states exactly.
57
+ // the phase emoji). Names must match the workspace's issue states exactly
58
+ // Linear silently IGNORES an unknown state, so a typo pushes clean and the
59
+ // issue never moves. `/spec-push` fetches the workspace's names and `push`
60
+ // refuses to run without them, so a wrong name here fails loudly rather than
61
+ // quietly. (Upgrading from 8.x? The right value inverts: the project status
62
+ // `Completed` becomes the issue state `Done`.)
58
63
  "states": {
59
64
  "backlog": "Backlog",
60
65
  "in-progress": "In Progress",
@@ -20,10 +20,36 @@ tell the user how to enable Linear sync and stop.
20
20
 
21
21
  Use the argument, else the spec in context; ask if unclear.
22
22
 
23
- ## 2. Get the plan from the engine
23
+ ## 2. Connect, and validate the issue states
24
+
25
+ Discover the issue **read + create/update** tools at runtime (`get_issue`,
26
+ `save_issue` — a single upsert covers create and update), plus the **project
27
+ list** tool if this push will mint the spec issue (see the picker below — it is
28
+ optional; without it the picker is skipped, not failed). If Linear isn't
29
+ connected or a needed tool is missing, relay the fix and stop, **writing
30
+ nothing**.
31
+
32
+ Then fetch the workspace's issue workflow-state **names** and write them to a
33
+ file as a JSON array (e.g. `["Backlog","In Progress","Done","Canceled"]`). Step 3
34
+ requires that file: `push` **refuses to run** without it, because Linear silently
35
+ ignores an unknown issue state — the description lands, the issue never moves,
36
+ and nothing errors. If the check reports a name that isn't in the workspace, stop
37
+ and fix `specs/.core/linear.config.json`.
38
+
39
+ **If the check refuses, offer to fix it.** The refusal lists every configured
40
+ name the workspace lacks, the workspace's real state names, and — where the
41
+ bucket makes it unambiguous — which one to use instead. Relay that, then offer to
42
+ apply it to `specs/.core/linear.config.json` → `states`, and do so on the user's
43
+ confirmation. Never edit their config without asking, and never guess a bucket
44
+ the refusal made no suggestion for — ask which state they want.
45
+
46
+ `--skip-state-check` exists for the deliberate exception; do not reach for it to
47
+ get past a failing check.
48
+
49
+ ## 3. Get the plan from the engine
24
50
 
25
51
  ```
26
- skitterspec spec-sync push <spec> --json
52
+ skitterspec spec-sync push <spec> --workspace-states <file> --json
27
53
  ```
28
54
 
29
55
  The engine prints a JSON **plan** (no network, no remote read):
@@ -43,19 +69,15 @@ date — say so and stop. `state` values are local buckets
43
69
  (`backlog`/`in-progress`/`complete`/`cancelled`); map each to the Linear
44
70
  issue-state NAME via `config.states` at apply time.
45
71
 
46
- ## 3. Discover the Linear MCP tools
47
-
48
- Discover the issue **read + create/update** tools at runtime (`get_issue`,
49
- `save_issue` — a single upsert covers create and update), plus the **project
50
- list** tool if this push will mint the spec issue (see the picker below — it is
51
- optional; without it the picker is skipped, not failed). If Linear isn't
52
- connected or a needed tool is missing, relay the fix and stop, **writing
53
- nothing**.
72
+ ### Stop if the plan reports a pre-9.0 mirror
54
73
 
55
- **Validate the issue states first.** Fetch the workspace's issue workflow-state
56
- names and run `skitterspec spec-sync status <spec> --workspace-states <file>`; if
57
- it errors (a configured `states` name isn't in the workspace), stop and fix the
58
- config Linear silently ignores an unknown issue state.
74
+ If the plan carries a **`legacy`** field, this spec was linked under the pre-9.0
75
+ model (`linear_project_id` / `linear_milestone_id`). v9 reads the new keys, finds
76
+ none, and the plan above is therefore **all-creates** applying it mints a fresh
77
+ mirror and **abandons** the existing one. **Stop.** Relay `legacy.keys`,
78
+ `legacy.files` and `legacy.orphanCount` ("this would orphan N live objects"),
79
+ point at `MIGRATION.md` → "v8 → v9", and apply nothing until the user has
80
+ migrated or explicitly confirms they want a new mirror.
59
81
 
60
82
  ## 4. Apply the plan (order matters)
61
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "9.2.0",
3
+ "version": "10.0.0",
4
4
  "description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
@@ -21,7 +21,8 @@
21
21
  "files": [
22
22
  "bin",
23
23
  "src",
24
- "assets"
24
+ "assets",
25
+ "MIGRATION.md"
25
26
  ],
26
27
  "engines": {
27
28
  "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 — on disk and matches the hash we recorded (ours to update)
136
- // customized — on disk but differs (or unknown) — a user edit; keep it
137
- function managedState(dir, relPath, manifest) {
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(fs.readFileSync(abs, 'utf8')) === known ? 'pristine' : 'customized'
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
- return report.customized.push(relPath)
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('customized (kept)', report.customized)
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 }
@@ -10,6 +10,7 @@
10
10
  *
11
11
  * spec-sync normalize <spec> print the local projection (JSON)
12
12
  * spec-sync push <spec> print the create/update PLAN the skill applies
13
+ * (requires --workspace-states; see stateCheckFailure)
13
14
  * spec-sync stamp <spec> write returned ids back into the spec files
14
15
  * spec-sync record <spec> write the last-pushed snapshot (after apply)
15
16
  * spec-sync status <spec> read-only drift report (never writes)
@@ -35,6 +36,7 @@ const {
35
36
  isEmptyPlan,
36
37
  remoteWorkflowState,
37
38
  validateStates,
39
+ stateSuggestions,
38
40
  lintPhases,
39
41
  writeFrontmatter,
40
42
  stampSubIssueId,
@@ -178,16 +180,22 @@ function specSyncNormalize(dir, config, specArg, out, err) {
178
180
  // applies it over MCP then calls `record`.
179
181
  function specSyncPush(dir, config, specArg, flags, out, err) {
180
182
  const snapshotDir = resolveOrExit(specArg, dir, out)
181
- if (!snapshotDir) return
183
+ if (!snapshotDir) return 1
184
+ const failure = stateCheckFailure(config, flags)
185
+ if (failure) {
186
+ out.write(failure.join('\n') + '\n')
187
+ return 1
188
+ }
182
189
  const identifier = specIdentifier(snapshotDir, config)
183
190
  const r = push({ dir, snapshotDir, identifier, config })
184
191
  if (flags.json || !out.isTTY) {
185
192
  warnToErr(snapshotDir, config, err)
186
193
  out.write(JSON.stringify(r.plan, null, 2) + '\n')
187
- return
194
+ return 0
188
195
  }
189
196
  const p = r.plan
190
197
  const lines = [`spec-sync push: ${identifier}`, ...warningLines(snapshotDir, config)]
198
+ if (p.legacy) lines.push(...legacyLines(p.legacy))
191
199
  if (r.empty) lines.push(' nothing to push — mirror matches the last push')
192
200
  else {
193
201
  if (p.issue) lines.push(' issue: description/state')
@@ -196,6 +204,84 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
196
204
  lines.push(' (run with --json for the full plan the skill applies)')
197
205
  }
198
206
  out.write(lines.join('\n') + '\n')
207
+ return 0
208
+ }
209
+
210
+ // The pre-9.0 mirror block. Loud on purpose: the plan below it looks entirely
211
+ // ordinary — an all-creates plan for a spec that reads as unlinked — and
212
+ // applying it mints a second mirror and abandons the first.
213
+ function legacyLines(legacy) {
214
+ const found = legacy.keys.length ? legacy.keys.join(', ') : 'a pre-9.0 last-pushed snapshot'
215
+ const where = legacy.files.length ? ` in ${legacy.files.join(', ')}` : ''
216
+ const out = [
217
+ ' !! PRE-9.0 MIRROR — do not apply this plan as-is',
218
+ ` found ${found}${where}`,
219
+ ]
220
+ if (legacy.orphans) {
221
+ const o = legacy.orphans
222
+ out.push(
223
+ ` applying it would orphan ${o.total} live object(s): ` +
224
+ `${o.projects} project(s), ${o.milestones} milestone(s), ${o.issues} task issue(s)`,
225
+ )
226
+ }
227
+ out.push(' migrate first — see MIGRATION.md ("v8 → v9")')
228
+ return out
229
+ }
230
+
231
+ /**
232
+ * Validate the configured `states` names against the workspace, for a command
233
+ * that REFUSES without them.
234
+ *
235
+ * The engine is offline — `mcp.js` is the skill's adapter, not ours — so the
236
+ * names have to be fetched over MCP and handed in via `--workspace-states`. That
237
+ * handoff used to be advisory: `/spec-push` told the agent to run it against
238
+ * `status`, and skipping it sent a state name Linear **silently ignores** (the
239
+ * description lands, the issue never moves, nothing errors). Requiring the file
240
+ * turns the one check that catches it from a convention into a precondition.
241
+ *
242
+ * Returns null when the caller may proceed, or the lines to print before exiting
243
+ * non-zero.
244
+ */
245
+ function stateCheckFailure(config, flags) {
246
+ if (flags.skipStateCheck) return null
247
+ if (!flags.workspaceStates) {
248
+ return [
249
+ 'spec-sync push: refusing — the configured issue states have not been validated',
250
+ ' pass --workspace-states <file> (a JSON array of the workspace\'s issue',
251
+ ' workflow-state names, which /spec-push fetches over MCP), or',
252
+ ' --skip-state-check to push anyway.',
253
+ ' Linear silently ignores an unknown issue state: the push would look',
254
+ ' clean and the issue would never move.',
255
+ ]
256
+ }
257
+ if (!fs.existsSync(flags.workspaceStates)) {
258
+ return [`spec-sync push: refusing — no such --workspace-states file: ${flags.workspaceStates}`]
259
+ }
260
+ let names
261
+ try {
262
+ names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
263
+ } catch (error) {
264
+ return [`spec-sync push: refusing — --workspace-states is not valid JSON: ${error.message}`]
265
+ }
266
+ const list = Array.isArray(names) ? names : []
267
+ const missing = validateStates(config, list)
268
+ if (missing.length) {
269
+ // Say what IS available, and what to use instead. "Done is not a state" sends
270
+ // you to the Linear UI to go and look; naming the replacement does not.
271
+ const lines = ['spec-sync push: refusing — configured state name(s) not in the workspace', '']
272
+ for (const { bucket, configured, suggestion } of stateSuggestions(config, list)) {
273
+ lines.push(` states.${bucket}: "${configured}" is not an issue state in this workspace`)
274
+ if (suggestion) lines.push(` use "${suggestion}" instead`)
275
+ }
276
+ lines.push(
277
+ '',
278
+ ` available: ${list.join(', ') || '(the workspace reported none)'}`,
279
+ ' Fix specs/.core/linear.config.json → states. Linear silently ignores an',
280
+ ' unknown issue state, so this would have pushed clean and moved nothing.',
281
+ )
282
+ return lines
283
+ }
284
+ return null
199
285
  }
200
286
 
201
287
  // A tracker id as it appears in a spec: `SKI-42`. Deliberately strict — the
@@ -347,12 +433,13 @@ async function specSync(rest, io = {}) {
347
433
  const [sub, ...args] = rest
348
434
  let dir = io.cwd || process.cwd()
349
435
  const positional = []
350
- const flags = { json: false, remote: null, workspaceStates: null, issue: null, url: null, subs: [] }
436
+ const flags = { json: false, remote: null, workspaceStates: null, skipStateCheck: false, issue: null, url: null, subs: [] }
351
437
  for (let i = 0; i < args.length; i++) {
352
438
  if (args[i] === '--dir') dir = path.resolve(args[++i])
353
439
  else if (args[i] === '--json') flags.json = true
354
440
  else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
355
441
  else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
442
+ else if (args[i] === '--skip-state-check') flags.skipStateCheck = true
356
443
  else if (args[i] === '--issue') flags.issue = args[++i]
357
444
  else if (args[i] === '--url') flags.url = args[++i]
358
445
  else if (args[i] === '--sub') flags.subs.push(args[++i])
@@ -374,8 +461,7 @@ async function specSync(rest, io = {}) {
374
461
  specSyncNormalize(dir, config, positional[0], out, err)
375
462
  return 0
376
463
  case 'push':
377
- specSyncPush(dir, config, positional[0], flags, out, err)
378
- return 0
464
+ return specSyncPush(dir, config, positional[0], flags, out, err) || 0
379
465
  case 'stamp':
380
466
  return specSyncStamp(dir, config, positional[0], flags, out)
381
467
  case 'record':
@@ -387,7 +473,8 @@ async function specSync(rest, io = {}) {
387
473
  specSyncLinked(dir, config, flags, out)
388
474
  return 0
389
475
  default:
390
- out.write('Usage: skitterspec spec-sync <normalize|push|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
476
+ out.write('Usage: skitterspec spec-sync <normalize|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
477
+ ' skitterspec spec-sync push <spec> --workspace-states <file> [--json] [--skip-state-check]\n' +
391
478
  ' skitterspec spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]\n' +
392
479
  ' skitterspec spec-sync linked [--json]\n')
393
480
  return 0
@@ -10,12 +10,13 @@
10
10
  * over its API. No remote content is read or merged.
11
11
  */
12
12
 
13
- const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates } = require('./src/normalize.js')
13
+ const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
14
14
  const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
15
15
  const { readBase, writeBase } = require('./src/base.js')
16
16
  const { push, recordPush, projectionOf } = require('./src/push.js')
17
17
  const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
18
18
  const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
19
+ const { detectLegacyMirror } = require('./src/legacy.js')
19
20
 
20
21
  module.exports = {
21
22
  normalizeLocal,
@@ -31,6 +32,7 @@ module.exports = {
31
32
  remoteWorkflowState,
32
33
  titleFromText,
33
34
  validateStates,
35
+ stateSuggestions,
34
36
  hashField,
35
37
  stableStringify,
36
38
  readBase,
@@ -41,4 +43,5 @@ module.exports = {
41
43
  findPhaseFileByTitle,
42
44
  listPhaseFiles,
43
45
  sanitizeSpecMarkdown,
46
+ detectLegacyMirror,
44
47
  }
@@ -0,0 +1,90 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Detect a spec whose mirror was created under the **pre-9.0 model**.
5
+ *
6
+ * v9 remapped the mirror: a spec is an issue (was a Project), a phase is a
7
+ * sub-issue (was a Milestone), and tasks are no longer objects at all. The
8
+ * frontmatter keys moved with it — `linear_project_id` → `linear_identifier`,
9
+ * `linear_milestone_id` → `linear_issue_id`.
10
+ *
11
+ * The failure this guards is silent and destructive: v9 looks for the new keys,
12
+ * finds nothing, and produces a perfectly ordinary **all-creates** plan. Applying
13
+ * it mints a fresh mirror and abandons the old one — in the field that would have
14
+ * been 17 new objects against 2 projects, 15 milestones and 145 task issues left
15
+ * orphaned, with nothing on screen suggesting a prior mirror existed. It was
16
+ * caught only because an all-creates plan looked wrong for specs synced an hour
17
+ * earlier.
18
+ *
19
+ * Pure reads; returns `null` for anything that is not demonstrably pre-9.0, so a
20
+ * never-pushed spec is never mistaken for a stranded one.
21
+ */
22
+
23
+ const fs = require('node:fs')
24
+ const path = require('node:path')
25
+
26
+ const { parseFrontmatter } = require('./normalize.js')
27
+ const { readBase } = require('./base.js')
28
+ const { listPhaseFiles } = require('./write.js')
29
+
30
+ // Frontmatter keys only the pre-9.0 model ever wrote.
31
+ const LEGACY_OVERVIEW_KEY = 'linear_project_id'
32
+ const LEGACY_PHASE_KEY = 'linear_milestone_id'
33
+
34
+ function frontmatterOf(file) {
35
+ try {
36
+ return parseFrontmatter(fs.readFileSync(file, 'utf-8')).data || {}
37
+ } catch {
38
+ return {}
39
+ }
40
+ }
41
+
42
+ // A pre-9.0 snapshot recorded `{project, milestones, issues}`; v9 records
43
+ // `{issue, subIssues}`. Counting what it holds turns the warning from "this
44
+ // looks old" into "this many live objects would be abandoned".
45
+ function countOrphans(snapshot) {
46
+ if (!snapshot || typeof snapshot !== 'object') return null
47
+ const size = (v) => (Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : 0)
48
+ const projects = snapshot.project ? 1 : 0
49
+ const milestones = size(snapshot.milestones)
50
+ const issues = size(snapshot.issues)
51
+ if (!projects && !milestones && !issues) return null
52
+ return { projects, milestones, issues, total: projects + milestones + issues }
53
+ }
54
+
55
+ /**
56
+ * @returns {null|{keys:string[], files:string[], orphans:object|null, orphanCount:number}}
57
+ */
58
+ function detectLegacyMirror({ dir, snapshotDir, identifier, config }) {
59
+ const keys = []
60
+ const files = []
61
+
62
+ const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
63
+ const overview = frontmatterOf(path.join(snapshotDir, overviewFile))
64
+ if (overview[LEGACY_OVERVIEW_KEY] != null) {
65
+ keys.push(LEGACY_OVERVIEW_KEY)
66
+ files.push(overviewFile)
67
+ }
68
+
69
+ for (const file of listPhaseFiles(snapshotDir)) {
70
+ if (frontmatterOf(path.join(snapshotDir, file))[LEGACY_PHASE_KEY] != null) {
71
+ if (!keys.includes(LEGACY_PHASE_KEY)) keys.push(LEGACY_PHASE_KEY)
72
+ files.push(file)
73
+ }
74
+ }
75
+
76
+ let orphans = null
77
+ try {
78
+ orphans = countOrphans(readBase(dir, identifier, config))
79
+ } catch {
80
+ /* an unreadable snapshot is not evidence either way */
81
+ }
82
+
83
+ // A legacy snapshot alone is enough: the spec may have had its frontmatter
84
+ // hand-cleaned while the mirror it names is still live.
85
+ if (!keys.length && !orphans) return null
86
+
87
+ return { keys, files, orphans, orphanCount: orphans ? orphans.total : 0 }
88
+ }
89
+
90
+ module.exports = { detectLegacyMirror }
@@ -343,13 +343,18 @@ function readPhaseFiles(snapshotDir) {
343
343
  // both sides keeps a wrapped goal from diffing forever.
344
344
  const goal = collapseHyphenAware((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
345
345
  // Rendered as markdown checklist lines, ready to drop into a sub-issue
346
- // description: indentation kept so nesting survives, the bullet marker
347
- // normalised to `-`, and any inline `(KEY-123)` stamped on a legacy task
346
+ // description: indentation kept so nesting survives, each bullet keeping
347
+ // the marker its author wrote, and any inline `(KEY-123)` stamped on a legacy task
348
348
  // line stripped — those ids were per-task issues we no longer create, and
349
349
  // they read as noise in the mirror.
350
350
  const tasks = findTaskBlocks(body.split('\n')).map((b) => {
351
- const parsed = parseTaskLine(`[${b.mark}] ${b.text}`)
352
- return `${b.indent}- [${b.mark}] ${parsed ? parsed.text : b.text}`
351
+ // `findTaskBlocks` also returns the plain sub-bullets written underneath
352
+ // a task. They carry no checkbox, so they render as the bullet their
353
+ // author used — emitting `- [ ]` here would invent a task that does not
354
+ // exist in the repo.
355
+ const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
356
+ const text = parsed ? parsed.text : b.text
357
+ return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
353
358
  })
354
359
  return {
355
360
  phase: file.replace(/\.md$/, ''),
@@ -663,7 +668,43 @@ function validateStates(config, workspaceStates) {
663
668
  return configured.filter((name) => !have.has(name.toLowerCase().trim()))
664
669
  }
665
670
 
671
+ // Words that identify a workspace state as belonging to a lifecycle bucket.
672
+ // Used only to SUGGEST a replacement for a configured name the workspace does
673
+ // not have — never to pick one silently. The 8→9 case this exists for is
674
+ // `complete`, where the correct value inverts: the project status `Completed`
675
+ // became the issue state `Done`, and no string-distance measure gets you from
676
+ // one to the other.
677
+ const BUCKET_WORDS = {
678
+ backlog: ['backlog', 'triage', 'todo', 'to do'],
679
+ 'in-progress': ['in progress', 'in-progress', 'doing', 'started', 'in review'],
680
+ complete: ['done', 'complete', 'completed', 'shipped', 'merged', 'released'],
681
+ cancelled: ['canceled', 'cancelled', 'abandoned', "won't do", 'wont do', 'duplicate'],
682
+ }
683
+
684
+ /**
685
+ * For each configured state name the workspace lacks, what to use instead.
686
+ *
687
+ * `validateStates` says a name is wrong; this says what is right, which is the
688
+ * difference between an error you can act on and one you have to go look up.
689
+ *
690
+ * @returns {Array<{bucket:string, configured:string, suggestion:string|null}>}
691
+ */
692
+ function stateSuggestions(config, workspaceStates) {
693
+ const names = (workspaceStates || []).map((s) => String(s)).filter(Boolean)
694
+ const have = new Set(names.map((n) => n.toLowerCase().trim()))
695
+ const out = []
696
+ for (const [bucket, configured] of Object.entries((config && config.states) || {})) {
697
+ if (typeof configured !== 'string') continue
698
+ if (have.has(configured.toLowerCase().trim())) continue
699
+ const words = BUCKET_WORDS[bucket] || []
700
+ const suggestion = names.find((n) => words.includes(n.toLowerCase().trim())) || null
701
+ out.push({ bucket, configured, suggestion })
702
+ }
703
+ return out
704
+ }
705
+
666
706
  module.exports = {
707
+ stateSuggestions,
667
708
  normalizeLocal,
668
709
  lintPhases,
669
710
  readSnapshot,
@@ -18,6 +18,7 @@
18
18
  const { normalizeLocal } = require('./normalize.js')
19
19
  const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
20
20
  const { readBase, writeBase } = require('./base.js')
21
+ const { detectLegacyMirror } = require('./legacy.js')
21
22
 
22
23
  // Build the one-way projection from a local snapshot: the spec issue's prose +
23
24
  // status, and its phase sub-issues. `status` is the local lifecycle bucket; the
@@ -36,6 +37,12 @@ function push({ dir, snapshotDir, identifier, config }) {
36
37
  const projection = projectionOf(snapshotDir, config)
37
38
  const snapshot = readBase(dir, identifier, config)
38
39
  const plan = planChanges(projection, snapshot)
40
+ // A spec still linked under the pre-9.0 model reads as unlinked here, so the
41
+ // plan above is all-creates and would abandon a live mirror. Carry the finding
42
+ // ON THE PLAN, not as a warning: `--json` routes warnings to stderr, and the
43
+ // skill that applies this plan is exactly the consumer that would miss them.
44
+ const legacy = detectLegacyMirror({ dir, snapshotDir, identifier, config })
45
+ if (legacy) plan.legacy = legacy
39
46
  return { ok: true, empty: isEmptyPlan(plan), plan, projection }
40
47
  }
41
48
 
@@ -25,6 +25,43 @@ const LIST_MARKER_RE = /^[ \t]*(?:[-*+]|\d+\.)\s/
25
25
  // A checkbox bullet — unambiguously a task, so it always starts its own block,
26
26
  // at any indent. (A bare list marker is ambiguous; a checkbox never is.)
27
27
  const CHECKBOX_RE = /^[ \t]*[-*+]\s*\[[ xX]\]/
28
+ // A bare list bullet — no checkbox — captured with its marker so a sub-bullet
29
+ // is re-rendered as the `-`/`*`/`1.` its author wrote.
30
+ const BULLET_RE = /^([ \t]*)([-*+]|\d+\.)\s+(.*)$/
31
+
32
+ function indentWidth(line) {
33
+ return line.length - line.trimStart().length
34
+ }
35
+
36
+ // Collect the wrapped continuation lines following the bullet opening at
37
+ // `start`, seeded with the opener's own text. Shared by task bullets and plain
38
+ // sub-bullets — both wrap the same way, and both stop at the first line that
39
+ // belongs to something else.
40
+ function collectContinuation(lines, start, hang, inFence, parts) {
41
+ let j = start + 1
42
+ for (; j < lines.length; j++) {
43
+ const l = lines[j]
44
+ if (!l.trim()) break
45
+ if (inFence[j]) break // a fence opening ends the bullet, never continues it
46
+ if (!CONTINUATION_RE.test(l)) break
47
+ if (BLOCK_BREAK_RE.test(l)) {
48
+ // Indent alone can't tell a nested child from a wrapped continuation —
49
+ // both sit at the hanging indent. The *marker* is the reliable signal:
50
+ // - a checkbox (- [ ] / - [x]) is unambiguously a task → always break;
51
+ // - a bare marker (-/*/+/N.) is wrapped continuation prose ONLY when it
52
+ // sits exactly at the hang; shallower or deeper it's a real sub/
53
+ // sibling list → break, and the caller's scan claims it as a block of
54
+ // its own. (Keeping the at-hang continuation preserves the task's
55
+ // stamped id, so the next push updates instead of creating a
56
+ // duplicate issue.)
57
+ // - headings, quotes, tables and fences always break.
58
+ if (CHECKBOX_RE.test(l)) break
59
+ if (!LIST_MARKER_RE.test(l) || indentWidth(l) !== hang) break
60
+ }
61
+ parts.push(l.trim())
62
+ }
63
+ return { end: j, parts }
64
+ }
28
65
 
29
66
  // Mark every line that lies inside a fenced code block — the opening fence, its
30
67
  // content, and the closing fence all count as `true`. Line scanners that hunt
@@ -107,51 +144,68 @@ function spanMask(body) {
107
144
  }
108
145
 
109
146
  /**
110
- * Find every task bullet in `lines` as a logical block.
111
- * @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
147
+ * Find every task bullet in `lines` as a logical block, plus the non-checkbox
148
+ * bullets that live inside a task's list subtree.
149
+ * @returns {Array<{start:number, end:number, indent:string, marker:string,
150
+ * checkbox:boolean, mark:string|null, text:string}>}
112
151
  * `end` is exclusive. `text` is the collapsed single-line form, id included.
152
+ * `checkbox` is false for a plain sub-bullet; its `mark` is then null and its
153
+ * `marker` is the bullet it was written with (`-`, `*`, `1.` …).
113
154
  */
114
155
  function findTaskBlocks(lines) {
115
156
  const blocks = []
116
157
  const inFence = fenceMask(lines)
158
+ // The marker indents of the task bullets whose list subtree is still open,
159
+ // outermost first. Without this the scan had no model of nesting at all: a
160
+ // bare bullet that dedented out of a nested checkbox belonged to nothing and
161
+ // was silently dropped, taking its wrapped continuations with it.
162
+ const open = []
163
+
117
164
  for (let i = 0; i < lines.length; i++) {
165
+ const line = lines[i]
166
+ // A blank line alone doesn't close a subtree — a loose list is still a list.
167
+ // What closes it is a later line at or shallower than the task's own indent.
168
+ if (!line.trim()) continue
169
+ const indent = indentWidth(line)
170
+ while (open.length && indent <= open[open.length - 1]) open.pop()
118
171
  if (inFence[i]) continue // an example bullet inside a ``` block is not a task
119
- const m = TASK_START_RE.exec(lines[i])
120
- if (!m) continue
121
- const parts = [m[3]]
122
- // The hanging indent: where the bullet's text starts (marker width). A
123
- // wrapped continuation aligns exactly AT this column.
124
- const hang = lines[i].length - m[3].length
125
- let j = i + 1
126
- for (; j < lines.length; j++) {
127
- const l = lines[j]
128
- if (!l.trim()) break
129
- if (inFence[j]) break // a fence opening ends the bullet, never continues it
130
- if (!CONTINUATION_RE.test(l)) break
131
- if (BLOCK_BREAK_RE.test(l)) {
132
- // Indent alone can't tell a nested child from a wrapped continuation —
133
- // both sit at the hanging indent. The *marker* is the reliable signal:
134
- // - a checkbox (- [ ] / - [x]) is unambiguously a task → always break;
135
- // - a bare marker (-/*/+/N.) is wrapped continuation prose ONLY when it
136
- // sits exactly at the hang; shallower or deeper it's a real sub/
137
- // sibling list → break. (Keeping the at-hang continuation preserves
138
- // the task's stamped id, so the next push updates instead of
139
- // creating a duplicate issue.)
140
- // - headings, quotes, tables and fences always break.
141
- if (CHECKBOX_RE.test(l)) break
142
- const indent = l.length - l.trimStart().length
143
- if (!LIST_MARKER_RE.test(l) || indent !== hang) break
144
- }
145
- parts.push(l.trim())
172
+
173
+ const t = TASK_START_RE.exec(line)
174
+ if (t) {
175
+ // The hanging indent: where the bullet's text starts (marker width). A
176
+ // wrapped continuation aligns exactly AT this column.
177
+ const { end, parts } = collectContinuation(lines, i, line.length - t[3].length, inFence, [t[3]])
178
+ blocks.push({
179
+ start: i,
180
+ end,
181
+ indent: t[1],
182
+ marker: '-',
183
+ checkbox: true,
184
+ mark: t[2].toLowerCase() === 'x' ? 'x' : ' ',
185
+ text: collapseHyphenAware(parts.join('\n')),
186
+ })
187
+ open.push(indent)
188
+ i = end - 1
189
+ continue
146
190
  }
191
+
192
+ // A bare bullet is claimed ONLY inside an open task's subtree. Outside one
193
+ // it is ordinary prose in the phase file: the projection is the task list,
194
+ // not the whole body, and widening it here would mirror a Notes section.
195
+ if (!open.length) continue
196
+ const b = BULLET_RE.exec(line)
197
+ if (!b) continue
198
+ const { end, parts } = collectContinuation(lines, i, line.length - b[3].length, inFence, [b[3]])
147
199
  blocks.push({
148
200
  start: i,
149
- end: j,
150
- indent: m[1],
151
- mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
201
+ end,
202
+ indent: b[1],
203
+ marker: b[2],
204
+ checkbox: false,
205
+ mark: null,
152
206
  text: collapseHyphenAware(parts.join('\n')),
153
207
  })
154
- i = j - 1
208
+ i = end - 1
155
209
  }
156
210
  return blocks
157
211
  }
@@ -128,6 +128,7 @@ function stampIssueId(snapshotDir, text, id) {
128
128
  const lines = fs.readFileSync(p, 'utf-8').split('\n')
129
129
  const width = inferWidth(lines)
130
130
  for (const b of findTaskBlocks(lines)) {
131
+ if (!b.checkbox) continue // a plain sub-bullet is not a task — never stamp one
131
132
  if (INLINE_ID_RE.test(b.text)) continue
132
133
  if (b.text !== want) continue
133
134
  const rendered = renderTaskBlock({ indent: b.indent, done: b.mark === 'x', text: want, id }, width)