@skitterbyte/skitterspec-jira 0.0.0-stage → 1.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 +1089 -0
- package/README.md +42 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +554 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +61 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +692 -0
- package/assets/rules/spec-reports.md +557 -0
- package/assets/skills/no-spec/SKILL.md +246 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +583 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +448 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +605 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +220 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +676 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +112 -0
- package/assets/skills/spec-sync/SKILL.md +218 -0
- package/assets/skills/spec-to-main/SKILL.md +139 -0
- package/bin/skitterspec-jira.js +61 -0
- package/package.json +40 -4
- package/src/cli.js +5901 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +753 -0
- package/src/env/dev.js +63 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +385 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +126 -0
- package/src/env/live.js +504 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +192 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2577 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1256 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +296 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +197 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/config.js +198 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +44 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +226 -0
- package/src/vendor/provider-kit/src/specs.js +166 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +306 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +989 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- 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 }
|
package/src/env/trust.js
ADDED
|
@@ -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
|
+
}
|