@skitterbyte/skitterspec-linear 15.0.0 → 17.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 +218 -0
- package/README.md +34 -1
- package/assets/claude-md-section.md +29 -18
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/core/SETUP.md +10 -6
- package/assets/core/env.config.json.example +4 -2
- package/assets/core/env.config.md +100 -23
- package/assets/core/linear.config.json.example +2 -1
- package/assets/core/linear.config.md +49 -22
- package/assets/review/page.html +1044 -101
- package/assets/rules/spec-planning.md +35 -3
- package/assets/rules/spec-reports.md +131 -20
- package/assets/skills/spec/SKILL.md +161 -4
- package/assets/skills/spec-bug/SKILL.md +68 -30
- package/assets/skills/spec-cancel/SKILL.md +2 -2
- package/assets/skills/spec-claim/SKILL.md +12 -4
- package/assets/skills/spec-complete/SKILL.md +2 -2
- package/assets/skills/spec-diff/SKILL.md +131 -36
- package/assets/skills/spec-hotfix/SKILL.md +61 -25
- package/assets/skills/spec-linear-setup/SKILL.md +19 -11
- package/assets/skills/spec-next/SKILL.md +121 -58
- package/assets/skills/spec-push/SKILL.md +45 -0
- package/assets/skills/spec-review/SKILL.md +89 -2
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +26 -3
- package/assets/skills/spec-status/SKILL.md +20 -6
- package/assets/skills/spec-sync/SKILL.md +1 -0
- package/package.json +2 -2
- package/src/cli.js +913 -116
- package/src/env/classify.js +87 -2
- package/src/env/config.js +214 -17
- package/src/env/live.js +94 -0
- package/src/env/resolve.js +36 -2
- package/src/env/review.js +542 -21
- package/src/env/serve.js +298 -19
- package/src/env/supervise.js +8 -1
- package/src/init.js +60 -9
- package/src/vendor/linear/api.js +111 -2
- package/src/vendor/linear/cli-sync.js +661 -11
- package/src/vendor/linear/config.js +41 -13
- package/src/vendor/linear/doctor.js +6 -5
- package/src/vendor/sync-core/index.js +11 -3
- package/src/vendor/sync-core/src/compare.js +65 -0
- package/src/vendor/sync-core/src/normalize.js +26 -0
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Mirrors the shape/idiom of `src/env/config.js` (frozen defaults, merge known
|
|
13
13
|
* keys only, forward-compatible on unknown keys). Zero-dependency. The one place
|
|
14
|
-
* it is stricter: a `sync.fieldOwnership` value outside `both|pull|push` is
|
|
15
|
-
* hard error — the engine's whole safety model rests on those enums.
|
|
14
|
+
* it is stricter: a `sync.fieldOwnership` value outside `both|pull|push|none` is
|
|
15
|
+
* a hard error — the engine's whole safety model rests on those enums.
|
|
16
16
|
*
|
|
17
17
|
* Shape (see assets/core/linear.config.md for field docs):
|
|
18
18
|
* {
|
|
19
19
|
* linear: { teamKey, teamId, projectId },
|
|
20
|
-
* intake: { label, bugLabels, hotfixLabels },
|
|
20
|
+
* intake: { label, bugLabels, hotfixLabels, preserveOriginal },
|
|
21
21
|
* mapping: { specFolder, phases, tasks },
|
|
22
22
|
* states: { backlog, "in-progress", complete, cancelled },
|
|
23
23
|
* release: { stages: [{ key, state }] },
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* branch: { pattern },
|
|
26
26
|
* sync: {
|
|
27
27
|
* baseDir, backupDir,
|
|
28
|
-
* fieldOwnership: { <field>: "both" | "pull" | "push" },
|
|
28
|
+
* fieldOwnership: { <field>: "both" | "pull" | "push" | "none" },
|
|
29
29
|
* localOnlySections: string[]
|
|
30
30
|
* }
|
|
31
31
|
* }
|
|
@@ -36,7 +36,14 @@ const { join } = require('node:path')
|
|
|
36
36
|
|
|
37
37
|
const CONFIG_FILE = join('specs', '.core', 'linear.config.json')
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// `none` is how a repo DECLINES a field the defaults own. It has to be a
|
|
40
|
+
// value rather than an omitted key, because an omitted key cannot override a
|
|
41
|
+
// default that is present — and `mergeFieldOwnership` merges per key onto the
|
|
42
|
+
// defaults, so there is no way to subtract one. Every reader asks `ownsField`
|
|
43
|
+
// rather than testing for the key, which is what keeps the two indistinguishable
|
|
44
|
+
// downstream: a declined field and an unlisted one both leave the projection
|
|
45
|
+
// without that key at all.
|
|
46
|
+
const OWNERSHIP = Object.freeze(['both', 'pull', 'push', 'none'])
|
|
40
47
|
|
|
41
48
|
// How a phase's task list is projected into its sub-issue description.
|
|
42
49
|
// checklist — mirror the tasks as a read-only markdown checklist (default)
|
|
@@ -125,7 +132,19 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
125
132
|
// and no routing. `hotfixLabels` wins over `bugLabels` on an issue carrying
|
|
126
133
|
// both: production is the more specific destination, and the cost of getting it
|
|
127
134
|
// wrong is asymmetric — a fix that lands only on main never reaches prod.
|
|
128
|
-
|
|
135
|
+
// `preserveOriginal` is the other half of intake, and it is about what
|
|
136
|
+
// adoption COSTS rather than where it routes. Adopting an existing issue
|
|
137
|
+
// replaces the reporter's description with the generated spec, so
|
|
138
|
+
// `spec-sync preserve` first posts that description as a comment — the one
|
|
139
|
+
// surface on the issue that sync never touches.
|
|
140
|
+
//
|
|
141
|
+
// ON BY DEFAULT, and `false` is how a project declines. Opt-in was rejected
|
|
142
|
+
// for the reason `sync.fieldOwnership.assignee` records below: a repo that
|
|
143
|
+
// never added the line would look identical to one that did not want the
|
|
144
|
+
// feature, and the only signal would be noticing a lost report weeks later.
|
|
145
|
+
// Being wrong in this direction costs a comment nobody needed; the other
|
|
146
|
+
// direction costs someone their words.
|
|
147
|
+
intake: Object.freeze({ label: '', bugLabels: Object.freeze([]), hotfixLabels: Object.freeze([]), preserveOriginal: true }),
|
|
129
148
|
// A spec is a Linear ISSUE; each phase is a SUB-ISSUE of it; tasks are not
|
|
130
149
|
// synced (they live only in the repo phase files).
|
|
131
150
|
// A spec is an ISSUE; each phase a SUB-ISSUE of it. `tasks` selects how the
|
|
@@ -165,17 +184,22 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
165
184
|
// the set, so the PM's triage is never touched. The `push` marker is retained
|
|
166
185
|
// for shape; any key you add joins the pushed projection.
|
|
167
186
|
//
|
|
168
|
-
// `assignee`
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
187
|
+
// `assignee` ships ON, and the opt-out is `"assignee": "none"`. It was
|
|
188
|
+
// opt-in until v16, and the cost of that was silent in exactly the wrong
|
|
189
|
+
// direction: a repo that never added the line looked identical to one that
|
|
190
|
+
// did not want the feature, so the only signal was noticing an unassigned
|
|
191
|
+
// issue weeks later.
|
|
192
|
+
//
|
|
193
|
+
// WHAT MAKES ON-BY-DEFAULT SAFE, and it is not this line: a spec that
|
|
194
|
+
// records no assignee sends none (so a PM's triage survives), and a snapshot
|
|
195
|
+
// with no assignee key means "never pushed" rather than "was null" (so no
|
|
196
|
+
// upgrade emits a clear). Flipping the default widens who gets assigned; it
|
|
197
|
+
// cannot widen who gets UNassigned. See `compare.js` `issueChanges`.
|
|
175
198
|
fieldOwnership: Object.freeze({
|
|
176
199
|
description: 'push',
|
|
177
200
|
subIssues: 'push',
|
|
178
201
|
workflowState: 'push',
|
|
202
|
+
assignee: 'push',
|
|
179
203
|
}),
|
|
180
204
|
localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
|
|
181
205
|
// Fields that are keyed collections (arrays of objects with a stable id),
|
|
@@ -203,6 +227,7 @@ function defaults() {
|
|
|
203
227
|
label: DEFAULT_CONFIG.intake.label,
|
|
204
228
|
bugLabels: [...DEFAULT_CONFIG.intake.bugLabels],
|
|
205
229
|
hotfixLabels: [...DEFAULT_CONFIG.intake.hotfixLabels],
|
|
230
|
+
preserveOriginal: DEFAULT_CONFIG.intake.preserveOriginal,
|
|
206
231
|
},
|
|
207
232
|
mapping: { ...DEFAULT_CONFIG.mapping },
|
|
208
233
|
states: { ...DEFAULT_CONFIG.states },
|
|
@@ -412,6 +437,9 @@ function mergeConfig(base, parsed) {
|
|
|
412
437
|
if (Array.isArray(parsed.intake.hotfixLabels)) {
|
|
413
438
|
base.intake.hotfixLabels = stringList(parsed.intake.hotfixLabels)
|
|
414
439
|
}
|
|
440
|
+
// `'boolean'`, so only a real `false` turns this off. A string, a null or a
|
|
441
|
+
// typo leaves the default standing rather than reading as a decline.
|
|
442
|
+
assign(base.intake, parsed.intake, 'preserveOriginal', 'boolean')
|
|
415
443
|
}
|
|
416
444
|
|
|
417
445
|
if (isObject(parsed.mapping)) {
|
|
@@ -261,10 +261,11 @@ function keyCheck(s = {}, tracker = {}) {
|
|
|
261
261
|
*
|
|
262
262
|
* WHAT WOULD MAKE THIS CHECK ACCUSE THE INNOCENT, and why it does not:
|
|
263
263
|
*
|
|
264
|
-
* - **A project that
|
|
265
|
-
* `
|
|
266
|
-
*
|
|
267
|
-
*
|
|
264
|
+
* - **A project that does not own the field.** `sync.fieldOwnership.assignee`
|
|
265
|
+
* decides, and `"none"` declines it as surely as omitting it does; without
|
|
266
|
+
* ownership nothing reads an identity, so reporting one as missing would
|
|
267
|
+
* tell a healthy project to fix something it deliberately does not use.
|
|
268
|
+
* That is `skipped`.
|
|
268
269
|
* - **An identity that is merely underivable.** No key, offline, a shared
|
|
269
270
|
* credential — all ordinary, all `missing` rather than `broken`, because the
|
|
270
271
|
* lifecycle skills skip assignment and carry on. Nothing here fails a run.
|
|
@@ -276,7 +277,7 @@ function keyCheck(s = {}, tracker = {}) {
|
|
|
276
277
|
function identityCheck(s = {}, tracker = {}) {
|
|
277
278
|
if (!tracker.present) return row('identity', 'identity', 'skipped', 'no tracker configured')
|
|
278
279
|
if (!s.owned) {
|
|
279
|
-
return row('identity', 'identity', 'skipped', 'assignment not
|
|
280
|
+
return row('identity', 'identity', 'skipped', 'assignment not owned here (sync.fieldOwnership.assignee)')
|
|
280
281
|
}
|
|
281
282
|
if (!s.ok) {
|
|
282
283
|
return row(
|
|
@@ -13,18 +13,19 @@
|
|
|
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, phaseModeFor, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
|
|
17
|
-
const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
|
|
16
|
+
const { ownsField, normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, phaseModeFor, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
|
|
17
|
+
const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify, remoteDescriptionEdited } = require('./src/compare.js')
|
|
18
18
|
const { readBase, writeBase } = require('./src/base.js')
|
|
19
19
|
const { push, recordPush, projectionOf } = require('./src/push.js')
|
|
20
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
|
-
const { compareStored } = require('./src/verify.js')
|
|
23
|
+
const { compareStored, stream } = require('./src/verify.js')
|
|
24
24
|
const { flattenNestedTables } = require('./src/tables.js')
|
|
25
25
|
const { planRetarget, applyRetarget, deriveRecordedKey, isEmptyRetarget, dirtyPaths } = require('./src/retarget.js')
|
|
26
26
|
|
|
27
27
|
module.exports = {
|
|
28
|
+
ownsField,
|
|
28
29
|
normalizeLocal,
|
|
29
30
|
// Exported for `spec-sync list`: an `inline`-mode spec has no phase sub-issues
|
|
30
31
|
// to read, so the listing must resolve the mode rather than assume `subissue`.
|
|
@@ -58,6 +59,13 @@ module.exports = {
|
|
|
58
59
|
sanitizeSpecMarkdown,
|
|
59
60
|
detectLegacyMirror,
|
|
60
61
|
compareStored,
|
|
62
|
+
// The tolerant reduction behind both `compareStored` and the snapshot's
|
|
63
|
+
// `descriptionStream` hash — exported so a provider can reduce a read-back the
|
|
64
|
+
// same way rather than writing a second one that disagrees.
|
|
65
|
+
stream,
|
|
66
|
+
// Did someone else edit the tracker's description since the last push?
|
|
67
|
+
// Three-valued; `null` is cannot-tell and must never be reported as drift.
|
|
68
|
+
remoteDescriptionEdited,
|
|
61
69
|
flattenNestedTables,
|
|
62
70
|
planRetarget,
|
|
63
71
|
applyRetarget,
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
const { createHash } = require('node:crypto')
|
|
22
|
+
// The tolerant reduction `verify.js` already uses to tell Linear's own
|
|
23
|
+
// reserialisation apart from a real edit. Imported rather than reimplemented:
|
|
24
|
+
// two reductions that disagree would be two different answers to one question.
|
|
25
|
+
const { stream } = require('./verify.js')
|
|
22
26
|
|
|
23
27
|
// Deterministic JSON: object keys sorted recursively; array order preserved.
|
|
24
28
|
// undefined normalises to null.
|
|
@@ -69,8 +73,68 @@ function specIssueFieldHashes(p) {
|
|
|
69
73
|
// "the feature is inert" has to include the files it writes, or opting in later
|
|
70
74
|
// would find a history of hashes it never agreed to.
|
|
71
75
|
if (p.assignee !== undefined) hashes.assignee = hashField(p.assignee ?? null)
|
|
76
|
+
|
|
77
|
+
// A SECOND HASH OF THE SAME TEXT, answering a different question.
|
|
78
|
+
//
|
|
79
|
+
// `description` above decides what to PUSH: it is exact, because any
|
|
80
|
+
// difference between the repo and what the repo last sent is a difference the
|
|
81
|
+
// repo should send again. This one decides whether SOMEBODY ELSE has been
|
|
82
|
+
// here, and for that exactness is precisely wrong — Linear reserialises
|
|
83
|
+
// markdown on save (bullets rewritten, ordered lists renumbered, table
|
|
84
|
+
// separators collapsed), so comparing the exact hash against a read-back would
|
|
85
|
+
// report a human edit on every intact mirror in the workspace.
|
|
86
|
+
//
|
|
87
|
+
// So it hashes the `stream` reduction instead: word characters only, which is
|
|
88
|
+
// what `compareStored` already compares and what makes every benign transform
|
|
89
|
+
// invisible. The cost is the mirror image — an edit consisting ONLY of
|
|
90
|
+
// punctuation or formatting is invisible here too — and that is the right way
|
|
91
|
+
// round: the unknown case goes to the branch that says nothing rather than the
|
|
92
|
+
// one that accuses (`.claude/rules/negative-checks.md` rule 4).
|
|
93
|
+
//
|
|
94
|
+
// It is recorded and never planned on. `issueChanges` reads `description`,
|
|
95
|
+
// `state` and `assignee` by name and never iterates this object, so adding a
|
|
96
|
+
// key here cannot make a push pending — there is a test for exactly that.
|
|
97
|
+
hashes.descriptionStream = hashField(stream(p.description ?? ''))
|
|
72
98
|
return hashes
|
|
73
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Has the tracker's copy of the description been edited by someone other than
|
|
103
|
+
* this repo, since the last push?
|
|
104
|
+
*
|
|
105
|
+
* THREE ANSWERS, and the third is the point: `true`, `false`, and `null` for
|
|
106
|
+
* cannot-tell. Only `true` may be reported to a user, because only `true` is
|
|
107
|
+
* evidence. The three cannot-tells, each an absence that proves nothing:
|
|
108
|
+
*
|
|
109
|
+
* - no `issueFields.descriptionStream` in the snapshot. EVERY snapshot written
|
|
110
|
+
* before this function existed lacks it, so reading that absence as evidence
|
|
111
|
+
* would accuse every spec in every repo on the first run after an upgrade.
|
|
112
|
+
* This is the blind spot that matters most here.
|
|
113
|
+
* - no `description` key on the remote object. The caller did not ask the
|
|
114
|
+
* tracker for it, or could not.
|
|
115
|
+
* - a `description` that is not a string. The same blind spot `compareStored`
|
|
116
|
+
* documents: a value the caller never fetched is indistinguishable from one
|
|
117
|
+
* the tracker really holds empty.
|
|
118
|
+
*
|
|
119
|
+
* Pure: no I/O, no clock, no randomness.
|
|
120
|
+
*
|
|
121
|
+
* @param {object|null} snapshot the committed last-pushed snapshot
|
|
122
|
+
* @param {object|null} remote what the tracker currently holds for the issue
|
|
123
|
+
* @returns {boolean|null}
|
|
124
|
+
*/
|
|
125
|
+
function remoteDescriptionEdited(snapshot, remote) {
|
|
126
|
+
const fields = snapshot && snapshot.issueFields
|
|
127
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return null
|
|
128
|
+
const recorded = fields.descriptionStream
|
|
129
|
+
if (typeof recorded !== 'string' || !recorded) return null
|
|
130
|
+
|
|
131
|
+
if (!remote || typeof remote !== 'object') return null
|
|
132
|
+
if (!('description' in remote)) return null
|
|
133
|
+
const text = remote.description
|
|
134
|
+
if (typeof text !== 'string') return null
|
|
135
|
+
|
|
136
|
+
return hashField(stream(text)) !== recorded
|
|
137
|
+
}
|
|
74
138
|
// A phase SUB-ISSUE: its name, goal and state (all repo-owned).
|
|
75
139
|
const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
|
|
76
140
|
|
|
@@ -228,6 +292,7 @@ function isEmptyPlan(plan) {
|
|
|
228
292
|
|
|
229
293
|
module.exports = {
|
|
230
294
|
planChanges,
|
|
295
|
+
remoteDescriptionEdited,
|
|
231
296
|
issueChanges,
|
|
232
297
|
specIssueFieldHashes,
|
|
233
298
|
snapshotOf,
|
|
@@ -488,11 +488,36 @@ function lintPhases(snapshotDir, config) {
|
|
|
488
488
|
|
|
489
489
|
// --- ownership-driven field set ---------------------------------------------
|
|
490
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Does this repo own `field` — i.e. does the projection carry it at all?
|
|
493
|
+
*
|
|
494
|
+
* THE VALUE DECIDES, NEVER THE KEY'S PRESENCE. A field configured `none` is
|
|
495
|
+
* declared not-ours, and it must be indistinguishable from one the config never
|
|
496
|
+
* listed: both produce a projection with no such key, which is the single
|
|
497
|
+
* vocabulary every downstream reader already speaks for "not in play"
|
|
498
|
+
* (`compare.js` hashes it only when defined; the status report prints an
|
|
499
|
+
* assignee line only when defined).
|
|
500
|
+
*
|
|
501
|
+
* Asked in one place so the two cannot drift. Testing `field in fieldOwnership`
|
|
502
|
+
* at a call site reads correct and is not: once a field is owned by DEFAULT the
|
|
503
|
+
* key is always present, and the test silently becomes "always true".
|
|
504
|
+
*/
|
|
505
|
+
function ownsField(config, field) {
|
|
506
|
+
const owned = config && config.sync && config.sync.fieldOwnership
|
|
507
|
+
return !!owned && field in owned && owned[field] !== 'none'
|
|
508
|
+
}
|
|
509
|
+
|
|
491
510
|
// Reduce an `extracted` map to exactly the configured field keys, defaulting a
|
|
492
511
|
// missing field to `null` so local and remote always share an identical set.
|
|
512
|
+
//
|
|
513
|
+
// A declined field is OMITTED, not set null. The two are not interchangeable
|
|
514
|
+
// here: `null` is a value this projection asserts — it is how a finished spec
|
|
515
|
+
// hands its issue back — so writing it for a field the repo does not own would
|
|
516
|
+
// clear in Linear exactly what declining ownership promised not to touch.
|
|
493
517
|
function toFieldSet(extracted, config) {
|
|
494
518
|
const out = {}
|
|
495
519
|
for (const field of Object.keys(config.sync.fieldOwnership)) {
|
|
520
|
+
if (!ownsField(config, field)) continue
|
|
496
521
|
out[field] = field in extracted ? extracted[field] : null
|
|
497
522
|
}
|
|
498
523
|
return out
|
|
@@ -1065,6 +1090,7 @@ function stateSuggestions(config, workspaceStates) {
|
|
|
1065
1090
|
}
|
|
1066
1091
|
|
|
1067
1092
|
module.exports = {
|
|
1093
|
+
ownsField,
|
|
1068
1094
|
stateSuggestions,
|
|
1069
1095
|
configuredStateNames,
|
|
1070
1096
|
normalizeLocal,
|