@vegastack/vegafactory 0.19.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +417 -0
  3. package/dist/dashboard-artifact.json +21240 -0
  4. package/dist/index.js +22752 -0
  5. package/dist/run-wrapper.js +377 -0
  6. package/package.json +46 -0
  7. package/skill/dev-architect/SKILL.md +106 -0
  8. package/skill/dev-architect/agents/openai.yaml +4 -0
  9. package/skill/dev-architect/references/ai-agents.md +96 -0
  10. package/skill/dev-architect/references/conventions.md +113 -0
  11. package/skill/dev-architect/references/data.md +89 -0
  12. package/skill/dev-architect/references/infra.md +99 -0
  13. package/skill/dev-architect/references/mobile.md +75 -0
  14. package/skill/dev-architect/references/pinned-facts.md +133 -0
  15. package/skill/dev-architect/references/principles.md +117 -0
  16. package/skill/dev-architect/references/security.md +90 -0
  17. package/skill/dev-architect/references/stack.md +38 -0
  18. package/skill/dev-architect/references/web.md +102 -0
  19. package/skill/dev-architect/refresh/REFRESH.md +31 -0
  20. package/skill/dev-architect/refresh/sources.json +239 -0
  21. package/skill/dev-chronicle/SKILL.md +52 -0
  22. package/skill/dev-chronicle/agents/openai.yaml +4 -0
  23. package/skill/dev-chronicle/references/conventions.md +113 -0
  24. package/skill/dev-chronicle/references/styles.md +75 -0
  25. package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
  26. package/skill/dev-chronicle/refresh/sources.json +6 -0
  27. package/skill/dev-debug/SKILL.md +43 -0
  28. package/skill/dev-debug/agents/openai.yaml +4 -0
  29. package/skill/dev-debug/references/conventions.md +113 -0
  30. package/skill/dev-debug/references/loop-ladder.md +20 -0
  31. package/skill/dev-debug/refresh/REFRESH.md +3 -0
  32. package/skill/dev-debug/refresh/sources.json +6 -0
  33. package/skill/dev-implement/SKILL.md +81 -0
  34. package/skill/dev-implement/agents/openai.yaml +4 -0
  35. package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
  36. package/skill/dev-implement/references/ask-route.md +80 -0
  37. package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
  38. package/skill/dev-implement/references/conventions.md +113 -0
  39. package/skill/dev-implement/references/ledger-and-resume.md +52 -0
  40. package/skill/dev-implement/references/parallel-children.md +35 -0
  41. package/skill/dev-implement/references/worktrees.md +63 -0
  42. package/skill/dev-implement/refresh/REFRESH.md +3 -0
  43. package/skill/dev-implement/refresh/sources.json +6 -0
  44. package/skill/dev-implement/scripts/children.mjs +479 -0
  45. package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
  46. package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
  47. package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
  48. package/skill/dev-implement/scripts/learning.mjs +41 -0
  49. package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
  50. package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
  51. package/skill/dev-implement/scripts/preflight.mjs +153 -0
  52. package/skill/dev-implement/scripts/questions.mjs +381 -0
  53. package/skill/dev-implement/scripts/reclaim.mjs +116 -0
  54. package/skill/dev-implement/scripts/recovery.mjs +213 -0
  55. package/skill/dev-implement/scripts/worktree.mjs +991 -0
  56. package/skill/dev-intake/SKILL.md +80 -0
  57. package/skill/dev-intake/agents/openai.yaml +4 -0
  58. package/skill/dev-intake/references/ask-route.md +80 -0
  59. package/skill/dev-intake/references/brief-template.md +89 -0
  60. package/skill/dev-intake/references/conventions.md +113 -0
  61. package/skill/dev-intake/refresh/REFRESH.md +3 -0
  62. package/skill/dev-intake/refresh/sources.json +6 -0
  63. package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
  64. package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
  65. package/skill/dev-intake/scripts/questions.mjs +381 -0
  66. package/skill/dev-plan/SKILL.md +54 -0
  67. package/skill/dev-plan/agents/openai.yaml +4 -0
  68. package/skill/dev-plan/references/ask-route.md +80 -0
  69. package/skill/dev-plan/references/conventions.md +113 -0
  70. package/skill/dev-plan/references/plan-format.md +82 -0
  71. package/skill/dev-plan/refresh/REFRESH.md +3 -0
  72. package/skill/dev-plan/refresh/sources.json +6 -0
  73. package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
  74. package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
  75. package/skill/dev-plan/scripts/questions.mjs +381 -0
  76. package/skill/dev-review/SKILL.md +76 -0
  77. package/skill/dev-review/agents/openai.yaml +4 -0
  78. package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
  79. package/skill/dev-review/references/conventions.md +113 -0
  80. package/skill/dev-review/references/cross-agent.md +39 -0
  81. package/skill/dev-review/references/dispatch-prompts.md +121 -0
  82. package/skill/dev-review/references/security-axis.md +45 -0
  83. package/skill/dev-review/refresh/REFRESH.md +5 -0
  84. package/skill/dev-review/refresh/sources.json +6 -0
  85. package/skill/dev-setup/SKILL.md +113 -0
  86. package/skill/dev-setup/agents/openai.yaml +4 -0
  87. package/skill/dev-setup/assets/agents-section.md.template +24 -0
  88. package/skill/dev-setup/assets/dev-profile.md.template +102 -0
  89. package/skill/dev-setup/assets/factory-board.yml.template +162 -0
  90. package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
  91. package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
  92. package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
  93. package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
  94. package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
  95. package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
  96. package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
  97. package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
  98. package/skill/dev-setup/references/ask-route.md +80 -0
  99. package/skill/dev-setup/references/conventions.md +113 -0
  100. package/skill/dev-setup/references/github-app.md +286 -0
  101. package/skill/dev-setup/references/harness-facts.md +169 -0
  102. package/skill/dev-setup/references/stack-playbooks.md +124 -0
  103. package/skill/dev-setup/refresh/REFRESH.md +13 -0
  104. package/skill/dev-setup/refresh/sources.json +602 -0
  105. package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
  106. package/skill/dev-setup/scripts/questions.mjs +381 -0
  107. package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
  108. package/skill/dev-ship/SKILL.md +56 -0
  109. package/skill/dev-ship/agents/openai.yaml +4 -0
  110. package/skill/dev-ship/references/conventions.md +113 -0
  111. package/skill/dev-ship/references/runbook.md +72 -0
  112. package/skill/dev-ship/refresh/REFRESH.md +3 -0
  113. package/skill/dev-ship/refresh/sources.json +6 -0
  114. package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
  115. package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
  116. package/skill/dev-status/SKILL.md +56 -0
  117. package/skill/dev-status/agents/openai.yaml +4 -0
  118. package/skill/dev-status/references/conventions.md +113 -0
  119. package/skill/dev-status/refresh/REFRESH.md +3 -0
  120. package/skill/dev-status/refresh/sources.json +6 -0
  121. package/skill/dev-status/scripts/effective-policy.mjs +694 -0
  122. package/skill/dev-status/scripts/status.mjs +311 -0
  123. package/skill/skill-maintainer/SKILL.md +82 -0
  124. package/skill/skill-maintainer/agents/openai.yaml +4 -0
  125. package/skill/skill-maintainer/references/release-ops.md +61 -0
  126. package/skill/skill-maintainer/references/standards.md +137 -0
  127. package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
  128. package/skill/skill-maintainer/refresh/sources.json +103 -0
  129. package/skill/skill-scan/SKILL.md +59 -0
  130. package/skill/skill-scan/agents/openai.yaml +4 -0
  131. package/skill/skill-scan/refresh/REFRESH.md +18 -0
  132. package/skill/skill-scan/refresh/sources.json +133 -0
  133. package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
  134. package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
  135. package/skill/skillify/SKILL.md +108 -0
  136. package/skill/skillify/agents/openai.yaml +4 -0
  137. package/skill/skillify/assets/templates/README.md.template +26 -0
  138. package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
  139. package/skill/skillify/assets/templates/SKILL.md.template +27 -0
  140. package/skill/skillify/assets/templates/evals.json.template +12 -0
  141. package/skill/skillify/assets/templates/openai.yaml.template +4 -0
  142. package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
  143. package/skill/skillify/assets/templates/sources.json.template +6 -0
  144. package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
  145. package/skill/skillify/references/authoring.md +103 -0
  146. package/skill/skillify/references/eval-playbook.md +112 -0
  147. package/skill/skillify/refresh/REFRESH.md +23 -0
  148. package/skill/skillify/refresh/sources.json +6 -0
  149. package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
  150. package/skill/skillify/scripts/trigger-check.mjs +209 -0
  151. package/skill/vegafactory-setup/SKILL.md +77 -0
  152. package/skill/vegafactory-setup/agents/openai.yaml +4 -0
  153. package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
  154. package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
  155. package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
  156. package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
  157. package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
  158. package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
  159. package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
  160. package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
  161. package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
  162. package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
  163. package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
  164. package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
  165. package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
  166. package/skill/vegafactory-setup/references/control-room.md +149 -0
  167. package/skill/vegafactory-setup/references/conventions.md +113 -0
  168. package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
  169. package/skill/vegafactory-setup/refresh/sources.json +6 -0
  170. package/skill-integrity.json +252 -0
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ // The ask round: render questions into a `questions` comment, parse whatever a
3
+ // teammate replies, re-ask only what is still open, and decide which route the
4
+ // round takes at all. Text in, text out — this script makes no `gh` call, no
5
+ // network call, and writes no file, so it needs no credentials and no dry run.
6
+ //
7
+ // The route, the comment shape and the reply grammar are documented once in
8
+ // dev-setup's references/ask-route.md; this file is the method behind them.
9
+ //
10
+ // node questions.mjs render --spec <file.json> [--rev <n>] [--json]
11
+ // node questions.mjs parse --comment <file> (--spec <file.json> | --round <file.md>) [--json]
12
+ // node questions.mjs re-ask (--spec <file.json> | --round <file.md>) --comment <file> --rev <n> [--json]
13
+ // node questions.mjs route --tool <name|none> --asker <login> --operator <login> [--json]
14
+ //
15
+ // Exit codes: 0 pass · 1 answers still open or malformed (nothing to re-ask, for
16
+ // `re-ask`) · 2 refusal or usage error.
17
+ import { lstatSync, readFileSync } from 'node:fs'
18
+ import { resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ // The comment marker, written once: the renderer emits it and the round reader
22
+ // finds it by the same string, so the two can never drift apart.
23
+ const MARKER_OPEN = '<!-- vsk:v1 type=questions rev='
24
+ const MARKER_CLOSE = ' -->'
25
+
26
+ // Options run a through h: eight is already more than a person will weigh, and a
27
+ // bounded set is what makes the reply grammar a single character.
28
+ const LETTERS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
29
+
30
+ // Every path this script reads goes through here. A symlink is refused rather
31
+ // than followed: the caller hands it a path from an issue comment's neighbourhood,
32
+ // and a guard that resolves links reads whatever the link points at.
33
+ export function readTextFile(path) {
34
+ const stats = lstatSync(path)
35
+ if (stats.isSymbolicLink()) throw new Error('refusing to read a symlink: ' + path)
36
+ if (!stats.isFile()) throw new Error('not a regular file: ' + path)
37
+ return readFileSync(path, 'utf8')
38
+ }
39
+
40
+ // The display number of a question: its `n` when the round is a re-ask carrying
41
+ // the original numbering, otherwise its position.
42
+ function numberOf(question, index) {
43
+ return typeof question.n === 'number' ? question.n : index + 1
44
+ }
45
+
46
+ // Text that would break out of the round it is rendered into. A round can be read
47
+ // back out of a comment anyone may have written, so this is checked on the way in
48
+ // as well as on the way out — rendered text is never allowed to carry a marker or
49
+ // close the block it sits in.
50
+ const UNSAFE_TEXT = ['<!--', '-->', '<questions>', '</questions>']
51
+
52
+ function checkText(label, value) {
53
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(label + ' is empty')
54
+ for (const fragment of UNSAFE_TEXT) {
55
+ if (value.includes(fragment)) throw new Error(label + ' carries markup that would break the round: ' + fragment)
56
+ }
57
+ }
58
+
59
+ // A spec that cannot render is a bug in the caller, not a question for the user,
60
+ // so every check throws with the question's own number in the message.
61
+ export function checkSpec(spec) {
62
+ const questions = spec && Array.isArray(spec.questions) ? spec.questions : null
63
+ if (!questions) throw new Error('spec has no questions array')
64
+ if (questions.length === 0) throw new Error('a round needs at least one question')
65
+ questions.forEach((question, index) => {
66
+ const n = numberOf(question, index)
67
+ const label = 'question ' + n
68
+ checkText(label, question.text)
69
+ const options = Array.isArray(question.options) ? question.options : []
70
+ if (options.length < 2) throw new Error(label + ' has fewer than two options')
71
+ const seen = new Set()
72
+ for (const option of options) {
73
+ checkText(label, option.text)
74
+ if (!LETTERS.includes(option.letter)) {
75
+ throw new Error(label + ' uses the letter "' + option.letter + '" — options run a through h')
76
+ }
77
+ if (seen.has(option.letter)) throw new Error(label + ' repeats the letter "' + option.letter + '"')
78
+ seen.add(option.letter)
79
+ if (option.recommended) {
80
+ if (typeof option.reason !== 'string' || option.reason.trim() === '') {
81
+ throw new Error(label + ' recommends "' + option.letter + '" without a reason')
82
+ }
83
+ checkText(label, option.reason)
84
+ }
85
+ }
86
+ const recommended = options.filter((option) => option.recommended)
87
+ if (recommended.length === 0) throw new Error(label + ' has no recommended option')
88
+ if (recommended.length > 1) throw new Error(label + ' has ' + recommended.length + ' recommended options')
89
+ })
90
+ return questions
91
+ }
92
+
93
+ // The rendered comment. The `<questions>` wrapper is what lets the parser and the
94
+ // model both find the round inside a comment that may carry prose around it.
95
+ export function renderQuestions(spec, options) {
96
+ const rev = options && options.rev ? Number(options.rev) : 1
97
+ if (!Number.isInteger(rev) || rev < 1) throw new Error('rev must be a positive integer')
98
+ const questions = checkSpec(spec)
99
+ const lines = []
100
+ lines.push(MARKER_OPEN + rev + MARKER_CLOSE)
101
+ lines.push('## Questions (v' + rev + ')')
102
+ lines.push('')
103
+ lines.push('<questions>')
104
+ questions.forEach((question, index) => {
105
+ if (index > 0) lines.push('')
106
+ lines.push('**Q' + numberOf(question, index) + '.** ' + question.text)
107
+ for (const option of question.options) {
108
+ let line = '- ' + option.letter + ') ' + option.text
109
+ if (option.recommended) line += ' (recommended — ' + option.reason + ')'
110
+ lines.push(line)
111
+ }
112
+ })
113
+ // The example in the reply line is the round's own first question and its first
114
+ // letter, so a re-ask that starts at Q3 tells the reader to write `3: a`.
115
+ const first = questions[0]
116
+ const example = numberOf(first, 0) + ': ' + first.options[0].letter
117
+ lines.push('')
118
+ lines.push('Reply with `' + example + '` per question, or `all recommended`.')
119
+ lines.push('</questions>')
120
+ lines.push('')
121
+ return lines.join('\n')
122
+ }
123
+
124
+ // An answer line: an optional bullet, the question number, `:` or `.`, the option
125
+ // word, and optional trailing prose — only after a dash. The dash is what makes
126
+ // `1: a — too slow` a note and `1: a is too slow, go with b` a sentence: without
127
+ // it the letter is not an answer, and a numbered line that is not an answer is
128
+ // reported rather than read as the letter it happens to open with. Lines with no
129
+ // number are prose — a reply is a comment written by a person, not a form.
130
+ const ANSWER_LINE = /^\s*(?:[-*]\s+)?(\d+)\s*[:.]\s*([A-Za-z]+)[.!]?\s*(?:(?:—|–|--|-)\s*(.*))?$/
131
+ const NUMBERED_LINE = /^\s*(?:[-*]\s+)?(\d+)\s*[:.]\s*(\S.*)$/
132
+ const ALL_RECOMMENDED = /^\s*(?:[-*]\s+)?all\s+recommended\s*[.!]?\s*$/i
133
+
134
+ // Reads a reply comment against the round it answers. Never throws on the reply
135
+ // itself: an unusable reply comes back as `malformed`, which is what a re-ask is
136
+ // built from. It throws only on a spec that could not have been rendered.
137
+ export function parseAnswers(replyText, spec) {
138
+ const questions = checkSpec(spec)
139
+ const numbers = questions.map((question, index) => numberOf(question, index))
140
+ const answers = {}
141
+ const malformed = []
142
+ let sawAnswerLine = false
143
+ let allRecommended = false
144
+
145
+ for (const line of String(replyText).split('\n')) {
146
+ if (ALL_RECOMMENDED.test(line)) {
147
+ allRecommended = true
148
+ sawAnswerLine = true
149
+ continue
150
+ }
151
+ const match = ANSWER_LINE.exec(line)
152
+ if (!match) {
153
+ const numbered = NUMBERED_LINE.exec(line)
154
+ if (numbered) {
155
+ sawAnswerLine = true
156
+ malformed.push('question ' + Number(numbered[1]) + ': "' + numbered[2].trim() + '" is not a bare answer — write "' + Number(numbered[1]) + ': a" or "' + Number(numbered[1]) + ': a — <note>"')
157
+ }
158
+ continue
159
+ }
160
+ sawAnswerLine = true
161
+ const n = Number(match[1])
162
+ const token = match[2].toLowerCase()
163
+ const trailing = (match[3] || '').trim()
164
+ const position = numbers.indexOf(n)
165
+ if (position === -1) {
166
+ malformed.push('question ' + n + ' is not in this round')
167
+ continue
168
+ }
169
+ if (Object.prototype.hasOwnProperty.call(answers, n)) {
170
+ malformed.push('question ' + n + ' answered twice — the first answer stands')
171
+ continue
172
+ }
173
+ const known = questions[position].options.some((option) => option.letter === token)
174
+ if (token !== 'other' && !known) {
175
+ malformed.push('question ' + n + ': "' + token + '" is not an option')
176
+ continue
177
+ }
178
+ answers[n] = { option: token, text: trailing === '' ? null : trailing }
179
+ }
180
+
181
+ if (allRecommended) {
182
+ questions.forEach((question, index) => {
183
+ const n = numbers[index]
184
+ if (Object.prototype.hasOwnProperty.call(answers, n)) return
185
+ const recommended = question.options.find((option) => option.recommended)
186
+ answers[n] = { option: recommended.letter, text: null }
187
+ })
188
+ }
189
+
190
+ const missing = numbers.filter((n) => !Object.prototype.hasOwnProperty.call(answers, n))
191
+ if (!sawAnswerLine) {
192
+ malformed.push('no answer line found — expected "<number>: <letter>" per question, or "all recommended"')
193
+ }
194
+ return { answers, missing, malformed }
195
+ }
196
+
197
+ // The next round: the questions a reply left open, each carrying the number it
198
+ // had in the original round, so a re-ask re-uses `**Q3.**` rather than renumbering
199
+ // and inviting an answer to a question nobody asked twice.
200
+ export function openQuestions(spec, parsed) {
201
+ const questions = checkSpec(spec)
202
+ const numbers = questions.map((question, index) => numberOf(question, index))
203
+ const open = new Set(parsed.missing)
204
+ // A malformed entry names its question; reopen it unless an earlier line in the
205
+ // same reply already answered it (a repeat is malformed, but the first stands).
206
+ for (const problem of parsed.malformed) {
207
+ const named = /^question (\d+)\b/.exec(problem)
208
+ if (!named) continue
209
+ const n = Number(named[1])
210
+ if (!numbers.includes(n)) continue
211
+ if (Object.prototype.hasOwnProperty.call(parsed.answers, n)) continue
212
+ open.add(n)
213
+ }
214
+ const kept = questions
215
+ .map((question, index) => ({ ...question, n: numbers[index] }))
216
+ .filter((question) => open.has(question.n))
217
+ return { ...spec, questions: kept }
218
+ }
219
+
220
+ // The inverse of `renderQuestions`: a posted `questions` comment read back into
221
+ // the spec it was rendered from. This is what makes the wrapper earn its place —
222
+ // the run that reads the reply is a different, later session with no round.json
223
+ // on disk, so the comment itself has to be the record of what was asked.
224
+ const ROUND_QUESTION = /^\*\*Q(\d+)\.\*\*\s+(.+)$/
225
+ const ROUND_OPTION = /^-\s+([a-z])\)\s+(.+)$/
226
+ const ROUND_RECOMMENDED = /^(.*?)\s+\(recommended — (.+)\)$/
227
+
228
+ export function parseRound(commentText) {
229
+ const text = String(commentText)
230
+ const opened = text.indexOf('<questions>')
231
+ const closed = text.indexOf('</questions>', opened + 1)
232
+ if (opened === -1 || closed === -1) throw new Error('no <questions> block in this comment')
233
+ const markerAt = text.indexOf(MARKER_OPEN)
234
+ const revDigits = markerAt === -1 ? null : /^(\d+)/.exec(text.slice(markerAt + MARKER_OPEN.length))
235
+ const rev = revDigits ? Number(revDigits[1]) : 1
236
+ const questions = []
237
+ for (const line of text.slice(opened + '<questions>'.length, closed).split('\n')) {
238
+ const asked = ROUND_QUESTION.exec(line)
239
+ if (asked) {
240
+ questions.push({ n: Number(asked[1]), text: asked[2].trim(), options: [] })
241
+ continue
242
+ }
243
+ const offered = ROUND_OPTION.exec(line)
244
+ if (!offered) continue
245
+ if (questions.length === 0) throw new Error('an option appears before any question')
246
+ const recommended = ROUND_RECOMMENDED.exec(offered[2].trim())
247
+ if (recommended) {
248
+ questions[questions.length - 1].options.push({
249
+ letter: offered[1],
250
+ text: recommended[1].trim(),
251
+ recommended: true,
252
+ reason: recommended[2].trim(),
253
+ })
254
+ } else {
255
+ questions[questions.length - 1].options.push({ letter: offered[1], text: offered[2].trim() })
256
+ }
257
+ }
258
+ const round = { rev, questions }
259
+ checkSpec(round)
260
+ return round
261
+ }
262
+
263
+ // Which surface the round goes to, in one precedence a caller cannot reorder:
264
+ // the environment the dispatcher sets, then whether this harness and run have a
265
+ // question tool at all, then whether the person being asked is the person who
266
+ // owns the issue. Anything unresolved routes to the issue, because a round in a
267
+ // comment is always readable and a round put to the wrong person is not.
268
+ export function decideRoute(input) {
269
+ const env = (input && input.env) || {}
270
+ const forced = typeof env.VSK_ASK_ROUTE === 'string' ? env.VSK_ASK_ROUTE.trim() : ''
271
+ if (forced !== '') {
272
+ if (forced !== 'issue' && forced !== 'tool') throw new Error('VSK_ASK_ROUTE must be issue or tool, not "' + forced + '"')
273
+ return { route: forced, reason: 'VSK_ASK_ROUTE=' + forced }
274
+ }
275
+ const tool = input && typeof input.tool === 'string' ? input.tool.trim() : ''
276
+ if (tool === '' || tool === 'none') return { route: 'issue', reason: 'no question tool in this harness or run' }
277
+ const asker = input && typeof input.asker === 'string' ? input.asker.trim() : ''
278
+ const operator = input && typeof input.operator === 'string' ? input.operator.trim() : ''
279
+ if (asker === '' || operator === '') return { route: 'issue', reason: 'the asker or the issue operator is unknown' }
280
+ if (asker !== operator) return { route: 'issue', reason: 'asker ' + asker + ' is not the issue operator ' + operator }
281
+ return { route: 'tool', reason: 'tool ' + tool + ' is available and the asker is the issue operator' }
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // CLI
286
+ // ---------------------------------------------------------------------------
287
+
288
+ const USAGE = [
289
+ 'usage:',
290
+ ' questions.mjs render --spec <file.json> [--rev <n>] [--json]',
291
+ ' questions.mjs parse --comment <file> (--spec <file.json> | --round <file.md>) [--json]',
292
+ ' questions.mjs re-ask (--spec <file.json> | --round <file.md>) --comment <file> --rev <n> [--json]',
293
+ ' questions.mjs route --tool <name|none> --asker <login> --operator <login> [--json]',
294
+ ].join('\n')
295
+
296
+ // A caller supplies the round it is answering either as the JSON it rendered from
297
+ // (--spec) or as the posted comment itself (--round), which is what a later,
298
+ // fresh session actually has.
299
+ function readSpec(path) {
300
+ return JSON.parse(readTextFile(path))
301
+ }
302
+
303
+ function loadRound(specPath, roundPath) {
304
+ if (specPath && roundPath) throw new Error('pass --spec or --round, not both')
305
+ if (specPath) return readSpec(specPath)
306
+ return parseRound(readTextFile(roundPath))
307
+ }
308
+
309
+ function report(json, payload, exitCode, humanLines) {
310
+ if (json) console.log(JSON.stringify(payload, null, 2))
311
+ else for (const line of humanLines) console.log(line)
312
+ process.exit(exitCode)
313
+ }
314
+
315
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
316
+ if (invokedDirectly) {
317
+ const argv = process.argv.slice(2)
318
+ const json = argv.includes('--json')
319
+ const get = (flag) => { const i = argv.indexOf(flag); return i === -1 ? undefined : argv[i + 1] }
320
+ const command = argv[0]
321
+ const refuse = (reason) => report(json, { command: command || null, ok: false, error: reason }, 2, ['questions: ' + reason])
322
+
323
+ if (command === 'render') {
324
+ const specPath = get('--spec')
325
+ if (!specPath) refuse(USAGE)
326
+ try {
327
+ const rev = get('--rev') ? Number(get('--rev')) : 1
328
+ const markdown = renderQuestions(readSpec(specPath), { rev })
329
+ report(json, { command: 'render', ok: true, rev, markdown }, 0, [markdown])
330
+ } catch (error) {
331
+ refuse(error.message)
332
+ }
333
+ } else if (command === 'parse') {
334
+ const specPath = get('--spec')
335
+ const roundPath = get('--round')
336
+ const commentPath = get('--comment')
337
+ if ((!specPath && !roundPath) || !commentPath) refuse(USAGE)
338
+ try {
339
+ const parsed = parseAnswers(readTextFile(commentPath), loadRound(specPath, roundPath))
340
+ const open = parsed.missing.length > 0 || parsed.malformed.length > 0
341
+ const human = ['questions: ' + Object.keys(parsed.answers).length + ' answered']
342
+ if (parsed.missing.length) human.push(' open: ' + parsed.missing.join(', '))
343
+ for (const problem of parsed.malformed) human.push(' malformed: ' + problem)
344
+ report(json, { command: 'parse', ok: !open, ...parsed }, open ? 1 : 0, human)
345
+ } catch (error) {
346
+ refuse(error.message)
347
+ }
348
+ } else if (command === 're-ask') {
349
+ const specPath = get('--spec')
350
+ const roundPath = get('--round')
351
+ const commentPath = get('--comment')
352
+ const revRaw = get('--rev')
353
+ if ((!specPath && !roundPath) || !commentPath || !revRaw) refuse(USAGE)
354
+ try {
355
+ const spec = loadRound(specPath, roundPath)
356
+ const parsed = parseAnswers(readTextFile(commentPath), spec)
357
+ const open = openQuestions(spec, parsed)
358
+ if (open.questions.length === 0) {
359
+ report(json, { command: 're-ask', ok: false, open: [], reason: 'nothing is open — every question is answered' }, 1, ['questions: nothing is open'])
360
+ }
361
+ const rev = Number(revRaw)
362
+ const markdown = renderQuestions(open, { rev })
363
+ report(json, { command: 're-ask', ok: true, rev, open: open.questions.map((q) => q.n), markdown }, 0, [markdown])
364
+ } catch (error) {
365
+ refuse(error.message)
366
+ }
367
+ } else if (command === 'route') {
368
+ const tool = get('--tool')
369
+ const asker = get('--asker')
370
+ const operator = get('--operator')
371
+ if (!tool || !asker || !operator) refuse(USAGE)
372
+ try {
373
+ const decision = decideRoute({ env: process.env, tool, asker, operator })
374
+ report(json, { command: 'route', ok: true, ...decision }, 0, ['questions: route ' + decision.route + ' — ' + decision.reason])
375
+ } catch (error) {
376
+ refuse(error.message)
377
+ }
378
+ } else {
379
+ refuse(USAGE)
380
+ }
381
+ }
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ // Ship-guard policy compiler: dev.md's declared intent → the enforcement copy the guard reads.
3
+ //
4
+ // The ship guard (assets/hooks/ship-guard.mjs) never reads dev.md. It reads one file,
5
+ // ~/.vegastack/guard/<owner>__<repo>.json — outside every worktree, so no task edits it and
6
+ // no commit carries it. This script writes that file from dev.md's `## Environments` policy
7
+ // lines, the `gates:` knob, the `repo:` line's default branch and the backticked commands on
8
+ // the `## Ship` runbook's `ask:` lines. dev-setup runs it on the operator's yes;
9
+ // `vegafactory guard sync` wraps it; the SessionStart hook runs `--check` to warn.
10
+ //
11
+ // node ship-policy.mjs [--dev-md PATH] [--repo owner/repo] [--policy PATH] [--check] [--write] [--json]
12
+ //
13
+ // Dry run by default: prints what it would write and changes nothing. --write writes the file
14
+ // (atomically, temp file + rename). --check compares the stored file with what dev.md compiles
15
+ // to now and exits 2 when they differ or the file is missing. `owner/repo` comes from the
16
+ // checkout's origin remote unless --repo names it; --policy overrides the home-directory path.
17
+ //
18
+ // Exit codes: 0 fresh, written, or dry run · 2 stale (--check) or a refusal (no dev.md, no repo).
19
+
20
+ import { execFileSync } from 'node:child_process'
21
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { dirname, resolve } from 'node:path'
24
+ import { pathToFileURL } from 'node:url'
25
+ import { policyPath, repoFromRemote } from '../assets/hooks/ship-guard.mjs'
26
+ import { loadConfiguredPolicy, parsePolicy, policyHash, resolvePolicy } from './effective-policy.mjs'
27
+
28
+ const DEFAULTS = { defaultBranch: 'main', gates: 3 }
29
+
30
+ function sectionOf(text, heading) {
31
+ const parts = text.split(new RegExp('^' + heading + '.*$', 'm'))
32
+ if (parts.length < 2) return ''
33
+ return parts[1].split(/^## /m)[0]
34
+ }
35
+
36
+ // The action grammar stays local to this profile; defaults/authority/provenance use the owner.
37
+ // A malformed known value refuses compilation and preserves the previous enforcement copy.
38
+ export function compilePolicy(devMdText, { repo, org = '', group = '', identity = {}, freshness = {}, resolved: supplied = null }) {
39
+ const resolved = supplied ?? resolvePolicy({ org, group, repo: devMdText, identity: { repo, ...identity }, freshness })
40
+ if (!resolved.ok) throw new Error(resolved.blocks.join('; '))
41
+ const text = parsePolicy(devMdText, 'repo').lines.join('\n')
42
+ const branch = text.match(/^repo:.*default branch (\S+)/m)
43
+ const environments = []
44
+ for (const line of sectionOf(text, '## Environments').split('\n')) {
45
+ const match = line.match(/^- ([\w][\w-]*): (auto|ask) — (.+)$/)
46
+ if (match) environments.push({ target: match[1], policy: match[2], pattern: match[3].trim() })
47
+ }
48
+ // An `ask:` step is prose for the operator; only a command it names in backticks is a
49
+ // pattern the guard can match.
50
+ const shipAsk = []
51
+ for (const line of sectionOf(text, '## Ship').split('\n')) {
52
+ const match = line.match(/^- ask: (.+)$/)
53
+ if (!match) continue
54
+ for (const span of match[1].matchAll(/`([^`]+)`/g)) {
55
+ const command = span[1].trim()
56
+ if (command && !shipAsk.includes(command)) shipAsk.push(command)
57
+ }
58
+ }
59
+ return {
60
+ schemaVersion: 2,
61
+ repo: String(repo),
62
+ policyDigest: resolved.policy.policyDigest,
63
+ sources: resolved.policy.sources,
64
+ defaultBranch: branch ? branch[1] : DEFAULTS.defaultBranch,
65
+ gates: resolved.policy.values.gates ?? DEFAULTS.gates,
66
+ environments,
67
+ shipAsk,
68
+ }
69
+ }
70
+
71
+ function describeEnvironments(entries) {
72
+ return entries.map((entry) => `${entry.target}: ${entry.policy} — ${entry.pattern}`)
73
+ }
74
+
75
+ // Whether the stored file still says what dev.md compiles to. Only the policy fields count,
76
+ // so a re-compile that changes nothing but the timestamp is never drift.
77
+ export function staleness(storedText, compiled) {
78
+ if (typeof storedText !== 'string') return { stale: true, reason: 'the policy file is missing' }
79
+ let stored
80
+ try {
81
+ stored = JSON.parse(storedText)
82
+ } catch {
83
+ return { stale: true, reason: 'the policy file is not valid JSON' }
84
+ }
85
+ if (!stored || typeof stored !== 'object') return { stale: true, reason: 'the policy file is not a JSON object' }
86
+ const drift = []
87
+ if (stored.schemaVersion !== compiled.schemaVersion) drift.push(`schemaVersion ${JSON.stringify(stored.schemaVersion)} → ${compiled.schemaVersion}`)
88
+ if (stored.repo !== compiled.repo) drift.push(`repo ${JSON.stringify(stored.repo)} → ${compiled.repo}`)
89
+ if (stored.policyDigest !== compiled.policyDigest) drift.push('effective policy digest changed')
90
+ if (policyHash(stored.sources ?? {}) !== policyHash(compiled.sources)) drift.push('effective policy sources changed')
91
+ if (stored.defaultBranch !== compiled.defaultBranch) drift.push(`default branch ${JSON.stringify(stored.defaultBranch)} → ${compiled.defaultBranch}`)
92
+ if (stored.gates !== compiled.gates) drift.push(`gates ${JSON.stringify(stored.gates)} → ${compiled.gates}`)
93
+ const before = describeEnvironments(Array.isArray(stored.environments) ? stored.environments.filter((entry) => entry && typeof entry === 'object') : [])
94
+ const after = describeEnvironments(compiled.environments)
95
+ for (const line of before) if (!after.includes(line)) drift.push(`environment line gone: ${line}`)
96
+ for (const line of after) if (!before.includes(line)) drift.push(`environment line new: ${line}`)
97
+ const askBefore = Array.isArray(stored.shipAsk) ? stored.shipAsk : []
98
+ for (const entry of askBefore) if (!compiled.shipAsk.includes(entry)) drift.push(`ask: command gone: ${entry}`)
99
+ for (const entry of compiled.shipAsk) if (!askBefore.includes(entry)) drift.push(`ask: command new: ${entry}`)
100
+ if (drift.length === 0) return { stale: false, reason: null }
101
+ return { stale: true, reason: drift.join('; ') }
102
+ }
103
+
104
+ // --- CLI -----------------------------------------------------------------------------
105
+
106
+ function flag(argv, name) {
107
+ const index = argv.indexOf(name)
108
+ if (index === -1 || index === argv.length - 1) return null
109
+ return argv[index + 1]
110
+ }
111
+
112
+ function originRemote(cwd) {
113
+ try {
114
+ return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim()
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+
120
+ function readIfPresent(path) {
121
+ try {
122
+ return readFileSync(path, 'utf8')
123
+ } catch {
124
+ return null
125
+ }
126
+ }
127
+
128
+ function emit(result, json) {
129
+ if (json) {
130
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n')
131
+ return
132
+ }
133
+ const lines = []
134
+ if (result.written) lines.push(`wrote ${result.path}`)
135
+ else if (result.check) lines.push(result.stale ? `stale: ${result.reason}` : `fresh: ${result.path}`)
136
+ else lines.push(`would write ${result.path}${result.stale ? ` (${result.reason})` : ' (unchanged)'}`)
137
+ for (const warn of result.warns) lines.push(`warn: ${warn}`)
138
+ for (const block of result.blocks) lines.push(`block: ${block}`)
139
+ process.stdout.write(lines.join('\n') + '\n')
140
+ }
141
+
142
+ function main(argv) {
143
+ const json = argv.includes('--json')
144
+ const check = argv.includes('--check')
145
+ const write = argv.includes('--write') && !check
146
+ const devMd = resolve(flag(argv, '--dev-md') || '.vegastack/dev.md')
147
+ const result = { guard: 'ship-policy', ok: true, written: false, check, stale: false, reason: null, path: null, policy: null, blocks: [], warns: [] }
148
+
149
+ const devMdText = readIfPresent(devMd)
150
+ if (devMdText === null) result.blocks.push(`no dev.md at ${devMd} — run dev-setup first, or pass --dev-md`)
151
+ const repo = flag(argv, '--repo') || repoFromRemote(originRemote(dirname(devMd)))
152
+ if (!repo) result.blocks.push('could not read an owner/repo from the origin remote — pass --repo owner/repo')
153
+ if (result.blocks.length > 0) {
154
+ result.ok = false
155
+ emit(result, json)
156
+ process.exit(2)
157
+ }
158
+
159
+ let compiled
160
+ try {
161
+ compiled = compilePolicy(devMdText, { repo, resolved: loadConfiguredPolicy({ home: homedir(), repo, devMd: devMdText }) })
162
+ } catch (error) {
163
+ result.ok = false
164
+ result.blocks.push(`policy compilation refused: ${error.message}`)
165
+ emit(result, json)
166
+ process.exit(2)
167
+ }
168
+ const path = flag(argv, '--policy') || policyPath(homedir(), repo)
169
+ const state = staleness(readIfPresent(path), compiled)
170
+ result.path = path
171
+ result.policy = compiled
172
+ result.stale = state.stale
173
+ result.reason = state.reason
174
+
175
+ if (check) {
176
+ result.ok = !state.stale
177
+ emit(result, json)
178
+ process.exit(state.stale ? 2 : 0)
179
+ }
180
+ if (write) {
181
+ const document = { ...compiled, source: { devMd, compiledAt: new Date().toISOString() } }
182
+ mkdirSync(dirname(path), { recursive: true })
183
+ const temp = path + '.ship-policy-tmp'
184
+ writeFileSync(temp, JSON.stringify(document, null, 2) + '\n', { mode: 0o600 })
185
+ renameSync(temp, path)
186
+ result.written = true
187
+ result.stale = false
188
+ } else if (!existsSync(path)) {
189
+ result.warns.push('dry run: nothing written — pass --write')
190
+ }
191
+ emit(result, json)
192
+ process.exit(0)
193
+ }
194
+
195
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(process.argv.slice(2))
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: dev-ship
3
+ description: Land finished work, each step only on the operator's explicit word. Use when the user says "make the PR", "open a pull request" for an issue, "ship it", "merge it", "merge issue 12", "release", "release everything since the last tag", or asks to close out a reviewed for-operator issue, merge a bot PR (Renovate, Dependabot), or roll back a bad release. Not for implementing issues (dev-implement), reviewing them (dev-review), or writing and approving them (dev-intake).
4
+ ---
5
+
6
+ # dev-ship
7
+
8
+ Act on the operator's word only: each gate is spent by their words asking for that step and nothing more.
9
+
10
+ **Gates are spent only by the operator's words** — their words asking for a PR authorize the PR and nothing more; their words asking to merge authorize the merge, because passing checks, PR permissions and the calendar say nothing about consent. The dev.md `gates` knob sets how many actions one word covers — `3` keeps PR and merge as separate words, `2` lets one "ship it" cover both, `1` is direct-to-main (the ship word merges locally and pushes; no PR object, everything else identical).
11
+
12
+ Nearest neighbor: `dev-implement` produces the `for-operator` issue with its evidence comment; ship packages and lands it. Corrections found here go back through implement's corrections loop.
13
+
14
+ ## Gate 1 — the PR
15
+
16
+ On the user's PR instruction:
17
+
18
+ - Run `node <path-to-this-skill>/scripts/ship-gate.mjs --issue <n> --branch <name> --json` (optional `--repo <o/r> --dev-md <path> --worktree <path>`). It checks the exact committed checkout before/after the project command, full evidence/review/head equality, canonical plan scope/base binding, scoped operator exceptions, changelog/chronicle and debug tags. Missing check or dirty/moved candidate refuses; ignored build output is allowed. Exit 2 stops; warnings remain visible. Metadata and migration: [runbook](references/runbook.md).
19
+ - Verify the issue is at `for-operator` with the evidence comment present — including its `**Docs:**` line (brief/plan revisions in sync) — and the branch is pushed. Not there yet → say what's missing instead of creating a premature PR. Docs out of sync is corrections work through implement's loop, because a brief patched from here has no ledger line behind it.
20
+ - Verify the changelog state matches the evidence comment's `**Changelog:**` line: a behavior-changing branch carries its entry per dev.md's `changelog:` knob (changesets: a `.changeset/*.md` in the diff; keep-a-changelog: the diff adds lines to CHANGELOG.md), while `none` with a reason that holds up (docs-only, test-only) is fine. An unexplained miss → corrections loop, not a PR.
21
+ - `gh pr create` from the task branch: title from the issue, body is `Closes #<n>` plus a link to the evidence comment — the issue holds the report; the PR links it rather than duplicating it.
22
+ - No draft PRs unless the user asks for one.
23
+ - If required checks fail on the PR, that's implement work: hand the failures to the corrections loop, update the evidence comment, and tell the user. Under `gates: 2` the standing ship word holds once checks are green again — subject to Gate 2's staleness bound (behavior change or >7 days → one-sentence re-confirm).
24
+ - A direct chat change (dev-implement's no-issue path) ships on the same words: the chat request stands in for the recorded approval, the PR body carries the evidence instead of linking an issue comment, and the changelog rule applies unchanged.
25
+ - User corrections left on the PR itself flow through the same corrections loop on the same branch — the PR updates with the push; nothing gets recreated.
26
+
27
+ With `gates: 1` there is no PR: the same verifications run, then the ship word triggers the merge below directly ([runbook](references/runbook.md) has the mechanics).
28
+
29
+ | Excuse (observed) | Reality |
30
+ |---|---|
31
+ | "Opening a PR is preparation, not shipping — it pushes nothing… exactly the state the workflow wants finished work parked in." | Under `gates: 3` the PR is a gate spent only by the operator's word. Finished work parks on the pushed branch; a draft PR is still a PR nobody asked for. |
32
+
33
+ ## Gate 2 — the merge
34
+
35
+ On the user's merge instruction:
36
+
37
+ - Re-run the gate and read back the remote PR head/base IDs and base repository/ref against the accepted candidate. Every new commit, including evidence/docs, invalidates review/checks. Renew exact full-candidate review before merge; keep evidence in comments to avoid self-reference commits.
38
+ - Pending `Decision:` lines exist (issue comments, or the evidence comment's `**Decision:**` line) → name them in the merge confirmation — "merging will record: …" — so the operator's word demonstrably covers them, because the register is append-only and an inferred line cannot be taken back. On the word, append each to the register dev.md names (`decisions:` knob) in conventions' Operator identity format; the register is append-only and this is its moment.
39
+ - A merge conflict with the default branch is corrections work: update the branch, run the checks the update touched, and the standing merge instruction holds once checks are green again — unless the update changed behavior, or more than 7 days have passed since the word; either way, re-confirm with one sentence rather than acting on a stale instruction.
40
+ - Merge per the dev.md `merge` knob (default `gh pr merge --squash`; `gates: 1` merges locally per the same knob and pushes). `Closes #<n>` closes the issue; confirm both happened.
41
+ - Then remove the merged branch's worktree — `node <path-to-dev-implement>/scripts/worktree.mjs remove --issue <n> --write --json` — which takes the **directory only**. Deleting the local branch and deleting the remote branch are each their own operator word, and an epic parent's worktree goes only when the parent PR merges. Child-into-parent merges and the one-PR-per-feature rule: [runbook](references/runbook.md).
42
+ - A bot PR (Renovate, Dependabot) has no issue or evidence comment and merging it is still shipping: green checks qualify it, only the operator's explicit word — per PR or per named batch — merges it; majors and security advisories get named before their word is acted on.
43
+
44
+ Final assembled parent review and postmerge `parentDelivery` proof follow the [runbook](references/runbook.md). One approved parent scope covers routine authorized child integration; final whole-parent review remains mandatory. Local child acceptance means implemented; only verified parent merge means delivered.
45
+
46
+ ## After the merge — the Ship runbook
47
+
48
+ Merge is not the end when dev.md has a `## Ship` section: follow its steps in order — `auto:` lines you just do, `ask:` lines wait for the operator's word, `guard:` lines are deterministic checks you run locally at their position (their CI copies are the backstop). With `release: per-merge`, the runbook is part of shipping the issue; with `release: on-request`, it runs only when the operator says "release" (covering everything merged since the last one). Report each step's outcome; a failing step — guard included — stops the sequence and goes to the operator, because a skipped guard is a guard the runbook does not have. Execution detail, release batching, direct-to-main, bot PRs, and rollback: [runbook](references/runbook.md).
49
+
50
+ Rollback rolls forward through the Ship section's rollback line, because a force-push erases the record the rollback needs. Gotchas surfaced here feed the Report's closing retro below.
51
+
52
+ ## Report
53
+
54
+ One short confirmation each gate, in plain language: what was created or merged, the link, decisions recorded, and anything that still needs the operator (failing check, failing guard, moved head, missing evidence or changelog entry). When a gate's condition isn't met, the answer is what's missing, because a gate skipped to be helpful is no gate.
55
+
56
+ Close every ship with the retro: any bounce, gotcha, or instruction the operator had to repeat during this issue? Propose the one dev.md (or runbook) line that would have prevented it, folded into an existing line, because a log in dev.md is read by nobody; a directional gotcha becomes a register proposal instead. Each lands only on the operator's yes.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "dev-ship"
3
+ short_description: "PR, merge, and release runbook, each on the user's word"
4
+ default_prompt: "Use $dev-ship to create the PR or merge this issue's work."