@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,81 @@
1
+ # `gating.config.json` — release-gating config
2
+
3
+ Opt-in config for **release gating**: whether each spec records a decision about
4
+ shipping behind a feature flag.
5
+
6
+ The problem it solves is not "we forgot to use a flag" — it is that
7
+ **nobody can tell whether the question was asked**. A spec can go from `/spec`
8
+ through
9
+ implementation to `/spec-complete` with no flag and no mention of one, and that
10
+ is indistinguishable from "we considered it and decided against". Prose guidance
11
+ in a project rule has already been shown not to close that gap; a required header
12
+ does, because its absence is visible.
13
+
14
+ **Skitterspec bakes in the offer, never the mechanism.** How your project does
15
+ flags — a resolver, an admin toggle, env precedence, a vendor SDK — is none of
16
+ its business, and it never reads your flag code. It asks the question, cites your
17
+ own documentation, and records the answer.
18
+
19
+ **Adopt it** by copying `gating.config.json.example` → `gating.config.json` here
20
+ (or `skitterspec init --gating`). While this file is absent the feature is
21
+ entirely unused: no grill question, no header, nothing to check — which is read
22
+ as "this project does not use feature flags".
23
+
24
+ ## Fields
25
+
26
+ ```jsonc
27
+ {
28
+ // Where THIS project documents how its flags work — a repo-relative path,
29
+ // cited when the question is asked so the answer is an informed one.
30
+ // Skitterspec never reads the file; it only names it. Empty = say nothing.
31
+ "guidance": ".claude/rules/feature-flags.md",
32
+
33
+ // The value written when the user declines a flag, so a project can
34
+ // standardise its wording. Must keep the `none: <reason>` shape — the reason
35
+ // half is the whole point (see below). Empty falls back to `none: <reason>`.
36
+ "default": "none: <reason>"
37
+ }
38
+ ```
39
+
40
+ ## The header it drives
41
+
42
+ With this file present, every spec `/spec`, `/spec-bug` and `/spec-hotfix` write
43
+ carries a `Gating:` field beside `Stack:` in `00-overview.md`:
44
+
45
+ ```
46
+ > **Gating:** search-ranking-v2
47
+ > **Gating:** none: additive, nothing to revert
48
+ ```
49
+
50
+ Two valid shapes, and one that is not:
51
+
52
+ | Value | Meaning |
53
+ |-------|---------|
54
+ | a flag name | ships behind that flag |
55
+ | `none: <reason>` | deliberately not flagged, and why |
56
+ | *missing, empty, or a bare `none`* | **not a decision** — nobody answered |
57
+
58
+ The reason half is load-bearing. `none: additive, nothing to revert` is a
59
+ decision a reviewer can disagree with; a bare `none` is a shrug, and a missing
60
+ line is an oversight. Distinguishing those three is the entire feature.
61
+
62
+ ## What it never does
63
+
64
+ - **It never blocks.** `skitterspec gating check` reports and exits 0;
65
+ `/spec-start` and `/spec-complete` mention a missing header and carry on.
66
+ Nothing here can stop a spec being started, completed or landed.
67
+ - **It never accuses an old spec.** Only `specs/backlog/` and
68
+ `specs/in-progress/` are read. Specs finished or abandoned before you adopted
69
+ gating are out of range by construction, not by a filter someone has to
70
+ remember.
71
+ - **It never learns your flag system.** `guidance` is a path it prints, nothing
72
+ more.
73
+
74
+ ## Checking
75
+
76
+ ```
77
+ skitterspec gating check # the spec in flight here
78
+ skitterspec gating check <spec> # one named spec
79
+ skitterspec gating check --all # every active spec
80
+ skitterspec gating check --json # for tooling
81
+ ```
@@ -0,0 +1,18 @@
1
+ {
2
+ "jira": {
3
+ "siteUrl": "https://your-site.atlassian.net",
4
+ "projectKey": "PROJ",
5
+ "issueType": "Task",
6
+ "subtaskType": "Subtask"
7
+ },
8
+ "states": {
9
+ "backlog": "To Do",
10
+ "in-progress": "In Progress",
11
+ "complete": "Done",
12
+ "cancelled": "Done"
13
+ },
14
+ "auth": {
15
+ "emailEnv": "JIRA_EMAIL",
16
+ "tokenEnv": "JIRA_API_TOKEN"
17
+ }
18
+ }
@@ -0,0 +1,88 @@
1
+ # `jira.config.json` — field reference
2
+
3
+ The committed, shareable half of Jira sync. It holds ids, names and env-var
4
+ NAMES — never a credential. Credentials live in the user-level store
5
+ (`skitterspec-jira spec-sync credentials set`) or the environment.
6
+
7
+ | Key | Meaning | Default |
8
+ |-----|---------|---------|
9
+ | `jira.siteUrl` | The Jira Cloud site (`https://<site>.atlassian.net`). Also keys the credentials-store entry. | `""` |
10
+ | `jira.projectKey` | Where spec issues land — the identifier prefix (`PROJ-123`). Jira's project is the analogue of Linear's team; placement is decided here, once. | `""` |
11
+ | `jira.issueType` | The issue type a spec mints as. | `"Task"` |
12
+ | `jira.subtaskType` | The type a phase mints as (must be a subtask type). | `"Subtask"` |
13
+ | `mapping.specFolder` / `mapping.phases` / `mapping.tasks` | Spec → issue, phase → subtask, tasks → ADF checklist. Same modes as the Linear provider (`subissue` \| `deferred` \| `inline`; `checklist` \| `none`). | `issue` / `subissue` / `checklist` |
14
+ | `states.<bucket>` | The Jira STATUS name for each lifecycle bucket. Must match the project's workflow statuses exactly; moves are applied by workflow transition, and an unreachable status refuses loudly. `cancelled` defaults to `Done` because default Jira workflows carry no Cancelled status. Pick work statuses, not pipeline stages — see **Choosing your status map** below. | see example |
15
+ | `auth.emailEnv` / `auth.tokenEnv` | Env-var NAMES for the email + API-token pair (basic auth). | `JIRA_EMAIL` / `JIRA_API_TOKEN` |
16
+ | `apply.transport` | `api` \| `mcp`, or empty for "api when credentials resolve, mcp when they don't". | `""` |
17
+ | `sync.baseDir` | Where last-pushed snapshots live. | `specs/.core/jira-base` |
18
+ | `sync.fieldOwnership` | Which projection fields push (`description`, `subIssues`, `workflowState`, `assignee`; `"none"` declines one). | all `push` |
19
+ | `release.ignorePaths` | Bookkeeping path prefixes `released` ignores, exactly as the Linear provider defines them. | `specs/`, changelogs |
20
+
21
+ Descriptions are stored as ADF (Atlassian Document Format): the adapter
22
+ renders markdown to ADF on push and reduces ADF back to text on read-back, so
23
+ verification compares words, not bytes. Phase checklists render as live ADF
24
+ task lists — still a read-only mirror: a box ticked in Jira is overwritten on
25
+ the next push.
26
+
27
+ ## More than one name per bucket — read-only aliases
28
+
29
+ A board usually has more states than the four buckets: "Blocked", "Next
30
+ Sprint", "Released". A bucket's value may be an **array** instead of a string:
31
+
32
+ ```jsonc
33
+ "states": {
34
+ "in-progress": ["Active", "Blocked"],
35
+ "complete": ["Closed", "Released"]
36
+ }
37
+ ```
38
+
39
+ - **The first name is the only one push writes**, and only when the spec
40
+ itself moves. The rest are never written.
41
+ - **The rest are read as that bucket** wherever a listing interprets a status.
42
+ The array is parsed and validated like every other provider's, so one config
43
+ shape works across all of them. `spec-sync status` never reads a ticket's
44
+ status at all: skitterspec tells Jira about its own transitions and nothing
45
+ else.
46
+ - **Nothing is pulled.** An alias changes what the report *says* about a
47
+ ticket, never the spec.
48
+ - A plain string works exactly as before. An alias that also names another
49
+ bucket is refused at load. An alias the tracker lacks is reported by
50
+ `spec-sync states` but never blocks a push, because it is never written.
51
+
52
+ ## Choosing your status map
53
+
54
+ Map the four buckets to **work** statuses, never to deployment stages. The
55
+ buckets mirror the repo's work lifecycle — folder buckets for the spec, phase
56
+ emoji for each subtask — so they translate honestly to To Do / In Progress /
57
+ Done and never to a pipeline (On Test → On Live): the repo learns where work
58
+ **is** as it happens, but it learns what **shipped** only when a release is
59
+ cut, so a pipeline status driven from the spec lifecycle is wrong the moment
60
+ it moves. The tell is the subtasks: a finished phase transitioned to `On Live`
61
+ before anything was released is the mapping asking status to carry two
62
+ different state machines.
63
+
64
+ If your workflow encodes a deployment pipeline, three standard Jira moves keep
65
+ the map honest:
66
+
67
+ - **Give subtasks their own workflow.** A workflow *scheme* can assign the
68
+ subtask type a plain To Do / In Progress / Done workflow while the parent
69
+ type keeps the pipeline — subtasks genuinely have no delivery lifecycle, and
70
+ Jira lets you say so structurally.
71
+ - **Track "which release contains this" with fix versions** — the Releases
72
+ hub. Your CI can fill it from `spec-sync released "<prev>..<tag>" --json`,
73
+ which lists the tickets a range contains from their `Refs:` trailers, each
74
+ with its spec and url. (Available on every plan; in the current "Spaces" UI it is not a
75
+ Features toggle but a **tab** — add it with `+` in the space's tab bar.)
76
+ - **Let CI/CD integrations stamp environment position** (the deployments
77
+ panel), so it never needs to be a status at all.
78
+
79
+ **Seeing the map is one command:** `spec-sync states` renders every bucket
80
+ against the live project — the configured status, a verdict per row with the
81
+ status's board category, the closest match when one is missing, and the
82
+ one-line remap. Run it whenever the wiring feels uncertain; it changes
83
+ nothing.
84
+
85
+ Walking the parent through pipeline stages at *release* time is the host
86
+ project's job, not skitterspec's: it writes nothing about releases or
87
+ deployments on any provider. A leftover `release.stages` is flagged by
88
+ `spec-sync doctor` as unused config.
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ /**
5
+ * Main guard — the harness half.
6
+ *
7
+ * `main` is where work **lands**, not where it happens. The skills say so, and
8
+ * a skill is prose: it can be chained past, typed around, or simply not read.
9
+ * So the rule is also enforced one level down, where an `Edit` or a `Write` is
10
+ * a tool call the harness has to ask about first.
11
+ *
12
+ * IT REFUSES THE FIRST WRITE, NOT THE COMMIT, and that is the whole reason it
13
+ * is a different hook from `review-gate.cjs` rather than another branch inside
14
+ * it. Nothing has been written when this fires, so the fix costs one command —
15
+ * `/no-spec <name>` and carry on. A commit-time gate fires once the work
16
+ * already exists in the primary checkout, and in worktree mode moving it is the
17
+ * genuinely painful part: the primary checkout cannot `switch -c` out from
18
+ * under the other worktrees.
19
+ *
20
+ * THIS SCRIPT DECIDES NOTHING. It reads the tool call, hands the directory to
21
+ * `spec-env main check`, and turns one exit status into an answer. Every
22
+ * judgement — is isolation configured, is this the primary checkout, is HEAD
23
+ * the base branch, is an allow in force — lives in the engine, where it is unit
24
+ * tested. A hook with its own opinions is a second implementation that nothing
25
+ * can test and nobody remembers to update.
26
+ *
27
+ * IT FAILS OPEN, EVERYWHERE, for the same reasons `review-gate.cjs` does and
28
+ * with more at stake, because this one fires far more often. No engine, an
29
+ * unreadable payload, a repo with no isolation, a crash of its own — all of
30
+ * them allow the write. Being wrong the permissive way costs one edit on a rule
31
+ * the skills also carry; being wrong the other way costs someone their work
32
+ * with no idea why (`.claude/rules/negative-checks.md`).
33
+ */
34
+
35
+ const fs = require('node:fs')
36
+ const path = require('node:path')
37
+ const { spawnSync } = require('node:child_process')
38
+
39
+ // Let the call through, saying nothing. Exit 0 with no JSON is "no opinion" —
40
+ // the tool proceeds exactly as if this hook did not exist, which is what the
41
+ // overwhelming majority of invocations must do.
42
+ function allow() {
43
+ process.exit(0)
44
+ }
45
+
46
+ /**
47
+ * Refuse, and say what to do instead.
48
+ *
49
+ * The JSON route rather than exit-2, because the reason reaches **Claude** —
50
+ * which is who has to act on it, by moving the work rather than by asking the
51
+ * user to switch the guard off. The exits are named in the refusal itself.
52
+ */
53
+ function deny(reason) {
54
+ process.stdout.write(
55
+ JSON.stringify({
56
+ hookSpecificOutput: {
57
+ hookEventName: 'PreToolUse',
58
+ permissionDecision: 'deny',
59
+ permissionDecisionReason: reason,
60
+ },
61
+ }) + '\n',
62
+ )
63
+ process.exit(0)
64
+ }
65
+
66
+ function readPayload() {
67
+ try {
68
+ const raw = fs.readFileSync(0, 'utf8')
69
+ const parsed = JSON.parse(raw)
70
+ return parsed && typeof parsed === 'object' ? parsed : null
71
+ } catch {
72
+ // Unreadable or unparseable stdin is the cannot-tell case, not evidence.
73
+ return null
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Where the engine is, in the order most likely to be right. Pure but for the
79
+ * existence check. Identical to `review-gate.cjs`'s, deliberately: a hook
80
+ * process does not inherit a package manager's `node_modules/.bin` on PATH, so
81
+ * the local install is looked for by path first — walking up from the project,
82
+ * because a git worktree's checkout has its own `node_modules`.
83
+ */
84
+ function findEngine(projectDir) {
85
+ if (process.env.SKITTERSPEC_BIN) return process.env.SKITTERSPEC_BIN
86
+ let at = projectDir
87
+ for (let i = 0; i < 6 && at; i++) {
88
+ for (const name of ['skitterspec', 'skitterspec-linear', 'skitterspec-jira', 'skitterspec-github', 'skitterspec-azure-boards']) {
89
+ const candidate = path.join(at, 'node_modules', '.bin', name)
90
+ if (fs.existsSync(candidate)) return candidate
91
+ }
92
+ const up = path.dirname(at)
93
+ if (up === at) break
94
+ at = up
95
+ }
96
+ return 'skitterspec'
97
+ }
98
+
99
+ // The tools that write a file. A matcher is configured too, but a hook that is
100
+ // reached some other way must still be inert rather than guessing.
101
+ //
102
+ // WHAT WOULD FOOL THIS: a write that does not go through a write TOOL — a shell
103
+ // heredoc, `sed -i`, an editor outside the session. Those are left unguarded on
104
+ // purpose. Covering them would mean parsing arbitrary shell for filesystem
105
+ // effects, which cannot be done reliably, and the failure mode of getting it
106
+ // wrong is refusing a command that has nothing to do with writing files. The
107
+ // skills carry the rule for everything this matcher cannot see.
108
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
109
+
110
+ // The path a write tool names: `file_path` for Edit, Write and MultiEdit,
111
+ // `notebook_path` for NotebookEdit. Anything else is no path at all.
112
+ function writeTarget(input) {
113
+ if (!input || typeof input !== 'object') return null
114
+ for (const key of ['file_path', 'notebook_path']) {
115
+ if (typeof input[key] === 'string' && input[key]) return input[key]
116
+ }
117
+ return null
118
+ }
119
+
120
+ function main() {
121
+ const payload = readPayload()
122
+ if (!payload) allow()
123
+
124
+ if (!WRITE_TOOLS.has(payload.tool_name)) allow()
125
+
126
+ // A worktree is its own checkout, and only the primary one is this guard's
127
+ // business. `cwd` is where the session stands; the target below is where the
128
+ // write lands, and the engine prefers it.
129
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd()
130
+ const session = typeof payload.session_id === 'string' ? payload.session_id : ''
131
+
132
+ const argv = ['spec-env', 'main', 'check', '--dir', cwd]
133
+ if (session) argv.push('--session', session)
134
+ // The FILE decides where the write lands, not the session's directory. Judged
135
+ // on `cwd` alone, a session on the base branch was refused its own scratchpad
136
+ // under /tmp, and a session in a worktree could write into the primary
137
+ // checkout unseen. No path in the payload → the engine judges `cwd`, as before.
138
+ const target = writeTarget(payload.tool_input)
139
+ if (target) argv.push('--path', target)
140
+
141
+ const result = spawnSync(findEngine(cwd), argv, { cwd, encoding: 'utf8', timeout: 8000 })
142
+
143
+ // No engine, a crash, a timeout — all cannot-tell. `status` is null when the
144
+ // process never ran or was killed, and neither is a refusal.
145
+ if (result.error || result.status === null) allow()
146
+ if (result.status !== 1) allow()
147
+
148
+ deny((result.stdout || '').trim())
149
+ }
150
+
151
+ // Guarded so the pieces above can be required and tested without the script
152
+ // reading stdin and exiting.
153
+ if (require.main === module) {
154
+ try {
155
+ main()
156
+ } catch {
157
+ // Even a bug in this file lets the write through.
158
+ allow()
159
+ }
160
+ }
161
+
162
+ module.exports = { findEngine, WRITE_TOOLS, writeTarget }
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ /**
5
+ * Review gate — the harness half.
6
+ *
7
+ * `/spec-next` refuses to build the next phase while a review is owed, but a
8
+ * skill is prose: it can be chained past, typed around, or simply not read. So
9
+ * a phase ending is also enforced one level down, where a `git commit` is a
10
+ * tool call the harness has to ask about first.
11
+ *
12
+ * THIS SCRIPT DECIDES NOTHING. It reads the tool call, hands the command line
13
+ * to `spec-env review gate --check --for-command`, and turns one exit status
14
+ * into an answer. Every judgement — is this a commit, is a verdict owed, is
15
+ * this project even using the gate — lives in the engine, where it is unit
16
+ * tested. A hook with its own opinions is a second implementation that nothing
17
+ * can test and nobody remembers to update.
18
+ *
19
+ * IT FAILS OPEN, EVERYWHERE. No engine on the machine, an unreadable payload, a
20
+ * repo with no isolation, a spec it cannot resolve, a crash of its own — all of
21
+ * them allow the commit. Blocking one is an accusation
22
+ * (`.claude/rules/negative-checks.md`), and the only thing worth accusing on is
23
+ * the engine saying, positively, that this spec owes a verdict. Being wrong the
24
+ * other way costs one unreviewed commit, on a gate the skills also enforce;
25
+ * being wrong this way costs someone their commit with no idea why.
26
+ */
27
+
28
+ const fs = require('node:fs')
29
+ const path = require('node:path')
30
+ const { spawnSync } = require('node:child_process')
31
+
32
+ // Let the call through, saying nothing. Exit 0 with no JSON is "no opinion" —
33
+ // the tool proceeds exactly as if this hook did not exist, which is what almost
34
+ // every invocation must do.
35
+ function allow() {
36
+ process.exit(0)
37
+ }
38
+
39
+ /**
40
+ * Refuse, and say what would clear it.
41
+ *
42
+ * The JSON route rather than exit-2, because the reason reaches **Claude** —
43
+ * which is who has to act on it. A commit blocked with no route onward is how a
44
+ * gate gets switched off wholesale instead of answered, so the three exits are
45
+ * named in the refusal itself.
46
+ */
47
+ function deny(reason) {
48
+ process.stdout.write(
49
+ JSON.stringify({
50
+ hookSpecificOutput: {
51
+ hookEventName: 'PreToolUse',
52
+ permissionDecision: 'deny',
53
+ permissionDecisionReason: reason,
54
+ },
55
+ }) + '\n',
56
+ )
57
+ process.exit(0)
58
+ }
59
+
60
+ function readPayload() {
61
+ try {
62
+ const raw = fs.readFileSync(0, 'utf8')
63
+ const parsed = JSON.parse(raw)
64
+ return parsed && typeof parsed === 'object' ? parsed : null
65
+ } catch {
66
+ // Unreadable or unparseable stdin is the cannot-tell case, not evidence.
67
+ return null
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Where the engine is, in the order most likely to be right. Pure but for the
73
+ * existence check.
74
+ *
75
+ * A hook process does not inherit a package manager's `node_modules/.bin` on
76
+ * PATH, so the local install is looked for by path first — walking up from the
77
+ * project, because a git worktree's checkout has its own `node_modules`. PATH
78
+ * is the fallback for a global install, and `SKITTERSPEC_BIN` overrides
79
+ * everything for anyone whose layout is neither.
80
+ */
81
+ function findEngine(projectDir) {
82
+ if (process.env.SKITTERSPEC_BIN) return process.env.SKITTERSPEC_BIN
83
+ let at = projectDir
84
+ for (let i = 0; i < 6 && at; i++) {
85
+ for (const name of ['skitterspec', 'skitterspec-linear', 'skitterspec-jira', 'skitterspec-github', 'skitterspec-azure-boards']) {
86
+ const candidate = path.join(at, 'node_modules', '.bin', name)
87
+ if (fs.existsSync(candidate)) return candidate
88
+ }
89
+ const up = path.dirname(at)
90
+ if (up === at) break
91
+ at = up
92
+ }
93
+ return 'skitterspec'
94
+ }
95
+
96
+ function main() {
97
+ const payload = readPayload()
98
+ if (!payload) allow()
99
+
100
+ // Only Bash runs a commit. A matcher is configured too, but a hook that is
101
+ // reached some other way must still be inert rather than guessing.
102
+ if (payload.tool_name !== 'Bash') allow()
103
+
104
+ const command = payload.tool_input && payload.tool_input.command
105
+ if (typeof command !== 'string' || !command.trim()) allow()
106
+
107
+ // The commit's own directory decides which spec this is about — a worktree is
108
+ // its own checkout, and the answer differs per worktree.
109
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd()
110
+
111
+ const result = spawnSync(
112
+ findEngine(cwd),
113
+ ['spec-env', 'review', 'gate', '--check', '--for-command', command, '--dir', cwd],
114
+ { cwd, encoding: 'utf8', timeout: 8000 },
115
+ )
116
+
117
+ // No engine, a crash, a timeout — all cannot-tell. `status` is null when the
118
+ // process never ran or was killed, and neither is a refusal.
119
+ if (result.error || result.status === null) allow()
120
+ if (result.status !== 1) allow()
121
+
122
+ deny(
123
+ (result.stdout || '').trim() +
124
+ '\n\nThis phase is waiting on a verdict. Relay these to the operator ' +
125
+ 'rather than choosing one: press a verdict on the page, /spec-reviewed if ' +
126
+ 'a pass is already waiting, or /spec-skip "<reason>" to move on with the ' +
127
+ 'reason recorded.\n\nAll three are theirs to type. /spec-skip is the same ' +
128
+ 'exit as \'skitterspec spec-env review skip "<reason>"\' — a command ' +
129
+ 'rather than an incantation, because the long form is what nobody retypes ' +
130
+ 'and a gate with an unreachable exit gets switched off wholesale instead ' +
131
+ 'of answered.',
132
+ )
133
+ }
134
+
135
+ // Guarded so the pieces above can be required and tested without the script
136
+ // reading stdin and exiting.
137
+ if (require.main === module) {
138
+ try {
139
+ main()
140
+ } catch {
141
+ // Even a bug in this file lets the commit through.
142
+ allow()
143
+ }
144
+ }
145
+
146
+ module.exports = { findEngine }