@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.
Files changed (40) hide show
  1. package/MIGRATION.md +88 -0
  2. package/README.md +3 -3
  3. package/assets/claude-md-section.md +20 -48
  4. package/assets/core/SETUP.md +1 -1
  5. package/assets/core/env.config.json.example +7 -1
  6. package/assets/core/env.config.md +56 -11
  7. package/assets/core/gating.config.json.example +4 -0
  8. package/assets/core/gating.config.md +81 -0
  9. package/assets/core/linear.config.md +12 -11
  10. package/assets/rules/spec-planning.md +53 -13
  11. package/assets/skills/spec/SKILL.md +46 -18
  12. package/assets/skills/spec-bug/SKILL.md +32 -27
  13. package/assets/skills/spec-cancel/SKILL.md +26 -0
  14. package/assets/skills/spec-complete/SKILL.md +70 -17
  15. package/assets/skills/spec-hotfix/SKILL.md +40 -19
  16. package/assets/skills/spec-init/SKILL.md +31 -8
  17. package/assets/skills/spec-linear-setup/SKILL.md +32 -7
  18. package/assets/skills/spec-next/SKILL.md +141 -0
  19. package/assets/skills/spec-push/SKILL.md +15 -16
  20. package/assets/skills/spec-review/SKILL.md +24 -11
  21. package/assets/skills/spec-start/SKILL.md +226 -0
  22. package/assets/skills/spec-status/SKILL.md +2 -2
  23. package/assets/skills/spec-sync/SKILL.md +8 -8
  24. package/assets/skills/spec-to-main/SKILL.md +21 -19
  25. package/package.json +1 -1
  26. package/src/cli.js +405 -15
  27. package/src/env/classify.js +91 -0
  28. package/src/env/config.js +39 -1
  29. package/src/env/integrate.js +61 -1
  30. package/src/env/live.js +27 -3
  31. package/src/env/provision.js +196 -5
  32. package/src/env/resolve.js +1 -0
  33. package/src/env/teardown.js +41 -3
  34. package/src/gating.js +155 -0
  35. package/src/init.js +45 -9
  36. package/src/prompts.js +41 -4
  37. package/src/vendor/linear/cli-sync.js +22 -2
  38. package/src/vendor/linear/config.js +1 -1
  39. package/src/vendor/sync-core/src/compare.js +25 -2
  40. 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-go provisions a worktree for every in-progress 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', 'env.config.json.example'),
435
- path.join(dir, 'specs', '.core', 'env.config.json'),
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-go (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
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-go, /spec-complete, /spec-cancel,' +
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
- async function init({ dir, force, claudeMd, mode, isolation }) {
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 — a git worktree per spec?',
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
- return { isolation: Boolean(ans.isolation) }
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-go',
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-go` provisions with,
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-go`).
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
- * create items carry a `ref` (local handle) and no id; update items carry both
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
- subIssues.create.push({ ref: s.ref, name: s.name, goal: s.goal, state: s.state })
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