@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,694 @@
1
+ // Canonical, dependency-free policy and authority resolver. Resolution is pure; the explicit
2
+ // snapshot read adapters at the end read verified Git objects and never write or fetch.
3
+ // Markdown is authored state. Callers supply verified identity/registry/snapshot context, never
4
+ // a login claimed in request text. This is a cooperative trusted-host boundary, not authentication.
5
+ import { createHash } from 'node:crypto'
6
+ import { execFileSync } from 'node:child_process'
7
+ import { lstatSync, readFileSync } from 'node:fs'
8
+ import { isAbsolute, join } from 'node:path'
9
+
10
+ const own = (value, key) => Object.hasOwn(value, key)
11
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
12
+ const forbidden = new Set(['__proto__', 'prototype', 'constructor'])
13
+ const stages = ['intake', 'plan', 'implement', 'review', 'status', 'chronicle']
14
+ const enums = {
15
+ stats: ['on', 'off', 'inherit'], 'stats-people': ['on', 'off'], 'stats-override': ['allowed', 'locked'],
16
+ review: ['subagent', 'cross-agent-risky', 'cross-agent'], tests: ['required', 'logic-only', 'best-effort', 'none'],
17
+ changelog: ['changesets', 'keep-a-changelog', 'pubspec+changelog', 'none'], chronicle: ['on', 'off'],
18
+ merge: ['rebase', 'squash', 'merge'], dispatch: ['off', 'local'], 'provider-mode': ['subscription-only'],
19
+ learning: ['normal-work', 'off'], 'learning-adoption': ['scoped-reversible', 'propose-only'],
20
+ 'stats-export': ['off', 'non-attributed', 'attributed'],
21
+ }
22
+ const lockable = new Set(['stats', 'stats-export', 'gates', 'tests', 'review', 'provider-mode', 'learning', 'learning-adoption'])
23
+ const ordinary = new Set([...Object.keys(enums), 'operators', 'harness-policy', 'gates', 'branch', 'labels', 'control-room', 'sync-max-age', 'workflow-labels', 'stats-local-retention-days', 'stats-shared-retention-months', 'stats-spool-warning-mib'])
24
+ const loginPattern = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i
25
+ const groupPattern = /^[a-z0-9][a-z0-9-]{0,63}$/
26
+ const repoPattern = /^[a-z\d][a-z\d-]*\/[a-z\d_.-]+$/i
27
+ const shaPattern = /^[a-f0-9]{40}$/
28
+ const digestPattern = /^[a-f0-9]{64}$/
29
+ const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i
30
+
31
+ function sorted(value) {
32
+ if (Array.isArray(value)) return value.map(sorted)
33
+ if (!object(value)) return value
34
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, sorted(value[key])]))
35
+ }
36
+ export const policyHash = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(sorted(value))).digest('hex')
37
+ function safeTree(value) {
38
+ if (!value || typeof value !== 'object') return true
39
+ return Object.entries(value).every(([key, child]) => !forbidden.has(key) && safeTree(child))
40
+ }
41
+ const same = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b))
42
+ const strings = (value, pattern) => Array.isArray(value) && value.every(item => typeof item === 'string' && pattern.test(item) && !forbidden.has(item))
43
+ const unique = values => [...new Set(values)]
44
+
45
+ // JSON.parse accepts duplicate object keys. Authority cannot choose the last self-grant, so
46
+ // validate the already syntax-checked token stream before accepting the parsed document.
47
+ function policyJson(text) {
48
+ const parsed = JSON.parse(text)
49
+ const tokens = text.match(/"(?:\\.|[^"\\])*"|[{}\[\],:]|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g) ?? []
50
+ let at = 0
51
+ function value(depth = 0) {
52
+ if (depth > 64) throw new Error('policy nesting exceeds 64 levels')
53
+ const token = tokens[at++]
54
+ if (token === '{') {
55
+ const keys = new Set()
56
+ while (tokens[at] !== '}') {
57
+ const key = JSON.parse(tokens[at++])
58
+ if (keys.has(key) || forbidden.has(key)) throw new Error('duplicate or prototype policy key')
59
+ keys.add(key); at++; value(depth + 1)
60
+ if (tokens[at] === ',') at++
61
+ }
62
+ at++
63
+ } else if (token === '[') {
64
+ while (tokens[at] !== ']') { value(depth + 1); if (tokens[at] === ',') at++ }
65
+ at++
66
+ }
67
+ }
68
+ value()
69
+ return parsed
70
+ }
71
+
72
+ /** @typedef {'needsOperator'|'needsPlan'|'ready'|'working'|'forOperator'} State */
73
+ /** @typedef {Record<State,string>} LabelMap */
74
+ /** @type {LabelMap} */
75
+ export const DEFAULT_LABELS = Object.freeze({ needsOperator: 'needs-operator', needsPlan: 'needs-plan', ready: 'ready', working: 'working', forOperator: 'for-operator' })
76
+ export const WORKFLOW_STATES = Object.freeze(Object.keys(DEFAULT_LABELS))
77
+
78
+ /** @param {unknown} value @returns {LabelMap} */
79
+ export function resolveLabels(value) {
80
+ if (value === undefined) return { ...DEFAULT_LABELS }
81
+ if (typeof value === 'string' || Array.isArray(value)) {
82
+ const names = typeof value === 'string' ? value.trim().split(/[,\s]+/) : value
83
+ if (!names.length || names.some(name => typeof name !== 'string' || !name.trim())
84
+ || new Set(names).size !== names.length || !Object.values(DEFAULT_LABELS).every(name => names.includes(name))) {
85
+ throw new Error('ambiguous legacy labels: preview an explicit workflow-labels semantic mapping; no labels or board options changed')
86
+ }
87
+ return { ...DEFAULT_LABELS }
88
+ }
89
+ if (!object(value) || Object.keys(value).length !== WORKFLOW_STATES.length
90
+ || !WORKFLOW_STATES.every(key => own(value, key) && typeof value[key] === 'string' && value[key].trim() === value[key]
91
+ && value[key].length > 0 && value[key].length <= 50 && !/[\x00-\x1f\x7f]/.test(value[key]))
92
+ || new Set(Object.values(value).map(name => name.toLowerCase())).size !== WORKFLOW_STATES.length) {
93
+ throw new Error('workflow-labels requires exactly five semantic keys with distinct nonempty label names')
94
+ }
95
+ return Object.fromEntries(WORKFLOW_STATES.map(key => [key, value[key]]))
96
+ }
97
+
98
+ /** @param {string[]} labels @param {LabelMap} map @returns {{state:State|null,blocks:string[]}} */
99
+ export function resolveState(labels, map) {
100
+ if (map === undefined || map === null) return { state: null, blocks: ['workflow label map unavailable'] }
101
+ try { map = resolveLabels(map) } catch (error) { return { state: null, blocks: [error.message] } }
102
+ if (!Array.isArray(labels) || labels.some(label => typeof label !== 'string')) return { state: null, blocks: ['unreadable issue labels'] }
103
+ const states = WORKFLOW_STATES.filter(key => labels.includes(map[key]))
104
+ return states.length === 1 ? { state: states[0], blocks: [] }
105
+ : { state: null, blocks: [states.length ? 'conflicting state labels: ' + states.map(key => map[key]).join(', ') : 'no known workflow state label'] }
106
+ }
107
+
108
+ // Resolve just the local label contract for read-only profile tooling. Runtime admission
109
+ // supplies the complete configured policy separately; this helper grants no authority.
110
+ export function readWorkflowLabels(text = '') {
111
+ const layer = parsePolicy(text)
112
+ if (layer.blocks.length) throw new Error(layer.blocks.join('; '))
113
+ return workflowLabelsFromValues(layer.values)
114
+ }
115
+ function workflowLabelsFromValues(values) {
116
+ const map = resolveLabels(values['workflow-labels'] ?? values.labels)
117
+ if (values['workflow-labels'] !== undefined && values.labels !== undefined && !same(map, resolveLabels(values.labels))) throw new Error('labels and workflow-labels disagree; explicit migration required')
118
+ return map
119
+ }
120
+
121
+ export const labelsDigest = labels => policyHash([...new Set(labels)].sort())
122
+
123
+ // Presentation-only preview; callers obtain an explicit mapping before writing any profile.
124
+ export function previewLabelMigration(value, proposed) {
125
+ return { oldNames: typeof value === 'string' ? value.trim().split(/[,\s]+/) : value ?? [], proposed: proposed === undefined ? null : resolveLabels(proposed), writes: false }
126
+ }
127
+
128
+ function parseStage(value) {
129
+ const parts = value.trim().split(/\s+/)
130
+ return parts.length === 3 && ['claude', 'codex'].includes(parts[0]) && parts.every(Boolean)
131
+ ? { harness: parts[0], model: parts[1], effort: parts[2] } : null
132
+ }
133
+ function knobValue(key, text) {
134
+ if (enums[key]) return enums[key].includes(text) ? text : undefined
135
+ if (key === 'gates') return /^[123]$/.test(text) ? Number(text) : undefined
136
+ if (key === 'operators') return text && strings(text.split(/[,\s]+/), loginPattern) ? unique(text.toLowerCase().split(/[,\s]+/)) : undefined
137
+ if (key === 'sync-max-age') {
138
+ const m = /^(\d+)([smh])$/.exec(text)
139
+ const seconds = m ? Number(m[1]) * ({ s: 1, m: 60, h: 3600 }[m[2]]) : 0
140
+ return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : undefined
141
+ }
142
+ if (key.startsWith('stats-') && !enums[key]) return /^[1-9]\d*$/.test(text) && Number.isSafeInteger(Number(text)) ? Number(text) : undefined
143
+ if (key === 'control-room') return text === 'none' || /^[a-z\d][a-z\d-]*\/[a-z\d_.-]+(?:#[a-z\d-]+)?(?:@[a-f\d]{7,40})?$/i.test(text) ? text : undefined
144
+ if (key === 'branch') return text && /^[a-z\d_/-]+$/i.test(text.replace(/<(?:type|issue|slug)>/g, 'value')) ? text : undefined
145
+ if (key === 'labels') return text && text.split(/[,\s]+/).every(label => /^[\w-]+$/.test(label)) ? text.split(/[,\s]+/) : undefined
146
+ if (key === 'workflow-labels') {
147
+ try { return resolveLabels(policyJson(text)) } catch { return undefined }
148
+ }
149
+ return undefined
150
+ }
151
+
152
+ export function parsePolicy(text = '', scope = 'repo') {
153
+ const layer = { scope, text, schemaVersion: 1, values: {}, extensions: {}, authority: null, blocks: [], lines: [] }
154
+ if (typeof text !== 'string' || !['org', 'group', 'repo'].includes(scope)) {
155
+ layer.blocks.push('policy layer needs Markdown and a known scope'); return layer
156
+ }
157
+ const seen = new Set()
158
+ let fence = null, policyLines = null
159
+ for (const line of text.split(/\r?\n/)) {
160
+ const boundary = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line)
161
+ if (boundary) {
162
+ if (!fence) { fence = boundary[1]; policyLines = boundary[2].trim() === 'vsk-policy' ? [] : null }
163
+ else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length && !boundary[2].trim()) {
164
+ if (policyLines) {
165
+ if (layer.authority) layer.blocks.push('duplicate vsk-policy block')
166
+ try {
167
+ const parsed = policyJson(policyLines.join('\n'))
168
+ if (!object(parsed) || !safeTree(parsed) || parsed.schemaVersion !== 2) layer.blocks.push('invalid vsk-policy schema or prototype key')
169
+ else if (scope !== 'org') layer.blocks.push('authority blocks belong only to the organization')
170
+ else layer.authority = parsed
171
+ } catch { layer.blocks.push('malformed vsk-policy JSON') }
172
+ }
173
+ fence = null; policyLines = null
174
+ }
175
+ continue
176
+ }
177
+ if (fence) { if (policyLines) policyLines.push(line); continue }
178
+ layer.lines.push(line)
179
+ const match = /^([a-z][a-z0-9-]*):\s*(.*?)\s*$/.exec(line)
180
+ if (!match) continue
181
+ const key = match[1], value = match[2].replace(/\s+#.*$/, '').trim()
182
+ if (forbidden.has(key)) { layer.blocks.push('prototype key in policy'); continue }
183
+ const stageLine = stages.includes(key) && !enums[key]?.includes(value)
184
+ if (key === 'policy-schema' || ordinary.has(key) || stageLine) {
185
+ if (seen.has(key)) layer.blocks.push(`duplicate policy key: ${key}`)
186
+ seen.add(key)
187
+ } else { layer.extensions[key] = value; continue }
188
+ if (key === 'policy-schema') {
189
+ if (!['1', '2'].includes(value)) layer.blocks.push('unsupported policy-schema')
190
+ else layer.schemaVersion = Number(value)
191
+ continue
192
+ }
193
+ if (key === 'harness-policy' || stageLine) {
194
+ layer.values.stages ??= {}
195
+ for (const segment of key === 'harness-policy' ? value.split('·') : [key + ' ' + value]) {
196
+ const parts = segment.trim().split(/\s+/), name = parts.shift(), parsed = parseStage(parts.join(' '))
197
+ if (!stages.includes(name) || !parsed || own(layer.values.stages, name)) layer.blocks.push(`invalid or duplicate harness stage: ${name}`)
198
+ else layer.values.stages[name] = parsed
199
+ }
200
+ continue
201
+ }
202
+ const parsed = knobValue(key, value)
203
+ if (parsed === undefined) layer.blocks.push(`invalid ${key} value`)
204
+ else if (!(key === 'stats' && parsed === 'inherit')) layer.values[key] = parsed
205
+ }
206
+ if (fence && policyLines) layer.blocks.push('unclosed vsk-policy block')
207
+ if (layer.authority && layer.schemaVersion !== 2) {
208
+ layer.blocks.push('vsk-policy authority requires policy-schema: 2; explicit migration required')
209
+ layer.authority = null
210
+ }
211
+ return layer
212
+ }
213
+
214
+ function validateAuthority(layer, blocks) {
215
+ const authority = layer.authority ?? {}, locked = authority.locked ?? {}, delegations = authority.delegations ?? []
216
+ if (!object(locked) || !Array.isArray(delegations)) { blocks.push('malformed locked/delegations'); return { locked: {}, delegations: [] } }
217
+ const validLocked = {}
218
+ for (const [key, value] of Object.entries(locked)) {
219
+ if (!lockable.has(key) || knobValue(key, String(value)) === undefined || value === 'inherit') blocks.push(`invalid locked key/value: ${key}`)
220
+ else validLocked[key] = knobValue(key, String(value))
221
+ }
222
+ const validDelegations = []
223
+ for (const grant of delegations) {
224
+ if (!object(grant) || !lockable.has(grant.key) || !strings(grant.groups, groupPattern) || !strings(grant.repos, repoPattern)
225
+ || grant.groups.length === 0 || grant.repos.length === 0 || !Array.isArray(grant.allowedValues) || !grant.allowedValues.length
226
+ || grant.allowedValues.some(value => knobValue(grant.key, String(value)) === undefined || value === 'inherit')) blocks.push('invalid exact org delegation')
227
+ else validDelegations.push({ ...grant, allowedValues: grant.allowedValues.map(value => knobValue(grant.key, String(value))) })
228
+ }
229
+ if (layer.values['stats-override'] === 'locked') {
230
+ if (layer.values.stats === undefined) blocks.push('legacy stats lock requires an explicit org stats value')
231
+ else if (own(validLocked, 'stats') && validLocked.stats !== layer.values.stats) blocks.push('conflicting legacy stats lock')
232
+ else validLocked.stats = layer.values.stats
233
+ }
234
+ return { locked: validLocked, delegations: validDelegations }
235
+ }
236
+
237
+ /**
238
+ * @param {{org?:string,group?:string,repo?:string,identity?:Record<string,any>,freshness?:Record<string,any>}} input
239
+ * @returns {{ok:boolean,policy:Record<string,any>,blocks:string[]}}
240
+ */
241
+ export function resolvePolicy({ org = '', group = '', repo = '', identity = {}, freshness = {} } = {}) {
242
+ const layers = [parsePolicy(org, 'org'), parsePolicy(group, 'group'), parsePolicy(repo, 'repo')]
243
+ const blocks = layers.flatMap(layer => layer.blocks.map(block => layer.scope + ': ' + block))
244
+ const { locked, delegations } = validateAuthority(layers[0], blocks)
245
+ const values = { dispatch: 'off', stats: 'on', 'stats-people': 'off', stages: {} }, sources = {}
246
+ const paths = identity.paths ?? {}
247
+ const source = layer => ({ scope: layer.scope, path: paths[layer.scope] ?? (layer.scope === 'org' ? 'org.md' : layer.scope === 'group' ? 'groups/' + (identity.group ?? 'unselected') + '/group.md' : '.vegastack/dev.md'), revision: layer.scope !== 'repo' && shaPattern.test(identity.roomSha ?? '') ? identity.roomSha : policyHash(layer.text), revisionKind: layer.scope !== 'repo' && shaPattern.test(identity.roomSha ?? '') ? 'git' : 'sha256' })
248
+ for (const layer of layers) {
249
+ if (layer.text.trim()) sources[`layer.${layer.scope}`] = source(layer)
250
+ for (const [key, value] of Object.entries(layer.values)) {
251
+ if (key === 'dispatch' && layer.scope !== 'repo') continue
252
+ if (key === 'stages') { for (const [name, stage] of Object.entries(value)) { values.stages[name] = stage; sources['stages.' + name] = source(layer) }; continue }
253
+ const permitted = delegations.some(grant => grant.key === key && grant.groups.includes(identity.group) && grant.repos.includes(identity.repo) && grant.allowedValues.some(allowed => same(allowed, value)))
254
+ if (layer.scope !== 'org' && own(locked, key) && !same(value, locked[key]) && !permitted) {
255
+ blocks.push(layer.scope + ': ' + key + ' override requires exact org delegation'); continue
256
+ }
257
+ if (key === 'stats-export' && value === 'attributed' && layer.scope !== 'org' && layers[0].values[key] !== 'attributed' && locked[key] !== 'attributed' && !permitted) {
258
+ blocks.push('attributed reporting requires explicit org authorization'); continue
259
+ }
260
+ values[key] = value; sources[key] = source(layer)
261
+ }
262
+ if (layer.scope === 'org') for (const [key, value] of Object.entries(locked)) { values[key] = value; sources[key] = source(layer) }
263
+ }
264
+ try { values['workflow-labels'] = workflowLabelsFromValues(values) } catch (error) { blocks.push(error.message) }
265
+ const configured = freshness.configured === true || Boolean(values['control-room'] && values['control-room'] !== 'none')
266
+ const now = typeof freshness.now === 'number' ? freshness.now : Date.parse(freshness.now ?? '')
267
+ const validated = Date.parse(freshness.validatedAt ?? '')
268
+ const maxAgeSeconds = values['sync-max-age'] ?? freshness.maxAgeSeconds ?? 1800
269
+ const ageSeconds = Number.isFinite(now) && Number.isFinite(validated) ? (now - validated) / 1000 : null
270
+ let state = 'local'
271
+ if (configured) {
272
+ state = ageSeconds === null || ageSeconds < 0 || !Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds <= 0 ? 'unavailable' : ageSeconds >= maxAgeSeconds ? 'stale' : 'fresh'
273
+ if (!org.trim() || !shaPattern.test(identity.roomSha ?? '')) { state = 'unavailable'; blocks.push('configured organization policy or validated source identity unavailable') }
274
+ if (state !== 'fresh') blocks.push(`mandatory policy ${state}: validated refresh required`)
275
+ }
276
+ const authority = layers[0].authority ?? {}
277
+ const registry = { org: identity.org ?? String(identity.repo ?? '').split('/')[0], peopleByScope: identity.peopleByScope ?? {}, repoGroups: identity.repoGroups ?? {}, repositoryIds: identity.repositoryIds ?? {} }
278
+ if ((authority.administration !== undefined || authority.fleet !== undefined)
279
+ && (!loginPattern.test(registry.org) || Object.keys(registry.repoGroups).some(repo => repo.split('/')[0].toLowerCase() !== registry.org.toLowerCase()))) blocks.push('registry contains an invalid or foreign organization')
280
+ const policy = { schemaVersion: 2, repo: identity.repo ?? null, group: identity.group ?? null, values, sources,
281
+ locked, delegations, administration: null, fleet: null, registry,
282
+ freshness: { configured, state, validatedAt: freshness.validatedAt ?? null, ageSeconds, maxAgeSeconds }, policyDigest: '', blocks }
283
+ if (authority.administration !== undefined) {
284
+ const result = resolveAdministration({ orgLayer: layers[0], peopleByScope: registry.peopleByScope, repoGroups: registry.repoGroups })
285
+ policy.administration = result.administration; blocks.push(...result.blocks)
286
+ sources.administration = source(layers[0])
287
+ }
288
+ if (authority.fleet !== undefined) {
289
+ const result = validateFleet(authority.fleet, registry)
290
+ policy.fleet = result.fleet; blocks.push(...result.blocks)
291
+ sources.fleet = source(layers[0])
292
+ }
293
+ policy.policyDigest = policyHash({ values, sources, locked, delegations, administration: policy.administration, fleet: policy.fleet, registry })
294
+ return { ok: blocks.length === 0, policy, blocks }
295
+ }
296
+
297
+ const capabilities = ['group.members.manage', 'group.repos.manage', 'group.defaults.manage', 'group.people.read']
298
+ function directory(peopleByScope, repoGroups) {
299
+ const people = new Map(), groups = new Set(Object.values(repoGroups ?? {}))
300
+ if (!object(peopleByScope) || !object(repoGroups) || !safeTree(peopleByScope) || !safeTree(repoGroups)) return { people, groups, blocks: ['invalid confirmed registry'] }
301
+ const blocks = []
302
+ for (const scope of Object.keys(peopleByScope)) if (scope !== 'org' && groupPattern.test(scope) && !forbidden.has(scope)) groups.add(scope)
303
+ for (const [repo, group] of Object.entries(repoGroups)) if (!repoPattern.test(repo) || !groupPattern.test(group) || forbidden.has(group)) blocks.push('invalid repository/group registration')
304
+ for (const [scope, rows] of Object.entries(peopleByScope)) {
305
+ if (!Array.isArray(rows)) { blocks.push('people registry scope must contain rows'); continue }
306
+ if (scope !== 'org' && (!groupPattern.test(scope) || forbidden.has(scope))) { blocks.push('invalid people group'); continue }
307
+ if (scope !== 'org') groups.add(scope)
308
+ for (const row of rows) {
309
+ if (!object(row) || !loginPattern.test(row.login ?? '') || forbidden.has(row.login) || !strings(row.groups ?? [], groupPattern)) { blocks.push('invalid confirmed person'); continue }
310
+ const login = row.login.toLowerCase()
311
+ const memberships = row.groups ?? []
312
+ if (memberships.some(group => !groups.has(group))) blocks.push(`unregistered person group: ${login}`)
313
+ // Descriptive group rows never replace a person's independent grants or membership evidence.
314
+ const prior = people.get(login) ?? { login, groups: [] }
315
+ people.set(login, { login, groups: unique([...prior.groups, ...memberships, ...(scope === 'org' ? [] : [scope])]) })
316
+ }
317
+ }
318
+ return { people, groups, blocks }
319
+ }
320
+
321
+ export function resolveAdministration({ orgLayer, peopleByScope = {}, repoGroups = {} } = {}) {
322
+ const blocks = [], layer = typeof orgLayer === 'string' ? parsePolicy(orgLayer, 'org') : orgLayer
323
+ if (!layer || layer.scope !== 'org' || layer.blocks?.length) return { ok: false, administration: null, blocks: ['administration needs a valid organization layer'] }
324
+ const raw = layer.authority?.administration
325
+ if (raw === undefined) return { ok: true, administration: null, blocks: [] }
326
+ const registry = directory(peopleByScope, repoGroups)
327
+ blocks.push(...registry.blocks)
328
+ if (!object(raw) || !safeTree(raw) || !strings(raw.orgAdmins, loginPattern) || !raw.orgAdmins.length || !object(raw.groupAdmins) || !object(raw.groupAdminCapabilities)) {
329
+ return { ok: false, administration: null, blocks: [...blocks, 'administration requires at least one confirmed org admin and explicit group maps'] }
330
+ }
331
+ const orgAdmins = unique(raw.orgAdmins.map(login => login.toLowerCase())), groupAdmins = {}, groupAdminCapabilities = {}
332
+ for (const login of orgAdmins) if (!registry.people.has(login)) blocks.push(`unconfirmed org admin: ${login}`)
333
+ for (const [group, logins] of Object.entries(raw.groupAdmins)) {
334
+ if (!registry.groups.has(group) || !strings(logins, loginPattern)) { blocks.push(`invalid group admin assignment: ${group}`); continue }
335
+ groupAdmins[group] = unique(logins.map(login => login.toLowerCase()))
336
+ for (const login of groupAdmins[group]) if (!registry.people.has(login) || !registry.people.get(login).groups.includes(group)) blocks.push(`unconfirmed group admin membership: ${group}/${login}`)
337
+ }
338
+ for (const [group, grants] of Object.entries(raw.groupAdminCapabilities)) {
339
+ if (!registry.groups.has(group) || !Array.isArray(grants) || grants.some(grant => !capabilities.includes(grant))) { blocks.push(`invalid group capability: ${group}`); continue }
340
+ groupAdminCapabilities[group] = unique(grants)
341
+ }
342
+ const administration = { orgAdmins, groupAdmins, groupAdminCapabilities }
343
+ return { ok: blocks.length === 0, administration: blocks.length ? null : administration, blocks }
344
+ }
345
+
346
+ const fleetDefaults = { pollSeconds: 120, maxRuns: 1, childConcurrent: 3, checkpoints: 'task-branch', recovery: 'verified-transfer' }
347
+ function validFleetValue(key, value) {
348
+ if (key === 'pollSeconds') return Number.isSafeInteger(value) && value >= 30 && value <= 3600
349
+ if (key === 'maxRuns') return Number.isSafeInteger(value) && value > 0
350
+ if (key === 'childConcurrent') return Number.isSafeInteger(value) && value >= 1 && value <= 16
351
+ if (key === 'checkpoints') return ['off', 'task-branch'].includes(value)
352
+ if (key === 'recovery') return ['original-host', 'verified-transfer'].includes(value)
353
+ return false
354
+ }
355
+ function validDefaults(value, full = false) {
356
+ return object(value) && safeTree(value) && Object.entries(value).every(([key, entry]) => validFleetValue(key, entry))
357
+ && (!full || Object.keys(fleetDefaults).every(key => own(value, key)))
358
+ }
359
+ function validDelegation(value) {
360
+ if (!object(value) || !Array.isArray(value.fields) || value.fields.some(key => !own(fleetDefaults, key))) return false
361
+ const fields = new Set(['fields', 'maxRunsMax', 'childConcurrentMax', 'pollSecondsMin', 'pollSecondsMax', 'checkpointValues', 'recoveryValues'])
362
+ if (Object.keys(value).some(key => !fields.has(key))) return false
363
+ for (const [field, key] of [['maxRunsMax', 'maxRuns'], ['childConcurrentMax', 'childConcurrent'], ['pollSecondsMin', 'pollSeconds'], ['pollSecondsMax', 'pollSeconds']]) {
364
+ if (value[field] !== undefined && !validFleetValue(key, value[field])) return false
365
+ }
366
+ if ((value.pollSecondsMin ?? 30) > (value.pollSecondsMax ?? 3600)) return false
367
+ for (const [field, key] of [['checkpointValues', 'checkpoints'], ['recoveryValues', 'recovery']]) {
368
+ if (value[field] !== undefined && (!Array.isArray(value[field]) || !value[field].length || value[field].some(entry => !validFleetValue(key, entry)))) return false
369
+ }
370
+ return true
371
+ }
372
+ function gitBranch(value) {
373
+ return typeof value === 'string' && value.length > 0 && !value.startsWith('-') && !value.startsWith('/') && !value.endsWith('/')
374
+ && !/[\s~^:?*\[\\]/.test(value) && !value.includes('..') && !value.includes('@{') && value !== '@'
375
+ && value.split('/').every(part => part && !part.startsWith('.') && !part.endsWith('.') && !part.endsWith('.lock'))
376
+ }
377
+ function nodeId(value) { return typeof value === 'string' && /^[A-Za-z0-9_=-]{3,200}$/.test(value) }
378
+
379
+ function validateFleet(raw, registry) {
380
+ const blocks = [], people = directory(registry.peopleByScope, registry.repoGroups)
381
+ blocks.push(...people.blocks)
382
+ if (!object(raw) || !safeTree(raw) || raw.schemaVersion !== 1 || !object(raw.coordination)
383
+ || !validDefaults(raw.defaults, true) || !object(raw.groupDefaults) || !object(raw.machines) || !object(raw.groupDelegations ?? {})) {
384
+ return { fleet: null, blocks: [...blocks, 'invalid fleet schema/defaults/maps'] }
385
+ }
386
+ const localOnly = /^(?:credentials?|secrets?|tokens?|password|privateKey|host(?:name|Id)?|home|(?:local)?path|command|shell)$/i
387
+ function containsLocalData(value) {
388
+ return object(value) && Object.entries(value).some(([key, child]) => localOnly.test(key) || containsLocalData(child))
389
+ }
390
+ if (containsLocalData(raw)) blocks.push('credentials, raw host identifiers, commands and local paths do not belong in fleet policy')
391
+ const c = raw.coordination
392
+ if (!nodeId(c.repositoryId) || !repoPattern.test(c.repository ?? '') || c.repository.split('/')[0].toLowerCase() !== registry.org?.toLowerCase()
393
+ || !gitBranch(c.branch) || !shaPattern.test(c.rootCommit ?? '') || !uuidPattern.test(c.installationId ?? '')) blocks.push('invalid fleet coordination identity')
394
+ for (const [group, defaults] of Object.entries(raw.groupDefaults)) if (!people.groups.has(group) || !validDefaults(defaults)) blocks.push(`invalid fleet group defaults: ${group}`)
395
+ for (const [group, grant] of Object.entries(raw.groupDelegations ?? {})) if (!people.groups.has(group) || !validDelegation(grant)) blocks.push(`invalid fleet group delegation: ${group}`)
396
+ const installations = new Set(), bindings = new Set()
397
+ for (const [id, machine] of Object.entries(raw.machines)) {
398
+ if (!groupPattern.test(id) || forbidden.has(id) || !object(machine) || !uuidPattern.test(machine.installationId ?? '') || !digestPattern.test(machine.hostBindingDigest ?? '')
399
+ || !loginPattern.test(machine.executionLogin ?? '') || !people.people.has(machine.executionLogin?.toLowerCase()) || !people.groups.has(machine.group)
400
+ || !strings(machine.repositories, repoPattern) || !machine.repositories.length || typeof machine.enabled !== 'boolean' || !validDefaults(machine.overrides)) {
401
+ blocks.push(`invalid machine registration: ${id}`); continue
402
+ }
403
+ if (installations.has(machine.installationId.toLowerCase()) || bindings.has(machine.hostBindingDigest)) blocks.push(`duplicate machine identity: ${id}`)
404
+ installations.add(machine.installationId.toLowerCase()); bindings.add(machine.hostBindingDigest)
405
+ if (new Set(machine.repositories).size !== machine.repositories.length) blocks.push(`duplicate machine repositories: ${id}`)
406
+ for (const repo of machine.repositories) if (registry.repoGroups[repo] !== machine.group || !nodeId(registry.repositoryIds[repo])) blocks.push(`unconfirmed or cross-group machine repository: ${id}/${repo}`)
407
+ }
408
+ return { fleet: blocks.length ? null : structuredClone(raw), blocks }
409
+ }
410
+
411
+ function trustedPolicy(policy) {
412
+ return policy?.schemaVersion === 2 && Array.isArray(policy.blocks) && !policy.blocks.length && digestPattern.test(policy.policyDigest ?? '')
413
+ && ['local', 'fresh'].includes(policy.freshness?.state)
414
+ && policy.policyDigest === policyHash({ values: policy.values, sources: policy.sources, locked: policy.locked, delegations: policy.delegations, administration: policy.administration, fleet: policy.fleet, registry: policy.registry })
415
+ }
416
+ function verifiedLogin(actor, policy) {
417
+ if (!object(actor) || actor.verified !== true || !loginPattern.test(actor.login ?? '')) return null
418
+ const login = actor.login.toLowerCase()
419
+ return directory(policy.registry?.peopleByScope ?? {}, policy.registry?.repoGroups ?? {}).people.has(login) ? login : null
420
+ }
421
+ const deny = reason => ({ allowed: false, reason })
422
+ const allow = () => ({ allowed: true, reason: null })
423
+ function delegatedFleetChange(changes, delegation) {
424
+ if (!validDefaults(changes) || !validDelegation(delegation) || Object.keys(changes).length === 0) return false
425
+ return Object.entries(changes).every(([key, value]) => {
426
+ if (!delegation.fields.includes(key)) return false
427
+ if (key === 'maxRuns') return value <= (delegation.maxRunsMax ?? Number.MAX_SAFE_INTEGER)
428
+ if (key === 'childConcurrent') return value <= (delegation.childConcurrentMax ?? 16)
429
+ if (key === 'pollSeconds') return value >= (delegation.pollSecondsMin ?? 30) && value <= (delegation.pollSecondsMax ?? 3600)
430
+ if (key === 'checkpoints') return delegation.checkpointValues === undefined || delegation.checkpointValues.includes(value)
431
+ if (key === 'recovery') return delegation.recoveryValues === undefined || delegation.recoveryValues.includes(value)
432
+ return false
433
+ })
434
+ }
435
+
436
+ // target.changes is the proposed field diff; policy/administration are the PREVIOUS trusted
437
+ // configuration. The caller validates/render the resulting complete document before delivery.
438
+ export function authorizeAdministration({ actor, action, target, administration, policy } = {}) {
439
+ if (!trustedPolicy(policy)) return deny('current validated policy required')
440
+ if (!administration || !same(administration, policy.administration)) return deny('previous trusted administration required')
441
+ const login = verifiedLogin(actor, policy)
442
+ if (!login) return deny('verified requester identity required; execution identity is not human authority')
443
+ if (!object(target) || !safeTree(target) || target.org !== policy.registry.org) return deny('exact organization target required')
444
+ if (target.changes !== undefined && !object(target.changes)) return deny('administration requires an explicit field diff')
445
+ const registry = directory(policy.registry.peopleByScope, policy.registry.repoGroups)
446
+ if (target.group !== undefined && !registry.groups.has(target.group)) return deny('unregistered group target')
447
+ if (target.repo !== undefined && (!own(policy.registry.repoGroups, target.repo) || (target.group && policy.registry.repoGroups[target.repo] !== target.group))) return deny('unregistered or mixed repository target')
448
+ const actions = ['administration.manage', 'fleet.enroll', 'fleet.configure', 'fleet.machine.settings', 'fleet.group.settings', ...capabilities]
449
+ if (!actions.includes(action)) return deny('unknown administration action; task and shipping authority are separate')
450
+ const isOrg = administration.orgAdmins.includes(login)
451
+ if (action === 'administration.manage') {
452
+ if (!isOrg) return deny('organization-admin appointment authority required')
453
+ if (target.proposedAdministration !== undefined) {
454
+ const result = resolveAdministration({ orgLayer: { scope: 'org', blocks: [], authority: { administration: target.proposedAdministration } }, peopleByScope: policy.registry.peopleByScope, repoGroups: policy.registry.repoGroups })
455
+ if (!result.ok) return deny(result.blocks.join('; '))
456
+ }
457
+ return allow()
458
+ }
459
+ if (['fleet.enroll', 'fleet.configure'].includes(action)) {
460
+ if (!isOrg) return deny('coordination identity and enrollment are organization-admin owned')
461
+ if (target.registration && (target.registration.enabled !== false || policy.fleet?.machines?.[target.machineId])) return deny('new enrollment must be disabled and have a unique machine identity')
462
+ return allow()
463
+ }
464
+ if (!target.group) return deny('exact group target required')
465
+ if (!isOrg && (!administration.groupAdmins[target.group]?.includes(login) || !administration.groupAdminCapabilities[target.group]?.includes(action.startsWith('fleet.') ? 'group.defaults.manage' : action))) return deny('requester lacks this group capability')
466
+ if (action.startsWith('fleet.')) {
467
+ const machine = target.machineId ? policy.fleet?.machines?.[target.machineId] : null
468
+ if (action === 'fleet.machine.settings' && (!machine || machine.group !== target.group)) return deny('settings require an existing machine in this group')
469
+ if (!validDefaults(target.changes)) return deny('settings cannot change enrollment, repositories, group or identity')
470
+ return isOrg || delegatedFleetChange(target.changes, policy.fleet?.groupDelegations?.[target.group]) ? allow() : deny('fleet setting outside previous org delegation')
471
+ }
472
+ if (action === 'group.defaults.manage') {
473
+ if (!object(target.changes) || !Object.keys(target.changes).length) return deny('explicit setting diff required')
474
+ for (const [key, value] of Object.entries(target.changes)) {
475
+ if (!ordinary.has(key) || ['operators', 'dispatch', 'control-room'].includes(key) || knobValue(key, String(value)) === undefined) return deny('group cannot modify protected authority or unknown settings')
476
+ if (own(policy.locked, key) && !same(policy.locked[key], value)) {
477
+ const delegated = policy.delegations.some(grant => grant.key === key && grant.groups.includes(target.group) && target.repo && grant.repos.includes(target.repo) && grant.allowedValues.some(allowed => same(allowed, value)))
478
+ if (!delegated) return deny('group setting requires exact org delegation')
479
+ }
480
+ }
481
+ }
482
+ if (action === 'group.repos.manage') {
483
+ if (!target.repo || policy.registry.repoGroups[target.repo] !== target.group) return deny('repository management is limited to preauthorized registrations')
484
+ if (target.changes && Object.keys(target.changes).some(key => !['board', 'owner'].includes(key))) return deny('group repository edits cannot change registration scope or authority')
485
+ if (target.changes?.owner !== undefined && !registry.people.has(String(target.changes.owner).toLowerCase())) return deny('repository owner must be a confirmed person')
486
+ }
487
+ if (action === 'group.members.manage' && target.changes) {
488
+ if (Object.keys(target.changes).some(key => !['login', 'name', 'role', 'slack', 'timezone', 'groups'].includes(key))) return deny('ordinary membership management cannot change authority')
489
+ if (target.changes.groups !== undefined && (!strings(target.changes.groups, groupPattern) || target.changes.groups.some(group => group !== target.group))) return deny('membership edit crosses the exact group target')
490
+ if (target.changes.login !== undefined && !loginPattern.test(target.changes.login)) return deny('member login must be a validated GitHub identity')
491
+ }
492
+ return allow()
493
+ }
494
+
495
+ /**
496
+ * @param {{viewer?:any,subject?:string|null,requestedRepos?:string[],administration?:any,policy?:any,repoGroups?:Record<string,string>}} input
497
+ * @returns {{allowedRepos:string[],subject:string|null,refusal:string|null}}
498
+ */
499
+ export function resolvePeopleReadScope({ viewer, subject = null, requestedRepos, administration, policy, repoGroups } = {}) {
500
+ const refuse = refusal => ({ allowedRepos: [], subject, refusal })
501
+ if (!trustedPolicy(policy)) return refuse('current validated policy required for people data')
502
+ if (!same(repoGroups, policy.registry.repoGroups) || !same(administration ?? null, policy.administration)) return refuse('read scope must use current confirmed registry and administration')
503
+ const login = verifiedLogin(viewer, policy)
504
+ if (!login) return refuse('verified viewer required')
505
+ const registry = directory(policy.registry.peopleByScope, repoGroups)
506
+ if (subject !== null && (typeof subject !== 'string' || !registry.people.has(subject.toLowerCase()))) return refuse('unknown person')
507
+ if (subject !== null) subject = subject.toLowerCase()
508
+ const requested = requestedRepos ?? Object.keys(repoGroups)
509
+ if (!strings(requested, repoPattern) || requested.some(repo => !own(repoGroups, repo))) return refuse('requested repository is unregistered')
510
+ const ownData = subject === login
511
+ if (!ownData && policy.values['stats-people'] !== 'on') return refuse('organization people reporting is off')
512
+ const orgAdmin = administration?.orgAdmins.includes(login) === true
513
+ const groups = Object.keys(administration?.groupAdmins ?? {}).filter(group => administration.groupAdmins[group].includes(login) && administration.groupAdminCapabilities[group]?.includes('group.people.read'))
514
+ const allowedRepos = unique(requested).filter(repo => ownData || orgAdmin || groups.includes(repoGroups[repo]))
515
+ return { allowedRepos, subject, refusal: allowedRepos.length ? null : 'viewer has no permitted repositories for this people query' }
516
+ }
517
+
518
+ export function resolveMachinePolicy({ policy, machineId, installationId, hostBindingDigest, executionLogin } = {}) {
519
+ const refuse = reason => ({ ok: false, machine: null, blocks: [reason] })
520
+ if (!trustedPolicy(policy)) return refuse('current validated policy required for machine resolution')
521
+ if (!policy.fleet) return refuse('no shared fleet configuration; explicit legacy mode must be selected by the caller')
522
+ const checked = validateFleet(policy.fleet, policy.registry)
523
+ if (checked.blocks.length) return { ok: false, machine: null, blocks: checked.blocks }
524
+ if (!groupPattern.test(machineId ?? '') || !own(policy.fleet.machines, machineId)) return refuse('machine is not enrolled')
525
+ const registration = policy.fleet.machines[machineId]
526
+ if (!registration.enabled) return refuse('machine enrollment is disabled')
527
+ if (registration.installationId !== installationId || registration.hostBindingDigest !== hostBindingDigest || registration.executionLogin.toLowerCase() !== executionLogin?.toLowerCase()) return refuse('machine installation, host binding or execution identity mismatch')
528
+ const defaults = { ...policy.fleet.defaults, ...policy.fleet.groupDefaults[registration.group], ...registration.overrides }
529
+ return { ok: true, machine: { id: machineId, ...registration, coordination: policy.fleet.coordination, defaults, allowedRepositories: [...registration.repositories], repositoryIds: Object.fromEntries(registration.repositories.map(repo => [repo, policy.registry.repositoryIds[repo]])), policyDigest: policy.policyDigest }, blocks: [] }
530
+ }
531
+
532
+ // Registry readers share the fixed authored formats. These rows are descriptive input to a
533
+ // verified snapshot; parsing a login is not verification of the requester or GitHub account.
534
+ export function parsePeopleRegistry(text = '') {
535
+ const lines = String(text).split(/\r?\n/).filter(line => line.trim())
536
+ const blocks = [], people = [], seen = new Set()
537
+ if (lines.shift()?.trim() !== 'login,name,role,slack,timezone,groups') return { people: [], blocks: ['people.csv needs its exact six-column header'] }
538
+ for (const line of lines) {
539
+ const cells = line.split(',').map(cell => cell.trim())
540
+ const [login, name, role, slack, timezone, rawGroups] = cells
541
+ const groups = (rawGroups ?? '').split(/[;|]/).map(group => group.trim()).filter(Boolean)
542
+ if (cells.length !== 6 || !loginPattern.test(login ?? '') || forbidden.has(login) || !strings(groups, groupPattern) || seen.has(login.toLowerCase())) {
543
+ blocks.push('invalid or duplicate people.csv row'); continue
544
+ }
545
+ seen.add(login.toLowerCase())
546
+ people.push({ login: login.toLowerCase(), name, role, slack, timezone, groups: unique(groups) })
547
+ }
548
+ return { people: blocks.length ? [] : people, blocks }
549
+ }
550
+
551
+ /** @returns {{repoGroups:Record<string,string>,repositoryIds:Record<string,string>,blocks:string[]}} */
552
+ export function parseRepositoryRegistry(text = '') {
553
+ const repoGroups = {}, repositoryIds = {}, blocks = [], seen = new Set()
554
+ let idColumn = -1
555
+ let fence = null
556
+ for (const line of String(text).split(/\r?\n/)) {
557
+ const boundary = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line)
558
+ if (boundary) {
559
+ if (!fence) fence = boundary[1]
560
+ else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length && !boundary[2].trim()) fence = null
561
+ continue
562
+ }
563
+ if (fence || !line.startsWith('|')) continue
564
+ const cells = line.split('|').slice(1, -1).map(cell => cell.trim())
565
+ const [repo, group] = cells
566
+ if (repo === 'repo') { idColumn = cells.indexOf('repository-id'); continue }
567
+ if (!repo?.includes('/')) continue
568
+ if (!repoPattern.test(repo) || !groupPattern.test(group ?? '') || forbidden.has(group) || seen.has(repo)) { blocks.push('invalid or duplicate repository registry row'); continue }
569
+ seen.add(repo); repoGroups[repo] = group
570
+ if (idColumn >= 0 && cells[idColumn]) {
571
+ if (!nodeId(cells[idColumn])) blocks.push('invalid repository node ID')
572
+ else repositoryIds[repo] = cells[idColumn]
573
+ }
574
+ }
575
+ return { repoGroups: blocks.length ? {} : repoGroups, repositoryIds: blocks.length ? {} : repositoryIds, blocks }
576
+ }
577
+
578
+ export function parseControlRoomReference(text) {
579
+ const value = parsePolicy(text, 'repo').values['control-room']
580
+ if (!value || value === 'none') return null
581
+ const [room, suffix = ''] = value.split('#')
582
+ const [group, sha] = suffix.split('@')
583
+ return { org: room.split('/')[0], repo: room, group: group || null, sha: sha || null }
584
+ }
585
+
586
+ function gitRead(cwd, args) {
587
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe', timeout: 5000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env } })
588
+ }
589
+
590
+ // Git batch output is byte framed, not line-oriented policy text. Keep the existing
591
+ // 4 MiB per-file ceiling, with a separate bounded allowance for the whole snapshot.
592
+ function parseGitBlobBatch(output, oids, blobLimit, aggregateLimit) {
593
+ const blobs = new Map()
594
+ let offset = 0, total = 0
595
+ for (const oid of oids) {
596
+ const end = output.indexOf(10, offset)
597
+ if (end < offset || end - offset > 63) throw new Error('invalid policy blob batch header')
598
+ const header = output.subarray(offset, end).toString('utf8')
599
+ const match = /^([a-f0-9]{40}) blob (0|[1-9][0-9]*)$/.exec(header)
600
+ if (!match || match[1] !== oid) throw new Error('unexpected policy blob batch identity or type')
601
+ const size = Number(match[2])
602
+ total += size
603
+ if (!Number.isSafeInteger(size) || size > blobLimit || total > aggregateLimit) throw new Error('policy blob batch exceeds byte limit')
604
+ offset = end + 1
605
+ if (offset + size >= output.length || output[offset + size] !== 10) throw new Error('truncated or invalid policy blob batch framing')
606
+ blobs.set(oid, output.subarray(offset, offset + size).toString('utf8'))
607
+ offset += size + 1
608
+ }
609
+ if (offset !== output.length) throw new Error('unexpected trailing policy blob batch output')
610
+ return blobs
611
+ }
612
+
613
+ export function gitReadBlobs(cwd, oids, run = execFileSync) {
614
+ const blobLimit = 4 * 1024 * 1024, aggregateLimit = 64 * 1024 * 1024
615
+ const options = {
616
+ cwd, input: oids.join('\n') + '\n', stdio: 'pipe', timeout: 5000,
617
+ maxBuffer: Math.min(oids.length * (blobLimit + 64), aggregateLimit + oids.length * 64),
618
+ env: { ...process.env },
619
+ }
620
+ const read = () => run('git', ['cat-file', '--batch'], options)
621
+ try { return parseGitBlobBatch(read(), oids, blobLimit, aggregateLimit) }
622
+ catch (error) {
623
+ // A busy runner can very rarely yield a short synchronous child-process pipe.
624
+ // Retry only the exact incomplete-frame case;
625
+ // identity, type, size and trailing-output violations remain hard failures.
626
+ if (error?.message !== 'truncated or invalid policy blob batch framing') throw error
627
+ return parseGitBlobBatch(read(), oids, blobLimit, aggregateLimit)
628
+ }
629
+ }
630
+
631
+ // A per-code-repository pointer is supplied by the snapshot owner. Read authoritative blobs
632
+ // from its exact Git commit, not symlink targets or a mutable worktree. No fetch or state write.
633
+ /** @param {{snapshot:any,repo:string,devMd:string,room?:any,expectedOrigin?:string,now?:string|number}} input */
634
+ export function loadSnapshotPolicy({ snapshot, repo, devMd, room = parseControlRoomReference(devMd), expectedOrigin, now = Date.now() }) {
635
+ const refused = reason => {
636
+ const result = resolvePolicy({ repo: devMd, identity: { repo }, freshness: { configured: true, now } })
637
+ result.blocks.unshift(reason); result.ok = false
638
+ return result
639
+ }
640
+ if (!room || !repoPattern.test(repo) || !object(snapshot) || snapshot.schemaVersion !== 2 || snapshot.org !== room.org
641
+ || snapshot.repository !== room.repo || snapshot.group !== room.group || !shaPattern.test(snapshot.sourceCommit ?? '')
642
+ || !digestPattern.test(snapshot.policyDigest ?? '') || typeof snapshot.contentPath !== 'string' || !isAbsolute(snapshot.contentPath) || typeof snapshot.origin !== 'string') return refused('no matching per-repository validated policy snapshot')
643
+ const canonicalOrigin = 'https://github.com/' + room.repo + '.git'
644
+ if (snapshot.origin !== (expectedOrigin ?? canonicalOrigin)) return refused('snapshot origin does not match the verified control-room connection')
645
+ try {
646
+ const root = lstatSync(snapshot.contentPath)
647
+ if (!root.isDirectory() || root.isSymbolicLink()) return refused('snapshot content path is not an immutable managed directory')
648
+ if (gitRead(snapshot.contentPath, ['rev-parse', 'HEAD']).trim() !== snapshot.sourceCommit
649
+ || gitRead(snapshot.contentPath, ['remote', 'get-url', 'origin']).trim() !== snapshot.origin
650
+ || gitRead(snapshot.contentPath, ['status', '--porcelain', '--untracked-files=all']).trim()) return refused('snapshot content/origin/source identity changed')
651
+ const entries = new Map(gitRead(snapshot.contentPath, ['ls-tree', '-r', '-z', snapshot.sourceCommit]).split('\0').filter(Boolean).map(line => {
652
+ const match = /^(\d{6}) (blob|tree|commit) ([a-f0-9]{40})\t([\s\S]+)$/.exec(line)
653
+ if (!match) throw new Error('invalid snapshot tree entry')
654
+ return [match[4], { mode: match[1], type: match[2], oid: match[3] }]
655
+ }))
656
+ const peoplePaths = [...entries.keys()].filter(path => path === 'people.csv' || /^groups\/[a-z0-9][a-z0-9-]{0,63}\/people\.csv$/.test(path))
657
+ const paths = ['org.md', ...(room.group ? ['groups/' + room.group + '/group.md'] : []), 'repos.md', ...peoplePaths]
658
+ for (const path of paths) {
659
+ const entry = entries.get(path)
660
+ if (!entry || !['100644', '100755'].includes(entry.mode) || entry.type !== 'blob') throw new Error(`snapshot policy file is missing or not a regular blob: ${path}`)
661
+ }
662
+ const blobs = gitReadBlobs(snapshot.contentPath, unique(paths.map(path => entries.get(path).oid)))
663
+ const read = path => blobs.get(entries.get(path).oid)
664
+ const org = read('org.md'), group = room.group ? read('groups/' + room.group + '/group.md') : ''
665
+ const registry = parseRepositoryRegistry(read('repos.md'))
666
+ if (registry.blocks.length || !own(registry.repoGroups, repo) || (room.group !== null && registry.repoGroups[repo] !== room.group)) return refused('code repository/group is missing or conflicts with the snapshot registry')
667
+ const peopleByScope = {}
668
+ for (const path of peoplePaths) {
669
+ const parsed = parsePeopleRegistry(read(path))
670
+ if (parsed.blocks.length) return refused(parsed.blocks.join('; '))
671
+ peopleByScope[path === 'people.csv' ? 'org' : path.split('/')[1]] = parsed.people
672
+ }
673
+ const result = resolvePolicy({ org, group, repo: devMd,
674
+ identity: { org: room.org, repo, group: room.group, roomSha: snapshot.sourceCommit, peopleByScope, repoGroups: registry.repoGroups, repositoryIds: registry.repositoryIds },
675
+ freshness: { configured: true, now, validatedAt: snapshot.validatedAt } })
676
+ if (result.policy.policyDigest !== snapshot.policyDigest) { result.blocks.push('snapshot effective policy digest changed; validate this repository profile again'); result.ok = false }
677
+ return result
678
+ } catch (error) { return refused(`snapshot policy read refused: ${error.message}`) }
679
+ }
680
+
681
+ // Existing settings, separate bindings per code repo. This is a read adapter, not a migration
682
+ // or a snapshot publisher. Missing/legacy/foreign slots cannot refresh validation time.
683
+ /** @param {{home:string,repo:string,devMd:string,now?:string|number}} input */
684
+ export function loadConfiguredPolicy({ home, repo, devMd, now = Date.now() }) {
685
+ const room = parseControlRoomReference(devMd)
686
+ if (!room) return resolvePolicy({ repo: devMd, identity: { repo }, freshness: { configured: false, now } })
687
+ let entry
688
+ try {
689
+ const settings = policyJson(readFileSync(join(home, '.vegastack', 'factory.json'), 'utf8'))
690
+ if (!object(settings) || ![1, 2].includes(settings.schemaVersion)) throw new Error('unknown settings schema')
691
+ entry = settings.controlRooms?.[room.org]
692
+ } catch { /* Missing/malformed settings are an unavailable configured policy, not defaults. */ }
693
+ return loadSnapshotPolicy({ snapshot: entry?.snapshots?.[repo], repo, devMd, room, expectedOrigin: entry?.remote, now })
694
+ }