@skitterbyte/skitterspec-linear 8.0.0 → 8.0.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/README.md +23 -25
- package/assets/core/SETUP.md +6 -0
- package/assets/core/linear.config.json.example +4 -5
- package/assets/core/linear.config.md +39 -71
- package/assets/skills/spec/SKILL.md +2 -2
- package/assets/skills/spec-go/SKILL.md +4 -5
- package/package.json +2 -2
- package/src/init.js +25 -0
- package/src/vendor/linear/cli-sanitise.js +0 -0
- package/src/vendor/linear/config.js +8 -12
- package/src/vendor/sync-core/src/normalize.js +35 -4
- package/src/vendor/sync-core/src/sanitise.js +41 -5
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# @skitterbyte/skitterspec-linear
|
|
2
2
|
|
|
3
3
|
Spec-driven development for [Claude Code](https://claude.com/claude-code), **with
|
|
4
|
-
Linear
|
|
4
|
+
one-way Linear sync**. A strict **superset** of
|
|
5
5
|
[`@skitterbyte/skitterspec`](https://www.npmjs.com/package/@skitterbyte/skitterspec):
|
|
6
|
-
everything in the base filesystem workflow, plus
|
|
7
|
-
its linked Linear project.
|
|
6
|
+
everything in the base filesystem workflow, plus one-way sync from a spec up to
|
|
7
|
+
its linked Linear project — the repo is canonical, Linear is a generated mirror.
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npx @skitterbyte/skitterspec-linear init
|
|
@@ -46,27 +46,26 @@ fuller guide):
|
|
|
46
46
|
That file is the opt-in gate — until it exists, everything below is inert and
|
|
47
47
|
the package behaves exactly like the base.
|
|
48
48
|
|
|
49
|
-
4. **Link and
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
4. **Link and push** — `/spec` creates a linked Linear Project (a Milestone per
|
|
50
|
+
phase) and stamps the id; then `/spec-push` publishes the spec up and
|
|
51
|
+
`/spec-status` reports what would push. Sync is **one-way**: the repo is the
|
|
52
|
+
source of truth and Linear is a generated mirror.
|
|
53
53
|
|
|
54
54
|
## What the superset adds
|
|
55
55
|
|
|
56
56
|
On top of the base skills (`/spec`, `/spec-go`, isolation, …):
|
|
57
57
|
|
|
58
|
-
- **`/spec-status`** — read-only
|
|
59
|
-
|
|
60
|
-
- **`/spec-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
concurrency-checked; refuses if Linear moved since base unless `--force`.
|
|
58
|
+
- **`/spec-status`** — read-only drift report: what the next push would create /
|
|
59
|
+
update, plus any workflow-state drift. Changes nothing.
|
|
60
|
+
- **`/spec-push`** — repo → Linear, one-way. Diffs the spec against a committed
|
|
61
|
+
last-pushed snapshot and applies only what changed (project description +
|
|
62
|
+
status, milestones, issues), stamping the returned ids back into the spec.
|
|
64
63
|
- **`spec-sync` CLI** (`skitterspec-linear spec-sync …`) — the deterministic
|
|
65
64
|
engine behind the skills, for CI / local runs.
|
|
66
65
|
|
|
67
66
|
The shared `/spec` and `/spec-go` skills come composed with the Linear steps
|
|
68
|
-
filled in: `/spec` links a new spec to a Linear Project (a Milestone per phase)
|
|
69
|
-
|
|
67
|
+
filled in: `/spec` links a new spec to a Linear Project (a Milestone per phase).
|
|
68
|
+
There is no pull — the repo is already canonical, so `/spec-go` just builds.
|
|
70
69
|
|
|
71
70
|
## Opt-in
|
|
72
71
|
|
|
@@ -78,16 +77,15 @@ behaves exactly like the base.
|
|
|
78
77
|
setup guide — connecting the `linear` MCP server, finding your team id, linking a
|
|
79
78
|
spec, and a smoke test. Per-field docs live in `specs/.core/linear.config.md`.
|
|
80
79
|
|
|
81
|
-
**What
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
(`specs/.core/linear-backups/`) are gitignored.
|
|
80
|
+
**What pushes:** the spec body travels as the Linear Project **`description`**,
|
|
81
|
+
phases as **Milestones**, tasks as **Issues** (a short first-sentence title, the
|
|
82
|
+
full task text as the description), and the lifecycle bucket sets the project's
|
|
83
|
+
**workflow state**. Priority, labels, cycles and comments are **Linear-native
|
|
84
|
+
triage** — the PM's to set in Linear; one-way sync neither pushes nor reads them,
|
|
85
|
+
so they're never clobbered. A workflow-state a teammate moves in Linear is
|
|
86
|
+
surfaced by `/spec-status` as drift and overwritten on the next push. **Last-pushed
|
|
87
|
+
snapshots** (`specs/.core/linear-base/`, content hashes) are committed so push
|
|
88
|
+
sends only what changed.
|
|
91
89
|
|
|
92
90
|
Branch naming that embeds the Linear id lives in the isolation config
|
|
93
91
|
(`env.config.json` → `branch.pattern` with `{identifier}`, `branch.identifierField:
|
package/assets/core/SETUP.md
CHANGED
|
@@ -168,3 +168,9 @@ With a linked spec, confirm push end-to-end:
|
|
|
168
168
|
(`Backlog / Planned / In Progress / Completed / Canceled`).
|
|
169
169
|
- **Reconnecting doesn't switch workspace** — Linear ties the OAuth session to one
|
|
170
170
|
workspace. Remove and re-add the server to authenticate against another.
|
|
171
|
+
- **Bold around an inline code span renders oddly in Linear** — Linear moves the
|
|
172
|
+
closing `**` before an inline code span on save (`**no unresolved `` `X` ``**`
|
|
173
|
+
→ `**no unresolved** `` `X` ``). This is a Linear rendering quirk in the mirror
|
|
174
|
+
only; it never touches your repo (one-way sync never reads content back), so
|
|
175
|
+
it's cosmetic. Avoid wrapping a whole phrase that ends in code in bold if the
|
|
176
|
+
mirror's rendering matters to you.
|
|
@@ -23,12 +23,11 @@
|
|
|
23
23
|
},
|
|
24
24
|
"sync": {
|
|
25
25
|
"baseDir": "specs/.core/linear-base",
|
|
26
|
-
"backupDir": "specs/.core/linear-backups",
|
|
27
26
|
"fieldOwnership": {
|
|
28
|
-
"description": "
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
27
|
+
"description": "push",
|
|
28
|
+
"milestones": "push",
|
|
29
|
+
"tasks": "push",
|
|
30
|
+
"workflowState": "push"
|
|
32
31
|
},
|
|
33
32
|
"localOnlySections": ["State log", "Changelog", "Open questions"],
|
|
34
33
|
"keyedFields": {}
|
|
@@ -61,101 +61,69 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
|
|
|
61
61
|
"pattern": "{type}/{slug}"
|
|
62
62
|
},
|
|
63
63
|
|
|
64
|
-
//
|
|
64
|
+
// One-way push engine state.
|
|
65
65
|
"sync": {
|
|
66
|
-
// Committed
|
|
67
|
-
// {baseDir}/{identifier}.base.json. Committed so each worktree
|
|
68
|
-
// own
|
|
66
|
+
// Committed last-pushed snapshot dir: content hashes of the last push per
|
|
67
|
+
// spec, as {baseDir}/{identifier}.base.json. Committed so each worktree
|
|
68
|
+
// carries its own, and push knows what changed without reading Linear back.
|
|
69
69
|
"baseDir": "specs/.core/linear-base",
|
|
70
70
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// "pull" — Linear→local only (e.g. status/priority); a local edit never
|
|
78
|
-
// pushes and a conflict resolves to remote-wins.
|
|
79
|
-
// "push" — local→Linear only; a remote edit never pulls and a conflict
|
|
80
|
-
// resolves to local-wins.
|
|
81
|
-
// Any field key you add here joins the compared field set; a value outside
|
|
82
|
-
// both|pull|push is rejected at load time. The default set is scoped to the
|
|
83
|
-
// fields that round-trip through the live skill today: the whole spec body
|
|
84
|
-
// travels as `description`; status/priority/labels are Linear-owned (pull).
|
|
85
|
-
// A phase/milestone or per-issue round-trip is a future extension — opt in by
|
|
86
|
-
// adding `milestones`/`phaseBodies`/`taskBreakdown` here once that write path
|
|
87
|
-
// exists (see the deferred body write-back note in the package README).
|
|
71
|
+
// The pushed projection field set (repo → Linear, one-way). The `push` marker
|
|
72
|
+
// is retained for shape; there is no pull. The default set is the project
|
|
73
|
+
// `description`, `milestones` (one per phase), `tasks` (one issue each), and
|
|
74
|
+
// the lifecycle `workflowState`. Priority, labels, cycles and comments are
|
|
75
|
+
// Linear-native triage — deliberately NOT here, so a PM's triage is never
|
|
76
|
+
// touched. Any key you add joins the pushed projection.
|
|
88
77
|
"fieldOwnership": {
|
|
89
|
-
"description": "
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
78
|
+
"description": "push",
|
|
79
|
+
"milestones": "push",
|
|
80
|
+
"tasks": "push",
|
|
81
|
+
"workflowState": "push"
|
|
93
82
|
},
|
|
94
83
|
|
|
95
84
|
// Markdown sections of 00-overview.md that are local-only scaffolding and
|
|
96
85
|
// are stripped from the pushed `description` (never sent to Linear).
|
|
97
86
|
"localOnlySections": ["State log", "Changelog", "Open questions"],
|
|
98
87
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
88
|
+
// Reserved. Milestones and tasks are always projected per item (each phase →
|
|
89
|
+
// a Milestone, each task → an Issue), so this no longer needs setting; it is
|
|
90
|
+
// validated but unused. Leave it `{}`.
|
|
102
91
|
"keyedFields": {}
|
|
103
92
|
}
|
|
104
93
|
}
|
|
105
94
|
```
|
|
106
95
|
|
|
107
|
-
##
|
|
96
|
+
## Phases → Milestones, tasks → Issues
|
|
108
97
|
|
|
109
|
-
|
|
110
|
-
|
|
98
|
+
Push maps the spec's structure to Linear's, keyed by id so it updates rather than
|
|
99
|
+
recreates:
|
|
111
100
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"fieldOwnership": { "milestones": "both", "tasks": "both" },
|
|
115
|
-
"keyedFields": { "milestones": "id", "tasks": "id" }
|
|
116
|
-
}
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
With this on:
|
|
120
|
-
|
|
121
|
-
- **Phases ↔ Milestones.** Each phase file maps to a Linear Milestone. The link id
|
|
122
|
-
lives in the phase file's frontmatter (`linear_milestone_id`); its title ← the
|
|
101
|
+
- **Phases → Milestones.** Each phase file maps to a Linear Milestone. The link id
|
|
102
|
+
lives in the phase file's frontmatter (`linear_milestone_id`); its name ← the
|
|
123
103
|
phase h1, its description ← the phase `**Goal:**` line. The `Phases` index is
|
|
124
|
-
|
|
125
|
-
- **Tasks
|
|
126
|
-
is carried **inline** on the line — `- [ ] do the thing (SKI-123)`.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
milestone B in Linear both apply; only the *same* item moving on both sides is a
|
|
130
|
-
conflict.
|
|
131
|
-
- **Deletions are report-only.** A phase/milestone or task/issue removed on either
|
|
132
|
-
side is surfaced by `/spec-status` (and the pull/push summaries) for you to
|
|
133
|
-
resolve by hand — it is never auto-deleted.
|
|
104
|
+
stripped from the pushed `description` (no duplication).
|
|
105
|
+
- **Tasks → Issues.** Each `- [ ]` task line maps to a Linear Issue. The link id
|
|
106
|
+
is carried **inline** on the line — `- [ ] do the thing (SKI-123)`. The issue
|
|
107
|
+
**title** is the task's first sentence; the **description** is the full task
|
|
108
|
+
text; `[x]`/`[ ]` ↔ a completed / non-completed issue state.
|
|
134
109
|
|
|
135
110
|
Unlinked local items (a new phase with no `linear_milestone_id`, a task with no
|
|
136
111
|
inline id) are created in Linear on the next `/spec-push`, which stamps the new id
|
|
137
112
|
back so they link from then on.
|
|
138
113
|
|
|
139
|
-
##
|
|
140
|
-
|
|
141
|
-
The spec is a set of structured fields, most written by only one side. Marking a
|
|
142
|
-
field's owner collapses which fields can genuinely conflict:
|
|
143
|
-
|
|
144
|
-
- A `pull` field (Linear owns it) never reports as **pushable** — a stray local
|
|
145
|
-
edit is informational and gets reverted on the next pull.
|
|
146
|
-
- A `push` field (the repo owns it) never reports as **pullable**.
|
|
147
|
-
- Only a `both` field where **both** sides moved off the committed base is a real
|
|
148
|
-
`conflict` — `/spec-push` / `/spec-pull` refuse it unless `--force` (which
|
|
149
|
-
backs up the losing side into `sync.backupDir` first).
|
|
114
|
+
## One direction — nothing to reconcile
|
|
150
115
|
|
|
151
|
-
|
|
152
|
-
|
|
116
|
+
The repo owns the spec and pushes it; there is no pull. `/spec-push` diffs the
|
|
117
|
+
current spec against the committed **last-pushed snapshot** and sends only what
|
|
118
|
+
changed — creates for unlinked items, updates for changed ones. A workflow-state a
|
|
119
|
+
teammate moves in Linear is surfaced by `/spec-status` as **drift** (advisory) and
|
|
120
|
+
overwritten on the next push. Priority, labels, cycles and comments are
|
|
121
|
+
Linear-native triage and are never touched. No base merge, no conflicts, no
|
|
122
|
+
`--force`.
|
|
153
123
|
|
|
154
124
|
## What to commit
|
|
155
125
|
|
|
156
|
-
- **`sync.baseDir`** (default `specs/.core/linear-base/`) — **commit it.** The
|
|
157
|
-
|
|
158
|
-
worktree carries its own
|
|
159
|
-
|
|
160
|
-
These are `--force` recovery copies (a local reflog), per-machine and not shared.
|
|
161
|
-
Add `specs/.core/linear-backups/` to your `.gitignore`.
|
|
126
|
+
- **`sync.baseDir`** (default `specs/.core/linear-base/`) — **commit it.** The
|
|
127
|
+
last-pushed snapshot is content hashes of the last push, so `/spec-push` knows
|
|
128
|
+
what changed without reading Linear back; each worktree carries its own, so it
|
|
129
|
+
must travel with the branch.
|
|
@@ -217,11 +217,11 @@ finish as above.
|
|
|
217
217
|
## Phase E — link to a ticketing provider (only if one is installed)
|
|
218
218
|
|
|
219
219
|
**Only when a ticketing provider is installed and configured** (it ships the
|
|
220
|
-
`/spec-push` · `/spec-
|
|
220
|
+
`/spec-push` · `/spec-status` skills and a provider config under
|
|
221
221
|
`specs/.core/`). If none is present, skip this phase entirely — the spec stays
|
|
222
222
|
local-only and `/spec` behaves exactly as above. When a provider is present, link
|
|
223
223
|
the spec to the tracker after writing it, so status and discussion live there
|
|
224
|
-
while the repo stays the
|
|
224
|
+
while the repo stays the source of truth — follow the provider's link steps
|
|
225
225
|
below (nothing to do here without one).
|
|
226
226
|
|
|
227
227
|
**Only when `specs/.core/linear.config.json` exists** (Linear sync is opted in).
|
|
@@ -129,12 +129,11 @@ Before writing any code for this phase, get the workspace clean:
|
|
|
129
129
|
next phase on top of an uncommitted one. (Skip if this is the first phase —
|
|
130
130
|
there's nothing prior to commit.)
|
|
131
131
|
|
|
132
|
-
## 3b.
|
|
132
|
+
## 3b. Sync with the tracker (only if a provider is installed)
|
|
133
133
|
|
|
134
|
-
**Only when a ticketing provider
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
here without one).
|
|
134
|
+
**Only when a ticketing provider is installed** and the spec is linked to the
|
|
135
|
+
tracker. Otherwise skip this step — no provider means zero change to the flow
|
|
136
|
+
below. Follow the provider's steps below (nothing to do here without one).
|
|
138
137
|
|
|
139
138
|
**Only when `specs/.core/linear.config.json` exists** and the spec carries a
|
|
140
139
|
`linear_project_id`. Otherwise skip this step — no config means zero change.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "8.0.
|
|
4
|
-
"description": "Spec-driven development for Claude Code, with Linear
|
|
3
|
+
"version": "8.0.2",
|
|
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",
|
|
7
7
|
"claude-code",
|
package/src/init.js
CHANGED
|
@@ -259,6 +259,30 @@ function removeRetiredFiles(dir) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
// Prune a file the manifest records as managed but the current package no longer
|
|
263
|
+
// ships — a retired skill (e.g. `spec-pull` after the one-way switch), rule, or
|
|
264
|
+
// template. Without this, upgrading leaves a live, model-visible skill on disk
|
|
265
|
+
// whose instructions invoke a command that no longer exists. Delete it only when
|
|
266
|
+
// PRISTINE (still matches the hash we last wrote) so a user edit is never lost; a
|
|
267
|
+
// customized retired file is kept with a warning. An emptied skill folder is
|
|
268
|
+
// removed. Takes the pre-flush manifest (which still holds the retired entries).
|
|
269
|
+
function pruneRetiredManaged(dir, manifest) {
|
|
270
|
+
const managedRel = new Set(managedTargets(dir).map((t) => t.relPath))
|
|
271
|
+
for (const relPath of Object.keys(manifest.files || {})) {
|
|
272
|
+
if (managedRel.has(relPath)) continue // still shipped by this version
|
|
273
|
+
const abs = path.join(dir, relPath)
|
|
274
|
+
if (!fs.existsSync(abs)) continue // already gone
|
|
275
|
+
if (managedState(dir, relPath, manifest) === 'customized') {
|
|
276
|
+
report.warnings.push(`retired but kept (you edited it): ${relPath} — delete manually if unused`)
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
fs.unlinkSync(abs)
|
|
280
|
+
report.removed.push(rel(dir, abs))
|
|
281
|
+
const folder = path.dirname(abs)
|
|
282
|
+
if (fs.existsSync(folder) && !fs.readdirSync(folder).length) fs.rmdirSync(folder)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
262
286
|
// Scaffold the opt-in isolation templates into specs/.core/ (the example config
|
|
263
287
|
// + its field docs). Copied, not activated: the feature stays off until the
|
|
264
288
|
// consumer copies env.config.json.example → env.config.json.
|
|
@@ -409,6 +433,7 @@ function resync(dir, { force = false, claudeMd = true } = {}) {
|
|
|
409
433
|
for (const t of managedTargets(dir)) resyncManagedFile(dir, t, manifest, force)
|
|
410
434
|
installFolders(dir)
|
|
411
435
|
removeRetiredFiles(dir)
|
|
436
|
+
pruneRetiredManaged(dir, manifest)
|
|
412
437
|
if (claudeMd) installClaudeMd(dir, { mode: 'update' })
|
|
413
438
|
flushManifest(dir)
|
|
414
439
|
printReport(dir, 'resync')
|
|
Binary file
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Config loader for the Linear
|
|
5
|
-
*
|
|
4
|
+
* Config loader for the one-way Linear sync feature (`/spec-status`, `/spec-push`
|
|
5
|
+
* and the Linear-aware paths of `/spec` and `/spec-go`).
|
|
6
6
|
*
|
|
7
7
|
* Reads `specs/.core/linear.config.json` from the project root and normalises it
|
|
8
8
|
* over frozen defaults. The feature is strictly opt-in: when the file is absent
|
|
@@ -54,21 +54,17 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
54
54
|
sync: Object.freeze({
|
|
55
55
|
baseDir: 'specs/.core/linear-base',
|
|
56
56
|
backupDir: 'specs/.core/linear-backups',
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// One-way (repo → Linear): every field is repo-owned and pushed. The
|
|
64
|
-
// `push` marker is kept for the projection field-set; there is no pull.
|
|
57
|
+
// One-way (repo → Linear): the projection field set the repo owns and pushes
|
|
58
|
+
// — the project `description`, `milestones` (one per phase), `tasks` (one
|
|
59
|
+
// issue each), and the lifecycle `workflowState`. There is no pull. Priority,
|
|
60
|
+
// labels, cycles and comments are Linear-native triage — deliberately NOT in
|
|
61
|
+
// the set, so the PM's triage is never touched. The `push` marker is retained
|
|
62
|
+
// for shape; any key you add joins the pushed projection.
|
|
65
63
|
fieldOwnership: Object.freeze({
|
|
66
64
|
description: 'push',
|
|
67
65
|
milestones: 'push',
|
|
68
66
|
tasks: 'push',
|
|
69
67
|
workflowState: 'push',
|
|
70
|
-
priority: 'push',
|
|
71
|
-
labels: 'push',
|
|
72
68
|
}),
|
|
73
69
|
localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
|
|
74
70
|
// Fields that are keyed collections (arrays of objects with a stable id),
|
|
@@ -346,13 +346,16 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
|
346
346
|
*/
|
|
347
347
|
function normalizeLocal(snapshotDir, config) {
|
|
348
348
|
const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
|
|
349
|
-
|
|
349
|
+
// Phases sync as first-class Milestones whenever `milestones` is in the pushed
|
|
350
|
+
// projection, so strip the `## Phases` index from the description to avoid
|
|
351
|
+
// duplicating it (as prose AND as milestones) in the Linear mirror.
|
|
352
|
+
const milestonesProjected = !!(config.sync.fieldOwnership && 'milestones' in config.sync.fieldOwnership)
|
|
350
353
|
const extracted = {
|
|
351
354
|
description: buildDescription(
|
|
352
355
|
title,
|
|
353
356
|
sections,
|
|
354
357
|
config.sync.localOnlySections,
|
|
355
|
-
|
|
358
|
+
milestonesProjected ? ['Phases'] : [],
|
|
356
359
|
),
|
|
357
360
|
// Milestone projection items. `ref` is the phase-file basename — the local
|
|
358
361
|
// handle the push skill stamps a newly-created milestone id back into.
|
|
@@ -430,6 +433,19 @@ function remoteWorkflowState(project, config) {
|
|
|
430
433
|
return name != null ? bucketForState(name, config) : null
|
|
431
434
|
}
|
|
432
435
|
|
|
436
|
+
// Trim a trailing parenthetical whose opener has no matching close — so a cut
|
|
437
|
+
// title never ends on a dangling `(`/`[`.
|
|
438
|
+
function dropUnclosedBracket(t) {
|
|
439
|
+
for (const [open, close] of [
|
|
440
|
+
['(', ')'],
|
|
441
|
+
['[', ']'],
|
|
442
|
+
]) {
|
|
443
|
+
const o = t.lastIndexOf(open)
|
|
444
|
+
if (o !== -1 && t.indexOf(close, o) === -1) t = t.slice(0, o)
|
|
445
|
+
}
|
|
446
|
+
return t
|
|
447
|
+
}
|
|
448
|
+
|
|
433
449
|
// Derive a short Linear issue title from a task's full text: the first sentence,
|
|
434
450
|
// falling back to the first `max` chars at a word boundary. A paragraph-length
|
|
435
451
|
// task keeps its full text as the issue *description* (see the projection); this
|
|
@@ -454,8 +470,23 @@ function titleFromText(text, max = 100) {
|
|
|
454
470
|
title = title.trim()
|
|
455
471
|
if (title.length > max) {
|
|
456
472
|
const cut = title.slice(0, max)
|
|
457
|
-
|
|
458
|
-
|
|
473
|
+
// Prefer the last clause boundary before the limit — a terminator/separator
|
|
474
|
+
// to cut AFTER (`.`/`;`/`:`…) or a dash to cut BEFORE — over a bare word break.
|
|
475
|
+
const minCut = Math.floor(max * 0.4)
|
|
476
|
+
let boundary = -1
|
|
477
|
+
let mm
|
|
478
|
+
const after = /[.!?;:](?=\s|$)/g
|
|
479
|
+
while ((mm = after.exec(cut)) !== null) boundary = Math.max(boundary, mm.index + 1)
|
|
480
|
+
const before = /\s[—–]/g
|
|
481
|
+
while ((mm = before.exec(cut)) !== null) boundary = Math.max(boundary, mm.index)
|
|
482
|
+
let t
|
|
483
|
+
if (boundary > minCut) {
|
|
484
|
+
t = cut.slice(0, boundary)
|
|
485
|
+
} else {
|
|
486
|
+
const sp = cut.lastIndexOf(' ')
|
|
487
|
+
t = sp > 40 ? cut.slice(0, sp) : cut
|
|
488
|
+
}
|
|
489
|
+
title = dropUnclosedBracket(t).replace(/[\s.,:;—–([]+$/, '').trim()
|
|
459
490
|
}
|
|
460
491
|
return title
|
|
461
492
|
}
|
|
@@ -36,6 +36,29 @@ function cleanLogicalLine(text) {
|
|
|
36
36
|
const BULLET_RE = /^(\s*)((?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)$/
|
|
37
37
|
// Structural blocks we never reflow.
|
|
38
38
|
const PASSTHROUGH_RE = /^\s*(#{1,6}\s|>|\||[-*_]{3,}\s*$|<)/
|
|
39
|
+
// A line that is its own structural block — a blockquote (`>`) or a table/pipe
|
|
40
|
+
// row (`|`). A hard boundary a join must never reach across (else the markers get
|
|
41
|
+
// absorbed into the preceding paragraph as literal text); copied verbatim.
|
|
42
|
+
const STRUCT_LINE_RE = /^[ \t]*[>|]/
|
|
43
|
+
// A fenced-code delimiter.
|
|
44
|
+
const FENCE_RE = /^[ \t]*```/
|
|
45
|
+
|
|
46
|
+
// A structural fingerprint of a document: the count of block-marker lines that a
|
|
47
|
+
// safe reflow must leave untouched. Used as a self-check — if sanitising changes
|
|
48
|
+
// any of these, the reflow corrupted structure (a blockquote absorbed, a fence
|
|
49
|
+
// eaten, a bullet collapsed) and the write is refused. Cheap insurance against the
|
|
50
|
+
// next corruption class, not just the ones we special-case.
|
|
51
|
+
function structuralSignature(text) {
|
|
52
|
+
let struct = 0 // blockquote or pipe-row lines
|
|
53
|
+
let fence = 0
|
|
54
|
+
let bullets = 0
|
|
55
|
+
for (const l of String(text).split('\n')) {
|
|
56
|
+
if (STRUCT_LINE_RE.test(l)) struct++
|
|
57
|
+
if (FENCE_RE.test(l)) fence++
|
|
58
|
+
if (BULLET_RE.test(l)) bullets++
|
|
59
|
+
}
|
|
60
|
+
return `${struct}|${fence}|${bullets}`
|
|
61
|
+
}
|
|
39
62
|
// A GFM table separator row (only pipes/colons/dashes/spaces, with a pipe AND a
|
|
40
63
|
// dash). Detecting a table by this — not by any stray `|` — so a pipe inside an
|
|
41
64
|
// inline `code|span` doesn't make us treat a whole list as an untouchable table.
|
|
@@ -99,21 +122,28 @@ function sanitizeSpecMarkdown(text, { width } = {}) {
|
|
|
99
122
|
while (i < lines.length) {
|
|
100
123
|
const line = lines[i]
|
|
101
124
|
// Fenced code — copy verbatim through the closing fence.
|
|
102
|
-
if (
|
|
125
|
+
if (FENCE_RE.test(line)) {
|
|
103
126
|
out.push(line)
|
|
104
127
|
i++
|
|
105
|
-
while (i < lines.length &&
|
|
128
|
+
while (i < lines.length && !FENCE_RE.test(lines[i])) out.push(lines[i++])
|
|
106
129
|
if (i < lines.length) out.push(lines[i++])
|
|
107
130
|
continue
|
|
108
131
|
}
|
|
132
|
+
// Blockquote / table row — copy the whole run verbatim. A hard boundary: a
|
|
133
|
+
// join must never reach across it, so it also terminates any preceding block.
|
|
134
|
+
if (STRUCT_LINE_RE.test(line)) {
|
|
135
|
+
while (i < lines.length && STRUCT_LINE_RE.test(lines[i])) out.push(lines[i++])
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
109
138
|
if (!line.trim()) {
|
|
110
139
|
out.push(line)
|
|
111
140
|
i++
|
|
112
141
|
continue
|
|
113
142
|
}
|
|
114
|
-
// Gather a block of consecutive non-blank,
|
|
143
|
+
// Gather a block of consecutive non-blank lines, stopping at any structural
|
|
144
|
+
// boundary (fence, blockquote, table row) so a reflow can't absorb one.
|
|
115
145
|
let j = i
|
|
116
|
-
while (j < lines.length && lines[j].trim() &&
|
|
146
|
+
while (j < lines.length && lines[j].trim() && !FENCE_RE.test(lines[j]) && !STRUCT_LINE_RE.test(lines[j])) j++
|
|
117
147
|
const block = lines.slice(i, j)
|
|
118
148
|
i = j
|
|
119
149
|
|
|
@@ -137,7 +167,13 @@ function sanitizeSpecMarkdown(text, { width } = {}) {
|
|
|
137
167
|
}
|
|
138
168
|
}
|
|
139
169
|
const result = out.join('\n')
|
|
170
|
+
// Self-check: a safe reflow never changes the count of structural markers. If it
|
|
171
|
+
// did, we corrupted something (a blockquote, fence, or bullet) — refuse the write
|
|
172
|
+
// and hand back the original untouched, flagged for the caller to report.
|
|
173
|
+
if (result !== src && structuralSignature(result) !== structuralSignature(src)) {
|
|
174
|
+
return { text: src, changed: false, fixes: 0, refused: true }
|
|
175
|
+
}
|
|
140
176
|
return { text: result, changed: result !== src, fixes }
|
|
141
177
|
}
|
|
142
178
|
|
|
143
|
-
module.exports = { sanitizeSpecMarkdown, hasStraddle }
|
|
179
|
+
module.exports = { sanitizeSpecMarkdown, hasStraddle, structuralSignature }
|