@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,157 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Process-supervision IO for host dev servers — the side-effecting seam the CLI
5
+ * drives (the planning lives in the pure `dev.js`). Keeps `cli.js` thin and lets
6
+ * start/stop/health be exercised against a fixture server in tests.
7
+ *
8
+ * A "proc" here is one entry from `planDev(...).procs` (it carries `command`,
9
+ * `env`, `logFile`, `pidFile`, `health`). Log/pid paths are relative to the
10
+ * primary checkout root, resolved against `rootDir`.
11
+ */
12
+
13
+ const fs = require('node:fs')
14
+ const path = require('node:path')
15
+ const { spawn } = require('node:child_process')
16
+
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
18
+
19
+ function resolveState(rootDir, rel) {
20
+ return path.resolve(rootDir, rel)
21
+ }
22
+
23
+ // Is `pid` a live process? `kill(pid, 0)` probes without signalling.
24
+ function isAlive(pid) {
25
+ try {
26
+ process.kill(pid, 0)
27
+ return true
28
+ } catch (err) {
29
+ // EPERM → exists but not ours; still "alive" for our purposes.
30
+ return err.code === 'EPERM'
31
+ }
32
+ }
33
+
34
+ // Signal a detached process's whole group (its leader pid is `pid`, so the group
35
+ // id is `-pid`) — reaches children like vite/tsc that a bare `pnpm dev` spawns.
36
+ // Falls back to signalling just the leader if the group send fails.
37
+ function signalGroup(pid, sig) {
38
+ try {
39
+ process.kill(-pid, sig)
40
+ } catch {
41
+ try {
42
+ process.kill(pid, sig)
43
+ } catch {
44
+ /* already gone */
45
+ }
46
+ }
47
+ }
48
+
49
+ // Read a pid from its file (absolute path). null when missing/malformed.
50
+ function readPid(pidFileAbs) {
51
+ try {
52
+ const n = Number(fs.readFileSync(pidFileAbs, 'utf-8').trim())
53
+ return Number.isInteger(n) && n > 0 ? n : null
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Start one planned process detached, appending stdout+stderr to its log and
61
+ * writing its pid file. Idempotent: if the pid file already names a live
62
+ * process, nothing is spawned — returns `{ started: false, pid }`.
63
+ *
64
+ * `spawnImpl` is injectable for tests; defaults to child_process.spawn.
65
+ */
66
+ function startProcess(proc, { cwd, rootDir, spawnImpl = spawn }) {
67
+ const logAbs = resolveState(rootDir, proc.logFile)
68
+ const pidAbs = resolveState(rootDir, proc.pidFile)
69
+
70
+ const existing = readPid(pidAbs)
71
+ if (existing && isAlive(existing)) {
72
+ return { started: false, pid: existing, logFile: logAbs, pidFile: pidAbs }
73
+ }
74
+
75
+ fs.mkdirSync(path.dirname(logAbs), { recursive: true })
76
+ fs.mkdirSync(path.dirname(pidAbs), { recursive: true })
77
+
78
+ const out = fs.openSync(logAbs, 'a')
79
+ try {
80
+ const child = spawnImpl('sh', ['-c', proc.command], {
81
+ cwd,
82
+ env: { ...process.env, ...proc.env },
83
+ detached: true,
84
+ stdio: ['ignore', out, out],
85
+ })
86
+ child.unref()
87
+ fs.writeFileSync(pidAbs, `${child.pid}\n`)
88
+ return { started: true, pid: child.pid, logFile: logAbs, pidFile: pidAbs }
89
+ } finally {
90
+ fs.closeSync(out)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Stop one planned process: SIGTERM its pid, wait up to `graceMs` for it to
96
+ * exit, then SIGKILL if still alive. Removes the pid file either way. Idempotent
97
+ * — a missing/dead pid is a clean no-op. Returns `{ stopped, pid }`.
98
+ */
99
+ async function stopProcess(proc, { rootDir, graceMs = 3000, now = () => Date.now(), wait = sleep } = {}) {
100
+ const pidAbs = resolveState(rootDir, proc.pidFile)
101
+ const pid = readPid(pidAbs)
102
+ let stopped = false
103
+
104
+ if (pid && isAlive(pid)) {
105
+ signalGroup(pid, 'SIGTERM')
106
+ const deadline = now() + graceMs
107
+ while (now() < deadline && isAlive(pid)) await wait(100)
108
+ if (isAlive(pid)) signalGroup(pid, 'SIGKILL')
109
+ stopped = true
110
+ }
111
+
112
+ try {
113
+ fs.unlinkSync(pidAbs)
114
+ } catch {
115
+ /* no pid file → nothing to clean */
116
+ }
117
+ return { stopped, pid }
118
+ }
119
+
120
+ /**
121
+ * Poll `url` until it answers (any HTTP status counts as "up") or `timeoutMs`
122
+ * elapses. Returns true when reachable, false on timeout. A null/empty url means
123
+ * "no health gate" → true immediately. `fetchImpl`/`now`/`wait` are injectable
124
+ * for deterministic tests.
125
+ */
126
+ async function waitHealthy(
127
+ url,
128
+ { timeoutMs = 30000, intervalMs = 500, fetchImpl = fetch, now = () => Date.now(), wait = sleep } = {},
129
+ ) {
130
+ if (!url) return true
131
+ const deadline = now() + timeoutMs
132
+ while (now() < deadline) {
133
+ try {
134
+ const ctrl = new AbortController()
135
+ const t = setTimeout(() => ctrl.abort(), intervalMs)
136
+ try {
137
+ await fetchImpl(url, { signal: ctrl.signal })
138
+ return true
139
+ } finally {
140
+ clearTimeout(t)
141
+ }
142
+ } catch {
143
+ /* not up yet */
144
+ }
145
+ await wait(intervalMs)
146
+ }
147
+ return false
148
+ }
149
+
150
+ // `signalGroup` is exported for TESTS that need to simulate a process dying.
151
+ // Killing the recorded pid alone is not that simulation: the pid is the detached
152
+ // `sh -c` leader, and where the shell forks rather than execs, the real server
153
+ // is its child and survives. A test that reaches for `process.kill(pid, …)`
154
+ // therefore kills the wrapper, leaves the server holding its port, and then
155
+ // blames whatever it asserts next. Killing the group is what `stopProcess` does
156
+ // and what a test must do to mean "it died".
157
+ module.exports = { startProcess, stopProcess, waitHealthy, isAlive, readPid, signalGroup }
@@ -0,0 +1,248 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure teardown planner for `spec-env down`.
5
+ *
6
+ * `planDown` evaluates the dirty/unpushed guards, plans an optional config-driven
7
+ * pre-drop backup, and returns the exact commands the `/spec-env-down` skill runs
8
+ * (`docker compose down` [+`--volumes`], `git worktree remove`). It performs no
9
+ * side effects: the caller (the CLI) queries git for `worktreeState` and supplies
10
+ * a `timestamp`, keeping this deterministic and unit-testable with no live
11
+ * git/docker.
12
+ *
13
+ * Volumes are the only destructive action — dropped by default (reclaims disk)
14
+ * unless `--keep-volumes`, and always backed up first when a `backupCommand` is
15
+ * configured.
16
+ *
17
+ * `commands` is safe for a caller to run BLIND — that is the property the remote
18
+ * delete must not break. A remote branch delete reaches outside this machine, so
19
+ * it is returned in a separate `remoteCommands` array that the skills confirm
20
+ * with the user before running, and it never enters `commands` unless the
21
+ * project has opted in with `teardown.deleteRemoteBranch: "always"`.
22
+ */
23
+
24
+ const { expandTokens } = require('./resolve.js')
25
+ const { composeFileArg } = require('./provision.js')
26
+
27
+ /**
28
+ * @param {object} spec resolved spec: { slug, branch, worktreePath, projectName, ... }
29
+ * @param {object} config normalised env config.
30
+ * @param {object} flags { keepVolumes, force }
31
+ * @param {object} ctx { worktreeState: { dirty, unpushed, merged, reachableFromTag,
32
+ * remoteBranch }, timestamp }
33
+ * @returns {object} { blocked, reason, commands, remoteCommands, backupCommand,
34
+ * backupPath, volumesDropped }
35
+ */
36
+ function planDown(spec, config, flags, ctx) {
37
+ const { worktreeState = {}, timestamp } = ctx || {}
38
+ const force = Boolean(flags && flags.force)
39
+ const keepVolumes = Boolean(flags && flags.keepVolumes)
40
+
41
+ // A hotfix lands by tag + cherry-pick, so its branch is never an ancestor of
42
+ // base — but once its head is captured by a tag (the deploy tag from
43
+ // `hotfix land`), the commits are recoverable and the branch is safe to drop.
44
+ // Treat "reachable from a tag" as landed, alongside merged. Read twice below:
45
+ // it decides whether the unpushed guard blocks, and whether the branch delete
46
+ // can use `-D` — the same question ("are these commits recoverable?"), so the
47
+ // two must never answer it differently.
48
+ const landed = Boolean(worktreeState.merged || worktreeState.reachableFromTag)
49
+
50
+ // --- guards (overridable with --force) ---
51
+ if (!force) {
52
+ if (config.guards.refuseTeardownIfDirty && worktreeState.dirty) {
53
+ return blocked('worktree has uncommitted changes')
54
+ }
55
+ // Unpushed commits are only unsafe when they aren't already integrated into
56
+ // the base branch (or captured by a tag). A landed branch carries nothing to
57
+ // lose even with no remote — so /spec-complete's local land-then-teardown
58
+ // needs no --force. Block only when unpushed AND not landed.
59
+ if (config.guards.refuseTeardownIfUnpushed && worktreeState.unpushed && !landed) {
60
+ return blocked('worktree has unpushed commits not yet merged into the base branch')
61
+ }
62
+ }
63
+
64
+ // Docker teardown only applies to a Docker-escalated spec. A worktree-only spec
65
+ // (Stack: worktree) has no stack/volumes even when the project master switch is
66
+ // on — tearing it down is just removing the worktree. A spec resolved without an
67
+ // explicit stack (legacy/tests) follows the master switch (pre-`Stack` behaviour).
68
+ //
69
+ // DELIBERATELY NOT GATED ON THE COMPOSE FILE, where `planUp` is. The two
70
+ // directions are not symmetric: bringing a stack up without evidence there is
71
+ // one to bring costs a failed provision, while skipping a `down` costs an
72
+ // orphaned stack and its volumes, still running, that nothing will come back
73
+ // for. So `up` needs a positive signal and `down` errs toward cleaning up —
74
+ // a `down` for a stack that was never started is a no-op (rule 4, pointing
75
+ // the other way because the harmless branch is the other branch).
76
+ const stack = spec.stack || (config.docker.enabled ? 'docker' : 'worktree')
77
+ const wantsDocker = stack === 'docker' && config.docker.enabled
78
+
79
+ const commands = []
80
+ const volumesDropped = !keepVolumes && wantsDocker
81
+
82
+ // --- optional pre-drop backup (only when volumes are actually dropped) ---
83
+ let backupCommand = null
84
+ let backupPath = null
85
+ if (volumesDropped && config.docker.backupCommand) {
86
+ backupPath = `.spec-env/backups/${spec.slug}-${timestamp}.dump`
87
+ backupCommand = expandTokens(config.docker.backupCommand, {
88
+ backupPath,
89
+ slug: spec.slug,
90
+ projectName: spec.projectName,
91
+ timestamp: String(timestamp),
92
+ })
93
+ commands.push(backupCommand)
94
+ }
95
+
96
+ // --- docker compose down (drop volumes unless kept) ---
97
+ // Run from the primary checkout with no `--env-file`, unlike `up`, and on
98
+ // purpose: compose finds the stack's containers, networks and volumes by the
99
+ // project label `--project-name` gives it, so it needs nothing from the env
100
+ // file — which may already be gone with a half-removed worktree.
101
+ if (wantsDocker) {
102
+ const base =
103
+ `docker compose${composeFileArg(config, ctx ? ctx.composeFilePresent : undefined)}` +
104
+ ` --project-name ${spec.projectName} down`
105
+ commands.push(volumesDropped ? `${base} --volumes` : base)
106
+ }
107
+
108
+ // --- remove the worktree (force needed if we bypassed a dirty guard) ---
109
+ commands.push(
110
+ force
111
+ ? `git worktree remove --force ${spec.worktreePath}`
112
+ : `git worktree remove ${spec.worktreePath}`,
113
+ )
114
+
115
+ // --- delete the branch ---
116
+ // Runs after the worktree remove frees the branch. `-D` exactly when we have
117
+ // PROVEN the commits survive the delete, `-d` otherwise.
118
+ //
119
+ // `-d` looks like the safe default and mostly is, but its refusal answers a
120
+ // different question from ours: it also declines a branch that is ahead of its
121
+ // upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
122
+ // even though it is merged to HEAD`. That fires on the ordinary spec flow —
123
+ // it fires on any branch with an upstream ref whose later commits were landed
124
+ // locally rather than pushed — the shape you get the moment someone publishes a
125
+ // spec branch by hand, which is the only way one reaches a remote now. Teardown
126
+ // then meets a branch whose every commit is on `main` and `-d` refuses it.
127
+ //
128
+ // DO NOT "simplify" this back to `-d` on the grounds that nothing pushes at
129
+ // provisioning any more. That removed the COMMON case, not the case: one
130
+ // hand-published branch is enough, and `merged` already establishes what we
131
+ // care about more strongly than `-d` checks. `merged` (HEAD is an ancestor
132
+ // of base) already establishes what we actually care about, and establishes it
133
+ // more strongly than `-d` checks.
134
+ //
135
+ // `reachableFromTag` is the same argument for a hotfix: never an ancestor of
136
+ // base, but its head is captured by the deploy tag.
137
+ //
138
+ // Everything else keeps `-d`, so a forced teardown of a genuinely unlanded
139
+ // branch fails loudly and the skill relays it rather than -D-ing.
140
+ if (spec.branch) {
141
+ commands.push(`git branch ${landed ? '-D' : '-d'} ${spec.branch}`)
142
+ }
143
+
144
+ // --- delete the branch on the remote (planned, never run here) ---
145
+ //
146
+ // A hand-published spec branch otherwise leaves a merged branch on the remote
147
+ // forever. Cleaning that up is the goal; doing it safely is the constraint.
148
+ // Nothing publishes at provision time any more, so this plans a delete only for
149
+ // a branch the user pushed themselves — which is exactly when they want it.
150
+ //
151
+ // Gated on `landed` because until the branch is merged (or captured by a tag)
152
+ // the remote copy is the ONLY backup of the work — that is the whole reason
153
+ // `refuseTeardownIfUnpushed` exists, and deleting the remote branch of an
154
+ // unlanded spec would defeat it. `--force` deliberately does NOT enable this:
155
+ // force is for "I accept losing this worktree", not "also reach out and delete
156
+ // the backup". The same `landed` that decides `-D` vs `-d` decides this, so the
157
+ // two can never disagree about whether the commits are recoverable.
158
+ //
159
+ // A null `remoteBranch` plans nothing. An absence is not evidence — the branch
160
+ // may well be on a remote this clone cannot see (pushed from another machine),
161
+ // and the honest answer to "is there a remote branch?" is then "cannot tell",
162
+ // which routes to doing nothing.
163
+ const remoteCommands = []
164
+ const policy = (config.teardown && config.teardown.deleteRemoteBranch) || 'prompt'
165
+ if (spec.branch && landed && worktreeState.remoteBranch && policy !== 'never') {
166
+ // `remoteBranch` is a short ref like "origin/feat/thing" and the branch name
167
+ // itself contains slashes, so the remote is what remains once the exact
168
+ // "/<branch>" suffix is stripped — not the text before the first slash.
169
+ //
170
+ // If it does not end that way the ref maps to a differently-named branch on
171
+ // the remote (a push refspec, or push.default set to something exotic). We
172
+ // cannot tell what to delete, so we plan nothing rather than guess at a
173
+ // branch name on someone's shared remote.
174
+ const suffix = `/${spec.branch}`
175
+ if (worktreeState.remoteBranch.endsWith(suffix)) {
176
+ const remote = worktreeState.remoteBranch.slice(0, -suffix.length)
177
+ if (remote) {
178
+ const cmd = `git push ${remote} --delete ${spec.branch}`
179
+ // "always" is the project saying it never wants to be asked, so the push
180
+ // joins the run-blind list. Everything else keeps it quarantined.
181
+ if (policy === 'always') commands.push(cmd)
182
+ else remoteCommands.push(cmd)
183
+ }
184
+ }
185
+ }
186
+
187
+ return {
188
+ blocked: false,
189
+ reason: null,
190
+ commands,
191
+ remoteCommands,
192
+ backupCommand,
193
+ backupPath,
194
+ volumesDropped,
195
+ }
196
+ }
197
+
198
+ function blocked(reason) {
199
+ return {
200
+ blocked: true,
201
+ reason,
202
+ commands: [],
203
+ remoteCommands: [],
204
+ backupCommand: null,
205
+ backupPath: null,
206
+ volumesDropped: false,
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Pure teardown planner for CHECKOUT mode.
212
+ *
213
+ * There is no worktree to remove, no slot and no volumes — the only thing a
214
+ * finished spec leaves behind is its branch, and the checkout standing on it.
215
+ *
216
+ * Order is load-bearing: git refuses to delete the branch you are on, so the
217
+ * switch to base must come first. It is also what returns the checkout to a
218
+ * state the next `spec-env up` will accept.
219
+ *
220
+ * The same "are these commits recoverable?" question decides `-d` vs `-D`, and
221
+ * it is asked exactly as worktree-mode teardown asks it — a landed branch is
222
+ * safe to force-delete because its commits are on base (or under a tag); an
223
+ * unlanded one is not, and is refused rather than quietly dropped.
224
+ *
225
+ * ctx: { dirty, landed, onBranch, base, checkoutPath }
226
+ */
227
+ function planDownCheckout(spec, config, flags, ctx) {
228
+ const { dirty, landed, onBranch, base, checkoutPath } = ctx || {}
229
+ const force = Boolean(flags && flags.force)
230
+ const result = { mode: 'checkout', blocked: false, reason: null, commands: [], branch: spec.branch }
231
+ const block = (reason) => ({ ...result, blocked: true, reason })
232
+
233
+ if (!force) {
234
+ if (config.guards.refuseTeardownIfDirty && dirty) {
235
+ return block('the checkout has uncommitted changes')
236
+ }
237
+ if (!landed) {
238
+ return block(`${spec.branch} is not merged into ${base} — landing it first is what makes the delete safe`)
239
+ }
240
+ }
241
+
242
+ const commands = []
243
+ if (onBranch !== false) commands.push(`git -C ${checkoutPath} switch ${base}`)
244
+ commands.push(`git -C ${checkoutPath} branch ${landed ? '-D' : '-d'} ${spec.branch}`)
245
+ return { ...result, commands }
246
+ }
247
+
248
+ module.exports = { planDown, planDownCheckout }
@@ -0,0 +1,87 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Trust a per-spec worktree directory with Claude Code.
5
+ *
6
+ * Worktrees live outside the primary checkout (`../{repo}-wt/{slug}`), so Claude
7
+ * Code treats them as untrusted and prompts on every edit until the operator
8
+ * grants access. Registering the shared worktree root in
9
+ * `permissions.additionalDirectories` lifts those prompts for every spec at once.
10
+ *
11
+ * The root is an **absolute** path (relative entries aren't reliable in
12
+ * `additionalDirectories`) and therefore machine-specific, so it belongs in the
13
+ * gitignored `.claude/settings.local.json` — never committed config. This merge
14
+ * is deliberately conservative: it preserves every existing key (notably
15
+ * `permissions.allow`), dedups by exact path, and refuses to clobber a file it
16
+ * can't parse. `fs` is the only side effect; callers own the reporting.
17
+ */
18
+
19
+ const fs = require('node:fs')
20
+ const path = require('node:path')
21
+
22
+ function isObject(value) {
23
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
24
+ }
25
+
26
+ // Absolute path to the machine-local Claude Code settings for `dir`.
27
+ function settingsPath(dir) {
28
+ return path.join(dir, '.claude', 'settings.local.json')
29
+ }
30
+
31
+ function writeSettings(file, settings) {
32
+ fs.mkdirSync(path.dirname(file), { recursive: true })
33
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n')
34
+ }
35
+
36
+ /**
37
+ * Ensure `rootAbs` is listed in `permissions.additionalDirectories` of `dir`'s
38
+ * `.claude/settings.local.json`. Idempotent and non-destructive. Returns
39
+ * `{ changed, reason }` where reason is one of:
40
+ * - `created` — the settings file was absent and was created
41
+ * - `added` — the root was merged into an existing file
42
+ * - `present` — the root was already listed (no write)
43
+ * - `malformed` — the file exists but isn't parseable JSON (left untouched)
44
+ */
45
+ function ensureWorktreeDirTrusted(dir, rootAbs) {
46
+ const file = settingsPath(dir)
47
+
48
+ let raw
49
+ try {
50
+ raw = fs.readFileSync(file, 'utf-8')
51
+ } catch (error) {
52
+ if (error.code === 'ENOENT') {
53
+ writeSettings(file, { permissions: { additionalDirectories: [rootAbs] } })
54
+ return { changed: true, reason: 'created' }
55
+ }
56
+ throw error
57
+ }
58
+
59
+ let parsed
60
+ try {
61
+ parsed = JSON.parse(raw)
62
+ } catch {
63
+ return { changed: false, reason: 'malformed' }
64
+ }
65
+ if (!isObject(parsed)) return { changed: false, reason: 'malformed' }
66
+
67
+ const permissions = isObject(parsed.permissions) ? parsed.permissions : {}
68
+ const dirs = Array.isArray(permissions.additionalDirectories)
69
+ ? permissions.additionalDirectories
70
+ : []
71
+
72
+ if (dirs.includes(rootAbs)) return { changed: false, reason: 'present' }
73
+
74
+ writeSettings(file, {
75
+ ...parsed,
76
+ permissions: {
77
+ ...permissions,
78
+ additionalDirectories: [...dirs, rootAbs],
79
+ },
80
+ })
81
+ return { changed: true, reason: 'added' }
82
+ }
83
+
84
+ module.exports = {
85
+ ensureWorktreeDirTrusted,
86
+ settingsPath,
87
+ }
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
+ }