@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.
- package/LICENSE +21 -0
- package/README.md +417 -0
- package/dist/dashboard-artifact.json +21240 -0
- package/dist/index.js +22752 -0
- package/dist/run-wrapper.js +377 -0
- package/package.json +46 -0
- package/skill/dev-architect/SKILL.md +106 -0
- package/skill/dev-architect/agents/openai.yaml +4 -0
- package/skill/dev-architect/references/ai-agents.md +96 -0
- package/skill/dev-architect/references/conventions.md +113 -0
- package/skill/dev-architect/references/data.md +89 -0
- package/skill/dev-architect/references/infra.md +99 -0
- package/skill/dev-architect/references/mobile.md +75 -0
- package/skill/dev-architect/references/pinned-facts.md +133 -0
- package/skill/dev-architect/references/principles.md +117 -0
- package/skill/dev-architect/references/security.md +90 -0
- package/skill/dev-architect/references/stack.md +38 -0
- package/skill/dev-architect/references/web.md +102 -0
- package/skill/dev-architect/refresh/REFRESH.md +31 -0
- package/skill/dev-architect/refresh/sources.json +239 -0
- package/skill/dev-chronicle/SKILL.md +52 -0
- package/skill/dev-chronicle/agents/openai.yaml +4 -0
- package/skill/dev-chronicle/references/conventions.md +113 -0
- package/skill/dev-chronicle/references/styles.md +75 -0
- package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
- package/skill/dev-chronicle/refresh/sources.json +6 -0
- package/skill/dev-debug/SKILL.md +43 -0
- package/skill/dev-debug/agents/openai.yaml +4 -0
- package/skill/dev-debug/references/conventions.md +113 -0
- package/skill/dev-debug/references/loop-ladder.md +20 -0
- package/skill/dev-debug/refresh/REFRESH.md +3 -0
- package/skill/dev-debug/refresh/sources.json +6 -0
- package/skill/dev-implement/SKILL.md +81 -0
- package/skill/dev-implement/agents/openai.yaml +4 -0
- package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
- package/skill/dev-implement/references/ask-route.md +80 -0
- package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
- package/skill/dev-implement/references/conventions.md +113 -0
- package/skill/dev-implement/references/ledger-and-resume.md +52 -0
- package/skill/dev-implement/references/parallel-children.md +35 -0
- package/skill/dev-implement/references/worktrees.md +63 -0
- package/skill/dev-implement/refresh/REFRESH.md +3 -0
- package/skill/dev-implement/refresh/sources.json +6 -0
- package/skill/dev-implement/scripts/children.mjs +479 -0
- package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
- package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
- package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
- package/skill/dev-implement/scripts/learning.mjs +41 -0
- package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
- package/skill/dev-implement/scripts/preflight.mjs +153 -0
- package/skill/dev-implement/scripts/questions.mjs +381 -0
- package/skill/dev-implement/scripts/reclaim.mjs +116 -0
- package/skill/dev-implement/scripts/recovery.mjs +213 -0
- package/skill/dev-implement/scripts/worktree.mjs +991 -0
- package/skill/dev-intake/SKILL.md +80 -0
- package/skill/dev-intake/agents/openai.yaml +4 -0
- package/skill/dev-intake/references/ask-route.md +80 -0
- package/skill/dev-intake/references/brief-template.md +89 -0
- package/skill/dev-intake/references/conventions.md +113 -0
- package/skill/dev-intake/refresh/REFRESH.md +3 -0
- package/skill/dev-intake/refresh/sources.json +6 -0
- package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
- package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-intake/scripts/questions.mjs +381 -0
- package/skill/dev-plan/SKILL.md +54 -0
- package/skill/dev-plan/agents/openai.yaml +4 -0
- package/skill/dev-plan/references/ask-route.md +80 -0
- package/skill/dev-plan/references/conventions.md +113 -0
- package/skill/dev-plan/references/plan-format.md +82 -0
- package/skill/dev-plan/refresh/REFRESH.md +3 -0
- package/skill/dev-plan/refresh/sources.json +6 -0
- package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
- package/skill/dev-plan/scripts/questions.mjs +381 -0
- package/skill/dev-review/SKILL.md +76 -0
- package/skill/dev-review/agents/openai.yaml +4 -0
- package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
- package/skill/dev-review/references/conventions.md +113 -0
- package/skill/dev-review/references/cross-agent.md +39 -0
- package/skill/dev-review/references/dispatch-prompts.md +121 -0
- package/skill/dev-review/references/security-axis.md +45 -0
- package/skill/dev-review/refresh/REFRESH.md +5 -0
- package/skill/dev-review/refresh/sources.json +6 -0
- package/skill/dev-setup/SKILL.md +113 -0
- package/skill/dev-setup/agents/openai.yaml +4 -0
- package/skill/dev-setup/assets/agents-section.md.template +24 -0
- package/skill/dev-setup/assets/dev-profile.md.template +102 -0
- package/skill/dev-setup/assets/factory-board.yml.template +162 -0
- package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
- package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
- package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
- package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
- package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
- package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
- package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
- package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
- package/skill/dev-setup/references/ask-route.md +80 -0
- package/skill/dev-setup/references/conventions.md +113 -0
- package/skill/dev-setup/references/github-app.md +286 -0
- package/skill/dev-setup/references/harness-facts.md +169 -0
- package/skill/dev-setup/references/stack-playbooks.md +124 -0
- package/skill/dev-setup/refresh/REFRESH.md +13 -0
- package/skill/dev-setup/refresh/sources.json +602 -0
- package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
- package/skill/dev-setup/scripts/questions.mjs +381 -0
- package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
- package/skill/dev-ship/SKILL.md +56 -0
- package/skill/dev-ship/agents/openai.yaml +4 -0
- package/skill/dev-ship/references/conventions.md +113 -0
- package/skill/dev-ship/references/runbook.md +72 -0
- package/skill/dev-ship/refresh/REFRESH.md +3 -0
- package/skill/dev-ship/refresh/sources.json +6 -0
- package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
- package/skill/dev-status/SKILL.md +56 -0
- package/skill/dev-status/agents/openai.yaml +4 -0
- package/skill/dev-status/references/conventions.md +113 -0
- package/skill/dev-status/refresh/REFRESH.md +3 -0
- package/skill/dev-status/refresh/sources.json +6 -0
- package/skill/dev-status/scripts/effective-policy.mjs +694 -0
- package/skill/dev-status/scripts/status.mjs +311 -0
- package/skill/skill-maintainer/SKILL.md +82 -0
- package/skill/skill-maintainer/agents/openai.yaml +4 -0
- package/skill/skill-maintainer/references/release-ops.md +61 -0
- package/skill/skill-maintainer/references/standards.md +137 -0
- package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
- package/skill/skill-maintainer/refresh/sources.json +103 -0
- package/skill/skill-scan/SKILL.md +59 -0
- package/skill/skill-scan/agents/openai.yaml +4 -0
- package/skill/skill-scan/refresh/REFRESH.md +18 -0
- package/skill/skill-scan/refresh/sources.json +133 -0
- package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
- package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
- package/skill/skillify/SKILL.md +108 -0
- package/skill/skillify/agents/openai.yaml +4 -0
- package/skill/skillify/assets/templates/README.md.template +26 -0
- package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
- package/skill/skillify/assets/templates/SKILL.md.template +27 -0
- package/skill/skillify/assets/templates/evals.json.template +12 -0
- package/skill/skillify/assets/templates/openai.yaml.template +4 -0
- package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
- package/skill/skillify/assets/templates/sources.json.template +6 -0
- package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
- package/skill/skillify/references/authoring.md +103 -0
- package/skill/skillify/references/eval-playbook.md +112 -0
- package/skill/skillify/refresh/REFRESH.md +23 -0
- package/skill/skillify/refresh/sources.json +6 -0
- package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
- package/skill/skillify/scripts/trigger-check.mjs +209 -0
- package/skill/vegafactory-setup/SKILL.md +77 -0
- package/skill/vegafactory-setup/agents/openai.yaml +4 -0
- package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
- package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
- package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
- package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
- package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
- package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
- package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
- package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
- package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
- package/skill/vegafactory-setup/references/control-room.md +149 -0
- package/skill/vegafactory-setup/references/conventions.md +113 -0
- package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
- package/skill/vegafactory-setup/refresh/sources.json +6 -0
- 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.
|