@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.
- package/MIGRATION.md +1266 -0
- package/README.md +44 -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 +606 -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 +88 -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 +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -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 +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -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 +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -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 +2622 -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 +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -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 +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -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 +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -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 +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -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 +338 -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 +971 -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,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 }
|