@skitterbyte/skitterspec-linear 8.0.4 → 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.
- package/README.md +34 -16
- package/assets/core/SETUP.md +47 -37
- package/assets/core/linear.config.json.example +10 -7
- package/assets/core/linear.config.md +80 -38
- package/assets/rules/spec-planning.md +3 -0
- package/assets/skills/spec/SKILL.md +106 -10
- package/assets/skills/spec-bug/SKILL.md +61 -0
- package/assets/skills/spec-cancel/SKILL.md +18 -3
- package/assets/skills/spec-complete/SKILL.md +26 -8
- package/assets/skills/spec-go/SKILL.md +16 -6
- package/assets/skills/spec-hotfix/SKILL.md +1 -0
- package/assets/skills/spec-push/SKILL.md +71 -28
- package/assets/skills/spec-status/SKILL.md +13 -12
- package/package.json +1 -1
- package/src/cli.js +18 -6
- package/src/env/provision.js +39 -3
- package/src/env/resolve.js +1 -0
- package/src/vendor/linear/cli-sync.js +87 -12
- package/src/vendor/linear/config.js +41 -17
- package/src/vendor/linear/mcp.js +57 -50
- package/src/vendor/sync-core/index.js +4 -3
- package/src/vendor/sync-core/src/base.js +1 -1
- package/src/vendor/sync-core/src/compare.js +29 -41
- package/src/vendor/sync-core/src/normalize.js +69 -47
- package/src/vendor/sync-core/src/push.js +5 -7
- package/src/vendor/sync-core/src/task-block.js +48 -8
- package/src/vendor/sync-core/src/write.js +14 -13
|
@@ -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.
|
|
95
|
-
if (p.
|
|
96
|
-
if (p.
|
|
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
|
|
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.
|
|
145
|
-
const u = plan.
|
|
146
|
-
lines.push(` push: pending — ${n} to create, ${u} to update${plan.
|
|
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,
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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: '
|
|
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
|
|
59
|
-
//
|
|
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
|
-
|
|
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. {
|
|
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, '
|
|
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
|
|
package/src/vendor/linear/mcp.js
CHANGED
|
@@ -3,38 +3,40 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The Linear MCP boundary — the one place that knows concrete Linear tool names.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
* inject a fake
|
|
15
|
-
*
|
|
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 `
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
-
|
|
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
|
|
36
|
-
//
|
|
37
|
-
const REQUIRED = ['
|
|
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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
// `
|
|
98
|
-
//
|
|
99
|
-
async
|
|
100
|
-
return callTool(need('
|
|
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
|
|
103
|
-
return callTool(need('
|
|
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
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
116
|
-
return callTool(need('
|
|
113
|
+
async updateSubIssue(id, updates) {
|
|
114
|
+
return callTool(need('issueUpdate'), { id, ...updates })
|
|
117
115
|
},
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
* (`{
|
|
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
|
|
44
|
-
// labels, cycles and comments are Linear-native triage — one-way sync
|
|
45
|
-
// pushes nor reads them, so a PM's triage is never clobbered.
|
|
46
|
-
function
|
|
47
|
-
return hashField({ description: p.description ?? 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
|
-
|
|
50
|
-
const
|
|
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:
|
|
54
|
-
* currently has an id. Create items (id == null)
|
|
55
|
-
* stamps their returned id and the next
|
|
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
|
-
|
|
66
|
-
|
|
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 {{
|
|
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
|
|
80
|
-
const snapI = snap.issues || {}
|
|
81
|
+
const snapS = snap.subIssues || {}
|
|
81
82
|
|
|
82
|
-
const
|
|
83
|
-
for (const
|
|
84
|
-
if (
|
|
85
|
-
|
|
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 = {
|
|
98
|
-
if (snap.
|
|
99
|
-
plan.
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
issueHash,
|
|
110
|
+
specIssueHash,
|
|
111
|
+
subIssueHash,
|
|
124
112
|
}
|