@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,334 @@
1
+ #!/usr/bin/env node
2
+ // Scaffold a new skill tree at skills/<name>/ (or skills/<group>/<name>/) from skillify's
3
+ // templates.
4
+ //
5
+ // node scripts/scaffold-skill.mjs <skill-name> --dir <repo-root> [--group <group>] [--write] [--json]
6
+ //
7
+ // --group places the skill in an existing group. Creating a group is skill-maintainer's job
8
+ // (packages/cli/scripts/structure.mjs create-group), so an unknown group is refused rather than
9
+ // invented: a mistyped group must never bring a stray family into existence.
10
+ //
11
+ // Dry-run by default: prints the plan (files that would be created plus the
12
+ // wiring actions that would be performed) and creates nothing. --write stages
13
+ // the tree in a temporary sibling inside skills/ and renames it into place,
14
+ // refusing existing directories and symlinks, then performs the repo wiring
15
+ // itself: packaging.json entry, root README row, changeset. Exit codes: 0 ok,
16
+ // 1 refusal or failure, 2 usage error.
17
+ import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
18
+ import { dirname, join, resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const here = dirname(fileURLToPath(import.meta.url))
22
+ const templatesRoot = resolve(here, '..', 'assets', 'templates')
23
+
24
+ // Template file -> output path inside the skill (null = tests/<name>.test.ts).
25
+ export const templateFiles = [
26
+ ['SKILL.md.template', 'SKILL.md'],
27
+ ['README.md.template', 'README.md'],
28
+ ['sources.json.template', 'refresh/sources.json'],
29
+ ['REFRESH.md.template', 'refresh/REFRESH.md'],
30
+ ['openai.yaml.template', 'agents/openai.yaml'],
31
+ // One placeholder case; the structure check warns until real cases replace it (skillify Phase 4).
32
+ ['evals.json.template', 'evals/evals.json'],
33
+ // Scaffolded empty so the shape test stays red until real queries are written.
34
+ ['trigger-queries.json.template', 'tests/fixtures/trigger-queries.json'],
35
+ ['skill.test.ts.template', null],
36
+ ]
37
+
38
+ // The scaffolded files that ship to installers (README and tests never package).
39
+ const defaultPackagedFiles = ['SKILL.md', 'agents/openai.yaml', 'refresh/REFRESH.md', 'refresh/sources.json']
40
+
41
+ // Full grammar shared by the repo validator and every target harness: starts
42
+ // with a lowercase letter, then lowercase letters/digits/hyphens, no
43
+ // consecutive hyphens, no trailing hyphen, at most 64 characters.
44
+ export function validateName(name) {
45
+ if (typeof name !== 'string' || name.length === 0) return 'skill name is required'
46
+ if (name.length > 64) return `name is ${name.length} characters; the maximum is 64`
47
+ if (!/^[a-z]/.test(name)) return 'name must start with a lowercase letter'
48
+ if (!/^[a-z0-9-]+$/.test(name)) return 'name may contain only lowercase letters, digits, and hyphens'
49
+ if (name.includes('--')) return 'name must not contain consecutive hyphens'
50
+ if (name.endsWith('-')) return 'name must not end with a hyphen'
51
+ return null
52
+ }
53
+
54
+ // This script ships inside the skillify skill, so it must stay dependency-free and cannot import
55
+ // the repo's lib/skills.mjs. Deliberate small duplication of its GROUP.md title read; the two are
56
+ // kept honest by structure.mjs check, which fails when the README and GROUP.md disagree.
57
+ export function groupTitle(markdown) {
58
+ const lines = markdown.split('\n')
59
+ const headingIndex = lines.findIndex(line => /^#\s+\S/.test(line))
60
+ if (headingIndex === -1) return null
61
+ const title = lines[headingIndex].replace(/^#\s+/, '').trim()
62
+ const blurb = lines.slice(headingIndex + 1).find(line => line.trim() !== '')?.trim()
63
+ if (!title || !blurb || blurb.startsWith('#')) return null
64
+ return title
65
+ }
66
+
67
+ // Two-level scan mirroring lib/skills.mjs's discovery. Duplicated for the same reason as
68
+ // groupTitle: this script ships inside the skillify skill and cannot import repo tooling.
69
+ async function findSkillAnywhere(skillsRoot, name) {
70
+ if ((await entryAt(join(skillsRoot, name, 'SKILL.md')))?.isFile()) return join(skillsRoot, name)
71
+ for (const entry of await readdir(skillsRoot, { withFileTypes: true })) {
72
+ if (!entry.isDirectory()) continue
73
+ const path = join(skillsRoot, entry.name, name)
74
+ if ((await entryAt(join(path, 'SKILL.md')))?.isFile()) return path
75
+ }
76
+ return null
77
+ }
78
+
79
+ async function entryAt(path) {
80
+ try {
81
+ return await lstat(path)
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ async function writeAtomic(path, body) {
88
+ const staging = `${path}.scaffold-tmp`
89
+ await writeFile(staging, body)
90
+ await rename(staging, path)
91
+ }
92
+
93
+ // Adds the new skill's entry to packages/cli/packaging.json with the default
94
+ // scaffolded runtime files. Extra authored files added later must be appended
95
+ // there by hand — sync-skill.mjs still fails loudly on anything unlisted.
96
+ async function wirePackaging(repoRoot, name, write) {
97
+ const path = join(repoRoot, 'packages/cli/packaging.json')
98
+ if (!(await entryAt(path))?.isFile()) return { step: 'packaging.json entry', status: 'skipped: packages/cli/packaging.json not found' }
99
+ const packaged = JSON.parse(await readFile(path, 'utf8'))
100
+ if (name in packaged) return { step: 'packaging.json entry', status: 'skipped: entry already exists' }
101
+ if (!write) return { step: 'packaging.json entry', status: 'planned' }
102
+ packaged[name] = defaultPackagedFiles
103
+ const sorted = Object.fromEntries(Object.keys(packaged).sort().map(key => [key, packaged[key]]))
104
+ await writeAtomic(path, `${JSON.stringify(sorted, null, 2)}\n`)
105
+ return { step: 'packaging.json entry', status: 'done' }
106
+ }
107
+
108
+ // Resolves where a row belongs: the end of the ungrouped table, or of the table under the
109
+ // group's "### <title>" section. Both windows are bounded by the "## Skills" region, so a row can
110
+ // never land in a neighbouring family's table or in an unrelated table elsewhere in the README.
111
+ // Returns null when the README has no usable table, so callers can refuse before writing rather
112
+ // than report a "skipped:" success afterwards.
113
+ export function findRowInsertion(lines, group, groupHeading) {
114
+ const regionStart = lines.findIndex(line => /^##\s+Skills\s*$/.test(line))
115
+ if (regionStart < 0) return null
116
+ const afterRegion = lines.findIndex((line, index) => index > regionStart && /^##\s+/.test(line) && !/^###/.test(line))
117
+ const regionEnd = afterRegion < 0 ? lines.length : afterRegion
118
+
119
+ let from = regionStart
120
+ let to = regionEnd
121
+ if (group) {
122
+ from = lines.findIndex((line, index) => index > regionStart && index < regionEnd && line.trim() === `### ${groupHeading}`)
123
+ if (from < 0) return { missingSection: true }
124
+ const next = lines.findIndex((line, index) => index > from && index < regionEnd && /^###\s+/.test(line))
125
+ to = next < 0 ? regionEnd : next
126
+ } else {
127
+ const firstSection = lines.findIndex((line, index) => index > regionStart && index < regionEnd && /^###\s+/.test(line))
128
+ if (firstSection >= 0) to = firstSection
129
+ }
130
+
131
+ const header = lines.findIndex((line, index) => index >= from && index < to && /^\| *Skill *\|/.test(line))
132
+ if (header < 0 || !/^\|[ -]*---/.test(lines[header + 1] ?? '')) return null
133
+ let last = header + 1
134
+ while (last + 1 < to && lines[last + 1]?.startsWith('|')) last += 1
135
+ return { index: last }
136
+ }
137
+
138
+ async function wireReadme(repoRoot, name, group, groupHeading, write) {
139
+ const path = join(repoRoot, 'README.md')
140
+ if (!(await entryAt(path))?.isFile()) return { step: 'root README row', status: 'skipped: README.md not found' }
141
+ const body = await readFile(path, 'utf8')
142
+ const relativePath = group ? `${group}/${name}` : name
143
+ if (body.includes(`](skills/${relativePath}/)`)) return { step: 'root README row', status: 'skipped: row already exists' }
144
+ const lines = body.split('\n')
145
+
146
+ const target = findRowInsertion(lines, group, groupHeading)
147
+ if (target?.missingSection) throw new Error(`README.md has no "### ${groupHeading}" section for group "${group}" - create it with structure.mjs create-group`)
148
+ if (!target) return { step: 'root README row', status: 'skipped: Skills table not found' }
149
+ const last = target.index
150
+ if (!write) return { step: 'root README row', status: 'planned' }
151
+ const row = `| [${name}](skills/${relativePath}/) | TODO: one-line description | [Walkthrough](skills/${relativePath}/README.md) · [SKILL.md](skills/${relativePath}/SKILL.md) |`
152
+ lines.splice(last + 1, 0, row)
153
+ await writeAtomic(path, lines.join('\n'))
154
+ return { step: 'root README row', status: 'done' }
155
+ }
156
+
157
+ // Writes the changeset introducing the skill (content versioning: new skill = minor).
158
+ async function wireChangeset(repoRoot, name, write) {
159
+ const directory = join(repoRoot, '.changeset')
160
+ if (!(await entryAt(directory))?.isDirectory()) return { step: 'changeset', status: 'skipped: .changeset/ not found' }
161
+ const path = join(directory, `add-${name}.md`)
162
+ if (await entryAt(path)) return { step: 'changeset', status: 'skipped: changeset already exists' }
163
+ if (!write) return { step: 'changeset', status: 'planned' }
164
+ await writeAtomic(path, `---\n"@vegastack/vegafactory": minor\n---\n\nAdd the ${name} skill.\n`)
165
+ return { step: 'changeset', status: 'done' }
166
+ }
167
+
168
+ export async function wireSkill({ name, repoRoot, group = null, groupHeading = null, write = false }) {
169
+ // groupHeading is an optimisation for scaffoldSkill, which has already read GROUP.md. A caller
170
+ // using the documented { name, repoRoot, group, write } shape gets it derived here rather than
171
+ // a row addressed to "### null".
172
+ if (group && !groupHeading) {
173
+ const doc = await entryAt(join(repoRoot, 'skills', group, 'GROUP.md'))
174
+ if (!doc?.isFile()) throw new Error(`Group "${group}" has no GROUP.md - every group carries one`)
175
+ groupHeading = groupTitle(await readFile(join(repoRoot, 'skills', group, 'GROUP.md'), 'utf8'))
176
+ if (!groupHeading) throw new Error(`skills/${group}/GROUP.md is malformed - it needs an H1 title followed by one non-empty blurb line`)
177
+ }
178
+ return [
179
+ await wirePackaging(repoRoot, name, write),
180
+ await wireReadme(repoRoot, name, group, groupHeading, write),
181
+ await wireChangeset(repoRoot, name, write),
182
+ ]
183
+ }
184
+
185
+ export async function scaffoldSkill({ name, dir, group = null, write = false, now = new Date() }) {
186
+ const nameError = validateName(name)
187
+ if (nameError) throw new Error(`Invalid skill name ${JSON.stringify(name ?? null)}: ${nameError}`)
188
+ if (!dir) throw new Error('--dir <repo-root> is required')
189
+ const repoRoot = resolve(dir)
190
+ const skillsRoot = join(repoRoot, 'skills')
191
+ const skillsEntry = await entryAt(skillsRoot)
192
+ // lstat does not follow symlinks, so a symlinked skills/ fails isDirectory().
193
+ if (!skillsEntry || !skillsEntry.isDirectory()) {
194
+ throw new Error(`${skillsRoot} is not a real directory - point --dir at the vegafactory repo root`)
195
+ }
196
+
197
+ // A group must already exist and carry a well-formed GROUP.md; creating one is
198
+ // skill-maintainer's structure.mjs create-group, never a side effect of scaffolding a skill.
199
+ let groupHeading = null
200
+ if (group) {
201
+ const groupError = validateName(group)
202
+ if (groupError) throw new Error(`Invalid group name ${JSON.stringify(group)}: ${groupError}`)
203
+ const groupRoot = join(skillsRoot, group)
204
+ const groupEntry = await entryAt(groupRoot)
205
+ if (!groupEntry || !groupEntry.isDirectory()) {
206
+ throw new Error(`Group "${group}" does not exist at ${groupRoot} - create it first with: node packages/cli/scripts/structure.mjs create-group ${group} --title <title> --blurb <blurb> --write`)
207
+ }
208
+ const groupDoc = await entryAt(join(groupRoot, 'GROUP.md'))
209
+ if (!groupDoc?.isFile()) throw new Error(`Group "${group}" has no GROUP.md - every group carries one; see skill-maintainer's group workflow`)
210
+ groupHeading = groupTitle(await readFile(join(groupRoot, 'GROUP.md'), 'utf8'))
211
+ if (!groupHeading) throw new Error(`skills/${group}/GROUP.md is malformed - it needs an H1 title followed by one non-empty blurb line`)
212
+ }
213
+
214
+ const parent = group ? join(skillsRoot, group) : skillsRoot
215
+ const target = join(parent, name)
216
+ if (await entryAt(target)) throw new Error(`Refusing to scaffold: ${target} already exists`)
217
+
218
+ // Skill names are unique across the whole tree, not just within one directory: the packaged
219
+ // bundle is flat, so a duplicate at the other depth would break the next build. Checked here,
220
+ // before anything is written, rather than left to that build.
221
+ const clash = await findSkillAnywhere(skillsRoot, name)
222
+ if (clash) throw new Error(`Refusing to scaffold: a skill named "${name}" already exists at ${clash} - the packaged bundle is flat, so skill names are unique across the whole tree`)
223
+
224
+ // Every refusal belongs in this pre-flight. wireReadme runs after the tree is renamed into
225
+ // place and the packaging entry written, so anything discovered there would leave a half-wired
226
+ // skill on disk while reporting a refusal - or, worse, report success with no row at all.
227
+ // Both wiring targets a scaffolded skill cannot do without must therefore be resolvable BEFORE
228
+ // the tree is staged: an absent one is a refusal, not a `skipped:` status, because a skill with
229
+ // no README row or no packaging entry is exactly the state structure.mjs check blocks. Only
230
+ // `.changeset/` still degrades to `skipped:` - a missing changeset breaks no check.
231
+ const readmePath = join(repoRoot, 'README.md')
232
+ if (!(await entryAt(readmePath))?.isFile()) {
233
+ throw new Error(`README.md not found at ${readmePath} - every skill needs its Skills-table row, so refusing rather than scaffolding a skill the structure check would block`)
234
+ }
235
+ const lines = (await readFile(readmePath, 'utf8')).split('\n')
236
+ const rowTarget = findRowInsertion(lines, group, groupHeading)
237
+ if (rowTarget?.missingSection) {
238
+ throw new Error(`README.md has no "### ${groupHeading}" section for group "${group}" - create it with structure.mjs create-group`)
239
+ }
240
+ if (!rowTarget) {
241
+ throw new Error(`README.md has no ${group ? `table under "### ${groupHeading}"` : 'ungrouped Skills table'} to add a row to - every skill needs its row, so refusing rather than scaffolding a skill the structure check would block`)
242
+ }
243
+
244
+ const packagingPath = join(repoRoot, 'packages/cli/packaging.json')
245
+ if (!(await entryAt(packagingPath))?.isFile()) {
246
+ throw new Error(`packages/cli/packaging.json not found at ${packagingPath} - every skill needs its packaging entry, so refusing rather than scaffolding a skill the structure check would block`)
247
+ }
248
+
249
+ // The generated test imports the repo validator by relative path, so its depth follows the
250
+ // skill's: skills/<name>/tests/ is three levels up, skills/<group>/<name>/tests/ is four.
251
+ const validatorPath = `${group ? '../../../..' : '../../..'}/packages/cli/scripts/validate-skill.mjs`
252
+ // Only a grouped skill gets the family-install block; an ungrouped one would otherwise ship a
253
+ // command naming a group that does not exist. It is its own fence, not a second line in the
254
+ // first one: pasting a shared fence would run the alternative too.
255
+ const groupInstallBlock = group
256
+ ? `\nOr the whole ${group} family at once:\n\n\`\`\`sh\nnpx @vegastack/vegafactory skills add --group ${group} --global\n\`\`\`\n`
257
+ : ''
258
+
259
+ const outputs = templateFiles.map(([source, output]) => [source, output ?? `tests/${name}.test.ts`])
260
+ const plan = { name, group, target, files: outputs.map(([, output]) => output), wrote: false }
261
+ if (!write) return { ...plan, wiring: await wireSkill({ name, repoRoot, group, groupHeading }) }
262
+
263
+ const date = now.toISOString().slice(0, 10)
264
+ const staging = await mkdtemp(join(parent, `.${name}.scaffold-`))
265
+ try {
266
+ for (const [source, output] of outputs) {
267
+ const body = await readFile(join(templatesRoot, source), 'utf8')
268
+ const rendered = body
269
+ .replaceAll('{{name}}', name)
270
+ .replaceAll('{{date}}', date)
271
+ .replaceAll('{{validatorPath}}', validatorPath)
272
+ .replaceAll('{{groupInstallBlock}}', groupInstallBlock)
273
+ const destination = join(staging, output)
274
+ await mkdir(dirname(destination), { recursive: true })
275
+ await writeFile(destination, rendered)
276
+ }
277
+ if (await entryAt(target)) throw new Error(`Refusing to scaffold: ${target} already exists`)
278
+ await rename(staging, target)
279
+ } catch (error) {
280
+ await rm(staging, { recursive: true, force: true })
281
+ throw error
282
+ }
283
+ return { ...plan, wrote: true, wiring: await wireSkill({ name, repoRoot, group, groupHeading, write: true }) }
284
+ }
285
+
286
+ function parseArguments(argv) {
287
+ const options = { name: undefined, dir: undefined, group: null, write: false, json: false }
288
+ const rest = [...argv]
289
+ while (rest.length) {
290
+ const flag = rest.shift()
291
+ if (flag === '--dir') {
292
+ const value = rest.shift()
293
+ if (value === undefined || value.startsWith('-')) throw new Error('--dir requires a value')
294
+ options.dir = value
295
+ } else if (flag === '--group') {
296
+ const value = rest.shift()
297
+ if (value === undefined || value.startsWith('-')) throw new Error('--group requires a value')
298
+ options.group = value
299
+ } else if (flag === '--write') options.write = true
300
+ else if (flag === '--json') options.json = true
301
+ else if (flag.startsWith('-')) throw new Error(`Unknown option: ${flag}`)
302
+ else if (options.name === undefined) options.name = flag
303
+ else throw new Error(`Unexpected argument: ${flag}`)
304
+ }
305
+ if (!options.name || !options.dir) {
306
+ throw new Error('Usage: node scripts/scaffold-skill.mjs <skill-name> --dir <repo-root> [--group <group>] [--write] [--json]')
307
+ }
308
+ return options
309
+ }
310
+
311
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
312
+ if (invokedDirectly) {
313
+ let options
314
+ try {
315
+ options = parseArguments(process.argv.slice(2))
316
+ } catch (error) {
317
+ console.error(String(error.message ?? error))
318
+ process.exit(2)
319
+ }
320
+ try {
321
+ const result = await scaffoldSkill(options)
322
+ if (options.json) {
323
+ console.log(JSON.stringify(result, null, 2))
324
+ } else {
325
+ console.log(result.wrote ? `Created ${result.target}` : `Dry run - pass --write to create ${result.target}`)
326
+ for (const file of result.files) console.log(` ${file}`)
327
+ console.log('Wiring:')
328
+ for (const { step, status } of result.wiring) console.log(` - ${step}: ${status}`)
329
+ }
330
+ } catch (error) {
331
+ console.error(String(error.message ?? error))
332
+ process.exit(1)
333
+ }
334
+ }
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ // skillify guard: deterministic family-level check over every authored skill's
3
+ // tests/fixtures/trigger-queries.json. Two skills claiming the same normalised
4
+ // query as should_trigger:true without naming each other in ambiguous_with is a
5
+ // contradiction the behavioral eval would only find by re-running the whole
6
+ // family; this script finds it mechanically. Shape errors block; fixture
7
+ // hygiene (unknown neighbour names, one-sided references, missing or short
8
+ // fixtures) warns.
9
+ //
10
+ // Exit codes: 0 pass · 1 warnings under --strict · 2 blocked or usage error.
11
+ // Usage: node trigger-check.mjs [--dir <repo-root>] [--strict] [--json]
12
+ // Self-contained on purpose apart from the repo's skill-discovery lib: skillify
13
+ // is repo-only, so the relative import to packages/cli is always present.
14
+ import { readFile } from 'node:fs/promises';
15
+ import { dirname, join, relative, resolve, sep } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { discoverSkills } from '../../../../packages/cli/scripts/lib/skills.mjs';
18
+
19
+ const MIN_ENTRIES = 8;
20
+ const FIXTURE = join('tests', 'fixtures', 'trigger-queries.json');
21
+
22
+ // Located messages are built by concatenation, not template literals. The skill
23
+ // scanner's bounded shell parser reads a backtick preceded by whitespace or one
24
+ // of ;|&()<>/ as a command substitution and reports static_parse_limit when the
25
+ // first whitespace-delimited token inside it carries a $ expansion — wherever
26
+ // the literal sits (an argument or an assignment; bisected in issue 104, which
27
+ // retired the earlier "assignment position" reading from issue 95). It degrades
28
+ // an analyzer for the whole skill. Concatenation, or a literal that opens with a
29
+ // plain word, scans complete.
30
+ const at = (where, message) => where + ': ' + message;
31
+ const quoted = (text) => '"' + text + '"';
32
+
33
+ // lowercase → collapse whitespace → trim → strip trailing sentence punctuation.
34
+ export function normalizeQuery(query) {
35
+ return query.toLowerCase().replace(/\s+/g, ' ').trim().replace(/[.!?,;:]+$/, '');
36
+ }
37
+
38
+ function isNameArray(value) {
39
+ return Array.isArray(value) && value.every((name) => typeof name === 'string' && name.length > 0);
40
+ }
41
+
42
+ // Shape-checks one fixture's entries. Returns the valid entries; every bad
43
+ // entry becomes a block naming the file and index.
44
+ function readEntries(file, data, blocks) {
45
+ const entries = [];
46
+ data.forEach((entry, index) => {
47
+ const where = file + '[' + index + ']';
48
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
49
+ blocks.push(at(where, 'entry must be an object'));
50
+ return;
51
+ }
52
+ if (typeof entry.query !== 'string' || entry.query.trim().length === 0) {
53
+ blocks.push(at(where, 'query must be a non-empty string'));
54
+ return;
55
+ }
56
+ if (typeof entry.should_trigger !== 'boolean') {
57
+ blocks.push(at(where, 'should_trigger must be true or false'));
58
+ return;
59
+ }
60
+ if ('ambiguous_with' in entry && !isNameArray(entry.ambiguous_with)) {
61
+ blocks.push(at(where, 'ambiguous_with must be an array of skill-name strings'));
62
+ return;
63
+ }
64
+ entries.push({
65
+ where,
66
+ query: entry.query.trim(),
67
+ norm: normalizeQuery(entry.query),
68
+ positive: entry.should_trigger,
69
+ names: entry.ambiguous_with ?? [],
70
+ });
71
+ });
72
+ return entries;
73
+ }
74
+
75
+ // fixtures: Map<skillName, { file, data?, error? } | null>. A key is a skill
76
+ // authored here; null means the skill ships no fixture file.
77
+ export function checkTriggers(fixtures) {
78
+ const blocks = [];
79
+ const warns = [];
80
+ const valid = new Map();
81
+
82
+ for (const [skill, input] of fixtures) {
83
+ if (input === null) {
84
+ warns.push(at(skill, 'no tests/fixtures/trigger-queries.json — every skill ships one (skillify item 2)'));
85
+ continue;
86
+ }
87
+ if (input.error) {
88
+ blocks.push(at(input.file, input.error));
89
+ continue;
90
+ }
91
+ if (!Array.isArray(input.data)) {
92
+ blocks.push(at(input.file, 'fixture must be a JSON array of entries'));
93
+ continue;
94
+ }
95
+ const entries = readEntries(input.file, input.data, blocks);
96
+ if (entries.length < MIN_ENTRIES) warns.push(at(skill, entries.length + ' fixture entries, fewer than ' + MIN_ENTRIES));
97
+ valid.set(skill, entries);
98
+ }
99
+
100
+ // Per-entry hygiene: neighbour names must be authored here, and a negative
101
+ // that hands a query to a neighbour needs that neighbour to hold the query.
102
+ for (const [, entries] of valid) {
103
+ for (const entry of entries) {
104
+ for (const name of entry.names) {
105
+ if (!fixtures.has(name)) {
106
+ warns.push(at(entry.where, 'ambiguous_with names ' + quoted(name) + ', which is no skill authored here — a typo is invisible to this guard, so confirm the name by hand'));
107
+ continue;
108
+ }
109
+ if (entry.positive) continue;
110
+ const theirs = valid.get(name);
111
+ if (theirs && !theirs.some((other) => other.norm === entry.norm)) {
112
+ warns.push(at(entry.where, 'should_trigger:false hands ' + quoted(entry.query) + ' to ' + name + ', whose fixture has no entry for it — add it there so the family-level eval walks both sides'));
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ // Collisions: index positives by normalised query, then compare each pair of
119
+ // claiming skills. A skill's claim on a query carries the union of the
120
+ // neighbour names its positive entries for that query list.
121
+ const claims = new Map();
122
+ for (const [skill, entries] of valid) {
123
+ for (const entry of entries) {
124
+ if (!entry.positive) continue;
125
+ const bySkill = claims.get(entry.norm) ?? new Map();
126
+ const names = bySkill.get(skill) ?? new Set();
127
+ for (const name of entry.names) names.add(name);
128
+ bySkill.set(skill, names);
129
+ claims.set(entry.norm, bySkill);
130
+ }
131
+ }
132
+ for (const [norm, bySkill] of claims) {
133
+ const skills = [...bySkill.keys()].sort();
134
+ for (let i = 0; i < skills.length; i += 1) {
135
+ for (let j = i + 1; j < skills.length; j += 1) {
136
+ const [x, y] = [skills[i], skills[j]];
137
+ const xNamesY = bySkill.get(x).has(y);
138
+ const yNamesX = bySkill.get(y).has(x);
139
+ if (xNamesY && yNamesX) continue;
140
+ if (!xNamesY && !yNamesX) {
141
+ blocks.push(quoted(norm) + ` is should_trigger:true in ${x} and ${y} without a mutual ambiguous_with — merge the trigger or have each fixture name the other`);
142
+ continue;
143
+ }
144
+ const [namer, silent] = xNamesY ? [x, y] : [y, x];
145
+ warns.push(quoted(norm) + ` is should_trigger:true in ${x} and ${y}; ${namer} names ${silent} in ambiguous_with but ${silent} does not name ${namer} — add the reciprocal entry`);
146
+ }
147
+ }
148
+ }
149
+
150
+ blocks.sort();
151
+ warns.sort();
152
+ return { blocks, warns };
153
+ }
154
+
155
+ // One key per discovered skill. ENOENT → null (no fixture); any other read
156
+ // error or a JSON.parse failure → { file, error }; a discovery error throws.
157
+ export async function loadFixtures(repoRoot) {
158
+ const skills = discoverSkills(join(repoRoot, 'skills'));
159
+ const fixtures = new Map();
160
+ for (const [name, skill] of skills) {
161
+ const path = join(skill.path, FIXTURE);
162
+ const file = relative(repoRoot, path).split(sep).join('/');
163
+ let text;
164
+ try {
165
+ text = await readFile(path, 'utf8');
166
+ } catch (error) {
167
+ fixtures.set(name, error.code === 'ENOENT' ? null : { file, error: error.message });
168
+ continue;
169
+ }
170
+ try {
171
+ fixtures.set(name, { file, data: JSON.parse(text) });
172
+ } catch (error) {
173
+ fixtures.set(name, { file, error: error.message });
174
+ }
175
+ }
176
+ return fixtures;
177
+ }
178
+
179
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
180
+ if (invokedDirectly) {
181
+ const argv = process.argv.slice(2);
182
+ const usage = 'usage: trigger-check.mjs [--dir <repo-root>] [--strict] [--json]';
183
+ let json = false;
184
+ let strict = false;
185
+ let dir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
186
+ let outcome = null;
187
+ for (let i = 0; i < argv.length; i += 1) {
188
+ if (argv[i] === '--json') json = true;
189
+ else if (argv[i] === '--strict') strict = true;
190
+ else if (argv[i] === '--dir' && argv[i + 1] && !argv[i + 1].startsWith('--')) dir = resolve(argv[(i += 1)]);
191
+ else outcome = { blocks: [usage], warns: [] };
192
+ }
193
+ if (!outcome) {
194
+ try {
195
+ outcome = checkTriggers(await loadFixtures(dir));
196
+ } catch (error) {
197
+ outcome = { blocks: [`cannot discover skills under ${dir}: ${error.message}`], warns: [] };
198
+ }
199
+ }
200
+ const ok = outcome.blocks.length === 0;
201
+ if (json) {
202
+ console.log(JSON.stringify({ guard: 'trigger-check', ok, ...outcome }, null, 2));
203
+ } else {
204
+ console.log(`trigger-check: ${ok ? (outcome.warns.length ? 'pass with warnings' : 'pass') : 'BLOCKED'}`);
205
+ for (const b of outcome.blocks) console.log(` block: ${b}`);
206
+ for (const w of outcome.warns) console.log(` warn: ${w}`);
207
+ }
208
+ process.exit(ok ? (outcome.warns.length && strict ? 1 : 0) : 2);
209
+ }
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: vegafactory-setup
3
+ description: Bootstrap and maintain the org control room — the org, group, people, repos, boards, rules, onboarding, and template files whose defaults every repo's dev profile layers on. Use when asked to "set up the control room", "bootstrap the org for the factory", "register this repo in the control room", "onboard a teammate", "add someone to the org", or "what are the org defaults here", and when a repo's control-room knob names a control room that does not exist yet. Not for a single repo's profile, labels, or knobs (dev-setup), not for architecture advice (dev-architect), not for writing or approving issues (dev-intake), and not for refreshing the local control-room clone (the `vegafactory sync` command).
4
+ ---
5
+
6
+ # vegafactory-setup
7
+
8
+ Act: give the org one control room, so every repo inherits the answers it would otherwise be asked for.
9
+
10
+ The control room is `<org>/vegafactory-control-room` — the org profile, the department groups, the people, the repo and board registries, the org-wide rules, the onboarding checklists, and the templates. `dev-setup` reads it before its interview and states an inherited knob instead of asking for it. The layout, the precedence rule, the read path, and what each file may carry live in [control-room](references/control-room.md); this file is the procedure.
11
+
12
+ Nearest neighbor: `dev-setup` owns one repo's profile — its knobs, labels, runbooks, and AGENTS.md section. This skill owns the defaults that profile layers on. When the question is "what should this repo do", that is dev-setup; when it is "what should every repo do", it is this one.
13
+
14
+ ## Bootstrap — the org questionnaire, then the files, then a stop
15
+
16
+ 1. **Ask the questionnaire.** Only what `org.md` holds and nothing a department owns: the org name, the goals in one paragraph, and what applies to everyone — the language, the date format (DD-MM-YYYY across the workflow), the "nothing ships without the operator's explicit instruction" stance, and the three statistics lines `stats:`, `stats-people:`, `stats-override:`. A knob a department decides — review, gates, merge, the harness policy — is never asked here, because two departments reading one org answer would each need the other's to be wrong.
17
+ 2. **Render every template** in `assets/control-room/` into a working directory the operator can read before anything is pushed: `org.md`, `people.csv`, `decisions.md`, `groups/dev/{group.md,people.csv,decisions.md}`, `repos.md`, `boards.md`, `rules/`, `onboarding/`, `templates/`. `people.csv` is seeded from `gh api orgs/<org>/members -q '.[].login'` — the logins come from the API, every `role` is asked for, and a role nobody answered stays unrecorded. Rendering is otherwise substitution only: a template is a default, and a default nobody has confirmed is still a default.
18
+ 3. **Stop before the repository exists.** Creating the org repository, granting anyone access, and recording anyone's role are the operator's own account actions. Name the exact commands, say what each one does, and let the operator run them. The skill positions the operator; it does not reach for the credential.
19
+
20
+ **Nothing secret goes in any file — names of secrets only.** A control room is readable by everyone the org onboards, so `NPM_TOKEN` is the entry and the value stays in the store that name points at.
21
+
22
+ ## Round — automation identity
23
+
24
+ The org's automated writes go out as the public GitHub App, not as a person's token. This round records the App by name and stops at every step that needs the operator's own account.
25
+
26
+ 1. **Hand the operator the creation walk** in dev-setup's `references/github-app.md` — the permission table, the "Any account" setting, the webhook left off, and the private-key step — rather than restating it here, so the App's contract has one home and one place to update.
27
+ 2. **Detect an existing installation:** `gh api orgs/<org>/installations --jq '.installations[] | select(.app_slug == "vegafactory") | .id'`. Write the returned id onto `org.md`'s `app-install:` line.
28
+ 3. **Leave the line as an unconfirmed placeholder when the call answers 403 or returns nothing, and say which happened** — the endpoint answers organization owners only, so a 403 means "not an owner", never "no App". An unconfirmed line is a question the next run asks again.
29
+ 4. **Never create the App, never ask for the PEM, never write a secret value into any control-room file.** Generating the private key is a browser download GitHub delivers once, to whoever pressed the button; an automated session's download never reaches the operator. Record the two names, `VEGAFACTORY_APP_ID` and `VEGAFACTORY_APP_PRIVATE_KEY`, and leave the values in GitHub org settings.
30
+
31
+ ## Round — the board
32
+
33
+ A board mirrors the state labels; it never drives them. This round records the board and hands the operator every command that touches a project.
34
+
35
+ 1. **Offer the board on the operator's yes**, one plain sentence: labels drive the workflow and the board follows, one way — a card dragged on the board is cosmetic until the next label change.
36
+ 2. **Run nothing that mutates a project.** Creating a board, replacing its Status field, linking a repo and switching on the built-in automations all need the `project` scope on the operator's own token. Hand them the sequence in [control-room](references/control-room.md)'s `## Boards` section verbatim, starting with `gh auth refresh -s project`.
37
+ 3. **Write the `boards.md` row** from `assets/control-room/boards.md.template` — board title, number, the repos mirroring onto it, and a notes cell — once the operator reports the number.
38
+ 4. **Set `board: <n>`** in each linked repo's `.vegastack/dev.md`, and leave it at `none` for a repo with no board; the workflow reads that line and does nothing when it says `none`.
39
+ 5. **Record a blocked repo rather than working around it:** where the org plan's auto-add cap refuses another board workflow, the row in `boards.md` says so with its date and the request goes back to the operator.
40
+
41
+ ## Seeding `groups/<g>/group.md`
42
+
43
+ A group file carries one default for every knob a group can decide for a repo's `.vegastack/dev.md` — per-repo facts such as `repo:`, `skill-scan:`, `board:` and `control-room:` stay in the repo — so a repo that answers nothing else still gets a complete profile. Seed it from an existing repo's dev.md: confirmed knob lines transfer verbatim, including `harness-policy:`, because a group file uses the same parser. When no confirmed policy exists, use dev-setup's refresh-tracked harness selection and flag mapping in `references/harness-facts.md`; this skill does not freeze vendor model/version defaults. <!-- mirrored -->
44
+
45
+ A knob whose value differs between two repos in the group is a question, not an average: ask which one is the group's default, and let the other repo keep its hand edit — the precedence rule already protects it.
46
+
47
+ ## `register <repo>`
48
+
49
+ 1. Confirm which group the repo belongs to.
50
+ 2. Run `dev-setup` in the repo. It reads the control room first, so every inherited knob is stated rather than asked, and only what no layer answers reaches the interview.
51
+ 3. Append the repo's row to `repos.md` — repo, group, board, owner.
52
+ 4. Link the board when `boards.md` names one for the group, and copy the board-mirror workflow and the CODEOWNERS pattern from `templates/` and `rules/`.
53
+ 5. Confirm the repo's `control-room:` knob names this control room and this group.
54
+
55
+ The full checklist ships as `onboarding/new-repo.md`, so the org can edit the procedure without editing this skill.
56
+
57
+ The org's third onboarding path is a machine rather than a repo or a person: `onboarding/dispatcher-box.md` provisions the always-on box that runs the Actions runner and the dispatcher, under two accounts so a CI job cannot read the dispatcher's tokens.
58
+
59
+ ## `onboard <login>`
60
+
61
+ Walk `onboarding/new-teammate.md` with the person: `gh auth login` at or above the group's `gh-floor:`, the harnesses the group's `harness-policy:` line names, `vegafactory skills add --group dev --global`, control-room read access, and the Slack subscription through the official GitHub Slack app. Then add their `people.csv` row — `login,name,role,slack,timezone,groups`.
62
+
63
+ **A person's `role` is descriptive and recorded only on the operator's word**, never inferred from org membership. It grants no people-level read authority. Cross-person visibility comes only from the resolved organization/group administration capability and repository read scope in [control-room](references/control-room.md). A group-level row overrides descriptive membership for the same `login`; it cannot override an organization lock or create administration authority without the exact organization delegation.
64
+
65
+ ## The declined step
66
+
67
+ Every step here can be declined, and a declined step is recorded, not skipped: write it into `org.md`'s `## Unconfirmed` section as one line, in the same form `dev-setup` writes for a knob it could not confirm. The next run asks again. An unrecorded decline becomes an assumption, and an assumption in a file every repo inherits is the most expensive kind.
68
+
69
+ A control room that does not exist yet, or that the caller cannot read, is not an error either: say which answers could not be inherited and let `dev-setup` ask them.
70
+
71
+ ## Routing
72
+
73
+ | Need | Read |
74
+ |---|---|
75
+ | the file tree, precedence, read path, `people.csv` rules | [control-room](references/control-room.md) |
76
+ | comment markers, operator identity, register line format, labels | dev-setup's `references/conventions.md`, installed beside this file |
77
+ | the seed text of any control-room file | `assets/control-room/<file>.template` |
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "vegafactory-setup"
3
+ short_description: "Bootstrap and maintain the org control room every repo inherits from"
4
+ default_prompt: "Use $vegafactory-setup to set up or update this org's control room."
@@ -0,0 +1,17 @@
1
+ # Boards
2
+
3
+ Project boards and the repos that mirror onto them. Labels stay the machine state; a board is a view. Several repos may share one board, and one repo may have its own.
4
+
5
+ **One way only.** `.github/workflows/factory-board.yml` reads a repo's state label and writes the board's Status field. Nothing reads the board: a card dragged on the board is cosmetic until the next label change overwrites it.
6
+
7
+ Status options, in this order: needs-operator · needs-plan · ready · working · for-operator · Done — the five state labels plus Done, which the built-in automations set.
8
+
9
+ Built-in automations, switched on once per board in its Workflows UI (no CLI covers them): auto-add `is:issue is:open`, item closed → Done, PR merged → Done, auto-archive after 14 days.
10
+
11
+ The commands that create, field-configure and link a board are in `vegafactory-setup`'s `references/control-room.md`, and the operator runs them — they need the `project` scope on a human token.
12
+
13
+ | board | number | repos | notes |
14
+ |---|---|---|---|
15
+ | {{board-title}} | {{number}} | {{owner/repo}} | {{blank, or what this board is for}} |
16
+
17
+ A repo the org plan's auto-add cap blocks (Team plans allow five auto-add workflows per board) is recorded here with its date and handed back to the operator, never worked around.
@@ -0,0 +1,5 @@
1
+ # Decision register — {{scope}}
2
+
3
+ Append-only, one dated line per directional decision, in the register line format dev-setup's `references/conventions.md` defines. Registers concatenate across org, group, and repo — a line here never overrides one anywhere else.
4
+
5
+ - DD-MM-YYYY (github-username) — the decision