@skitterbyte/skitterspec-linear 10.8.0 → 12.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 +88 -0
- package/README.md +3 -3
- package/assets/claude-md-section.md +20 -48
- package/assets/core/SETUP.md +1 -1
- package/assets/core/env.config.json.example +7 -1
- package/assets/core/env.config.md +56 -11
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/linear.config.md +12 -11
- package/assets/rules/spec-planning.md +53 -13
- package/assets/skills/spec/SKILL.md +46 -18
- package/assets/skills/spec-bug/SKILL.md +32 -27
- package/assets/skills/spec-cancel/SKILL.md +26 -0
- package/assets/skills/spec-complete/SKILL.md +70 -17
- package/assets/skills/spec-hotfix/SKILL.md +40 -19
- package/assets/skills/spec-init/SKILL.md +31 -8
- package/assets/skills/spec-linear-setup/SKILL.md +32 -7
- package/assets/skills/spec-next/SKILL.md +141 -0
- package/assets/skills/spec-push/SKILL.md +15 -16
- package/assets/skills/spec-review/SKILL.md +24 -11
- package/assets/skills/spec-start/SKILL.md +226 -0
- package/assets/skills/spec-status/SKILL.md +2 -2
- package/assets/skills/spec-sync/SKILL.md +8 -8
- package/assets/skills/spec-to-main/SKILL.md +21 -19
- package/package.json +1 -1
- package/src/cli.js +405 -15
- package/src/env/classify.js +91 -0
- package/src/env/config.js +39 -1
- package/src/env/integrate.js +61 -1
- package/src/env/live.js +27 -3
- package/src/env/provision.js +196 -5
- package/src/env/resolve.js +1 -0
- package/src/env/teardown.js +41 -3
- package/src/gating.js +155 -0
- package/src/init.js +45 -9
- package/src/prompts.js +41 -4
- package/src/vendor/linear/cli-sync.js +22 -2
- package/src/vendor/linear/config.js +1 -1
- package/src/vendor/sync-core/src/compare.js +25 -2
- package/assets/skills/spec-go/SKILL.md +0 -233
package/src/gating.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Release gating — the loader, the header reader, and the check.
|
|
5
|
+
*
|
|
6
|
+
* The feature records ONE decision per spec: does this ship behind a feature
|
|
7
|
+
* flag, or land live? Skitterspec never learns how a project does flags; it asks
|
|
8
|
+
* the question, cites the project's own documentation, and reads back what was
|
|
9
|
+
* written. Strictly opt-in: with `specs/.core/gating.config.json` absent, every
|
|
10
|
+
* function here reports "not configured" and nothing else changes.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors `src/env/config.js` (frozen defaults, merge known keys only, never
|
|
13
|
+
* throws on absence) so the two opt-in configs behave alike.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
const path = require('node:path')
|
|
18
|
+
|
|
19
|
+
const CONFIG_FILE = path.join('specs', '.core', 'gating.config.json')
|
|
20
|
+
|
|
21
|
+
// Only these two buckets are ever read.
|
|
22
|
+
//
|
|
23
|
+
// A SPEC WRITTEN BEFORE GATING WAS ADOPTED HAS NO HEADER AND IS NOT BROKEN. That
|
|
24
|
+
// is the blind spot this check would otherwise walk into: "no Gating: line" is
|
|
25
|
+
// evidence of an unanswered question only for a spec that could have been asked,
|
|
26
|
+
// and every finished or abandoned spec predates the question by definition. They
|
|
27
|
+
// are excluded STRUCTURALLY rather than by a filter someone must remember — a
|
|
28
|
+
// completed spec is not in range, so no future edit can make it fire. Pre-existing
|
|
29
|
+
// specs still in flight ARE reported, deliberately: they are live work, the
|
|
30
|
+
// question genuinely still applies, and the report never blocks anything.
|
|
31
|
+
const ACTIVE_BUCKETS = ['backlog', 'in-progress']
|
|
32
|
+
|
|
33
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
34
|
+
guidance: '',
|
|
35
|
+
default: 'none: <reason>',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load `specs/.core/gating.config.json`. Returns `{ config, present }`;
|
|
40
|
+
* `present:false` means the project has not adopted gating, which is read as
|
|
41
|
+
* "this project does not use feature flags" — never as an error.
|
|
42
|
+
*/
|
|
43
|
+
function loadGatingConfig(dir = process.cwd()) {
|
|
44
|
+
const base = { ...DEFAULT_CONFIG }
|
|
45
|
+
let raw
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFileSync(path.join(dir, CONFIG_FILE), 'utf-8')
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') return { config: base, present: false }
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
let parsed
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
|
|
57
|
+
}
|
|
58
|
+
if (parsed && typeof parsed === 'object') {
|
|
59
|
+
if (typeof parsed.guidance === 'string') base.guidance = parsed.guidance.trim()
|
|
60
|
+
if (typeof parsed.default === 'string' && parsed.default.trim()) {
|
|
61
|
+
base.default = parsed.default.trim()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { config: base, present: true }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read a spec's `> **Gating:** …` blockquote field from `00-overview.md`.
|
|
69
|
+
*
|
|
70
|
+
* Returns `{ raw, kind }` with four kinds, because there are four states and
|
|
71
|
+
* collapsing them is what made the omission invisible in the first place:
|
|
72
|
+
*
|
|
73
|
+
* flag a flag name — ships behind it
|
|
74
|
+
* none `none: <reason>` — deliberately not flagged, and why
|
|
75
|
+
* invalid present but says nothing: empty, or a bare `none` with no reason
|
|
76
|
+
* missing no field at all
|
|
77
|
+
*
|
|
78
|
+
* `invalid` and `missing` are kept apart on purpose. A bare `none` is someone
|
|
79
|
+
* answering without deciding; a missing line is nobody having been asked. They
|
|
80
|
+
* want different words.
|
|
81
|
+
*/
|
|
82
|
+
function readGatingField(specPath) {
|
|
83
|
+
const overview = path.join(specPath, '00-overview.md')
|
|
84
|
+
let raw
|
|
85
|
+
try {
|
|
86
|
+
raw = fs.readFileSync(overview, 'utf-8')
|
|
87
|
+
} catch {
|
|
88
|
+
return { raw: null, kind: 'missing' }
|
|
89
|
+
}
|
|
90
|
+
const m = /^>\s*\*\*Gating:\*\*\s*(.*)$/m.exec(raw)
|
|
91
|
+
if (!m) return { raw: null, kind: 'missing' }
|
|
92
|
+
const value = m[1].trim().replace(/^["'`]|["'`]$/g, '')
|
|
93
|
+
if (!value) return { raw: value, kind: 'invalid' }
|
|
94
|
+
const bare = /^none\b/i.test(value)
|
|
95
|
+
if (bare) {
|
|
96
|
+
// `none` alone, or `none:` with nothing after it, is a shrug rather than a
|
|
97
|
+
// decision — the reason half is the whole point of recording it.
|
|
98
|
+
const reason = value.replace(/^none\b:?/i, '').trim()
|
|
99
|
+
return { raw: value, kind: reason ? 'none' : 'invalid' }
|
|
100
|
+
}
|
|
101
|
+
return { raw: value, kind: 'flag' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Active specs on disk, as `{ folder, bucket, path }`. A bucket that does not
|
|
105
|
+
// exist is simply empty — git does not store empty directories, so a missing
|
|
106
|
+
// `specs/backlog/` is the ordinary state of a project with nothing queued.
|
|
107
|
+
function activeSpecs(dir) {
|
|
108
|
+
const out = []
|
|
109
|
+
for (const bucket of ACTIVE_BUCKETS) {
|
|
110
|
+
const root = path.join(dir, 'specs', bucket)
|
|
111
|
+
let entries
|
|
112
|
+
try {
|
|
113
|
+
entries = fs.readdirSync(root, { withFileTypes: true })
|
|
114
|
+
} catch {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
for (const e of entries) {
|
|
118
|
+
if (e.isDirectory()) out.push({ folder: e.name, bucket, path: path.join(root, e.name) })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check specs for a recorded gating decision.
|
|
126
|
+
*
|
|
127
|
+
* Advisory by construction: it returns findings and says nothing about what the
|
|
128
|
+
* caller should do. Nothing here exits, throws on a finding, or blocks.
|
|
129
|
+
*
|
|
130
|
+
* @returns {{configured: boolean, findings: Array<{folder, bucket, kind, raw}>,
|
|
131
|
+
* checked: number, guidance: string}}
|
|
132
|
+
*/
|
|
133
|
+
function checkGating(dir, specs) {
|
|
134
|
+
const { config, present } = loadGatingConfig(dir)
|
|
135
|
+
if (!present) return { configured: false, findings: [], checked: 0, guidance: '' }
|
|
136
|
+
const targets = specs && specs.length ? specs : activeSpecs(dir)
|
|
137
|
+
const findings = []
|
|
138
|
+
for (const spec of targets) {
|
|
139
|
+
const { kind, raw } = readGatingField(spec.path)
|
|
140
|
+
if (kind === 'missing' || kind === 'invalid') {
|
|
141
|
+
findings.push({ folder: spec.folder, bucket: spec.bucket, kind, raw })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { configured: true, findings, checked: targets.length, guidance: config.guidance }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
CONFIG_FILE,
|
|
149
|
+
DEFAULT_CONFIG,
|
|
150
|
+
ACTIVE_BUCKETS,
|
|
151
|
+
loadGatingConfig,
|
|
152
|
+
readGatingField,
|
|
153
|
+
activeSpecs,
|
|
154
|
+
checkGating,
|
|
155
|
+
}
|
package/src/init.js
CHANGED
|
@@ -423,19 +423,43 @@ function installCore(dir, opts) {
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
// Activate opt-in per-spec isolation: write specs/.core/env.config.json from the
|
|
426
|
-
// example asset so /spec-
|
|
426
|
+
// example asset so /spec-start provisions a worktree for every in-progress spec.
|
|
427
427
|
// Only called when the operator opts in, and never on `update` (adopting isolation
|
|
428
428
|
// is a deliberate choice, not something a re-sync flips on). Idempotent: writeFile
|
|
429
429
|
// never clobbers an existing env.config.json without --force.
|
|
430
|
-
function installIsolation(dir, { enabled }, opts) {
|
|
430
|
+
function installIsolation(dir, { enabled, workspaceMode }, opts) {
|
|
431
|
+
if (!enabled) return
|
|
432
|
+
const target = path.join(dir, 'specs', '.core', 'env.config.json')
|
|
433
|
+
copyAsset(dir, path.join('core', 'env.config.json.example'), target, opts)
|
|
434
|
+
|
|
435
|
+
// Only 'checkout' is written; 'worktree' is already what the template says and
|
|
436
|
+
// what the loader defaults to, so the common path leaves the file untouched.
|
|
437
|
+
// Guarded by existsSync because copyAsset legitimately declines to overwrite a
|
|
438
|
+
// config the operator already customized — rewriting it here would undo that.
|
|
439
|
+
if (workspaceMode === 'checkout' && fs.existsSync(target)) {
|
|
440
|
+
const parsed = JSON.parse(fs.readFileSync(target, 'utf8'))
|
|
441
|
+
parsed.mode = 'checkout'
|
|
442
|
+
fs.writeFileSync(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
443
|
+
}
|
|
444
|
+
trustWorktreeRoot(dir)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Activate opt-in release gating: write specs/.core/gating.config.json from the
|
|
448
|
+
// example asset, so /spec and friends start asking whether a change ships behind
|
|
449
|
+
// a feature flag and recording the answer.
|
|
450
|
+
//
|
|
451
|
+
// Only called when the operator opts in, and NEVER on `update` — for the same
|
|
452
|
+
// reason as isolation: adopting a policy is a deliberate choice, not something a
|
|
453
|
+
// re-sync flips on. Idempotent; copyAsset never clobbers an existing config
|
|
454
|
+
// without --force, so an operator's edited `guidance` survives a re-init.
|
|
455
|
+
function installGating(dir, { enabled }, opts) {
|
|
431
456
|
if (!enabled) return
|
|
432
457
|
copyAsset(
|
|
433
458
|
dir,
|
|
434
|
-
path.join('core', '
|
|
435
|
-
path.join(dir, 'specs', '.core', '
|
|
459
|
+
path.join('core', 'gating.config.json.example'),
|
|
460
|
+
path.join(dir, 'specs', '.core', 'gating.config.json'),
|
|
436
461
|
opts,
|
|
437
462
|
)
|
|
438
|
-
trustWorktreeRoot(dir)
|
|
439
463
|
}
|
|
440
464
|
|
|
441
465
|
// Seed the absolute worktree root into .claude/settings.local.json (gitignored)
|
|
@@ -673,9 +697,16 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
673
697
|
const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
|
|
674
698
|
const isolationNote = isolationOn
|
|
675
699
|
? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +
|
|
676
|
-
' at /spec-
|
|
700
|
+
' at /spec-start (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
|
|
677
701
|
: 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
|
|
678
702
|
' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
|
|
703
|
+
const gatingOn = fs.existsSync(path.join(dir, 'specs', '.core', 'gating.config.json'))
|
|
704
|
+
const gatingNote = gatingOn
|
|
705
|
+
? 'Release gating is ON: /spec asks whether a change ships behind a feature' +
|
|
706
|
+
' flag and records the answer on the spec (skitterspec gating check reports' +
|
|
707
|
+
' any that have none).\n'
|
|
708
|
+
: 'Release gating is opt-in: re-run with --gating (or copy' +
|
|
709
|
+
' specs/.core/gating.config.json.example → gating.config.json) to enable it.\n'
|
|
679
710
|
// A provider superset ships its own `spec-<provider>-setup` skill; the base
|
|
680
711
|
// ships none. Discovering it from what was actually installed keeps this file
|
|
681
712
|
// tracker-free — it never has to know which tracker (if any) is in the box.
|
|
@@ -696,16 +727,20 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
696
727
|
' (it discovers your workspace and writes the config), or see' +
|
|
697
728
|
' specs/.core/SETUP.md.\n'
|
|
698
729
|
process.stdout.write(
|
|
699
|
-
'\nDone. Skills resolve as /spec, /spec-
|
|
730
|
+
'\nDone. Skills resolve as /spec, /spec-start, /spec-next, /spec-complete,' +
|
|
700
731
|
' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
|
|
701
732
|
'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
|
|
702
733
|
" project's stack, then run /spec.\n" +
|
|
703
734
|
isolationNote +
|
|
735
|
+
gatingNote +
|
|
704
736
|
trackerNote,
|
|
705
737
|
)
|
|
706
738
|
}
|
|
707
739
|
|
|
708
|
-
|
|
740
|
+
// `mode` here is the INSTALL mode ('init' | 'update'), long-standing and
|
|
741
|
+
// unrelated to the config's own `mode` key — which arrives as `workspaceMode`
|
|
742
|
+
// precisely so the two cannot be confused at a call site.
|
|
743
|
+
async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gating }) {
|
|
709
744
|
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
710
745
|
resetReport()
|
|
711
746
|
|
|
@@ -716,7 +751,8 @@ async function init({ dir, force, claudeMd, mode, isolation }) {
|
|
|
716
751
|
removeRetiredFiles(dir)
|
|
717
752
|
installCore(dir, { force })
|
|
718
753
|
// Adopting isolation writes the live env.config.json — init only, never update.
|
|
719
|
-
if (mode !== 'update') installIsolation(dir, { enabled: isolation }, { force })
|
|
754
|
+
if (mode !== 'update') installIsolation(dir, { enabled: isolation, workspaceMode }, { force })
|
|
755
|
+
if (mode !== 'update') installGating(dir, { enabled: gating }, { force })
|
|
720
756
|
if (claudeMd) installClaudeMd(dir, { mode })
|
|
721
757
|
|
|
722
758
|
// Record what we wrote (and migrate a pre-manifest repo) so a later resync can
|
package/src/prompts.js
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* test suite never imports the interactive UI.
|
|
8
8
|
*
|
|
9
9
|
* `isolationSeed` pre-fills the per-spec isolation question. Returns
|
|
10
|
-
* `{ isolation }
|
|
10
|
+
* `{ isolation, mode }` — `mode` is only asked when isolation is enabled, and
|
|
11
|
+
* is `'worktree'` otherwise (the value the config defaults to anyway).
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
async function promptSetup({ isolationSeed = false } = {}) {
|
|
14
|
+
async function promptSetup({ isolationSeed = false, gatingSeed = false } = {}) {
|
|
14
15
|
const prompts = require('prompts')
|
|
15
16
|
|
|
16
17
|
let cancelled = false
|
|
@@ -23,15 +24,51 @@ async function promptSetup({ isolationSeed = false } = {}) {
|
|
|
23
24
|
{
|
|
24
25
|
type: 'confirm',
|
|
25
26
|
name: 'isolation',
|
|
26
|
-
message: 'Enable per-spec isolation —
|
|
27
|
+
message: 'Enable per-spec isolation — build each spec on its own branch?',
|
|
27
28
|
initial: isolationSeed,
|
|
28
29
|
},
|
|
30
|
+
{
|
|
31
|
+
// Only reachable when isolation was accepted: `prev` is the previous
|
|
32
|
+
// answer, and returning null skips the question entirely.
|
|
33
|
+
type: (prev) => (prev ? 'select' : null),
|
|
34
|
+
name: 'mode',
|
|
35
|
+
message: 'Where should a spec be built?',
|
|
36
|
+
hint: '- this is the trade, not a preference',
|
|
37
|
+
initial: 0,
|
|
38
|
+
choices: [
|
|
39
|
+
{
|
|
40
|
+
title: 'Its own worktree (default)',
|
|
41
|
+
value: 'worktree',
|
|
42
|
+
description: 'several specs at once, main left free — one terminal session per spec',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
title: 'The checkout you are in',
|
|
46
|
+
value: 'checkout',
|
|
47
|
+
description: 'one spec at a time, no second session — your terminal follows the work',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
// Orthogonal to isolation, so it is asked unconditionally rather than
|
|
53
|
+
// nested under it — a project can adopt either, both, or neither.
|
|
54
|
+
type: 'confirm',
|
|
55
|
+
name: 'gating',
|
|
56
|
+
message: 'Record a release-gating decision on each spec — flag, or land live?',
|
|
57
|
+
initial: gatingSeed,
|
|
58
|
+
},
|
|
29
59
|
]
|
|
30
60
|
|
|
31
61
|
const ans = await prompts(questions, { onCancel })
|
|
32
62
|
if (cancelled) throw new Error('Setup cancelled')
|
|
33
63
|
|
|
34
|
-
|
|
64
|
+
// Anything other than an explicit 'checkout' resolves to the default, so a
|
|
65
|
+
// skipped or cancelled-into-default answer can never select the mode that
|
|
66
|
+
// puts a spec's work in the primary checkout.
|
|
67
|
+
return {
|
|
68
|
+
isolation: Boolean(ans.isolation),
|
|
69
|
+
mode: ans.mode === 'checkout' ? 'checkout' : 'worktree',
|
|
70
|
+
gating: Boolean(ans.gating),
|
|
71
|
+
}
|
|
35
72
|
}
|
|
36
73
|
|
|
37
74
|
/**
|
|
@@ -242,6 +242,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
242
242
|
if (p.subIssues.update.length) lines.push(` sub-issues update: ${p.subIssues.update.map((s) => s.id).join(', ')}`)
|
|
243
243
|
lines.push(' (run with --json for the full plan the skill applies)')
|
|
244
244
|
}
|
|
245
|
+
lines.push(...unstampedLines(p))
|
|
245
246
|
out.write(lines.join('\n') + '\n')
|
|
246
247
|
return 0
|
|
247
248
|
}
|
|
@@ -249,10 +250,28 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
249
250
|
// `mapping.phases: 'deferred'` is holding phases back. Said plainly wherever a
|
|
250
251
|
// plan or a status report is printed, because the alternative reading of a spec
|
|
251
252
|
// with no sub-issues is that its phase files failed to parse.
|
|
253
|
+
// Phases whose stamp is missing while the snapshot still remembers an id nobody
|
|
254
|
+
// claims. Reported OUTSIDE the empty-plan branch on purpose: a plan carrying
|
|
255
|
+
// nothing but these is "empty" for applying, and saying `up to date` about it
|
|
256
|
+
// would hide the one thing the operator has to act on.
|
|
257
|
+
function unstampedLines(plan) {
|
|
258
|
+
const u = (plan && plan.unstamped) || []
|
|
259
|
+
if (!u.length) return []
|
|
260
|
+
const lines = [
|
|
261
|
+
` unstamped: ${u.length} phase(s) have no linear_issue_id, and ${
|
|
262
|
+
u[0].candidates.length
|
|
263
|
+
} sub-issue(s) from the last push are unclaimed —`,
|
|
264
|
+
' not creating, since a lost stamp and a new phase look identical from here.',
|
|
265
|
+
]
|
|
266
|
+
for (const item of u) lines.push(` ${item.ref} — could be: ${item.candidates.join(', ')}`)
|
|
267
|
+
lines.push(' re-stamp the phase file (linear_issue_id) and push again.')
|
|
268
|
+
return lines
|
|
269
|
+
}
|
|
270
|
+
|
|
252
271
|
function deferredLines(n) {
|
|
253
272
|
return [
|
|
254
273
|
` ${n} phase(s) deferred — mapping.phases is "deferred" and this spec has not started`,
|
|
255
|
-
' they are created on the push that follows /spec-
|
|
274
|
+
' they are created on the push that follows /spec-start',
|
|
256
275
|
]
|
|
257
276
|
}
|
|
258
277
|
|
|
@@ -483,6 +502,7 @@ function specSyncStatus(dir, config, specArg, flags, out) {
|
|
|
483
502
|
const u = plan.subIssues.update.length
|
|
484
503
|
lines.push(` push: pending — ${n} to create, ${u} to update${plan.issue ? ', issue changed' : ''}`)
|
|
485
504
|
}
|
|
505
|
+
lines.push(...unstampedLines(plan))
|
|
486
506
|
|
|
487
507
|
if (flags.remote && fs.existsSync(flags.remote)) {
|
|
488
508
|
const remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
|
|
@@ -1327,7 +1347,7 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
|
1327
1347
|
* beats no ref for looking correct). An explicit override cannot be blinded, and
|
|
1328
1348
|
* needs no answer for mixed staging.
|
|
1329
1349
|
*
|
|
1330
|
-
* The branch→spec direction is the INVERSE of what `/spec-
|
|
1350
|
+
* The branch→spec direction is the INVERSE of what `/spec-start` provisions with,
|
|
1331
1351
|
* so it is computed by running `branchFor` over each spec and matching, rather
|
|
1332
1352
|
* than by re-deriving the pattern here. A second implementation of the naming
|
|
1333
1353
|
* rule would drift from the one that created the branch.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Config loader for the one-way Linear sync feature (`/spec-status`, `/spec-push`
|
|
5
|
-
* and the Linear-aware paths of `/spec` and `/spec-
|
|
5
|
+
* and the Linear-aware paths of `/spec` and `/spec-next`).
|
|
6
6
|
*
|
|
7
7
|
* Reads `specs/.core/linear.config.json` from the project root and normalises it
|
|
8
8
|
* over frozen defaults. The feature is strictly opt-in: when the file is absent
|
|
@@ -94,7 +94,9 @@ function snapshotOf(projection) {
|
|
|
94
94
|
/**
|
|
95
95
|
* Diff the local projection against the last-pushed snapshot.
|
|
96
96
|
* @returns {{ issue?: object, subIssues: {create,update} }}
|
|
97
|
-
*
|
|
97
|
+
* `unstamped` (when present) lists phases whose stamp is missing while the
|
|
98
|
+
* snapshot still remembers an unminted-for id — ambiguous, so neither created
|
|
99
|
+
* nor updated. create items carry a `ref` (local handle) and no id; update items carry both
|
|
98
100
|
* — the `ref` because the read-back check matches sub-issues to phases BY ref,
|
|
99
101
|
* and an update with only an id makes every one of them look unmatched.
|
|
100
102
|
* `plan.issue` (when present) is the spec issue's description + state; the push
|
|
@@ -105,16 +107,37 @@ function planChanges(projection, snapshot) {
|
|
|
105
107
|
const snap = snapshot || {}
|
|
106
108
|
const snapS = snap.subIssues || {}
|
|
107
109
|
|
|
110
|
+
// Ids the last push minted that no phase claims any more. A phase file's stamp
|
|
111
|
+
// is the only link back to its sub-issue, and it lives in frontmatter — so a
|
|
112
|
+
// whole-file rewrite, a hand edit or a bad merge drops it while the sub-issue
|
|
113
|
+
// carries on existing. This snapshot is the only memory that it was ever
|
|
114
|
+
// minted, and nothing else in the push path re-reads the tracker.
|
|
115
|
+
const claimed = new Set()
|
|
116
|
+
for (const s of p.subIssues || []) if (s.id != null) claimed.add(String(s.id))
|
|
117
|
+
const unclaimed = Object.keys(snapS).filter((id) => !claimed.has(id))
|
|
118
|
+
|
|
108
119
|
const subIssues = { create: [], update: [] }
|
|
120
|
+
const unstamped = []
|
|
109
121
|
for (const s of p.subIssues || []) {
|
|
110
122
|
if (s.id == null) {
|
|
111
|
-
|
|
123
|
+
// THREE STATES, NOT TWO. "No stamp" means "new phase" only when every id
|
|
124
|
+
// the snapshot remembers is still claimed. With an unclaimed id sitting
|
|
125
|
+
// beside an unstamped phase the two readings — a new phase, and a phase
|
|
126
|
+
// whose stamp was lost — are indistinguishable from here, so this routes to
|
|
127
|
+
// the harmless branch and mints nothing. Being wrong this way costs a
|
|
128
|
+
// re-stamp; the other way cost a duplicate sub-issue and a manual cancel.
|
|
129
|
+
if (unclaimed.length) {
|
|
130
|
+
unstamped.push({ ref: s.ref, name: s.name, candidates: unclaimed.slice() })
|
|
131
|
+
} else {
|
|
132
|
+
subIssues.create.push({ ref: s.ref, name: s.name, goal: s.goal, state: s.state })
|
|
133
|
+
}
|
|
112
134
|
} else if (snapS[String(s.id)] !== subIssueHash(s)) {
|
|
113
135
|
subIssues.update.push({ ref: s.ref, id: s.id, name: s.name, goal: s.goal, state: s.state })
|
|
114
136
|
}
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
const plan = { subIssues }
|
|
140
|
+
if (unstamped.length) plan.unstamped = unstamped
|
|
118
141
|
const issue = issueChanges(p, snap)
|
|
119
142
|
if (issue) plan.issue = issue
|
|
120
143
|
return plan
|