@skitterbyte/skitterspec-linear 10.1.0 → 10.2.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 +68 -23
- package/assets/core/SETUP.md +73 -13
- package/assets/core/linear.config.json.example +8 -1
- package/assets/core/linear.config.md +177 -10
- package/assets/rules/spec-planning.md +17 -11
- package/assets/skills/spec/SKILL.md +106 -66
- package/assets/skills/spec-bug/SKILL.md +99 -17
- package/assets/skills/spec-cancel/SKILL.md +34 -0
- package/assets/skills/spec-complete/SKILL.md +70 -15
- package/assets/skills/spec-hotfix/SKILL.md +157 -4
- package/assets/skills/spec-linear-setup/SKILL.md +172 -0
- package/assets/skills/spec-push/SKILL.md +108 -32
- package/assets/skills/spec-review/SKILL.md +34 -0
- package/assets/skills/spec-status/SKILL.md +9 -0
- package/bin/skitterspec-linear.js +19 -0
- package/package.json +1 -1
- package/src/cli.js +30 -19
- package/src/env/resolve.js +7 -2
- package/src/env/teardown.js +23 -9
- package/src/init.js +11 -1
- package/src/vendor/linear/api.js +246 -0
- package/src/vendor/linear/cli-sync.js +701 -3
- package/src/vendor/linear/config.js +116 -14
- package/src/vendor/sync-core/src/normalize.js +232 -85
- package/src/vendor/sync-core/src/push.js +10 -1
- package/src/vendor/sync-core/src/task-block.js +18 -7
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Shape (see assets/core/linear.config.md for field docs):
|
|
18
18
|
* {
|
|
19
19
|
* linear: { teamKey, teamId, projectId },
|
|
20
|
-
* intake: { label, bugLabels },
|
|
20
|
+
* intake: { label, bugLabels, hotfixLabels },
|
|
21
21
|
* mapping: { specFolder, phases, tasks },
|
|
22
22
|
* states: { backlog, "in-progress", complete, cancelled },
|
|
23
23
|
* snapshot: { overviewFile },
|
|
@@ -53,7 +53,30 @@ const TASK_MAPPINGS = Object.freeze(['checklist', 'none'])
|
|
|
53
53
|
// already carries an id keeps projecting either way — one-way sync
|
|
54
54
|
// has no delete, so withholding a LINKED sub-issue would freeze it
|
|
55
55
|
// in the tracker rather than remove it.
|
|
56
|
-
|
|
56
|
+
// inline — never: each phase becomes a SECTION of the spec issue's own
|
|
57
|
+
// description instead, and the `## Phases` index stays as its table
|
|
58
|
+
// of contents. For work nobody will pick up phase by phase — 250
|
|
59
|
+
// finished specs are 250 issues worth reading and 669 sub-issues
|
|
60
|
+
// worth nobody's attention. Keeps an already-linked phase's
|
|
61
|
+
// sub-issue for the same reason `deferred` does.
|
|
62
|
+
//
|
|
63
|
+
// `mapping.phases` takes one of these as a scalar (one mode for the whole repo)
|
|
64
|
+
// OR a map keyed by lifecycle bucket — `{ "backlog": "subissue", "complete":
|
|
65
|
+
// "deferred" }` — because a repo can want assignable sub-issues for work in
|
|
66
|
+
// flight and something else entirely for work that finished long ago. A bucket
|
|
67
|
+
// the map omits gets DEFAULT_PHASE_MODE, so a partial map adds an exception
|
|
68
|
+
// rather than silently suppressing phases everywhere it is silent.
|
|
69
|
+
const PHASE_MAPPINGS = Object.freeze(['subissue', 'deferred', 'inline'])
|
|
70
|
+
const DEFAULT_PHASE_MODE = 'subissue'
|
|
71
|
+
|
|
72
|
+
// How `spec-sync apply` reaches Linear. `api` talks to the GraphQL API directly;
|
|
73
|
+
// `mcp` prints the plan for the skill to apply over MCP, as it always has.
|
|
74
|
+
const TRANSPORTS = Object.freeze(['api', 'mcp'])
|
|
75
|
+
|
|
76
|
+
// The environment variable a Linear personal API key is read from, unless
|
|
77
|
+
// `auth.keyEnv` names another. The config names the VARIABLE, never the key —
|
|
78
|
+
// nothing secret is ever written to the repo.
|
|
79
|
+
const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
|
|
57
80
|
|
|
58
81
|
const DEFAULT_CONFIG = Object.freeze({
|
|
59
82
|
// `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
|
|
@@ -65,14 +88,18 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
65
88
|
linear: Object.freeze({ teamKey: '', teamId: '', projectId: '' }),
|
|
66
89
|
// Issue intake (`/spec <ISSUE-REF>`, `/spec --from-issue`). `label` is the
|
|
67
90
|
// inbox filter — issues carrying it are what the web app files; `bugLabels`
|
|
68
|
-
// route an issue to `/spec-bug` instead of `/spec
|
|
69
|
-
//
|
|
70
|
-
|
|
91
|
+
// route an issue to `/spec-bug` instead of `/spec`, and `hotfixLabels` route it
|
|
92
|
+
// to `/spec-hotfix` — a bug that has to be patched on the released version, not
|
|
93
|
+
// fixed on main. All empty = no inbox to browse (a bare issue ref still works)
|
|
94
|
+
// and no routing. `hotfixLabels` wins over `bugLabels` on an issue carrying
|
|
95
|
+
// both: production is the more specific destination, and the cost of getting it
|
|
96
|
+
// wrong is asymmetric — a fix that lands only on main never reaches prod.
|
|
97
|
+
intake: Object.freeze({ label: '', bugLabels: Object.freeze([]), hotfixLabels: Object.freeze([]) }),
|
|
71
98
|
// A spec is a Linear ISSUE; each phase is a SUB-ISSUE of it; tasks are not
|
|
72
99
|
// synced (they live only in the repo phase files).
|
|
73
100
|
// A spec is an ISSUE; each phase a SUB-ISSUE of it. `tasks` selects how the
|
|
74
101
|
// phase's checkboxes reach that sub-issue's description — see TASK_MAPPINGS.
|
|
75
|
-
mapping: Object.freeze({ specFolder: 'issue', phases:
|
|
102
|
+
mapping: Object.freeze({ specFolder: 'issue', phases: DEFAULT_PHASE_MODE, tasks: 'checklist' }),
|
|
76
103
|
// Linear ISSUE workflow-state names — the spec issue's state (from the folder
|
|
77
104
|
// bucket) and each sub-issue's state (from the phase emoji) both map through
|
|
78
105
|
// this one table. They must match the workspace's issue states exactly;
|
|
@@ -85,6 +112,12 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
85
112
|
}),
|
|
86
113
|
snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
|
|
87
114
|
branch: Object.freeze({ pattern: '{type}/{slug}' }),
|
|
115
|
+
// `keyEnv` names the env var holding the personal API key. It is a NAME, not a
|
|
116
|
+
// key: putting the secret itself here would commit it.
|
|
117
|
+
auth: Object.freeze({ keyEnv: DEFAULT_KEY_ENV }),
|
|
118
|
+
// `transport` is the default for `spec-sync apply --via`. Empty means "decide
|
|
119
|
+
// at run time": use the API when a key is present, MCP when it isn't.
|
|
120
|
+
apply: Object.freeze({ transport: '' }),
|
|
88
121
|
sync: Object.freeze({
|
|
89
122
|
baseDir: 'specs/.core/linear-base',
|
|
90
123
|
backupDir: 'specs/.core/linear-backups',
|
|
@@ -108,6 +141,11 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
108
141
|
}),
|
|
109
142
|
})
|
|
110
143
|
|
|
144
|
+
// The lifecycle buckets a per-bucket `mapping.phases` map may key on. Derived
|
|
145
|
+
// from `states` rather than restated: both maps key on the spec's folder bucket,
|
|
146
|
+
// so they cannot drift apart.
|
|
147
|
+
const LIFECYCLE_BUCKETS = Object.freeze(Object.keys(DEFAULT_CONFIG.states))
|
|
148
|
+
|
|
111
149
|
function isObject(value) {
|
|
112
150
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
113
151
|
}
|
|
@@ -116,11 +154,17 @@ function isObject(value) {
|
|
|
116
154
|
function defaults() {
|
|
117
155
|
return {
|
|
118
156
|
linear: { ...DEFAULT_CONFIG.linear },
|
|
119
|
-
intake: {
|
|
157
|
+
intake: {
|
|
158
|
+
label: DEFAULT_CONFIG.intake.label,
|
|
159
|
+
bugLabels: [...DEFAULT_CONFIG.intake.bugLabels],
|
|
160
|
+
hotfixLabels: [...DEFAULT_CONFIG.intake.hotfixLabels],
|
|
161
|
+
},
|
|
120
162
|
mapping: { ...DEFAULT_CONFIG.mapping },
|
|
121
163
|
states: { ...DEFAULT_CONFIG.states },
|
|
122
164
|
snapshot: { ...DEFAULT_CONFIG.snapshot },
|
|
123
165
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
166
|
+
auth: { ...DEFAULT_CONFIG.auth },
|
|
167
|
+
apply: { ...DEFAULT_CONFIG.apply },
|
|
124
168
|
sync: {
|
|
125
169
|
baseDir: DEFAULT_CONFIG.sync.baseDir,
|
|
126
170
|
backupDir: DEFAULT_CONFIG.sync.backupDir,
|
|
@@ -164,6 +208,47 @@ function mergeFieldOwnership(base, parsed) {
|
|
|
164
208
|
}
|
|
165
209
|
}
|
|
166
210
|
|
|
211
|
+
// Merge (and validate) `mapping.phases` in either of its two forms: a scalar
|
|
212
|
+
// mode for the whole repo, or a map of lifecycle bucket → mode. Loud on a bad
|
|
213
|
+
// key or value, like fieldOwnership and mapping.tasks — a misspelt bucket would
|
|
214
|
+
// otherwise read as a deliberate default and go on minting the sub-issues the
|
|
215
|
+
// config was written to stop.
|
|
216
|
+
function mergePhaseMapping(base, parsed) {
|
|
217
|
+
const value = parsed.phases
|
|
218
|
+
if (typeof value === 'string') {
|
|
219
|
+
if (value.trim()) base.phases = value.trim()
|
|
220
|
+
} else if (isObject(value)) {
|
|
221
|
+
const byBucket = {}
|
|
222
|
+
for (const [bucket, mode] of Object.entries(value)) {
|
|
223
|
+
if (!LIFECYCLE_BUCKETS.includes(bucket)) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Invalid ${CONFIG_FILE}: mapping.phases.${bucket} is not a lifecycle bucket ` +
|
|
226
|
+
`(expected one of ${LIFECYCLE_BUCKETS.join('|')})`,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
if (!PHASE_MAPPINGS.includes(mode)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Invalid ${CONFIG_FILE}: mapping.phases.${bucket} = ${JSON.stringify(mode)} ` +
|
|
232
|
+
`(expected one of ${PHASE_MAPPINGS.join('|')})`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
byBucket[bucket] = mode
|
|
236
|
+
}
|
|
237
|
+
base.phases = byBucket
|
|
238
|
+
} else if (value !== undefined) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(value)} ` +
|
|
241
|
+
`(expected one of ${PHASE_MAPPINGS.join('|')}, or a map of lifecycle bucket to mode)`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
if (typeof base.phases === 'string' && !PHASE_MAPPINGS.includes(base.phases)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(base.phases)} ` +
|
|
247
|
+
`(expected one of ${PHASE_MAPPINGS.join('|')}, or a map of lifecycle bucket to mode)`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
167
252
|
// Merge (and validate) sync.keyedFields. Each value is the item's id property
|
|
168
253
|
// name (a non-empty string); a field listed here is compared per item.
|
|
169
254
|
function mergeKeyedFields(base, parsed) {
|
|
@@ -197,11 +282,13 @@ function mergeConfig(base, parsed) {
|
|
|
197
282
|
if (Array.isArray(parsed.intake.bugLabels)) {
|
|
198
283
|
base.intake.bugLabels = stringList(parsed.intake.bugLabels)
|
|
199
284
|
}
|
|
285
|
+
if (Array.isArray(parsed.intake.hotfixLabels)) {
|
|
286
|
+
base.intake.hotfixLabels = stringList(parsed.intake.hotfixLabels)
|
|
287
|
+
}
|
|
200
288
|
}
|
|
201
289
|
|
|
202
290
|
if (isObject(parsed.mapping)) {
|
|
203
291
|
assign(base.mapping, parsed.mapping, 'specFolder', 'string')
|
|
204
|
-
assign(base.mapping, parsed.mapping, 'phases', 'string')
|
|
205
292
|
assign(base.mapping, parsed.mapping, 'tasks', 'string')
|
|
206
293
|
// Loud on a typo, like fieldOwnership above. Quietly falling back would make
|
|
207
294
|
// a misspelt value look like a deliberate `none` — the same silent
|
|
@@ -212,12 +299,7 @@ function mergeConfig(base, parsed) {
|
|
|
212
299
|
`(expected one of ${TASK_MAPPINGS.join('|')})`,
|
|
213
300
|
)
|
|
214
301
|
}
|
|
215
|
-
|
|
216
|
-
throw new Error(
|
|
217
|
-
`Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(base.mapping.phases)} ` +
|
|
218
|
-
`(expected one of ${PHASE_MAPPINGS.join('|')})`,
|
|
219
|
-
)
|
|
220
|
-
}
|
|
302
|
+
mergePhaseMapping(base.mapping, parsed.mapping)
|
|
221
303
|
}
|
|
222
304
|
|
|
223
305
|
if (isObject(parsed.states)) {
|
|
@@ -234,6 +316,22 @@ function mergeConfig(base, parsed) {
|
|
|
234
316
|
assign(base.branch, parsed.branch, 'pattern', 'string')
|
|
235
317
|
}
|
|
236
318
|
|
|
319
|
+
if (isObject(parsed.auth)) {
|
|
320
|
+
assign(base.auth, parsed.auth, 'keyEnv', 'string')
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (isObject(parsed.apply)) {
|
|
324
|
+
assign(base.apply, parsed.apply, 'transport', 'string?')
|
|
325
|
+
// Loud on a typo, like the mapping enums: a misspelt transport must not
|
|
326
|
+
// quietly fall back to MCP and look like a deliberate choice.
|
|
327
|
+
if (base.apply.transport && !TRANSPORTS.includes(base.apply.transport)) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`Invalid ${CONFIG_FILE}: apply.transport = ${JSON.stringify(base.apply.transport)} ` +
|
|
330
|
+
`(expected one of ${TRANSPORTS.join('|')})`,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
237
335
|
if (isObject(parsed.sync)) {
|
|
238
336
|
assign(base.sync, parsed.sync, 'baseDir', 'string')
|
|
239
337
|
assign(base.sync, parsed.sync, 'backupDir', 'string')
|
|
@@ -279,9 +377,13 @@ function loadLinearConfig(dir = process.cwd()) {
|
|
|
279
377
|
module.exports = {
|
|
280
378
|
loadLinearConfig,
|
|
281
379
|
mergeConfig,
|
|
380
|
+
defaults,
|
|
282
381
|
DEFAULT_CONFIG,
|
|
283
382
|
CONFIG_FILE,
|
|
284
383
|
OWNERSHIP,
|
|
285
384
|
TASK_MAPPINGS,
|
|
286
385
|
PHASE_MAPPINGS,
|
|
386
|
+
LIFECYCLE_BUCKETS,
|
|
387
|
+
TRANSPORTS,
|
|
388
|
+
DEFAULT_KEY_ENV,
|
|
287
389
|
}
|
|
@@ -307,7 +307,10 @@ function phaseStateBucket(body) {
|
|
|
307
307
|
// its checkbox state, its text, and the inline Linear issue identifier if present
|
|
308
308
|
// (`… (SKI-123)`). Returns null for a non-task line.
|
|
309
309
|
function parseTaskLine(line) {
|
|
310
|
-
|
|
310
|
+
// Any single-character mark, matching the parser (`TASK_START_RE`). Only
|
|
311
|
+
// `x`/`X` is done — every other mark (`~`, `>`, `-` …) is a project's own
|
|
312
|
+
// vocabulary, carried through verbatim rather than coerced to unchecked.
|
|
313
|
+
const m = /^\[([^\]])\]\s*(.*)$/.exec(line)
|
|
311
314
|
if (!m) return null
|
|
312
315
|
const done = m[1].toLowerCase() === 'x'
|
|
313
316
|
let text = m[2].trim()
|
|
@@ -320,44 +323,31 @@ function parseTaskLine(line) {
|
|
|
320
323
|
return { id, text, done }
|
|
321
324
|
}
|
|
322
325
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
* criterion written under `## Acceptance` arrived in the mirror as an ordinary
|
|
328
|
-
* open task. Nothing was lost — it was just unreadable.
|
|
329
|
-
*
|
|
330
|
-
* Grouping is done by MAPPING blocks onto headings, not by re-parsing the body
|
|
331
|
-
* section by section: `findTaskBlocks` tracks open task subtrees across a
|
|
332
|
-
* continuous body, and slicing that body at every heading would change what a
|
|
333
|
-
* block claims at a section boundary. A heading inside a fence is not a heading
|
|
334
|
-
* (`fenceMask`), and the phase file's own `#` H1 is not a section.
|
|
335
|
-
*
|
|
336
|
-
* @returns {Array<{heading:string|null, tasks:string[]}>} in source order;
|
|
337
|
-
* `heading` is null for blocks that precede every heading, and a heading with
|
|
338
|
-
* no blocks under it never appears.
|
|
339
|
-
*/
|
|
340
|
-
function groupTasksByHeading(lines, blocks, renderTask) {
|
|
341
|
-
const inFence = fenceMask(lines)
|
|
342
|
-
const headings = []
|
|
343
|
-
for (let i = 0; i < lines.length; i++) {
|
|
344
|
-
if (inFence[i]) continue
|
|
345
|
-
const m = /^(#{2,6})\s+(.*\S)\s*$/.exec(lines[i])
|
|
346
|
-
if (m) headings.push({ line: i, heading: `${m[1]} ${m[2]}` })
|
|
347
|
-
}
|
|
326
|
+
// Tasks used to be grouped onto their source headings here, because the body was
|
|
327
|
+
// rebuilt from harvested task lines and the headings had to be put back. The
|
|
328
|
+
// projection now emits the phase file itself, so the headings never leave in the
|
|
329
|
+
// first place (bug-phase-content-dropped) and the grouping is gone with them.
|
|
348
330
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
331
|
+
// The phase's stated purpose, in either shape people actually write it: an
|
|
332
|
+
// inline `**Goal:** …` paragraph, or a `## Goal` section. Only the inline form
|
|
333
|
+
// was recognised — so the phase files written the other way (4 of the 96 in this
|
|
334
|
+
// repo, all of them recent) projected an empty goal.
|
|
335
|
+
function phaseGoal(body) {
|
|
336
|
+
const inline = /\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body)
|
|
337
|
+
if (inline) return inline[1]
|
|
338
|
+
return parseSections(body).sections.Goal || ''
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// One task block back to a single markdown line. `findTaskBlocks` also returns
|
|
342
|
+
// the plain sub-bullets written underneath a task; they carry no checkbox, so
|
|
343
|
+
// they render as the bullet their author used — emitting `- [ ]` here would
|
|
344
|
+
// invent a task that does not exist in the repo. Any inline `(KEY-123)` stamped
|
|
345
|
+
// on a legacy task line is stripped: those ids were per-task issues we no longer
|
|
346
|
+
// create, and they read as noise in the mirror.
|
|
347
|
+
function renderTaskBlockLine(b) {
|
|
348
|
+
const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
|
|
349
|
+
const text = parsed ? parsed.text : b.text
|
|
350
|
+
return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
|
|
361
351
|
}
|
|
362
352
|
|
|
363
353
|
// Read the phase files (01-*.md, 02-*.md …) in execution order. Each yields its
|
|
@@ -382,25 +372,13 @@ function readPhaseFiles(snapshotDir) {
|
|
|
382
372
|
// Collapsed, not just captured: the goal becomes a milestone description,
|
|
383
373
|
// and Linear may canonicalize a soft line break away on save. Collapsing
|
|
384
374
|
// both sides keeps a wrapped goal from diffing forever.
|
|
385
|
-
const goal = collapseHyphenAware((
|
|
375
|
+
const goal = collapseHyphenAware(phaseGoal(body))
|
|
386
376
|
// Rendered as markdown checklist lines, ready to drop into a sub-issue
|
|
387
377
|
// description: indentation kept so nesting survives, each bullet keeping
|
|
388
|
-
// the marker its author wrote
|
|
389
|
-
// line stripped — those ids were per-task issues we no longer create, and
|
|
390
|
-
// they read as noise in the mirror.
|
|
378
|
+
// the marker its author wrote.
|
|
391
379
|
const lines = body.split('\n')
|
|
392
|
-
const renderTask = (b) => {
|
|
393
|
-
// `findTaskBlocks` also returns the plain sub-bullets written underneath
|
|
394
|
-
// a task. They carry no checkbox, so they render as the bullet their
|
|
395
|
-
// author used — emitting `- [ ]` here would invent a task that does not
|
|
396
|
-
// exist in the repo.
|
|
397
|
-
const parsed = parseTaskLine(`[${b.checkbox ? b.mark : ' '}] ${b.text}`)
|
|
398
|
-
const text = parsed ? parsed.text : b.text
|
|
399
|
-
return b.checkbox ? `${b.indent}- [${b.mark}] ${text}` : `${b.indent}${b.marker} ${text}`
|
|
400
|
-
}
|
|
401
380
|
const blocks = findTaskBlocks(lines)
|
|
402
|
-
const tasks = blocks.map(
|
|
403
|
-
const taskGroups = groupTasksByHeading(lines, blocks, renderTask)
|
|
381
|
+
const tasks = blocks.map(renderTaskBlockLine)
|
|
404
382
|
return {
|
|
405
383
|
phase: file.replace(/\.md$/, ''),
|
|
406
384
|
file,
|
|
@@ -415,7 +393,11 @@ function readPhaseFiles(snapshotDir) {
|
|
|
415
393
|
emoji: headingEmoji(body),
|
|
416
394
|
statusLine: phaseStatusLine(body),
|
|
417
395
|
tasks,
|
|
418
|
-
|
|
396
|
+
// The raw body and the parsed blocks, so `subIssueBody` can project the
|
|
397
|
+
// file rather than rebuild it from fragments. Not part of the pushed
|
|
398
|
+
// field set — `toFieldSet` picks the projection's keys explicitly.
|
|
399
|
+
lines,
|
|
400
|
+
blocks,
|
|
419
401
|
}
|
|
420
402
|
})
|
|
421
403
|
}
|
|
@@ -539,19 +521,127 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
|
539
521
|
// push. Without it a sub-issue is a title and one sentence, which is too thin to
|
|
540
522
|
// act on; with it the phase is legible to someone working in the tracker without
|
|
541
523
|
// tasks becoming individually-synced objects again.
|
|
542
|
-
function subIssueBody(phase, tasksMode) {
|
|
543
|
-
if (tasksMode !== 'checklist'
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
524
|
+
function subIssueBody(phase, tasksMode, localOnlySections) {
|
|
525
|
+
if (tasksMode !== 'checklist') return flattenNestedTables(phase.goal)
|
|
526
|
+
return flattenNestedTables(projectPhaseBody(phase, localOnlySections))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Project a phase file into its sub-issue body: the file as written, with each
|
|
531
|
+
* task bullet replaced by its rendered single-line form.
|
|
532
|
+
*
|
|
533
|
+
* This used to REBUILD the body from two harvested fragments — the `**Goal:**`
|
|
534
|
+
* paragraph and the task lines `findTaskBlocks` claimed — and silently dropped
|
|
535
|
+
* everything neither covered: prose, whole `##` sections, and any table or
|
|
536
|
+
* fenced block nested under a task. `buildDescription` never had that problem
|
|
537
|
+
* because it projects the overview section by section, so the fix is to make the
|
|
538
|
+
* phase body work the same way — lossless by construction rather than by
|
|
539
|
+
* enumerating the shapes we remembered.
|
|
540
|
+
*
|
|
541
|
+
* Only two things are deliberately left out, and both are pushed as their own
|
|
542
|
+
* field, so keeping them would duplicate:
|
|
543
|
+
*
|
|
544
|
+
* - the `#` h1, projected as the sub-issue's `name`;
|
|
545
|
+
* - the `> **Status:**` line, projected as its `state`.
|
|
546
|
+
*
|
|
547
|
+
* Local-only sections are stripped exactly as they are from the description.
|
|
548
|
+
*/
|
|
549
|
+
function projectPhaseBody(phase, localOnlySections) {
|
|
550
|
+
const skip = new Set(localOnlySections || [])
|
|
551
|
+
const lines = phase.lines || []
|
|
552
|
+
const inFence = fenceMask(lines)
|
|
553
|
+
// Where each task block starts, so the walk can emit its rendered form and
|
|
554
|
+
// jump the lines it claims. Line-indexed splicing is what `task-block.js` is
|
|
555
|
+
// built for; everything it does NOT claim is content, and passes through.
|
|
556
|
+
const blockAt = new Map()
|
|
557
|
+
for (const b of phase.blocks || []) blockAt.set(b.start, b)
|
|
558
|
+
|
|
559
|
+
const out = []
|
|
560
|
+
let dropping = false // inside a local-only section
|
|
561
|
+
let seenTitle = false
|
|
562
|
+
for (let i = 0; i < lines.length; i++) {
|
|
563
|
+
const block = blockAt.get(i)
|
|
564
|
+
if (block) {
|
|
565
|
+
if (!dropping) out.push(renderTaskBlockLine(block))
|
|
566
|
+
i = block.end - 1
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
const line = lines[i]
|
|
570
|
+
if (!inFence[i]) {
|
|
571
|
+
const h2 = /^##\s+(.*\S)\s*$/.exec(line)
|
|
572
|
+
if (h2) dropping = skip.has(h2[1].trim())
|
|
573
|
+
if (!seenTitle && /^#\s+/.test(line)) {
|
|
574
|
+
seenTitle = true
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
if (/^>\s*\*\*Status:\*\*/.test(line)) continue
|
|
578
|
+
}
|
|
579
|
+
if (!dropping) out.push(line)
|
|
580
|
+
}
|
|
581
|
+
// Collapse the blank runs the removals leave behind, and trim the ends.
|
|
582
|
+
return out
|
|
583
|
+
.join('\n')
|
|
584
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
585
|
+
.trim()
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Phases inline as `###`, hanging under the `## Phases` index that lists them.
|
|
589
|
+
// The body they carry is a whole document in its own right and starts at `##`,
|
|
590
|
+
// so it demotes to sit under that heading rather than break out of it.
|
|
591
|
+
const INLINE_PHASE_LEVEL = 3
|
|
592
|
+
const MAX_HEADING_LEVEL = 6
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Shift every heading in a projected phase body `by` levels, so an inlined phase
|
|
596
|
+
* nests under the `###` that introduces it.
|
|
597
|
+
*
|
|
598
|
+
* Projected for a sub-issue the body is the whole description and its `## Tasks`
|
|
599
|
+
* is correctly top-level. Inlined it is a subsection, and an undemoted `## Tasks`
|
|
600
|
+
* would read as a sibling of `## Problem` — pulling every following phase under
|
|
601
|
+
* it in the outline. Only the `#` run length changes; the content is the same
|
|
602
|
+
* bytes either way, which is the point of sharing one composer.
|
|
603
|
+
*/
|
|
604
|
+
function demoteHeadings(body, by) {
|
|
605
|
+
const lines = body.split('\n')
|
|
606
|
+
const inFence = fenceMask(lines)
|
|
607
|
+
return lines
|
|
608
|
+
.map((line, i) => {
|
|
609
|
+
if (inFence[i]) return line
|
|
610
|
+
const h = /^(#{1,6})(\s)/.exec(line)
|
|
611
|
+
if (!h) return line
|
|
612
|
+
return '#'.repeat(Math.min(h[1].length + by, MAX_HEADING_LEVEL)) + line.slice(h[1].length)
|
|
613
|
+
})
|
|
614
|
+
.join('\n')
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// The phase file's h1 exactly as written — `Phase 2 — The inline projection 🔄`.
|
|
618
|
+
// `projectPhaseBody` drops it because a sub-issue projects the title as `name`
|
|
619
|
+
// and the emoji as `state`; inlined there are no such fields, so this heading is
|
|
620
|
+
// the only place either can live, and the raw line is where both still are.
|
|
621
|
+
function phaseHeading(phase) {
|
|
622
|
+
for (const line of phase.lines || []) {
|
|
623
|
+
const h1 = /^#\s+(.*\S)\s*$/.exec(line)
|
|
624
|
+
if (h1) return h1[1]
|
|
625
|
+
}
|
|
626
|
+
return phase.name
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Append the inlined phases to the spec issue's description as `###` sections.
|
|
631
|
+
*
|
|
632
|
+
* The body is `subIssueBody`'s — the SAME composer the sub-issue form uses, so
|
|
633
|
+
* `inline` inherits its fidelity guarantee instead of growing a second extractor
|
|
634
|
+
* that drops content, which is exactly what the reporting project had to
|
|
635
|
+
* hand-roll before this existed.
|
|
636
|
+
*/
|
|
637
|
+
function withInlinePhases(description, inlined, tasksMode, localOnlySections) {
|
|
638
|
+
if (!inlined.length) return description
|
|
639
|
+
const sections = inlined.map((phase) => {
|
|
640
|
+
const body = demoteHeadings(subIssueBody(phase, tasksMode, localOnlySections) || '', INLINE_PHASE_LEVEL - 1)
|
|
641
|
+
const heading = '#'.repeat(INLINE_PHASE_LEVEL) + ` ${phaseHeading(phase)}`
|
|
642
|
+
return body ? `${heading}\n\n${body}` : heading
|
|
553
643
|
})
|
|
554
|
-
return
|
|
644
|
+
return [description, ...sections].filter(Boolean).join('\n\n') || null
|
|
555
645
|
}
|
|
556
646
|
|
|
557
647
|
/**
|
|
@@ -570,6 +660,35 @@ function bucketFromPath(snapshotDir) {
|
|
|
570
660
|
// the states in which phases are not yet worth minting as sub-issues.
|
|
571
661
|
const UNSTARTED_BUCKETS = ['backlog', 'cancelled']
|
|
572
662
|
|
|
663
|
+
// The mode a bucket gets when a per-bucket `mapping.phases` map omits it. Adding
|
|
664
|
+
// a bucket to the map is therefore an EXCEPTION for that bucket, not a switch
|
|
665
|
+
// that silently suppresses phases everywhere the map is silent. Matches the
|
|
666
|
+
// scalar default in the provider's config so both forms start from one place.
|
|
667
|
+
const DEFAULT_PHASE_MODE = 'subissue'
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* The phase mode configured for one lifecycle bucket.
|
|
671
|
+
*
|
|
672
|
+
* `mapping.phases` is EITHER a scalar — one mode for the whole repo, which is
|
|
673
|
+
* what every config was before per-bucket mapping — or a map keyed by lifecycle
|
|
674
|
+
* bucket (`{ backlog: 'subissue', complete: 'inline' }`), because a repo can
|
|
675
|
+
* legitimately want assignable sub-issues for work in flight and none at all for
|
|
676
|
+
* 250 finished specs. A scalar resolves to itself for every bucket, so existing
|
|
677
|
+
* configs are unchanged.
|
|
678
|
+
*
|
|
679
|
+
* The SINGLE place a mode is decided: the projection and the description's
|
|
680
|
+
* `## Phases` index both read it, so they cannot disagree about one spec.
|
|
681
|
+
*/
|
|
682
|
+
function phaseModeFor(bucket, config) {
|
|
683
|
+
const configured = config && config.mapping && config.mapping.phases
|
|
684
|
+
if (typeof configured === 'string') return configured
|
|
685
|
+
if (configured && typeof configured === 'object' && !Array.isArray(configured)) {
|
|
686
|
+
const mode = configured[bucket]
|
|
687
|
+
return typeof mode === 'string' ? mode : DEFAULT_PHASE_MODE
|
|
688
|
+
}
|
|
689
|
+
return DEFAULT_PHASE_MODE
|
|
690
|
+
}
|
|
691
|
+
|
|
573
692
|
/**
|
|
574
693
|
* Which phases the projection sends, and how many the `deferred` mapping is
|
|
575
694
|
* holding back. Pure — split out so both the projection and the CLI's "N phases
|
|
@@ -591,10 +710,25 @@ function specStatus(snapshotDir, frontmatter) {
|
|
|
591
710
|
|
|
592
711
|
function phaseProjection(phases, workflowState, config) {
|
|
593
712
|
const named = phases.filter((p) => p.name)
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
713
|
+
const mode = phaseModeFor(workflowState, config)
|
|
714
|
+
// `deferred` and `inline` both send only phases that ALREADY carry an id, and
|
|
715
|
+
// for the same reason: one-way sync has no delete op, so withholding a live
|
|
716
|
+
// sub-issue would freeze it in the tracker rather than remove it (Decision 4).
|
|
717
|
+
// They differ only in where an unlinked phase goes — nowhere yet, or into the
|
|
718
|
+
// spec issue's own description.
|
|
719
|
+
const deferring = mode === 'deferred' && UNSTARTED_BUCKETS.includes(workflowState)
|
|
720
|
+
const projected = deferring || mode === 'inline' ? named.filter((p) => p.id != null) : named
|
|
721
|
+
const unlinked = named.filter((p) => p.id == null)
|
|
722
|
+
return {
|
|
723
|
+
projected,
|
|
724
|
+
// Held back until the work starts. Nothing carries them meanwhile, which is
|
|
725
|
+
// why the `## Phases` index stays and the CLI says how many.
|
|
726
|
+
withheld: deferring ? unlinked.length : 0,
|
|
727
|
+
// Rendered into the description instead. Deliberately NOT counted as
|
|
728
|
+
// withheld: nothing is missing from the mirror, so a report that said "N
|
|
729
|
+
// phases deferred" would be describing the opposite of what happened.
|
|
730
|
+
inlined: mode === 'inline' ? unlinked : [],
|
|
731
|
+
}
|
|
598
732
|
}
|
|
599
733
|
|
|
600
734
|
function normalizeLocal(snapshotDir, config) {
|
|
@@ -606,22 +740,34 @@ function normalizeLocal(snapshotDir, config) {
|
|
|
606
740
|
// status — so the issue's state and its sub-issues always agree on whether the
|
|
607
741
|
// work has started, however that status was arrived at.
|
|
608
742
|
const workflowState = specStatus(snapshotDir, frontmatter)
|
|
609
|
-
const { projected, withheld } = phaseProjection(phases, workflowState, config)
|
|
610
|
-
|
|
611
|
-
// Phases
|
|
612
|
-
// so
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
|
|
618
|
-
|
|
743
|
+
const { projected, withheld, inlined } = phaseProjection(phases, workflowState, config)
|
|
744
|
+
|
|
745
|
+
// Strip the `## Phases` index only when the SUB-ISSUES replace it: they carry
|
|
746
|
+
// every phase, so keeping the index would duplicate the list as prose AND as
|
|
747
|
+
// objects. It stays whenever they do not — while deferral holds a phase back
|
|
748
|
+
// the index is the only place that phase appears, and under `inline` there are
|
|
749
|
+
// no new sub-issues at all, so it is the issue's only table of contents
|
|
750
|
+
// (Decision 3).
|
|
751
|
+
//
|
|
752
|
+
// Keyed on the resolved MODE rather than on `subIssues` being an owned field:
|
|
753
|
+
// ownership says WHICH fields sync, which is a different question from how
|
|
754
|
+
// phases are shaped, and `inline` answers the second one per spec (Decision 5).
|
|
755
|
+
// Ownership still gets a say — an unowned `subIssues` pushes no sub-issues at
|
|
756
|
+
// all, so the index has to stay there too.
|
|
757
|
+
const syncsSubIssues = !!(config.sync.fieldOwnership && 'subIssues' in config.sync.fieldOwnership)
|
|
758
|
+
const phasesCarriedBySubIssues =
|
|
759
|
+
syncsSubIssues && phaseModeFor(workflowState, config) !== 'inline' && withheld === 0
|
|
619
760
|
const extracted = {
|
|
620
|
-
description:
|
|
621
|
-
|
|
622
|
-
|
|
761
|
+
description: withInlinePhases(
|
|
762
|
+
buildDescription(
|
|
763
|
+
title,
|
|
764
|
+
sections,
|
|
765
|
+
config.sync.localOnlySections,
|
|
766
|
+
phasesCarriedBySubIssues ? ['Phases'] : [],
|
|
767
|
+
),
|
|
768
|
+
inlined,
|
|
769
|
+
tasksMode,
|
|
623
770
|
config.sync.localOnlySections,
|
|
624
|
-
phasesProjected ? ['Phases'] : [],
|
|
625
771
|
),
|
|
626
772
|
// Sub-issue projection: one per phase. `ref` is the phase-file basename — the
|
|
627
773
|
// local handle the push skill stamps a newly-created sub-issue id back into.
|
|
@@ -633,7 +779,7 @@ function normalizeLocal(snapshotDir, config) {
|
|
|
633
779
|
id: p.id,
|
|
634
780
|
ref: p.phase,
|
|
635
781
|
name: p.name,
|
|
636
|
-
goal: subIssueBody(p, tasksMode),
|
|
782
|
+
goal: subIssueBody(p, tasksMode, config.sync.localOnlySections),
|
|
637
783
|
state: p.state,
|
|
638
784
|
})),
|
|
639
785
|
workflowState,
|
|
@@ -819,6 +965,7 @@ module.exports = {
|
|
|
819
965
|
stateSuggestions,
|
|
820
966
|
normalizeLocal,
|
|
821
967
|
phaseProjection,
|
|
968
|
+
phaseModeFor,
|
|
822
969
|
phasesWithheld,
|
|
823
970
|
lintPhases,
|
|
824
971
|
readSnapshot,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Date.now(). `recordPush` writes the snapshot sidecar.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const { normalizeLocal, phasesWithheld } = require('./normalize.js')
|
|
18
|
+
const { normalizeLocal, phasesWithheld, phaseModeFor } = require('./normalize.js')
|
|
19
19
|
const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
|
|
20
20
|
const { readBase, writeBase } = require('./base.js')
|
|
21
21
|
const { detectLegacyMirror } = require('./legacy.js')
|
|
@@ -34,6 +34,11 @@ function projectionOf(snapshotDir, config) {
|
|
|
34
34
|
// only — `snapshotOf`/`specIssueHash` read named fields, so this never
|
|
35
35
|
// reaches a hash and cannot make an unchanged spec look edited.
|
|
36
36
|
phasesWithheld: phasesWithheld(snapshotDir, config),
|
|
37
|
+
// The phase mode that resolved for THIS spec's bucket. Reporting only, on
|
|
38
|
+
// the same terms: a spec with no sub-issues has to read as deliberate rather
|
|
39
|
+
// than as phase files that failed to parse, and with `mapping.phases` now a
|
|
40
|
+
// per-bucket map, which mode applied is no longer readable off the config.
|
|
41
|
+
phaseMode: phaseModeFor(local.workflowState, config),
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
44
|
|
|
@@ -51,6 +56,10 @@ function push({ dir, snapshotDir, identifier, config }) {
|
|
|
51
56
|
// because `--json` routes warnings to stderr and the skill applying the plan
|
|
52
57
|
// is the consumer that most needs to know the missing sub-issues are deliberate.
|
|
53
58
|
if (projection.phasesWithheld) plan.phasesDeferred = projection.phasesWithheld
|
|
59
|
+
// Always set, unlike the two above: the skill relaying this should not have to
|
|
60
|
+
// know that an absent field means `subissue`. `isEmptyPlan` and `snapshotOf`
|
|
61
|
+
// both read named fields, so an extra key cannot make a spec look edited.
|
|
62
|
+
plan.phaseMode = projection.phaseMode
|
|
54
63
|
return { ok: true, empty: isEmptyPlan(plan), plan, projection }
|
|
55
64
|
}
|
|
56
65
|
|