@skitterbyte/skitterspec 2.0.1 → 7.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.
@@ -0,0 +1,150 @@
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
+ module.exports = { startProcess, stopProcess, waitHealthy, isAlive, readPid }
package/src/init.js CHANGED
@@ -275,8 +275,8 @@ function printReport(dir, mode) {
275
275
  : 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
276
276
  ' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
277
277
  process.stdout.write(
278
- '\nDone. Skills resolve as /spec, /spec-ready, /spec-go, /spec-complete,' +
279
- ' /spec-cancel, /spec-bug, /spec-init, /spec-env, /spec-env-down.\n' +
278
+ '\nDone. Skills resolve as /spec, /spec-go, /spec-complete, /spec-cancel,' +
279
+ ' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
280
280
  'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
281
281
  " project's stack, then run /spec.\n" +
282
282
  isolationNote,
@@ -1,63 +0,0 @@
1
- ---
2
- name: spec-env
3
- description: Provision an isolated environment for a spec — a git worktree on its own branch + a namespaced Docker stack (isolated containers/networks/volumes + a reserved port block), plus an optional editor/terminal opener. Runs `skitterspec spec-env up` and executes the printed git/docker/open commands. Opt-in — needs specs/.core/env.config.json. Use when the user says "/spec-env", "spin up an environment for <spec>", "give this spec its own worktree/stack", or "isolate <spec>".
4
- ---
5
-
6
- # /spec-env — provision an isolated environment for a spec
7
-
8
- Give an in-progress spec its own **git worktree** (a sibling directory on its own
9
- branch, no stashing) + a **namespaced Docker stack** (`COMPOSE_PROJECT_NAME`
10
- isolates containers/networks/volumes; `PORT_OFFSET` reserves a port block), so N
11
- specs run side by side and `main` stays clean. An optional `open.command` then
12
- opens the worktree however you like.
13
-
14
- This skill is **opt-in**: it only works when `specs/.core/env.config.json` exists
15
- (copy `env.config.json.example` to adopt it). If it's absent, tell the user how
16
- to enable it and stop.
17
-
18
- ## 1. Identify the target spec
19
-
20
- - Use the spec named as an argument, else the spec **currently in context**. If
21
- neither is clear, ask which spec.
22
-
23
- ## 2. Plan the environment
24
-
25
- Run the engine — it allocates the slot (idempotent), persists the registry, and
26
- **prints** the plan (worktree path, branch, project name, port block, the exact
27
- commands, the `.env` contents, and the opener):
28
-
29
- ```
30
- skitterspec spec-env up <spec>
31
- ```
32
-
33
- If it reports the feature isn't enabled, relay that and stop — do not hand-roll a
34
- worktree/stack.
35
-
36
- ## 3. Execute the printed side effects
37
-
38
- Run the printed commands **in order**, exactly as printed:
39
-
40
- 1. **`git worktree add …`** — creates the sibling worktree on its branch. It is a
41
- **sibling** of this checkout, **never nested** inside it. If the worktree
42
- already exists, the engine prints the *attach* form (no `-b`) — do not clobber
43
- an existing worktree/branch.
44
- 2. **Write the `.env`** — write the printed `.env` contents into the new
45
- worktree's env file (default `.env`). Do this *after* the worktree exists.
46
- 3. **`docker compose … up -d`** — only printed when Docker is enabled. Brings the
47
- namespaced stack up in the spec's reserved port block.
48
- 4. **Opener** — if an `open.command` line was printed, run it (e.g. opens the
49
- worktree in your editor/terminal). Skipped silently when unset.
50
- 5. **Trust the worktree root for this session** — the engine already wrote the
51
- printed `trusted:` root into `.claude/settings.local.json` (gitignored, so it
52
- persists for future sessions). That file likely won't hot-reload mid-session,
53
- so also run `/add-dir <trusted root>` now to lift `Edit`/`Write` prompts for
54
- the **current** session. (If the engine printed a `trusted: ! …` warning,
55
- `settings.local.json` isn't valid JSON — fix it, then re-run.)
56
-
57
- ## 4. Report
58
-
59
- Echo the summary: worktree path, branch, project name, the allocated slot + port
60
- block, and whether the stack was brought up. **Idempotent** — re-running attaches
61
- to the existing slot/worktree and never reallocates.
62
-
63
- Tear down later with `/spec-env-down <spec>`.
@@ -1,64 +0,0 @@
1
- ---
2
- name: spec-env-down
3
- description: Tear down a spec's isolated environment — stop and remove its namespaced Docker stack (optionally backing up + dropping volumes), remove its git worktree, and free its slot. Guards refuse teardown on a dirty or unpushed worktree unless --force. Runs `skitterspec spec-env down` and executes the printed commands. Opt-in — needs specs/.core/env.config.json. Use when the user says "/spec-env-down", "tear down <spec>'s environment", "clean up the worktree/stack for <spec>", or "reclaim <spec>'s slot".
4
- ---
5
-
6
- # /spec-env-down — tear down a spec's isolated environment
7
-
8
- Reverse `/spec-env`: stop + remove the spec's Docker stack, remove its git
9
- worktree, and free its slot so the ports/slot are reclaimed. **Volumes are the
10
- only destructive part** — dropped by default (to reclaim disk) unless
11
- `--keep-volumes`, and always backed up first when `docker.backupCommand` is set.
12
-
13
- Opt-in: only works when `specs/.core/env.config.json` exists. If absent, say so
14
- and stop.
15
-
16
- ## 1. Identify the target spec
17
-
18
- - Use the spec named as an argument, else the spec **currently in context**. If
19
- neither is clear, ask which spec.
20
-
21
- ## 2. Plan the teardown
22
-
23
- Run the engine — it checks the guards, frees the slot, and **prints** the plan:
24
-
25
- ```
26
- skitterspec spec-env down <spec> [--keep-volumes] [--force]
27
- ```
28
-
29
- - **`--keep-volumes`** — keep the stack's data (plain `down`, no backup, no drop).
30
- - **`--force`** — override the guards below.
31
-
32
- ## 3. Handle a guard block
33
-
34
- If the CLI reports **blocked** (the worktree has uncommitted changes, or unpushed
35
- commits that aren't yet merged into the base branch), **relay the reason and
36
- stop** — do not destroy unreviewed work. Offer the user `--force` (and suggest
37
- committing/pushing first). Only re-run with `--force` when the user explicitly
38
- asks. **A branch already merged into the base needs no `--force`** — the unpushed
39
- guard treats "landed on base" as safe, so a completed spec (post-`/spec-complete`
40
- integrate) tears down cleanly even with no remote.
41
-
42
- ## 4. Execute the printed side effects
43
-
44
- When not blocked, run the printed commands **in order**, exactly as printed:
45
-
46
- 1. **Backup** (only when a `docker.backupCommand` is configured and volumes are
47
- being dropped) — writes a dump under `.spec-env/backups/` before anything is
48
- destroyed.
49
- 2. **`docker compose … down`** — with `--volumes` unless `--keep-volumes`.
50
- 3. **`git worktree remove …`** — removes the sibling worktree.
51
- 4. **`git branch -d <branch>`** — deletes the spec's branch (freed by the worktree
52
- removal above). It's `-d` (merged-only), never `-D`: if it reports the branch
53
- isn't fully merged, **relay that and stop** — don't `-D` it. That only happens
54
- on a `--force` teardown of unmerged work; the user can delete it by hand if
55
- they're sure.
56
-
57
- The slot is already freed by the CLI.
58
-
59
- ## 5. Report
60
-
61
- Confirm what happened: worktree removed, branch deleted, containers down, volumes
62
- **dropped|kept**, slot freed, and the backup path (if any). If a `git branch -d`
63
- was refused (unmerged), say so. If the spec wasn't provisioned / was already torn
64
- down, the CLI reports a clean **no-op** — relay that; it's not an error.
@@ -1,50 +0,0 @@
1
- ---
2
- name: spec-ready
3
- description: Mark a Draft spec as Ready — confirm it's groomed (no unresolved open questions, phases and per-phase tests defined, decisions captured) and flip its status to Ready so it's a candidate for /spec-go. Stays in specs/backlog/. Targets a spec by name (arg) or the spec in context. Use when the user says "/spec-ready", "this spec is ready", or "mark <spec> ready to start".
4
- ---
5
-
6
- # /spec-ready — promote a Draft spec to Ready
7
-
8
- A grooming gate between authoring (`/spec`, status `Draft`) and implementation
9
- (`/spec-go`, status `In Progress`). It does **not** move the spec — it stays in
10
- `specs/backlog/`; it only confirms quality and flips the status to `Ready` so
11
- you can see at a glance which backlog specs are good to start.
12
-
13
- ## 1. Identify the target spec
14
-
15
- - Use the name/path argument if given, else the spec **in context**. If unclear,
16
- ask which spec.
17
- - Locate the spec folder under `specs/backlog/`. Entry point is its
18
- `00-overview.md`; phases are separate files (`01-<slug>.md`, `02-…`) listed in
19
- its phase index (legacy specs may be a bare `<name>.md`).
20
-
21
- ## 2. Check it's actually ready — don't rubber-stamp
22
-
23
- Review the spec against the readiness bar. If any of these fail, **stop and tell
24
- the user what's missing** rather than marking it Ready:
25
-
26
- - **Open questions resolved** — the `## Open questions` section is empty or
27
- reads "None". Unresolved branches mean it isn't ready.
28
- - **Decisions captured** — the chosen solution and key trade-offs are recorded.
29
- - **Phased with clear tasks** — work is broken into phases, and **every phase in
30
- the `00-overview.md` index has a matching phase file** (`0N-<slug>.md`) with
31
- verb-first `- [ ]` tasks granular enough for one session. No index row without
32
- a file, no orphan file without an index row.
33
- - **Tests baked into every phase** — each phase file ends with a
34
- create-and-run-tests task (a phase isn't done until green).
35
- - **Concise and current** — no stale/contradictory sections.
36
-
37
- Offer to fix small gaps inline if the user wants; otherwise leave it `Draft`.
38
-
39
- ## 3. Mark Ready
40
-
41
- - Set the **Status** header in the entry point:
42
- `> **Status:** Ready (<YYYY-MM-DD>)`.
43
- - Append a **State log** row: `| <YYYY-MM-DD> | Ready | backlog | <git user.name> |`
44
- (no folder change — Ready stays in `backlog`).
45
- - Optionally add a **Changelog** note if grooming changed anything substantive.
46
-
47
- ## 4. Report
48
-
49
- Confirm it's Ready and note it stays in `backlog` until `/spec-go` picks it up.
50
- If you blocked it, list exactly what needs resolving first.