@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.
Files changed (109) hide show
  1. package/MIGRATION.md +1089 -0
  2. package/README.md +42 -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 +554 -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 +61 -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 +692 -0
  23. package/assets/rules/spec-reports.md +557 -0
  24. package/assets/skills/no-spec/SKILL.md +246 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +583 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +448 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +605 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +220 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +676 -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 +112 -0
  40. package/assets/skills/spec-sync/SKILL.md +218 -0
  41. package/assets/skills/spec-to-main/SKILL.md +139 -0
  42. package/bin/skitterspec-jira.js +61 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5901 -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 +753 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/hooks.js +226 -0
  53. package/src/env/hotfix.js +385 -0
  54. package/src/env/hotfixland.js +302 -0
  55. package/src/env/integrate.js +126 -0
  56. package/src/env/live.js +504 -0
  57. package/src/env/mainguard.js +299 -0
  58. package/src/env/provision.js +466 -0
  59. package/src/env/proxy.js +192 -0
  60. package/src/env/prune.js +119 -0
  61. package/src/env/registry.js +159 -0
  62. package/src/env/render.js +17 -0
  63. package/src/env/resolve.js +716 -0
  64. package/src/env/review.js +2577 -0
  65. package/src/env/reviewers/coderabbit.js +228 -0
  66. package/src/env/reviewers.js +517 -0
  67. package/src/env/serve.js +1044 -0
  68. package/src/env/supervise.js +157 -0
  69. package/src/env/teardown.js +248 -0
  70. package/src/env/trust.js +87 -0
  71. package/src/gating.js +155 -0
  72. package/src/init.js +1150 -0
  73. package/src/lines-diff.js +114 -0
  74. package/src/prompts.js +129 -0
  75. package/src/vendor/jira/adf.js +414 -0
  76. package/src/vendor/jira/api.js +295 -0
  77. package/src/vendor/jira/cli-sync.js +1256 -0
  78. package/src/vendor/jira/commands.js +44 -0
  79. package/src/vendor/jira/config.js +296 -0
  80. package/src/vendor/jira/credentials.js +108 -0
  81. package/src/vendor/jira/dialect.js +45 -0
  82. package/src/vendor/jira/mcp.js +129 -0
  83. package/src/vendor/provider-kit/index.js +41 -0
  84. package/src/vendor/provider-kit/src/adapter.js +23 -0
  85. package/src/vendor/provider-kit/src/apply.js +197 -0
  86. package/src/vendor/provider-kit/src/args.js +68 -0
  87. package/src/vendor/provider-kit/src/config.js +198 -0
  88. package/src/vendor/provider-kit/src/credentials.js +404 -0
  89. package/src/vendor/provider-kit/src/doctor.js +35 -0
  90. package/src/vendor/provider-kit/src/http.js +44 -0
  91. package/src/vendor/provider-kit/src/identity.js +93 -0
  92. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  93. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  94. package/src/vendor/provider-kit/src/prompt.js +50 -0
  95. package/src/vendor/provider-kit/src/released.js +226 -0
  96. package/src/vendor/provider-kit/src/specs.js +166 -0
  97. package/src/vendor/provider-kit/src/transport.js +22 -0
  98. package/src/vendor/sync-core/index.js +92 -0
  99. package/src/vendor/sync-core/src/base.js +81 -0
  100. package/src/vendor/sync-core/src/compare.js +306 -0
  101. package/src/vendor/sync-core/src/dialect.js +51 -0
  102. package/src/vendor/sync-core/src/fields.js +36 -0
  103. package/src/vendor/sync-core/src/legacy.js +100 -0
  104. package/src/vendor/sync-core/src/normalize.js +989 -0
  105. package/src/vendor/sync-core/src/push.js +85 -0
  106. package/src/vendor/sync-core/src/retarget.js +278 -0
  107. package/src/vendor/sync-core/src/task-block.js +308 -0
  108. package/src/vendor/sync-core/src/verify.js +67 -0
  109. 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 }