@skitterbyte/skitterspec-linear 8.0.5 → 9.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.
@@ -12,6 +12,7 @@
12
12
  * spec-sync push <spec> print the create/update PLAN the skill applies
13
13
  * spec-sync record <spec> write the last-pushed snapshot (after apply)
14
14
  * spec-sync status <spec> read-only drift report (never writes)
15
+ * spec-sync linked list every spec's linear_identifier (offline)
15
16
  *
16
17
  * The `/spec-push` skill: `push` → apply the plan over MCP → stamp returned ids
17
18
  * into the repo → `record`. There is no pull — Linear is not read for content.
@@ -20,10 +21,11 @@
20
21
  const fs = require('node:fs')
21
22
  const path = require('node:path')
22
23
 
23
- const { findSpecFolder } = require('../../env/resolve.js')
24
+ const { BUCKETS, findSpecFolder } = require('../../env/resolve.js')
24
25
  const {
25
26
  normalizeLocal,
26
27
  readSnapshot,
28
+ parseFrontmatter,
27
29
  readBase,
28
30
  push,
29
31
  recordPush,
@@ -58,6 +60,77 @@ function specIdentifier(snapshotDir, config) {
58
60
  return path.basename(snapshotDir)
59
61
  }
60
62
 
63
+ // Read a spec's `linear_identifier` without throwing: an unlinked spec, an
64
+ // unreadable file and a spec with no frontmatter all read as `null`.
65
+ function linkedIdentifier(overviewPath) {
66
+ let raw
67
+ try {
68
+ raw = fs.readFileSync(overviewPath, 'utf-8')
69
+ } catch {
70
+ return null
71
+ }
72
+ const { data } = parseFrontmatter(raw)
73
+ return data.linear_identifier ? String(data.linear_identifier) : null
74
+ }
75
+
76
+ // Every spec under specs/<bucket>/, paired with the Linear issue it is linked to
77
+ // (`null` when it has never been pushed). Folder specs read `snapshot.overviewFile`;
78
+ // legacy bare `<name>.md` specs read the file itself. Sorted for stable output.
79
+ function listSpecs(dir, config) {
80
+ const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
81
+ const specs = []
82
+ for (const bucket of BUCKETS) {
83
+ const root = path.join(dir, 'specs', bucket)
84
+ let entries
85
+ try {
86
+ entries = fs.readdirSync(root, { withFileTypes: true })
87
+ } catch {
88
+ continue
89
+ }
90
+ for (const entry of entries) {
91
+ let name
92
+ let overviewPath
93
+ if (entry.isDirectory()) {
94
+ name = entry.name
95
+ overviewPath = path.join(root, name, overviewFile)
96
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
97
+ name = entry.name.slice(0, -3)
98
+ overviewPath = path.join(root, entry.name)
99
+ } else {
100
+ continue
101
+ }
102
+ specs.push({ spec: name, bucket, identifier: linkedIdentifier(overviewPath) })
103
+ }
104
+ }
105
+ return specs.sort((a, b) => a.spec.localeCompare(b.spec))
106
+ }
107
+
108
+ /**
109
+ * `spec-sync linked` — which Linear issues are already adopted by a spec.
110
+ *
111
+ * Offline and read-only: the intake seam (`/spec <ISSUE-REF>`,
112
+ * `/spec --from-issue`) subtracts these from the inbox and refuses to adopt an
113
+ * issue twice, without reading Linear back.
114
+ */
115
+ function specSyncLinked(dir, config, flags, out) {
116
+ const specs = listSpecs(dir, config)
117
+ if (flags.json) {
118
+ out.write(JSON.stringify(specs, null, 2) + '\n')
119
+ return
120
+ }
121
+ if (!specs.length) {
122
+ out.write('spec-sync linked: no specs found under specs/\n')
123
+ return
124
+ }
125
+ const lines = ['spec-sync linked:']
126
+ for (const s of specs) {
127
+ lines.push(` ${s.identifier || '—'}\t${s.spec} (${s.bucket})`)
128
+ }
129
+ const n = specs.filter((s) => s.identifier).length
130
+ lines.push(` ${n}/${specs.length} linked`)
131
+ out.write(lines.join('\n') + '\n')
132
+ }
133
+
61
134
  function resolveOrExit(specArg, dir, out) {
62
135
  if (!specArg) return null
63
136
  const snapshotDir = resolveSnapshotDir(specArg, dir)
@@ -91,11 +164,9 @@ function specSyncPush(dir, config, specArg, flags, out) {
91
164
  const lines = [`spec-sync push: ${identifier}`]
92
165
  if (r.empty) lines.push(' nothing to push — mirror matches the last push')
93
166
  else {
94
- if (p.project) lines.push(' project: description/status')
95
- if (p.milestones.create.length) lines.push(` milestones create: ${p.milestones.create.map((m) => m.name).join(', ')}`)
96
- if (p.milestones.update.length) lines.push(` milestones update: ${p.milestones.update.map((m) => m.id).join(', ')}`)
97
- if (p.issues.create.length) lines.push(` issues create: ${p.issues.create.length}`)
98
- if (p.issues.update.length) lines.push(` issues update: ${p.issues.update.map((i) => i.id).join(', ')}`)
167
+ if (p.issue) lines.push(' issue: description/state')
168
+ if (p.subIssues.create.length) lines.push(` sub-issues create: ${p.subIssues.create.map((s) => s.name).join(', ')}`)
169
+ if (p.subIssues.update.length) lines.push(` sub-issues update: ${p.subIssues.update.map((s) => s.id).join(', ')}`)
99
170
  lines.push(' (run with --json for the full plan the skill applies)')
100
171
  }
101
172
  out.write(lines.join('\n') + '\n')
@@ -128,7 +199,7 @@ function specSyncStatus(dir, config, specArg, flags, out) {
128
199
  if (missing.length) {
129
200
  out.write(
130
201
  `spec-sync status: ERROR — configured state name(s) not in the workspace: ${missing.join(', ')}. ` +
131
- `Linear silently ignores an unknown project status; fix specs/.core/linear.config.json.\n`,
202
+ `Linear silently ignores an unknown issue state; fix specs/.core/linear.config.json.\n`,
132
203
  )
133
204
  return 1
134
205
  }
@@ -141,9 +212,9 @@ function specSyncStatus(dir, config, specArg, flags, out) {
141
212
  if (!snapshot) lines.push(' push: never pushed — everything is pending')
142
213
  else if (isEmptyPlan(plan)) lines.push(' push: up to date — nothing changed since the last push')
143
214
  else {
144
- const n = plan.milestones.create.length + plan.issues.create.length
145
- const u = plan.milestones.update.length + plan.issues.update.length
146
- lines.push(` push: pending — ${n} to create, ${u} to update${plan.project ? ', project changed' : ''}`)
215
+ const n = plan.subIssues.create.length
216
+ const u = plan.subIssues.update.length
217
+ lines.push(` push: pending — ${n} to create, ${u} to update${plan.issue ? ', issue changed' : ''}`)
147
218
  }
148
219
 
149
220
  if (flags.remote && fs.existsSync(flags.remote)) {
@@ -197,10 +268,14 @@ async function specSync(rest, io = {}) {
197
268
  return 0
198
269
  case 'status':
199
270
  return specSyncStatus(dir, config, positional[0], flags, out) || 0
271
+ case 'linked':
272
+ specSyncLinked(dir, config, flags, out)
273
+ return 0
200
274
  default:
201
- out.write('Usage: skitterspec spec-sync <normalize|push|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n')
275
+ out.write('Usage: skitterspec spec-sync <normalize|push|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n' +
276
+ ' skitterspec spec-sync linked [--json]\n')
202
277
  return 0
203
278
  }
204
279
  }
205
280
 
206
- module.exports = { specSync }
281
+ module.exports = { specSync, listSpecs }
@@ -16,7 +16,8 @@
16
16
  *
17
17
  * Shape (see assets/core/linear.config.md for field docs):
18
18
  * {
19
- * linear: { teamKey, teamId, initiativeId },
19
+ * linear: { teamKey, teamId, projectId },
20
+ * intake: { label, bugLabels },
20
21
  * mapping: { specFolder, phases, tasks },
21
22
  * states: { backlog, "in-progress", complete, cancelled },
22
23
  * snapshot: { overviewFile },
@@ -37,16 +38,29 @@ const CONFIG_FILE = join('specs', '.core', 'linear.config.json')
37
38
  const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
38
39
 
39
40
  const DEFAULT_CONFIG = Object.freeze({
40
- linear: Object.freeze({ teamKey: '', teamId: '', initiativeId: '' }),
41
- mapping: Object.freeze({ specFolder: 'project', phases: 'milestone', tasks: 'issue' }),
42
- // Linear PROJECT statuses (mapping.specFolder is `project`) NOT issue-status
43
- // names. Linear silently no-ops save_project on an unknown status (200,
44
- // unchanged), so these must match the workspace exactly; `validateStates`
45
- // checks them at push/status time.
41
+ // `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
42
+ // first `/spec-push` offer the team's projects and pre-select this one; empty
43
+ // means "None (team only)" is pre-selected. It is passed on the issue-create
44
+ // call only and never stored in the spec or the snapshot, so a PM re-homing the
45
+ // issue in Linear is invisible to sync. (The old `initiativeId` grouped
46
+ // Projects, which no longer exist.)
47
+ linear: Object.freeze({ teamKey: '', teamId: '', projectId: '' }),
48
+ // Issue intake (`/spec <ISSUE-REF>`, `/spec --from-issue`). `label` is the
49
+ // inbox filter — issues carrying it are what the web app files; `bugLabels`
50
+ // route an issue to `/spec-bug` instead of `/spec`. Both empty = no inbox to
51
+ // browse (a bare issue ref still works) and no bug routing.
52
+ intake: Object.freeze({ label: '', bugLabels: Object.freeze([]) }),
53
+ // A spec is a Linear ISSUE; each phase is a SUB-ISSUE of it; tasks are not
54
+ // synced (they live only in the repo phase files).
55
+ mapping: Object.freeze({ specFolder: 'issue', phases: 'subissue', tasks: 'none' }),
56
+ // Linear ISSUE workflow-state names — the spec issue's state (from the folder
57
+ // bucket) and each sub-issue's state (from the phase emoji) both map through
58
+ // this one table. They must match the workspace's issue states exactly;
59
+ // `validateStates` checks them at push/status time.
46
60
  states: Object.freeze({
47
61
  backlog: 'Backlog',
48
62
  'in-progress': 'In Progress',
49
- complete: 'Completed',
63
+ complete: 'Done',
50
64
  cancelled: 'Canceled',
51
65
  }),
52
66
  snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
@@ -55,22 +69,21 @@ const DEFAULT_CONFIG = Object.freeze({
55
69
  baseDir: 'specs/.core/linear-base',
56
70
  backupDir: 'specs/.core/linear-backups',
57
71
  // 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,
72
+ // — the issue `description`, its `subIssues` (one per phase, name + goal +
73
+ // state), and the lifecycle `workflowState`. There is no pull. Priority,
60
74
  // labels, cycles and comments are Linear-native triage — deliberately NOT in
61
75
  // the set, so the PM's triage is never touched. The `push` marker is retained
62
76
  // for shape; any key you add joins the pushed projection.
63
77
  fieldOwnership: Object.freeze({
64
78
  description: 'push',
65
- milestones: 'push',
66
- tasks: 'push',
79
+ subIssues: 'push',
67
80
  workflowState: 'push',
68
81
  }),
69
82
  localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
70
83
  // Fields that are keyed collections (arrays of objects with a stable id),
71
84
  // compared/merged per item rather than as one opaque value. Map field name →
72
85
  // the item's id property. Empty by default — a workspace opts a field in
73
- // (e.g. { milestones: "id", tasks: "id" }) once the body round-trip is wired.
86
+ // (e.g. { subIssues: "ref" }) once the body round-trip is wired.
74
87
  keyedFields: Object.freeze({}),
75
88
  }),
76
89
  })
@@ -83,6 +96,7 @@ function isObject(value) {
83
96
  function defaults() {
84
97
  return {
85
98
  linear: { ...DEFAULT_CONFIG.linear },
99
+ intake: { label: DEFAULT_CONFIG.intake.label, bugLabels: [...DEFAULT_CONFIG.intake.bugLabels] },
86
100
  mapping: { ...DEFAULT_CONFIG.mapping },
87
101
  states: { ...DEFAULT_CONFIG.states },
88
102
  snapshot: { ...DEFAULT_CONFIG.snapshot },
@@ -110,6 +124,11 @@ function assign(base, parsed, key, type) {
110
124
  }
111
125
  }
112
126
 
127
+ // Normalise an array config value to trimmed, non-empty strings.
128
+ function stringList(value) {
129
+ return value.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
130
+ }
131
+
113
132
  // Merge (and validate) sync.fieldOwnership. Any key the caller lists joins the
114
133
  // compared field set; the value MUST be one of both|pull|push.
115
134
  function mergeFieldOwnership(base, parsed) {
@@ -150,7 +169,14 @@ function mergeConfig(base, parsed) {
150
169
  if (isObject(parsed.linear)) {
151
170
  assign(base.linear, parsed.linear, 'teamKey', 'string?')
152
171
  assign(base.linear, parsed.linear, 'teamId', 'string?')
153
- assign(base.linear, parsed.linear, 'initiativeId', 'string?')
172
+ assign(base.linear, parsed.linear, 'projectId', 'string?')
173
+ }
174
+
175
+ if (isObject(parsed.intake)) {
176
+ assign(base.intake, parsed.intake, 'label', 'string?')
177
+ if (Array.isArray(parsed.intake.bugLabels)) {
178
+ base.intake.bugLabels = stringList(parsed.intake.bugLabels)
179
+ }
154
180
  }
155
181
 
156
182
  if (isObject(parsed.mapping)) {
@@ -179,9 +205,7 @@ function mergeConfig(base, parsed) {
179
205
  mergeFieldOwnership(base.sync.fieldOwnership, parsed.sync.fieldOwnership)
180
206
  mergeKeyedFields(base.sync.keyedFields, parsed.sync.keyedFields)
181
207
  if (Array.isArray(parsed.sync.localOnlySections)) {
182
- base.sync.localOnlySections = parsed.sync.localOnlySections
183
- .filter((s) => typeof s === 'string' && s.trim())
184
- .map((s) => s.trim())
208
+ base.sync.localOnlySections = stringList(parsed.sync.localOnlySections)
185
209
  }
186
210
  }
187
211
 
@@ -3,38 +3,40 @@
3
3
  /**
4
4
  * The Linear MCP boundary — the one place that knows concrete Linear tool names.
5
5
  *
6
- * `discoverLinear(tools)` resolves the operations the sync needs (read/update a
7
- * Project, list/create/update Milestones + Issues) against the *connected*
8
- * server's advertised tool list at runtime, rather than hardcoding names that
9
- * drift. If Linear isn't connected (empty / zero-match tool list) it returns a
10
- * clean `{ ok:false, error }` so the caller can stop and do nothing destructive.
6
+ * A spec is a Linear **issue** and each phase a **sub-issue** (a child issue
7
+ * with a `parentId`); tasks are not synced. `discoverLinear(tools)` resolves the
8
+ * issue operations the sync needs (read / create / update an issue, optionally
9
+ * list a parent's children) against the *connected* server's advertised tool
10
+ * list at runtime, rather than hardcoding names that drift. If Linear isn't
11
+ * connected (empty / zero-match tool list) it returns a clean `{ ok:false,
12
+ * error }` so the caller can stop and do nothing destructive.
11
13
  *
12
14
  * `makeAdapter(callTool, resolved)` wraps a generic `callTool(name, args)` (the
13
- * skill's MCP invoker) into the typed async operations push/pull consume. Tests
14
- * inject a fake adapter directly (an in-memory Project), so the engine stays
15
- * offline and deterministic; production wires `callTool` to the real MCP server.
15
+ * skill's MCP invoker) into the typed async operations push consumes. Tests
16
+ * inject a fake `callTool`, so the engine stays offline and deterministic;
17
+ * production wires `callTool` to the real MCP server.
16
18
  */
17
19
 
18
20
  // Canonical operations, and the regexes that match a Linear MCP tool name to
19
21
  // each. Ordered patterns: first match wins. Matched against the real connected
20
- // Linear MCP server: it exposes a single upsert `save_*` tool per object (create
21
- // when no id, update when id given) rather than separate create/update verbs, so
22
- // each write op accepts `save_*` as well as the legacy `create_`/`update_` names.
22
+ // Linear MCP server: it exposes a single upsert `save_issue` tool (create when
23
+ // no id, update when id given) rather than separate create/update verbs, so each
24
+ // write op accepts `save_issue` as well as the legacy `create_`/`update_` names.
23
25
  const MATCHERS = {
24
- projectRead: [/get_?project\b/i, /read_?project/i, /project_?get/i],
25
- projectUpdate: [/save_?project/i, /update_?project/i, /project_?update/i],
26
- projectCreate: [/save_?project/i, /create_?project/i, /project_?create/i],
27
- milestoneList: [/list_?.*milestone/i, /milestones?_?list/i, /get_?.*milestones?/i],
28
- milestoneCreate: [/save_?.*milestone/i, /create_?.*milestone/i, /milestone_?create/i],
29
- milestoneUpdate: [/save_?.*milestone/i, /update_?.*milestone/i, /milestone_?update/i],
30
- issueList: [/list_?issues?/i, /issues?_?list/i, /get_?issues?/i],
26
+ issueRead: [/get_?issue\b/i, /read_?issue/i, /issue_?get/i],
31
27
  issueCreate: [/save_?issue/i, /create_?issue/i, /issue_?create/i],
32
28
  issueUpdate: [/save_?issue/i, /update_?issue/i, /issue_?update/i],
29
+ // list only — NOT `get_issues?`, which would greedily claim the singular
30
+ // `get_issue` (first-name-wins) and leave issueRead/issueList conflated.
31
+ issueList: [/list_?issues?/i, /issues?_?list/i],
32
+ // The team's Linear Projects, for the `/spec` + `/spec-push` project picker.
33
+ // Plural-only for the same reason as issueList: `get_project` is a read.
34
+ projectList: [/list_?projects?/i, /projects?_?list/i],
33
35
  }
34
36
 
35
- // The minimum the push/pull engine can't run without. Milestone/issue ops are
36
- // optional in Phase 2 (project description round-trips first).
37
- const REQUIRED = ['projectRead', 'projectUpdate']
37
+ // The minimum the push engine can't run without: read an issue back and
38
+ // create/upsert one (which also covers sub-issues via a `parentId`).
39
+ const REQUIRED = ['issueRead', 'issueCreate']
38
40
 
39
41
  // Normalise a tools argument (array of strings or {name} objects) to names.
40
42
  function toolNames(tools) {
@@ -70,7 +72,7 @@ function discoverLinear(tools) {
70
72
  ok: false,
71
73
  error:
72
74
  `Linear MCP is connected but missing required tools: ${missing.join(', ')}. ` +
73
- 'Check the linear server exposes project read + update.',
75
+ 'Check the linear server exposes issue read + create.',
74
76
  resolved,
75
77
  missing,
76
78
  }
@@ -90,41 +92,46 @@ function makeAdapter(callTool, resolved) {
90
92
  return name
91
93
  }
92
94
  return {
93
- // Linear's project-read tool keys on `query` (accepts a UUID, key, or slug).
94
- async readProject(id) {
95
- return callTool(need('projectRead'), { query: id })
95
+ // Linear's issue-read tool keys on `query` (accepts a UUID, identifier, or
96
+ // title). Used to read a mirror issue's workflow state for drift reporting.
97
+ async readIssue(id) {
98
+ return callTool(need('issueRead'), { query: id })
96
99
  },
97
- // `save_project` upserts: with `id` it updates, without it creates. Create
98
- // needs a name and at least one team (`addTeams`).
99
- async createProject(project) {
100
- return callTool(need('projectCreate'), { ...project })
100
+ // The SPEC issue. `save_issue` upserts: without `id` it creates, with `id` it
101
+ // updates. Create needs `title` + `team`; `project` (optional) groups it.
102
+ async createIssue(issue) {
103
+ return callTool(need('issueCreate'), { ...issue })
101
104
  },
102
- async updateProject(id, updates) {
103
- return callTool(need('projectUpdate'), { id, ...updates })
104
- },
105
- // List a project's milestones (the pull read side). Most Linear reads also
106
- // return milestones inline on the project via includeMilestones — this is the
107
- // explicit list op for callers that need it on its own.
108
- async listMilestones(projectId) {
109
- return callTool(need('milestoneList'), { project: projectId })
105
+ async updateIssue(id, updates) {
106
+ return callTool(need('issueUpdate'), { id, ...updates })
110
107
  },
111
- // `save_milestone` requires the owning `project`; upserts on `id`.
112
- async createMilestone(projectId, milestone) {
113
- return callTool(need('milestoneCreate'), { project: projectId, ...milestone })
108
+ // A phase SUB-ISSUE: a child issue carrying `parentId` = the spec issue.
109
+ // Same upsert tool, so it inherits create-on-no-id / update-on-id.
110
+ async createSubIssue(parentId, subIssue) {
111
+ return callTool(need('issueCreate'), { parentId, ...subIssue })
114
112
  },
115
- async updateMilestone(projectId, id, updates) {
116
- return callTool(need('milestoneUpdate'), { project: projectId, id, ...updates })
113
+ async updateSubIssue(id, updates) {
114
+ return callTool(need('issueUpdate'), { id, ...updates })
117
115
  },
118
- // Issues (tasks). List the project's issues (pull read side); `save_issue`
119
- // upserts on `id`, attached to the project (and optionally a milestone).
120
- async listIssues(projectId) {
121
- return callTool(need('issueList'), { project: projectId })
116
+ // List a spec issue's sub-issues (read side, for drift). Optional op.
117
+ async listSubIssues(parentId) {
118
+ return callTool(need('issueList'), { parentId })
122
119
  },
123
- async createIssue(projectId, issue) {
124
- return callTool(need('issueCreate'), { project: projectId, ...issue })
120
+ // The intake inbox: issues matching a label and/or a free-text query, scoped
121
+ // to a team. Rides the same discovered list op as `listSubIssues` — Linear's
122
+ // `list_issues` filters on all three — so intake adds no new required tool.
123
+ // Omitted filters are left off the call rather than sent empty.
124
+ async searchIssues({ label, query, teamId } = {}) {
125
+ const args = {}
126
+ if (label) args.label = label
127
+ if (query) args.query = query
128
+ if (teamId) args.team = teamId
129
+ return callTool(need('issueList'), args)
125
130
  },
126
- async updateIssue(id, updates) {
127
- return callTool(need('issueUpdate'), { id, ...updates })
131
+ // The team's projects, for the project picker. Optional: a server without a
132
+ // project-list tool just means the picker is unavailable, never a failed push.
133
+ async listProjects(teamId) {
134
+ return callTool(need('projectList'), teamId ? { team: teamId } : {})
128
135
  },
129
136
  }
130
137
  }
@@ -10,16 +10,17 @@
10
10
  * over its API. No remote content is read or merged.
11
11
  */
12
12
 
13
- const { normalizeLocal, readSnapshot, remoteWorkflowState, titleFromText, validateStates } = require('./src/normalize.js')
13
+ const { normalizeLocal, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates } = 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
- const { writeFrontmatter, stampMilestoneId, stampIssueId, findPhaseFileByTitle } = require('./src/write.js')
17
+ const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle } = require('./src/write.js')
18
18
  const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
19
19
 
20
20
  module.exports = {
21
21
  normalizeLocal,
22
22
  readSnapshot,
23
+ parseFrontmatter,
23
24
  projectionOf,
24
25
  planChanges,
25
26
  snapshotOf,
@@ -34,7 +35,7 @@ module.exports = {
34
35
  readBase,
35
36
  writeBase,
36
37
  writeFrontmatter,
37
- stampMilestoneId,
38
+ stampSubIssueId,
38
39
  stampIssueId,
39
40
  findPhaseFileByTitle,
40
41
  sanitizeSpecMarkdown,
@@ -6,7 +6,7 @@
6
6
  * One-way sync (repo → Linear) records what it last pushed per spec at
7
7
  * `{sync.baseDir}/{identifier}.base.json`, committed so each worktree carries its
8
8
  * own snapshot. The snapshot is a set of content hashes
9
- * (`{ project, milestones: {id:hash}, issues: {id:hash} }`, see compare.js
9
+ * (`{ issue, subIssues: {id:hash} }`, see compare.js
10
10
  * `snapshotOf`); `planChanges` diffs the current projection against it to decide
11
11
  * create/update/skip — no remote read. After a successful push the engine
12
12
  * rewrites it (`writeBase`). Generic JSON read/write; the shape is the caller's.
@@ -40,19 +40,20 @@ function hashField(value) {
40
40
  // --- content hashes (id / local handles excluded, so they never affect the
41
41
  // diff — an id stamped in after a create must not read as an edit) ---------
42
42
 
43
- // The project fields the repo owns and pushes: prose + workflow state. Priority,
44
- // labels, cycles and comments are Linear-native triage — one-way sync neither
45
- // pushes nor reads them, so a PM's triage is never clobbered.
46
- function projectHash(p) {
47
- return hashField({ description: p.description ?? null, status: p.status ?? null })
43
+ // The spec ISSUE fields the repo owns and pushes: prose + workflow state.
44
+ // Priority, labels, cycles and comments are Linear-native triage — one-way sync
45
+ // neither pushes nor reads them, so a PM's triage is never clobbered.
46
+ function specIssueHash(p) {
47
+ return hashField({ description: p.description ?? null, state: p.status ?? null })
48
48
  }
49
- const milestoneHash = (m) => hashField({ name: m.name ?? null, goal: m.goal ?? null })
50
- const issueHash = (t) => hashField({ title: t.title ?? null, description: t.description ?? null, done: !!t.done })
49
+ // A phase SUB-ISSUE: its name, goal and state (all repo-owned).
50
+ const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
51
51
 
52
52
  /**
53
- * The snapshot to commit after a successful push: a content hash per object that
54
- * currently has an id. Create items (id == null) aren't recorded until the skill
55
- * stamps their returned id and the next projection includes it.
53
+ * The snapshot to commit after a successful push: the spec-issue hash plus a
54
+ * content hash per sub-issue that currently has an id. Create items (id == null)
55
+ * aren't recorded until the skill stamps their returned id and the next
56
+ * projection includes it.
56
57
  */
57
58
  function snapshotOf(projection) {
58
59
  const p = projection || {}
@@ -62,54 +63,42 @@ function snapshotOf(projection) {
62
63
  return out
63
64
  }
64
65
  return {
65
- project: projectHash(p),
66
- milestones: byId(p.milestones, milestoneHash),
67
- issues: byId(p.issues, issueHash),
66
+ issue: specIssueHash(p),
67
+ subIssues: byId(p.subIssues, subIssueHash),
68
68
  }
69
69
  }
70
70
 
71
71
  /**
72
72
  * Diff the local projection against the last-pushed snapshot.
73
- * @returns {{ project?: object, milestones: {create,update}, issues: {create,update} }}
73
+ * @returns {{ issue?: object, subIssues: {create,update} }}
74
74
  * create items carry a `ref` (local handle) and no id; update items carry `id`.
75
+ * `plan.issue` (when present) is the spec issue's description + state; the push
76
+ * skill applies `config.linear.projectId` grouping on top of it.
75
77
  */
76
78
  function planChanges(projection, snapshot) {
77
79
  const p = projection || {}
78
80
  const snap = snapshot || {}
79
- const snapM = snap.milestones || {}
80
- const snapI = snap.issues || {}
81
+ const snapS = snap.subIssues || {}
81
82
 
82
- const milestones = { create: [], update: [] }
83
- for (const m of p.milestones || []) {
84
- if (m.id == null) milestones.create.push({ ref: m.ref, name: m.name, goal: m.goal })
85
- else if (snapM[String(m.id)] !== milestoneHash(m)) milestones.update.push({ id: m.id, name: m.name, goal: m.goal })
86
- }
87
-
88
- const issues = { create: [], update: [] }
89
- for (const t of p.issues || []) {
90
- if (t.id == null) {
91
- issues.create.push({ ref: t.ref, title: t.title, description: t.description, done: !!t.done, milestoneRef: t.milestoneRef })
92
- } else if (snapI[String(t.id)] !== issueHash(t)) {
93
- issues.update.push({ id: t.id, title: t.title, description: t.description, done: !!t.done })
83
+ const subIssues = { create: [], update: [] }
84
+ for (const s of p.subIssues || []) {
85
+ if (s.id == null) {
86
+ subIssues.create.push({ ref: s.ref, name: s.name, goal: s.goal, state: s.state })
87
+ } else if (snapS[String(s.id)] !== subIssueHash(s)) {
88
+ subIssues.update.push({ id: s.id, name: s.name, goal: s.goal, state: s.state })
94
89
  }
95
90
  }
96
91
 
97
- const plan = { milestones, issues }
98
- if (snap.project !== projectHash(p)) {
99
- plan.project = { description: p.description ?? null, status: p.status ?? null }
92
+ const plan = { subIssues }
93
+ if (snap.issue !== specIssueHash(p)) {
94
+ plan.issue = { description: p.description ?? null, state: p.status ?? null }
100
95
  }
101
96
  return plan
102
97
  }
103
98
 
104
99
  // True when a plan would push nothing.
105
100
  function isEmptyPlan(plan) {
106
- return (
107
- !plan.project &&
108
- !plan.milestones.create.length &&
109
- !plan.milestones.update.length &&
110
- !plan.issues.create.length &&
111
- !plan.issues.update.length
112
- )
101
+ return !plan.issue && !plan.subIssues.create.length && !plan.subIssues.update.length
113
102
  }
114
103
 
115
104
  module.exports = {
@@ -118,7 +107,6 @@ module.exports = {
118
107
  isEmptyPlan,
119
108
  hashField,
120
109
  stableStringify,
121
- projectHash,
122
- milestoneHash,
123
- issueHash,
110
+ specIssueHash,
111
+ subIssueHash,
124
112
  }