@skitterbyte/skitterspec-linear 12.0.0 → 14.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.
Files changed (58) hide show
  1. package/MIGRATION.md +296 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +38 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/SETUP.md +21 -3
  7. package/assets/core/env.config.json.example +7 -3
  8. package/assets/core/env.config.md +90 -30
  9. package/assets/core/linear.config.md +58 -0
  10. package/assets/hooks/review-gate.js +141 -0
  11. package/assets/review/page.html +1787 -0
  12. package/assets/rules/spec-planning.md +250 -10
  13. package/assets/rules/spec-reports.md +321 -0
  14. package/assets/skills/spec/SKILL.md +33 -5
  15. package/assets/skills/spec-bug/SKILL.md +172 -9
  16. package/assets/skills/spec-cancel/SKILL.md +98 -21
  17. package/assets/skills/spec-claim/SKILL.md +114 -0
  18. package/assets/skills/spec-complete/SKILL.md +94 -25
  19. package/assets/skills/spec-diff/SKILL.md +678 -0
  20. package/assets/skills/spec-hotfix/SKILL.md +172 -11
  21. package/assets/skills/spec-init/SKILL.md +56 -7
  22. package/assets/skills/spec-linear-setup/SKILL.md +55 -1
  23. package/assets/skills/spec-list/SKILL.md +218 -0
  24. package/assets/skills/spec-next/SKILL.md +419 -7
  25. package/assets/skills/spec-push/SKILL.md +32 -8
  26. package/assets/skills/spec-review/SKILL.md +40 -5
  27. package/assets/skills/spec-reviewed/SKILL.md +258 -0
  28. package/assets/skills/spec-start/SKILL.md +386 -106
  29. package/assets/skills/spec-status/SKILL.md +24 -2
  30. package/assets/skills/spec-sync/SKILL.md +40 -4
  31. package/assets/skills/spec-to-main/SKILL.md +28 -6
  32. package/package.json +11 -7
  33. package/src/cli.js +1808 -89
  34. package/src/env/building.js +143 -0
  35. package/src/env/commitcmd.js +108 -0
  36. package/src/env/config.js +58 -9
  37. package/src/env/hooks.js +117 -0
  38. package/src/env/provision.js +54 -15
  39. package/src/env/proxy.js +34 -1
  40. package/src/env/render.js +3 -12
  41. package/src/env/resolve.js +295 -9
  42. package/src/env/review.js +1536 -0
  43. package/src/env/serve.js +573 -0
  44. package/src/env/teardown.js +13 -6
  45. package/src/init.js +150 -1
  46. package/src/vendor/linear/api.js +104 -1
  47. package/src/vendor/linear/cli-sync.js +854 -17
  48. package/src/vendor/linear/config.js +8 -0
  49. package/src/vendor/linear/credentials.js +94 -0
  50. package/src/vendor/linear/doctor.js +35 -0
  51. package/src/vendor/linear/identity.js +105 -0
  52. package/src/vendor/linear/mcp.js +26 -0
  53. package/src/vendor/sync-core/index.js +6 -2
  54. package/src/vendor/sync-core/src/compare.js +49 -3
  55. package/src/vendor/sync-core/src/normalize.js +30 -0
  56. package/src/vendor/sync-core/src/push.js +11 -1
  57. package/src/vendor/sync-core/src/write.js +38 -0
  58. 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 }
@@ -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
- return {
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 = () => ({ description: p.description ?? null, state: p.status ?? null })
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
- return snap.issue === specIssueHash(p) ? null : both()
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
- return {
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.