@skitterbyte/skitterspec-linear 12.0.0 → 13.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 +208 -10
- package/README.md +32 -2
- package/assets/claude-md-section.md +38 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/SETUP.md +21 -3
- package/assets/core/env.config.json.example +6 -3
- package/assets/core/env.config.md +77 -30
- package/assets/core/linear.config.md +58 -0
- package/assets/review/page.html +1501 -0
- package/assets/rules/spec-planning.md +198 -10
- package/assets/rules/spec-reports.md +269 -0
- package/assets/skills/spec/SKILL.md +33 -5
- package/assets/skills/spec-bug/SKILL.md +172 -9
- package/assets/skills/spec-cancel/SKILL.md +98 -21
- package/assets/skills/spec-claim/SKILL.md +114 -0
- package/assets/skills/spec-complete/SKILL.md +94 -25
- package/assets/skills/spec-diff/SKILL.md +564 -0
- package/assets/skills/spec-hotfix/SKILL.md +172 -11
- package/assets/skills/spec-init/SKILL.md +34 -7
- package/assets/skills/spec-linear-setup/SKILL.md +55 -1
- package/assets/skills/spec-list/SKILL.md +218 -0
- package/assets/skills/spec-next/SKILL.md +299 -6
- package/assets/skills/spec-push/SKILL.md +32 -8
- package/assets/skills/spec-review/SKILL.md +40 -5
- package/assets/skills/spec-reviewed/SKILL.md +241 -0
- package/assets/skills/spec-start/SKILL.md +386 -106
- package/assets/skills/spec-status/SKILL.md +24 -2
- package/assets/skills/spec-sync/SKILL.md +40 -4
- package/assets/skills/spec-to-main/SKILL.md +28 -6
- package/package.json +11 -7
- package/src/cli.js +1513 -89
- package/src/env/building.js +143 -0
- package/src/env/config.js +42 -9
- package/src/env/provision.js +54 -15
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +295 -9
- package/src/env/review.js +1329 -0
- package/src/env/serve.js +549 -0
- package/src/env/teardown.js +13 -6
- package/src/init.js +96 -1
- package/src/vendor/linear/api.js +104 -1
- package/src/vendor/linear/cli-sync.js +854 -17
- package/src/vendor/linear/config.js +8 -0
- package/src/vendor/linear/credentials.js +94 -0
- package/src/vendor/linear/doctor.js +35 -0
- package/src/vendor/linear/identity.js +105 -0
- package/src/vendor/linear/mcp.js +26 -0
- package/src/vendor/sync-core/index.js +6 -2
- package/src/vendor/sync-core/src/compare.js +49 -3
- package/src/vendor/sync-core/src/normalize.js +30 -0
- package/src/vendor/sync-core/src/push.js +11 -1
- package/src/vendor/sync-core/src/write.js +38 -0
- package/LICENSE +0 -21
|
@@ -164,6 +164,14 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
164
164
|
// labels, cycles and comments are Linear-native triage — deliberately NOT in
|
|
165
165
|
// the set, so the PM's triage is never touched. The `push` marker is retained
|
|
166
166
|
// for shape; any key you add joins the pushed projection.
|
|
167
|
+
//
|
|
168
|
+
// `assignee` is the one field that OPTS IN this way rather than shipping on:
|
|
169
|
+
// add `"assignee": "push"` and the issue is assigned to whoever is building
|
|
170
|
+
// the spec, and released when it completes. Left out (the default) the whole
|
|
171
|
+
// feature is inert — nothing is written, no hash is recorded in a snapshot,
|
|
172
|
+
// and `status` prints no assignee line. It is deliberately not a new config
|
|
173
|
+
// key: this map is already the documented extension point, and assignment is
|
|
174
|
+
// exactly "one more field the repo owns".
|
|
167
175
|
fieldOwnership: Object.freeze({
|
|
168
176
|
description: 'push',
|
|
169
177
|
subIssues: 'push',
|
|
@@ -106,6 +106,34 @@ function keyForTeam(store, teamId) {
|
|
|
106
106
|
return typeof key === 'string' && key.trim() ? key.trim() : null
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The Linear IDENTITY recorded for one team, or null.
|
|
111
|
+
*
|
|
112
|
+
* Identity lives here rather than in the repo's `linear.config.json` for the
|
|
113
|
+
* same reason the key does: that file is COMMITTED, so a user id written there
|
|
114
|
+
* would follow the repo to every teammate who clones it and assign their specs
|
|
115
|
+
* to whoever set it up. Who you are is a fact about this machine and this
|
|
116
|
+
* workspace, which is exactly what this store is keyed for.
|
|
117
|
+
*
|
|
118
|
+
* Unlike a key, an identity is NOT a secret — it is a name and a public user id,
|
|
119
|
+
* both of which appear on every issue in Linear. So it may be printed, passed as
|
|
120
|
+
* an argument, and returned in an error.
|
|
121
|
+
*/
|
|
122
|
+
function userForTeam(store, teamId) {
|
|
123
|
+
if (!teamId) return null
|
|
124
|
+
const teams = store && store.teams
|
|
125
|
+
const entry = teams && typeof teams === 'object' ? teams[teamId] : null
|
|
126
|
+
if (!entry || typeof entry !== 'object') return null
|
|
127
|
+
const user = entry.user
|
|
128
|
+
if (!user || typeof user !== 'object') return null
|
|
129
|
+
// The id is what the assignment is actually made with, so an entry without one
|
|
130
|
+
// is not a partial identity — it is no identity at all.
|
|
131
|
+
const id = typeof user.id === 'string' && user.id.trim() ? user.id.trim() : null
|
|
132
|
+
if (!id) return null
|
|
133
|
+
const name = typeof user.name === 'string' && user.name.trim() ? user.name.trim() : null
|
|
134
|
+
return { id, name }
|
|
135
|
+
}
|
|
136
|
+
|
|
109
137
|
// Last 4 characters, for reporting that a key exists without revealing it.
|
|
110
138
|
function fingerprint(key) {
|
|
111
139
|
if (typeof key !== 'string' || !key) return null
|
|
@@ -153,6 +181,69 @@ function writeKey(file, teamId, key, deps = {}) {
|
|
|
153
181
|
return { ok: true, path: file, created }
|
|
154
182
|
}
|
|
155
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Record the Linear identity for one team, preserving that team's key (or
|
|
186
|
+
* `keyCommand`) and every other team's entry.
|
|
187
|
+
*
|
|
188
|
+
* Same store, same 0600 / 0700 guards, same refusal on an over-permissive file
|
|
189
|
+
* as `writeKey` — the file holds a key whether or not this particular write
|
|
190
|
+
* carries one, so relaxing the guard here would relax it for the key too.
|
|
191
|
+
*
|
|
192
|
+
* Returns `{ ok: true, path, created }` or `{ ok: false, reason }`.
|
|
193
|
+
*/
|
|
194
|
+
function writeUser(file, teamId, user, deps = {}) {
|
|
195
|
+
const mkdir = deps.mkdir || fs.mkdirSync
|
|
196
|
+
const write = deps.write || fs.writeFileSync
|
|
197
|
+
const chmod = deps.chmod || fs.chmodSync
|
|
198
|
+
const exists = deps.exists || fs.existsSync
|
|
199
|
+
|
|
200
|
+
if (!teamId) return { ok: false, reason: 'no team id — nothing to key the entry by' }
|
|
201
|
+
const id = user && typeof user.id === 'string' ? user.id.trim() : ''
|
|
202
|
+
if (!id) return { ok: false, reason: 'no user id — nothing stored' }
|
|
203
|
+
const name = user && typeof user.name === 'string' ? user.name.trim() : ''
|
|
204
|
+
|
|
205
|
+
const created = !exists(file)
|
|
206
|
+
let store = { version: 1, teams: {} }
|
|
207
|
+
if (!created) {
|
|
208
|
+
const current = readStore(file, deps)
|
|
209
|
+
if (!current.ok) return { ok: false, reason: current.reason, code: current.code }
|
|
210
|
+
store = current.store
|
|
211
|
+
if (!store.teams || typeof store.teams !== 'object') store.teams = {}
|
|
212
|
+
if (!store.version) store.version = 1
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Spread the existing entry, exactly as `writeKey` does: recording an identity
|
|
216
|
+
// must never cost the user the key sitting beside it.
|
|
217
|
+
store.teams[teamId] = { ...(store.teams[teamId] || {}), user: name ? { id, name } : { id } }
|
|
218
|
+
|
|
219
|
+
mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
220
|
+
write(file, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 })
|
|
221
|
+
chmod(file, 0o600)
|
|
222
|
+
return { ok: true, path: file, created }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Forget the identity recorded for one team, leaving that team's KEY in place.
|
|
227
|
+
*
|
|
228
|
+
* Deliberately not `removeKey`'s shape. That one drops the whole team entry,
|
|
229
|
+
* which is right when the key is what you are revoking; here it would sign the
|
|
230
|
+
* user out of Linear entirely because they corrected their own name. A store or
|
|
231
|
+
* entry that isn't there is a clean no-op, not an error.
|
|
232
|
+
*/
|
|
233
|
+
function removeUser(file, teamId, deps = {}) {
|
|
234
|
+
const write = deps.write || fs.writeFileSync
|
|
235
|
+
const current = readStore(file, deps)
|
|
236
|
+
if (!current.ok) {
|
|
237
|
+
if (current.code === 'absent') return { ok: true, path: file, removed: false }
|
|
238
|
+
return { ok: false, reason: current.reason, code: current.code }
|
|
239
|
+
}
|
|
240
|
+
const entry = current.store.teams && current.store.teams[teamId]
|
|
241
|
+
if (!entry || !entry.user) return { ok: true, path: file, removed: false }
|
|
242
|
+
delete entry.user
|
|
243
|
+
write(file, JSON.stringify(current.store, null, 2) + '\n', { mode: 0o600 })
|
|
244
|
+
return { ok: true, path: file, removed: true }
|
|
245
|
+
}
|
|
246
|
+
|
|
156
247
|
/**
|
|
157
248
|
* Remove one team's entry, leaving every other team intact. A store or entry
|
|
158
249
|
* that isn't there is a clean no-op, not an error.
|
|
@@ -288,11 +379,14 @@ module.exports = {
|
|
|
288
379
|
storePath,
|
|
289
380
|
readStore,
|
|
290
381
|
keyForTeam,
|
|
382
|
+
userForTeam,
|
|
291
383
|
fingerprint,
|
|
292
384
|
writeKey,
|
|
385
|
+
writeUser,
|
|
293
386
|
writeKeyCommand,
|
|
294
387
|
resolveTeamKey,
|
|
295
388
|
removeKey,
|
|
389
|
+
removeUser,
|
|
296
390
|
storeMode,
|
|
297
391
|
DIR_NAME,
|
|
298
392
|
FILE_NAME,
|
|
@@ -60,6 +60,7 @@ function runChecks(state = {}) {
|
|
|
60
60
|
trackerCheck(state.tracker),
|
|
61
61
|
projectCheck(state.project, state.tracker, state.remote),
|
|
62
62
|
keyCheck(state.key, state.tracker),
|
|
63
|
+
identityCheck(state.identity, state.tracker),
|
|
63
64
|
remoteCheck(state.remote),
|
|
64
65
|
ladderCheck(state.ladder, state.tracker),
|
|
65
66
|
mcpCheck(state.mcp, state.tracker, state.project, state.remote),
|
|
@@ -255,6 +256,40 @@ function keyCheck(s = {}, tracker = {}) {
|
|
|
255
256
|
return row('key', 'key', 'ok', `${s.fingerprint || 'set'} from ${s.source || 'unknown'}`)
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Who this machine is in Linear — the fact assignment rests on.
|
|
261
|
+
*
|
|
262
|
+
* WHAT WOULD MAKE THIS CHECK ACCUSE THE INNOCENT, and why it does not:
|
|
263
|
+
*
|
|
264
|
+
* - **A project that never opted in.** Assignment is opt-in through
|
|
265
|
+
* `sync.fieldOwnership.assignee`; without it nothing reads an identity, so
|
|
266
|
+
* reporting one as missing would tell a healthy project to fix something it
|
|
267
|
+
* deliberately does not use. That is `skipped`, and it is the common case.
|
|
268
|
+
* - **An identity that is merely underivable.** No key, offline, a shared
|
|
269
|
+
* credential — all ordinary, all `missing` rather than `broken`, because the
|
|
270
|
+
* lifecycle skills skip assignment and carry on. Nothing here fails a run.
|
|
271
|
+
*
|
|
272
|
+
* So the only states it can produce are `skipped`, `missing` and `ok`. There is
|
|
273
|
+
* deliberately no `broken`: being unable to name you is never evidence that
|
|
274
|
+
* something is wrong with the install.
|
|
275
|
+
*/
|
|
276
|
+
function identityCheck(s = {}, tracker = {}) {
|
|
277
|
+
if (!tracker.present) return row('identity', 'identity', 'skipped', 'no tracker configured')
|
|
278
|
+
if (!s.owned) {
|
|
279
|
+
return row('identity', 'identity', 'skipped', 'assignment not enabled (sync.fieldOwnership.assignee)')
|
|
280
|
+
}
|
|
281
|
+
if (!s.ok) {
|
|
282
|
+
return row(
|
|
283
|
+
'identity',
|
|
284
|
+
'identity',
|
|
285
|
+
'missing',
|
|
286
|
+
s.error || 'no Linear identity for this workspace',
|
|
287
|
+
'skitterspec spec-sync whoami',
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
return row('identity', 'identity', 'ok', `${s.name || s.id} from ${s.source === 'store' ? 'the credentials store' : 'the API key'}`)
|
|
291
|
+
}
|
|
292
|
+
|
|
258
293
|
function remoteCheck(s = {}) {
|
|
259
294
|
if (!s.checked) {
|
|
260
295
|
return row('remote', 'remote', 'skipped', 'pass --check-remote to verify against Linear')
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Who am I in this Linear workspace?" — the question assignment rests on.
|
|
5
|
+
*
|
|
6
|
+
* The answer is DERIVED, not configured. A Linear personal API key is issued to
|
|
7
|
+
* a person, so the workspace can be asked directly (`viewer` on the API path,
|
|
8
|
+
* `get_user "me"` over MCP). That is why assignment needs no repo config in the
|
|
9
|
+
* common case, and why there is no `linear.userId`: `specs/.core/linear.config.json`
|
|
10
|
+
* is committed, so an id written there would follow the repo to every teammate
|
|
11
|
+
* who clones it and assign their specs to whoever set it up.
|
|
12
|
+
*
|
|
13
|
+
* Resolution order, first hit wins:
|
|
14
|
+
*
|
|
15
|
+
* 1. the user-level credentials store (`source: 'store'`) — no network
|
|
16
|
+
* 2. the credential itself, via `viewer` (`source: 'viewer'`)
|
|
17
|
+
* 3. nothing (`source: 'unknown'`)
|
|
18
|
+
*
|
|
19
|
+
* **Unknown is a normal state, not a failure** — the same contract
|
|
20
|
+
* `resolveApiKey` holds for a missing key. A shared/bot key answers `viewer`
|
|
21
|
+
* with the bot; an offline machine answers with nothing; neither is a broken
|
|
22
|
+
* install, and both are why `spec-sync whoami --set` exists. So this returns a
|
|
23
|
+
* structured result the caller branches on, never an exception.
|
|
24
|
+
*
|
|
25
|
+
* It also NEVER PROMPTS and NEVER WRITES. Prompting belongs to the skill that
|
|
26
|
+
* has a human in front of it, and caching belongs to the command that decided
|
|
27
|
+
* the answer was worth keeping — a resolver that wrote to disk as a side effect
|
|
28
|
+
* of being asked a question would cache a bot's identity the first time CI ran.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { resolveApiKey, makeApiAdapter } = require('./api.js')
|
|
32
|
+
const { storePath, readStore, userForTeam } = require('./credentials.js')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the current user's Linear identity.
|
|
36
|
+
*
|
|
37
|
+
* @returns {Promise<object>} `{ ok: true, id, name, source }` where `source` is
|
|
38
|
+
* `'store'` or `'viewer'`; otherwise `{ ok: false, source: 'unknown', reason }`.
|
|
39
|
+
* `reason` explains what was tried, so "no key" and "Linear rejected the key"
|
|
40
|
+
* stay distinguishable — an unexplained "unknown" is what makes people
|
|
41
|
+
* re-enter a name that was never the problem.
|
|
42
|
+
*/
|
|
43
|
+
async function resolveIdentity(config, env = process.env, deps = {}) {
|
|
44
|
+
const teamId = (config && config.linear && config.linear.teamId) || ''
|
|
45
|
+
// The store is keyed by team, so with no teamId there is nothing to look up.
|
|
46
|
+
// Fall through rather than fail: `viewer` does not need a team, and a repo
|
|
47
|
+
// mid-setup should still be able to answer who you are.
|
|
48
|
+
const file = (deps.storePath || storePath)(env)
|
|
49
|
+
|
|
50
|
+
if (teamId) {
|
|
51
|
+
const result = (deps.readStore || readStore)(file)
|
|
52
|
+
if (result.ok) {
|
|
53
|
+
const cached = (deps.userForTeam || userForTeam)(result.store, teamId)
|
|
54
|
+
if (cached) return { ok: true, id: cached.id, name: cached.name, source: 'store', path: file }
|
|
55
|
+
}
|
|
56
|
+
// A store that is present but unusable (over-permissive, malformed) is NOT
|
|
57
|
+
// reported as an identity failure — the key resolution below hits the same
|
|
58
|
+
// file and reports it properly. Duplicating it here would print the same
|
|
59
|
+
// permissions warning twice for one cause.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const key = (deps.resolveApiKey || resolveApiKey)(config, env)
|
|
63
|
+
if (!key.ok) {
|
|
64
|
+
return { ok: false, source: 'unknown', reason: key.error, envVar: key.envVar, path: file }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const adapter = deps.adapter || makeApiAdapter({ apiKey: key.key, fetch: deps.fetch })
|
|
68
|
+
let viewer
|
|
69
|
+
try {
|
|
70
|
+
viewer = await adapter.readViewer()
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Offline, revoked key, Linear down. All are "cannot tell", which routes to
|
|
73
|
+
// the harmless branch: the caller skips assignment rather than guessing.
|
|
74
|
+
return { ok: false, source: 'unknown', reason: error.message, path: file }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const id = viewer && typeof viewer.id === 'string' ? viewer.id.trim() : ''
|
|
78
|
+
if (!id) {
|
|
79
|
+
return { ok: false, source: 'unknown', reason: 'Linear returned no viewer for this key', path: file }
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
id,
|
|
84
|
+
name: displayNameOf(viewer),
|
|
85
|
+
email: (viewer && viewer.email) || null,
|
|
86
|
+
source: 'viewer',
|
|
87
|
+
path: file,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The name to show for a Linear user, preferring the one Linear itself puts on
|
|
93
|
+
* an issue. Falls back through `displayName` and `email` so a user with an odd
|
|
94
|
+
* profile still reads as a person rather than a UUID.
|
|
95
|
+
*/
|
|
96
|
+
function displayNameOf(user) {
|
|
97
|
+
if (!user || typeof user !== 'object') return null
|
|
98
|
+
for (const field of ['name', 'displayName', 'email']) {
|
|
99
|
+
const value = user[field]
|
|
100
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { resolveIdentity, displayNameOf }
|
package/src/vendor/linear/mcp.js
CHANGED
|
@@ -33,6 +33,14 @@ const MATCHERS = {
|
|
|
33
33
|
// The team's Linear Projects, for the `/spec` + `/spec-push` project picker.
|
|
34
34
|
// Plural-only for the same reason as issueList: `get_project` is a read.
|
|
35
35
|
projectList: [/list_?projects?/i, /projects?_?list/i],
|
|
36
|
+
// "Who am I" — Linear's user-read tool takes the literal string `me`, which is
|
|
37
|
+
// the MCP path's equivalent of the API's `viewer`.
|
|
38
|
+
userRead: [/get_?user\b/i, /user_?get/i],
|
|
39
|
+
// The user search behind `/spec-claim --to` and the identity fallback.
|
|
40
|
+
// Singular/plural split is load-bearing here exactly as it is for issues:
|
|
41
|
+
// `\b` on userRead stops it claiming `list_users`, and userList's `list_`
|
|
42
|
+
// prefix stops it claiming `get_user`.
|
|
43
|
+
userList: [/list_?users?/i, /users?_?list/i],
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
// The minimum the push engine can't run without: read an issue back and
|
|
@@ -134,6 +142,24 @@ function makeAdapter(callTool, resolved) {
|
|
|
134
142
|
async listProjects(teamId) {
|
|
135
143
|
return callTool(need('projectList'), teamId ? { team: teamId } : {})
|
|
136
144
|
},
|
|
145
|
+
// "Who am I". Linear's user-read tool accepts the literal `me` alongside an
|
|
146
|
+
// id, name or email — so the MCP path answers identity without a config
|
|
147
|
+
// entry, exactly as the API path does through `viewer`.
|
|
148
|
+
//
|
|
149
|
+
// Optional, like `listProjects`: a server without a user-read tool means
|
|
150
|
+
// identity has to be set by hand (`spec-sync whoami --set`), never a failed
|
|
151
|
+
// push. `REQUIRED` is deliberately unchanged.
|
|
152
|
+
async readViewer() {
|
|
153
|
+
return callTool(need('userRead'), { query: 'me' })
|
|
154
|
+
},
|
|
155
|
+
// Find a person by name or email. Linear's `list_users` takes an optional
|
|
156
|
+
// `query` and pages, so search and listing are one tool rather than two.
|
|
157
|
+
async searchUsers(query, { limit } = {}) {
|
|
158
|
+
const args = {}
|
|
159
|
+
if (query) args.query = query
|
|
160
|
+
if (limit) args.limit = limit
|
|
161
|
+
return callTool(need('userList'), args)
|
|
162
|
+
},
|
|
137
163
|
}
|
|
138
164
|
}
|
|
139
165
|
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
* stored matches what was sent — it merges nothing (see `src/verify.js`).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
|
|
16
|
+
const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, phaseModeFor, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
|
|
17
17
|
const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
|
|
18
18
|
const { readBase, writeBase } = require('./src/base.js')
|
|
19
19
|
const { push, recordPush, projectionOf } = require('./src/push.js')
|
|
20
|
-
const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
|
|
20
|
+
const { writeFrontmatter, deleteFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
|
|
21
21
|
const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
|
|
22
22
|
const { detectLegacyMirror } = require('./src/legacy.js')
|
|
23
23
|
const { compareStored } = require('./src/verify.js')
|
|
@@ -26,6 +26,9 @@ const { planRetarget, applyRetarget, deriveRecordedKey, isEmptyRetarget, dirtyPa
|
|
|
26
26
|
|
|
27
27
|
module.exports = {
|
|
28
28
|
normalizeLocal,
|
|
29
|
+
// Exported for `spec-sync list`: an `inline`-mode spec has no phase sub-issues
|
|
30
|
+
// to read, so the listing must resolve the mode rather than assume `subissue`.
|
|
31
|
+
phaseModeFor,
|
|
29
32
|
lintPhases,
|
|
30
33
|
readSnapshot,
|
|
31
34
|
parseFrontmatter,
|
|
@@ -47,6 +50,7 @@ module.exports = {
|
|
|
47
50
|
readBase,
|
|
48
51
|
writeBase,
|
|
49
52
|
writeFrontmatter,
|
|
53
|
+
deleteFrontmatter,
|
|
50
54
|
stampSubIssueId,
|
|
51
55
|
stampIssueId,
|
|
52
56
|
findPhaseFileByTitle,
|
|
@@ -60,10 +60,16 @@ function specIssueHash(p) {
|
|
|
60
60
|
// dragged the issue back. Diffing them apart is what lets a push touch the
|
|
61
61
|
// description without re-asserting a state someone else now owns.
|
|
62
62
|
function specIssueFieldHashes(p) {
|
|
63
|
-
|
|
63
|
+
const hashes = {
|
|
64
64
|
description: hashField(p.description ?? null),
|
|
65
65
|
state: hashField(p.status ?? null),
|
|
66
66
|
}
|
|
67
|
+
// ONLY WHEN THE FIELD IS IN PLAY. A repo that has not opted `assignee` into
|
|
68
|
+
// `sync.fieldOwnership` must not accumulate assignee hashes in its snapshots —
|
|
69
|
+
// "the feature is inert" has to include the files it writes, or opting in later
|
|
70
|
+
// would find a history of hashes it never agreed to.
|
|
71
|
+
if (p.assignee !== undefined) hashes.assignee = hashField(p.assignee ?? null)
|
|
72
|
+
return hashes
|
|
67
73
|
}
|
|
68
74
|
// A phase SUB-ISSUE: its name, goal and state (all repo-owned).
|
|
69
75
|
const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
|
|
@@ -159,19 +165,59 @@ function planChanges(projection, snapshot) {
|
|
|
159
165
|
function issueChanges(projection, snapshot) {
|
|
160
166
|
const p = projection || {}
|
|
161
167
|
const snap = snapshot || {}
|
|
162
|
-
const both = () =>
|
|
168
|
+
const both = () => {
|
|
169
|
+
const out = { description: p.description ?? null, state: p.status ?? null }
|
|
170
|
+
// ASSERT, NEVER CLEAR, on this path. There are no split hashes here, so
|
|
171
|
+
// whether an assignee was ever pushed is unknown — and the harmless reading
|
|
172
|
+
// of "unknown" is to send one we have and stay silent about one we don't.
|
|
173
|
+
if (p.assignee != null) out.assignee = p.assignee
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
163
176
|
|
|
164
177
|
const fields = snap.issueFields
|
|
165
178
|
if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) {
|
|
166
179
|
// No split hashes recorded: an old snapshot, or no snapshot at all (a
|
|
167
180
|
// create, which needs both fields anyway).
|
|
168
|
-
|
|
181
|
+
if (snap.issue !== specIssueHash(p)) return both()
|
|
182
|
+
// Description and state are unchanged — but the COMBINED HASH SAYS NOTHING
|
|
183
|
+
// ABOUT THE ASSIGNEE, which was never one of its inputs. Absence of evidence
|
|
184
|
+
// again, and the two directions are not equally safe: asserting an assignee
|
|
185
|
+
// the repo actually recorded costs one redundant write, while staying quiet
|
|
186
|
+
// would strand it until some unrelated prose edit happened to push. Clearing
|
|
187
|
+
// is still never guessed at. The push rewrites the snapshot in the split
|
|
188
|
+
// shape, so a spec passes through here exactly once.
|
|
189
|
+
return p.assignee != null ? { assignee: p.assignee } : null
|
|
169
190
|
}
|
|
170
191
|
|
|
171
192
|
const want = specIssueFieldHashes(p)
|
|
172
193
|
const changed = {}
|
|
173
194
|
if (fields.description !== want.description) changed.description = p.description ?? null
|
|
174
195
|
if (fields.state !== want.state) changed.state = p.status ?? null
|
|
196
|
+
|
|
197
|
+
// ASSIGNEE — AN ABSENT SNAPSHOT KEY MEANS "NEVER PUSHED", NOT "WAS NULL".
|
|
198
|
+
//
|
|
199
|
+
// What would fool this check: every spec linked before assignee existed has a
|
|
200
|
+
// snapshot with no `assignee` key, and so does every spec in a repo that never
|
|
201
|
+
// opted the field in. Read that absence as `null` and the first push after
|
|
202
|
+
// upgrade computes null → null → "unchanged"… except where a PM had assigned
|
|
203
|
+
// the issue in Linear, which the repo would then silently clear. The bill for
|
|
204
|
+
// getting this wrong is somebody else's triage, workspace-wide, in one command.
|
|
205
|
+
//
|
|
206
|
+
// So absence is routed to the harmless branch: assert an assignee we actually
|
|
207
|
+
// have, and never send a clear at something we never set.
|
|
208
|
+
// (`.claude/rules/negative-checks.md` — prefer a positive signal to an absence,
|
|
209
|
+
// and bias the unknown case toward inaction.)
|
|
210
|
+
if (want.assignee !== undefined) {
|
|
211
|
+
if (fields.assignee === undefined) {
|
|
212
|
+
if (p.assignee != null) changed.assignee = p.assignee
|
|
213
|
+
} else if (fields.assignee !== want.assignee) {
|
|
214
|
+
// A recorded hash IS a positive signal that the repo pushed this field
|
|
215
|
+
// before, so a change to null here is a retraction of our own assignment
|
|
216
|
+
// rather than a guess at someone else's.
|
|
217
|
+
changed.assignee = p.assignee ?? null
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
175
221
|
return Object.keys(changed).length ? changed : null
|
|
176
222
|
}
|
|
177
223
|
|
|
@@ -675,6 +675,30 @@ function bucketFromPath(snapshotDir) {
|
|
|
675
675
|
// the states in which phases are not yet worth minting as sub-issues.
|
|
676
676
|
const UNSTARTED_BUCKETS = ['backlog', 'cancelled']
|
|
677
677
|
|
|
678
|
+
// Buckets where a spec is FINISHED, and so has nobody working on it.
|
|
679
|
+
const TERMINAL_BUCKETS = ['complete', 'cancelled']
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The Linear user this spec is assigned to, AS PROJECTED — which is not simply
|
|
683
|
+
* what the frontmatter records.
|
|
684
|
+
*
|
|
685
|
+
* The BUCKET decides, not the stamp's presence. A spec that is live (`backlog`,
|
|
686
|
+
* `in-progress`) projects whoever the repo recorded; a finished one projects
|
|
687
|
+
* `null`, so completing a spec hands the issue back with no unassign step for
|
|
688
|
+
* anyone to remember. The stamp itself is deliberately left in the spec file:
|
|
689
|
+
* `> **Developer:**` and the frontmatter are the durable record of who actioned
|
|
690
|
+
* the work, and that outlives the assignment.
|
|
691
|
+
*
|
|
692
|
+
* `null` here does NOT mean "clear it in Linear" on its own — see
|
|
693
|
+
* `issueChanges`, where an assignee that was never pushed is left alone rather
|
|
694
|
+
* than retracted. That is what stops this wiping a PM's triage.
|
|
695
|
+
*/
|
|
696
|
+
function assigneeFor(workflowState, frontmatter) {
|
|
697
|
+
if (TERMINAL_BUCKETS.includes(String(workflowState))) return null
|
|
698
|
+
const id = frontmatter && frontmatter.linear_assignee_id
|
|
699
|
+
return typeof id === 'string' && id.trim() ? id.trim() : null
|
|
700
|
+
}
|
|
701
|
+
|
|
678
702
|
// The mode a bucket gets when a per-bucket `mapping.phases` map omits it. Adding
|
|
679
703
|
// a bucket to the map is therefore an EXCEPTION for that bucket, not a switch
|
|
680
704
|
// that silently suppresses phases everywhere the map is silent. Matches the
|
|
@@ -798,6 +822,11 @@ function normalizeLocal(snapshotDir, config) {
|
|
|
798
822
|
state: p.state,
|
|
799
823
|
})),
|
|
800
824
|
workflowState,
|
|
825
|
+
// Who is building this. `toFieldSet` drops it unless the repo listed
|
|
826
|
+
// `assignee` in `sync.fieldOwnership`, so the opt-in costs no extra check
|
|
827
|
+
// here — an un-opted-in repo simply has no such key on its projection, and
|
|
828
|
+
// every downstream reader treats that as "the field is not in play".
|
|
829
|
+
assignee: assigneeFor(workflowState, frontmatter),
|
|
801
830
|
}
|
|
802
831
|
return toFieldSet(extracted, config)
|
|
803
832
|
}
|
|
@@ -1039,6 +1068,7 @@ module.exports = {
|
|
|
1039
1068
|
stateSuggestions,
|
|
1040
1069
|
configuredStateNames,
|
|
1041
1070
|
normalizeLocal,
|
|
1071
|
+
assigneeFor,
|
|
1042
1072
|
phaseProjection,
|
|
1043
1073
|
phaseModeFor,
|
|
1044
1074
|
phasesWithheld,
|
|
@@ -26,7 +26,7 @@ const { detectLegacyMirror } = require('./legacy.js')
|
|
|
26
26
|
// config.states at apply time.
|
|
27
27
|
function projectionOf(snapshotDir, config) {
|
|
28
28
|
const local = normalizeLocal(snapshotDir, config)
|
|
29
|
-
|
|
29
|
+
const projection = {
|
|
30
30
|
description: local.description ?? null,
|
|
31
31
|
status: local.workflowState ?? null,
|
|
32
32
|
subIssues: Array.isArray(local.subIssues) ? local.subIssues : [],
|
|
@@ -40,6 +40,16 @@ function projectionOf(snapshotDir, config) {
|
|
|
40
40
|
// per-bucket map, which mode applied is no longer readable off the config.
|
|
41
41
|
phaseMode: phaseModeFor(local.workflowState, config),
|
|
42
42
|
}
|
|
43
|
+
// ASSIGNEE IS SET ONLY WHEN THE FIELD IS IN PLAY, and the distinction between
|
|
44
|
+
// `undefined` and `null` is load-bearing all the way down: `undefined` means
|
|
45
|
+
// the repo never opted in, so no hash is recorded and no op is ever emitted;
|
|
46
|
+
// `null` means opted in with nobody assigned, which CAN legitimately clear a
|
|
47
|
+
// previously-pushed assignee. Defaulting the key to null here would collapse
|
|
48
|
+
// the two and make an un-opted-in repo start clearing assignees on its next
|
|
49
|
+
// push. `normalizeLocal` omits the key entirely when `sync.fieldOwnership`
|
|
50
|
+
// does not list it.
|
|
51
|
+
if ('assignee' in local) projection.assignee = local.assignee ?? null
|
|
52
|
+
return projection
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
function push({ dir, snapshotDir, identifier, config }) {
|
|
@@ -77,6 +77,43 @@ function writeFrontmatter(snapshotDir, config, patch) {
|
|
|
77
77
|
return Object.keys(clean)
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Remove keys from `00-overview.md` frontmatter. Returns the keys actually
|
|
82
|
+
* removed (absent ones are a clean no-op, not an error).
|
|
83
|
+
*
|
|
84
|
+
* A separate function because `writeFrontmatter` SKIPS nullish values by
|
|
85
|
+
* design — that is what lets a caller pass a sparse patch without clearing the
|
|
86
|
+
* fields it left out, and it means "remove this key" is inexpressible there. A
|
|
87
|
+
* released assignment has to actually leave the file: setting it empty would
|
|
88
|
+
* leave a spec claiming to be assigned to nobody in particular, and the
|
|
89
|
+
* projection reads presence, not emptiness.
|
|
90
|
+
*/
|
|
91
|
+
function deleteFrontmatter(snapshotDir, config, keys) {
|
|
92
|
+
const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
93
|
+
const file = path.join(snapshotDir, overviewFile)
|
|
94
|
+
const raw = fs.readFileSync(file, 'utf-8')
|
|
95
|
+
const { fmLines, body, had } = splitFrontmatter(raw)
|
|
96
|
+
if (!had) return []
|
|
97
|
+
|
|
98
|
+
const drop = new Set(keys)
|
|
99
|
+
const removed = []
|
|
100
|
+
const kept = fmLines.filter((line) => {
|
|
101
|
+
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
|
|
102
|
+
if (kv && drop.has(kv[1])) {
|
|
103
|
+
removed.push(kv[1])
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
107
|
+
})
|
|
108
|
+
if (!removed.length) return []
|
|
109
|
+
|
|
110
|
+
// An emptied block is dropped entirely rather than left as `---\n---`, which
|
|
111
|
+
// some markdown renderers show as a horizontal rule.
|
|
112
|
+
const next = kept.length ? `---\n${kept.join('\n')}\n---\n${body}` : body
|
|
113
|
+
fs.writeFileSync(file, next, 'utf-8')
|
|
114
|
+
return removed
|
|
115
|
+
}
|
|
116
|
+
|
|
80
117
|
// Phase files in a snapshot dir (01-*.md …), execution order.
|
|
81
118
|
function listPhaseFiles(snapshotDir) {
|
|
82
119
|
try {
|
|
@@ -142,6 +179,7 @@ function stampIssueId(snapshotDir, text, id) {
|
|
|
142
179
|
|
|
143
180
|
module.exports = {
|
|
144
181
|
writeFrontmatter,
|
|
182
|
+
deleteFrontmatter,
|
|
145
183
|
splitFrontmatter,
|
|
146
184
|
serialize,
|
|
147
185
|
listPhaseFiles,
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Reuben Greaves
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|