@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,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,61 @@
|
|
|
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.stages` / `release.ignorePaths` | The deployment ladder and bookkeeping path prefixes, 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
|
+
## Choosing your status map
|
|
28
|
+
|
|
29
|
+
Map the four buckets to **work** statuses, never to deployment stages. The
|
|
30
|
+
buckets mirror the repo's work lifecycle — folder buckets for the spec, phase
|
|
31
|
+
emoji for each subtask — so they translate honestly to To Do / In Progress /
|
|
32
|
+
Done and never to a pipeline (On Test → On Live): the repo learns where work
|
|
33
|
+
**is** as it happens, but it learns what **shipped** only when a release is
|
|
34
|
+
cut, so a pipeline status driven from the spec lifecycle is wrong the moment
|
|
35
|
+
it moves. The tell is the subtasks: a finished phase transitioned to `On Live`
|
|
36
|
+
before anything was released is the mapping asking status to carry two
|
|
37
|
+
different state machines.
|
|
38
|
+
|
|
39
|
+
If your workflow encodes a deployment pipeline, three standard Jira moves keep
|
|
40
|
+
the map honest:
|
|
41
|
+
|
|
42
|
+
- **Give subtasks their own workflow.** A workflow *scheme* can assign the
|
|
43
|
+
subtask type a plain To Do / In Progress / Done workflow while the parent
|
|
44
|
+
type keeps the pipeline — subtasks genuinely have no delivery lifecycle, and
|
|
45
|
+
Jira lets you say so structurally.
|
|
46
|
+
- **Track "which release contains this" with fix versions** — the Releases
|
|
47
|
+
hub, the analogue of what `spec-sync released` computes from `Refs:`
|
|
48
|
+
trailers. (Available on every plan; in the current "Spaces" UI it is not a
|
|
49
|
+
Features toggle but a **tab** — add it with `+` in the space's tab bar.)
|
|
50
|
+
- **Let CI/CD integrations stamp environment position** (the deployments
|
|
51
|
+
panel), so it never needs to be a status at all.
|
|
52
|
+
|
|
53
|
+
**Seeing the map is one command:** `spec-sync states` renders every bucket
|
|
54
|
+
against the live project — the configured status, a verdict per row with the
|
|
55
|
+
status's board category, the closest match when one is missing, and the
|
|
56
|
+
one-line remap. Run it whenever the wiring feels uncertain; it changes
|
|
57
|
+
nothing.
|
|
58
|
+
|
|
59
|
+
Walking the parent through pipeline stages at *release* time is the
|
|
60
|
+
`release.stages` ladder — deferred in this provider's v1 — not a job for the
|
|
61
|
+
lifecycle skills.
|
|
@@ -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']) {
|
|
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']) {
|
|
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 }
|