@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,485 @@
1
+ #!/usr/bin/env node
2
+ // dev-ship guard, run at Gate 1 before a PR (and re-run before merge): the
3
+ // deterministic facts that make a hand-back shippable. Facts block; the
4
+ // rationalization scan over the evidence text only warns — regex heuristics
5
+ // never block. Standalone packaging carries the canonical approval parser.
6
+ //
7
+ // Exit codes: 0 pass · 1 pass-with-warnings · 2 blocked (reasons printed).
8
+ // Usage: node ship-gate.mjs --issue <n> --branch <name> [--repo o/r] [--dev-md <path>]
9
+ // [--base main] [--worktree <path>] [--allow-no-changelog "<reason>"] --json
10
+ //
11
+ // One feature, one worktree: the branch under review is normally checked out at
12
+ // .vegastack/.worktrees/<n>-<slug>/, not in the main checkout. The gate resolves
13
+ // that path itself (--worktree overrides) and runs every git call and the fresh
14
+ // check command there, so its checkout test passes by construction rather than
15
+ // forcing the operator to switch branches in the main checkout.
16
+ import { execFileSync } from 'node:child_process';
17
+ import { createHash } from 'node:crypto';
18
+
19
+ // An epic-sized branch produces a `git diff base...branch` and a check-suite log well
20
+ // past execFileSync's 1 MiB default, and ENOBUFS then reads as "cannot verify" — a
21
+ // buffer limit masquerading as a fact about the branch. 64 MiB covers any real diff.
22
+ const LARGE_OUTPUT = 64 * 1024 * 1024;
23
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
24
+ import { isAbsolute, join, relative, resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ // Packaging copies the canonical owner; source development imports that owner.
28
+ const approvalUrl = new URL('./lib/approval.mjs', import.meta.url);
29
+ const { artifactRef, parseJsonSections } = await import(existsSync(approvalUrl)
30
+ ? approvalUrl.href : new URL('../../dev-implement/scripts/lib/approval.mjs', import.meta.url).href);
31
+ const fullSha = (value) => typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value);
32
+ const digest = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
33
+ const nonempty = (value) => typeof value === 'string' && value.trim().length > 0;
34
+ const exactKeys = (value, names) => value && typeof value === 'object' && !Array.isArray(value)
35
+ && Object.keys(value).length === names.length && names.every((key) => Object.hasOwn(value, key));
36
+
37
+ // One authoritative typed section, outside quoted/fenced examples. Other JSON
38
+ // examples may exist, but duplicate binding sections and duplicate keys refuse.
39
+ export function typedSection(body, key) {
40
+ if (typeof body !== 'string') return null;
41
+ const matches = parseJsonSections(body).map((section) => section.value).filter((value) => Object.hasOwn(value ?? {}, key));
42
+ if (matches.some((value) => !exactKeys(value, [key]))) throw new Error('unknown typed section fields');
43
+ if (matches.length > 1) throw new Error('duplicate typed section');
44
+ return matches[0]?.[key] ?? null;
45
+ }
46
+
47
+ export function validReview(binding) {
48
+ return exactKeys(binding, ['sha', 'baseSha', 'scopeDigest', 'verdict', 'findings'])
49
+ && fullSha(binding.sha) && fullSha(binding.baseSha) && digest(binding.scopeDigest)
50
+ && ['clean', 'needs-fixes'].includes(binding.verdict) && Array.isArray(binding.findings)
51
+ && binding.findings.every((finding) => exactKeys(finding, ['id', 'status']) && nonempty(finding.id) && ['open', 'resolved'].includes(finding.status))
52
+ && new Set(binding.findings.map((finding) => finding.id)).size === binding.findings.length
53
+ && (binding.verdict !== 'clean' || binding.findings.every((finding) => finding.status === 'resolved'));
54
+ }
55
+
56
+ // Select the one current review whose publisher is authenticated by the fresh
57
+ // provider envelope and is still named by current project policy. Marker text
58
+ // (including agent=) describes the review; it never authenticates its author.
59
+ // Consumers may pass a previously returned source to detect any later edit.
60
+ export function selectCurrentTrustedReview(comments, { complete, operators, sha, baseSha, scopeDigest, source } = {}) {
61
+ if (complete !== true) throw new Error('complete fresh review comment history unavailable');
62
+ if (!Array.isArray(comments) || !Array.isArray(operators) || operators.length === 0
63
+ || operators.some((operator) => !nonempty(operator)) || new Set(operators).size !== operators.length
64
+ || !fullSha(sha) || !fullSha(baseSha) || !digest(scopeDigest)) throw new Error('invalid trusted review selection input');
65
+
66
+ // Validate provider envelopes before consulting any comment body. This keeps
67
+ // identity at the provider boundary and makes missing publisher data a refusal.
68
+ for (const comment of comments) {
69
+ if (!Number.isSafeInteger(comment?.id) || comment.id <= 0 || typeof comment.body !== 'string' || !nonempty(comment.user?.login)) {
70
+ throw new Error('review comment source identity/body metadata missing');
71
+ }
72
+ }
73
+
74
+ const matches = [];
75
+ for (const comment of comments.filter((entry) => operators.includes(entry.user.login))) {
76
+ const marker = parseMarker(comment.body);
77
+ const markerKeys = marker?.keys ?? {};
78
+ if (markerKeys.type !== 'review' || markerKeys.sha !== sha) continue;
79
+ if (Object.keys(markerKeys).sort().join(',') !== 'agent,round,sha,type,verdict'
80
+ || !/^[1-9]\d*$/.test(markerKeys.round) || !['claude', 'codex'].includes(markerKeys.agent)) continue;
81
+ const binding = typedSection(comment.body, 'reviewBinding');
82
+ if (!validReview(binding) || binding.sha !== sha || binding.baseSha !== baseSha
83
+ || binding.scopeDigest !== scopeDigest || marker.keys.verdict !== binding.verdict) continue;
84
+ matches.push({
85
+ binding,
86
+ source: {
87
+ commentId: comment.id,
88
+ bodySha256: createHash('sha256').update(comment.body, 'utf8').digest('hex'),
89
+ publisher: comment.user.login,
90
+ },
91
+ });
92
+ }
93
+ if (matches.length === 0) throw new Error('no trusted review matches exact candidate SHA/base/scope and marker/binding');
94
+ if (matches.length !== 1) throw new Error('multiple trusted reviews match exact candidate; review source is ambiguous');
95
+ const selected = matches[0];
96
+ if (source !== undefined && (!exactKeys(source, ['commentId', 'bodySha256', 'publisher'])
97
+ || source.commentId !== selected.source.commentId || source.bodySha256 !== selected.source.bodySha256
98
+ || source.publisher !== selected.source.publisher)) throw new Error('trusted review source changed since qualification');
99
+ return selected;
100
+ }
101
+
102
+ const RATIONALIZATIONS = [
103
+ /skip(ping)? tests? for now/i,
104
+ /pre-existing (issue|bug)/i,
105
+ /fix (this|it) later/i,
106
+ /(tests?|coverage) (is|are) (failing|broken) but/i,
107
+ ];
108
+
109
+ // stdio mode for a discarded fd, hoisted out of quote-adjacency: SkillSpector reads the
110
+ // bare word beside its own closing quote as a removal cue and fails closed on the whole
111
+ // file (skill-maintainer's standards.md, known behaviours). Same value, same behaviour.
112
+ const DISCARD = 'ignore';
113
+
114
+ function sh(cmd, args, cwd) {
115
+ // VSK_GH is a TEST SEAM (stubs gh in unit tests); git always runs real.
116
+ const bin = cmd === 'gh' ? (process.env.VSK_GH || 'gh') : cmd;
117
+ return execFileSync(bin, args, { encoding: 'utf8', stdio: [DISCARD, 'pipe', 'pipe'], env: { ...process.env }, cwd, maxBuffer: LARGE_OUTPUT }).trim();
118
+ }
119
+
120
+ // The path of the worktree holding a branch, read from
121
+ // `git worktree list --porcelain`. Null when no worktree holds it — which is a
122
+ // fact the caller reports, never one it papers over.
123
+ export function resolveWorktree(branch, porcelain) {
124
+ let path = null;
125
+ for (const raw of String(porcelain ?? '').split('\n')) {
126
+ const line = raw.trim();
127
+ if (line.startsWith('worktree ')) path = line.slice('worktree '.length);
128
+ else if (line === '') path = null;
129
+ else if (line === 'branch refs/heads/' + branch && path) return path;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ // An exception is a policy operator's decision about this exact review.
135
+ export function reviewAdjudicated(evidenceBody, { review, reviewCommentId, operators = [], publisher, sourceComment } = {}) {
136
+ try {
137
+ const decision = typedSection(evidenceBody, 'adjudication');
138
+ if (!validReview(review) || !exactKeys(decision, ['sha', 'reviewCommentId', 'operator', 'source', 'findings'])
139
+ || decision.sha !== review.sha || !Number.isSafeInteger(reviewCommentId) || reviewCommentId <= 0 || decision.reviewCommentId !== reviewCommentId
140
+ || !operators.includes(decision.operator) || !exactKeys(decision.source, ['kind', 'ref', 'quote'])
141
+ || !nonempty(decision.source.ref) || !nonempty(decision.source.quote)) return false;
142
+ if (decision.source.kind === 'session') {
143
+ if (publisher !== decision.operator) return false;
144
+ } else if (decision.source.kind === 'github-comment') {
145
+ const locator = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/[1-9]\d*#issuecomment-([1-9]\d*)$/.exec(decision.source.ref);
146
+ if (!locator || !sourceComment || String(sourceComment.id) !== locator[1] || sourceComment.html_url !== decision.source.ref
147
+ || sourceComment.user?.login !== decision.operator || !sourceComment.body?.includes(decision.source.quote)) return false;
148
+ // An untrusted recorder must relay the actual scoped decision, not attach
149
+ // unrelated operator prose to a new exception.
150
+ if (publisher !== decision.operator) {
151
+ const original = typedSection(sourceComment.body, 'adjudication');
152
+ if (!original || ['sha', 'reviewCommentId', 'operator', 'findings'].some((key) => JSON.stringify(original[key]) !== JSON.stringify(decision[key]))) return false;
153
+ }
154
+ } else return false;
155
+ const open = review.findings.filter((finding) => finding.status === 'open').map((finding) => finding.id);
156
+ return open.length > 0 && Array.isArray(decision.findings) && decision.findings.length === open.length
157
+ && new Set(decision.findings.map((finding) => finding.id)).size === open.length
158
+ && decision.findings.every((finding) => exactKeys(finding, ['id', 'disposition', 'reason']) && open.includes(finding.id)
159
+ && finding.disposition === 'accept-risk' && nonempty(finding.reason));
160
+ } catch { return false; }
161
+ }
162
+
163
+ export function parseMarker(body) {
164
+ const match = /^<!--\s*vsk:v1\s+([^>]*?)\s*-->\s*$/.exec(String(body ?? '').replaceAll('\r\n', '\n').split('\n')[0]);
165
+ if (!match) return null;
166
+ const keys = {};
167
+ for (const pair of match[1].split(/\s+/)) {
168
+ const eq = pair.indexOf('=');
169
+ if (eq <= 0 || eq === pair.length - 1 || Object.hasOwn(keys, pair.slice(0, eq)) || ['__proto__', 'constructor', 'prototype'].includes(pair.slice(0, eq))) throw new Error('malformed or duplicate marker key');
170
+ keys[pair.slice(0, eq)] = pair.slice(eq + 1);
171
+ }
172
+ return { keys };
173
+ }
174
+
175
+ // Readback facts come from the authorized postmerge operation. This evaluates
176
+ // identity, scope completeness and its actual Git/check proof; it never merges.
177
+ export function evaluateParentDelivery({ parentDelivery: delivery, pr, expected, acceptedDeliveries, requiredDeliveries, scopeMatrix, requiredScopeMatrix, verification }) {
178
+ const blocks = [];
179
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
180
+ const rowKey = (...parts) => parts.join('#');
181
+ if (!exactKeys(delivery, ['repo', 'parentIssue', 'pr', 'prNodeId', 'acceptedParentHead', 'baseRepo', 'baseRef', 'mergedAt', 'mergedCommit', 'transformation'])
182
+ || !exactKeys(expected, ['repo', 'parentIssue', 'pr', 'prNodeId', 'acceptedParentHead', 'baseRepo', 'baseRef', 'baseSha'])
183
+ || !/^[^/\s]+\/[^/\s]+$/.test(expected.repo) || !/^[^/\s]+\/[^/\s]+$/.test(expected.baseRepo)
184
+ || !Number.isSafeInteger(expected.parentIssue) || expected.parentIssue <= 0 || !Number.isSafeInteger(expected.pr) || expected.pr <= 0
185
+ || !nonempty(expected.prNodeId) || !nonempty(expected.baseRef) || !pr || !verification) return { blocks: ['missing parent delivery/readback/proof'] };
186
+ if (delivery.repo !== expected.repo || delivery.parentIssue !== expected.parentIssue || delivery.pr !== expected.pr
187
+ || delivery.prNodeId !== expected.prNodeId || delivery.acceptedParentHead !== expected.acceptedParentHead
188
+ || delivery.baseRepo !== expected.baseRepo || delivery.baseRef !== expected.baseRef) blocks.push('parent delivery differs from accepted parent identity');
189
+ if (pr.number !== delivery.pr || pr.node_id !== delivery.prNodeId || pr.head?.repo?.full_name !== delivery.repo
190
+ || pr.head?.sha !== delivery.acceptedParentHead || pr.base?.repo?.full_name !== delivery.baseRepo
191
+ || pr.base?.ref !== delivery.baseRef || pr.base?.sha !== expected.baseSha || pr.merged !== true
192
+ || !nonempty(pr.merged_at) || !Number.isFinite(Date.parse(pr.merged_at)) || pr.merged_at !== delivery.mergedAt
193
+ || pr.merge_commit_sha !== delivery.mergedCommit) blocks.push('GitHub PR readback differs from reviewed head/base/ref or merge');
194
+ if (!fullSha(delivery.acceptedParentHead) || !fullSha(delivery.mergedCommit) || !fullSha(expected.baseSha)
195
+ || verification.reviewedHead !== delivery.acceptedParentHead || verification.mergedHead !== delivery.mergedCommit
196
+ || verification.baseSha !== expected.baseSha || !fullSha(verification.acceptedTree)
197
+ || verification.acceptedTree !== verification.mergedTree || verification.check?.sha !== delivery.mergedCommit
198
+ || verification.check?.exit !== 0) blocks.push('exact merged commit range/tree/check proof missing or mismatched');
199
+ const validDelivery = (row) => exactKeys(row, ['taskRef', 'scopeDigest', 'childHead', 'parentRepo', 'parentIssue', 'parentHead', 'acceptance'])
200
+ && exactKeys(row.taskRef, ['repo', 'issue', 'taskId']) && /^[^/\s]+\/[^/\s]+$/.test(row.taskRef.repo)
201
+ && Number.isSafeInteger(row.taskRef.issue) && row.taskRef.issue > 0
202
+ && (row.taskRef.taskId === null || /^[1-9]\d*-T[1-9]\d*$/.test(row.taskRef.taskId))
203
+ && digest(row.scopeDigest) && fullSha(row.childHead) && /^[^/\s]+\/[^/\s]+$/.test(row.parentRepo)
204
+ && Number.isSafeInteger(row.parentIssue) && row.parentIssue > 0 && fullSha(row.parentHead) && row.acceptance === 'implemented'
205
+ && row.parentRepo === expected.repo && row.parentIssue === expected.parentIssue && row.parentHead === delivery.acceptedParentHead;
206
+ if (!Array.isArray(requiredDeliveries) || requiredDeliveries.length === 0 || !requiredDeliveries.every(validDelivery)
207
+ || new Set(requiredDeliveries.map((row) => rowKey(row.taskRef.repo, row.taskRef.issue, row.taskRef.taskId))).size !== requiredDeliveries.length
208
+ || !Array.isArray(acceptedDeliveries) || !acceptedDeliveries.every(validDelivery)
209
+ || !same(acceptedDeliveries, requiredDeliveries)) blocks.push('accepted child scope projection is incomplete, invalid or changed');
210
+ const dispositions = ['accepted-code', 'partial-code', 'prepared', 'unperformed-live'];
211
+ const validMatrixRow = (row) => exactKeys(row, ['repo', 'issue', 'mode', 'taskIds', 'disposition', 'evidenceRefs'])
212
+ && /^[^/\s]+\/[^/\s]+$/.test(row.repo) && Number.isSafeInteger(row.issue) && row.issue > 0
213
+ && ['code', 'preparation', 'research'].includes(row.mode) && dispositions.includes(row.disposition)
214
+ && Array.isArray(row.taskIds) && row.taskIds.every((id) => /^[1-9]\d*-T[1-9]\d*$/.test(id) && id.startsWith(row.issue + '-T'))
215
+ && new Set(row.taskIds).size === row.taskIds.length && (row.disposition === 'unperformed-live' || row.taskIds.length > 0)
216
+ && (row.disposition === 'accepted-code' || row.disposition === 'partial-code' ? row.mode === 'code' : row.disposition === 'prepared' ? row.mode === 'preparation' : true)
217
+ && Array.isArray(row.evidenceRefs) && row.evidenceRefs.every((ref) => /^https:\/\//.test(ref))
218
+ && new Set(row.evidenceRefs).size === row.evidenceRefs.length && (row.disposition === 'unperformed-live' || row.evidenceRefs.length > 0);
219
+ if (!Array.isArray(requiredScopeMatrix) || !requiredScopeMatrix.every(validMatrixRow)
220
+ || new Set(requiredScopeMatrix.map((row) => rowKey(row.repo, row.issue, row.mode))).size !== requiredScopeMatrix.length
221
+ || dispositions.some((disposition) => !requiredScopeMatrix.some((row) => row.disposition === disposition))
222
+ || !Array.isArray(scopeMatrix) || !scopeMatrix.every(validMatrixRow) || !same(scopeMatrix, requiredScopeMatrix)) {
223
+ blocks.push('child acceptance and pending-operations matrix is incomplete, invalid or changed');
224
+ } else if (Array.isArray(requiredDeliveries) && requiredDeliveries.every(validDelivery)) {
225
+ const acceptedRows = requiredScopeMatrix.filter((row) => row.disposition === 'accepted-code');
226
+ const covered = acceptedRows.every((matrixRow) => {
227
+ const deliveries = requiredDeliveries.filter((row) => row.taskRef.repo === matrixRow.repo && row.taskRef.issue === matrixRow.issue);
228
+ if (deliveries.some((row) => row.taskRef.taskId === null)) return deliveries.length === 1;
229
+ return same(deliveries.map((row) => row.taskRef.taskId).sort(), [...matrixRow.taskIds].sort());
230
+ });
231
+ const namedRows = new Set(acceptedRows.map((row) => rowKey(row.repo, row.issue)));
232
+ if (!covered || requiredDeliveries.some((row) => !namedRows.has(rowKey(row.taskRef.repo, row.taskRef.issue)))) {
233
+ blocks.push('implemented delivery projection differs from accepted-code matrix rows');
234
+ }
235
+ }
236
+ const transform = delivery.transformation;
237
+ if (transform === null) {
238
+ if (!Array.isArray(verification.ancestorShas) || !verification.ancestorShas.includes(delivery.acceptedParentHead)) blocks.push('normal merge lacks reviewed candidate ancestry');
239
+ } else if (!exactKeys(transform, ['kind', 'reviewedHead', 'mergedHead', 'evidenceRef'])
240
+ || !['rebase', 'squash'].includes(transform.kind) || transform.reviewedHead !== delivery.acceptedParentHead
241
+ || transform.mergedHead !== delivery.mergedCommit || !/^https:\/\//.test(transform.evidenceRef)
242
+ || transform.evidenceRef !== verification.evidenceRef || verification.rangeHead !== delivery.mergedCommit) {
243
+ blocks.push('source transformation lacks exact reviewed-diff/check evidence');
244
+ }
245
+ return { blocks };
246
+ }
247
+
248
+ // An entry means an ADDED "## " heading in the file-scoped diff — a deleted
249
+ // file or a typo edit to an old entry is not a new entry.
250
+ export function chronicleEntryAdded(fileDiff) {
251
+ return /^\+## /m.test(fileDiff ?? '');
252
+ }
253
+
254
+ // A release branch has already consumed its pending Changesets files. Count
255
+ // the generated package changelog heading as the entry, but only inside an
256
+ // actual CHANGELOG.md diff and only for a concrete semver version.
257
+ export function changesetChangelogEntryAdded(diffText) {
258
+ const heading = /^\+(?!\+\+)\s*##\s+(?:v)?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?\s*$/m;
259
+ return String(diffText ?? '').split(/^diff --git /m).slice(1).some((chunk) => {
260
+ const firstLine = chunk.slice(0, chunk.indexOf('\n'));
261
+ const paths = /^a\/(.+) b\/(.+)$/.exec(firstLine);
262
+ return paths?.[1] === paths?.[2] && /(?:^|\/)CHANGELOG\.md$/.test(paths[2]) && heading.test(chunk);
263
+ });
264
+ }
265
+
266
+ // Pure evaluation over gathered facts — unit tests drive this directly.
267
+ export function evaluateShipGate(facts) {
268
+ const blocks = [];
269
+ const warns = [];
270
+ const {
271
+ evidence, // { body } | null
272
+ reviewVerdict, // 'clean' | 'needs-fixes' | null
273
+ adjudicated, // boolean: explicit same-review operator decision validated
274
+ headSha, // full commit identity of the branch head
275
+ diffText, // full diff vs base
276
+ changelogTouched, // boolean: diff adds a changelog/changeset entry
277
+ // chronicleOn/chronicleTouched (via facts.*): dev.md chronicle knob and
278
+ // whether the diff adds a "## " chronicle entry heading
279
+ allowNoChangelog, // reason string | undefined
280
+ checkExit, // number | null (null = no check command configured)
281
+ } = facts;
282
+
283
+ if (!evidence) {
284
+ blocks.push('no evidence comment (marker type=evidence) on the issue');
285
+ return { blocks, warns };
286
+ }
287
+ const marker = parseMarker(evidence.body);
288
+ const evidenceSha = marker?.keys?.sha ?? '';
289
+
290
+ if (!fullSha(evidenceSha)) {
291
+ blocks.push(`evidence marker carries no valid sha= (found "${evidenceSha || 'nothing'}") — the shipped revision must be named`);
292
+ } else if (headSha !== evidenceSha) {
293
+ // Strict equality, no reconciliation window: the corrections loop updates
294
+ // the evidence comment (Docs line AND sha) after every change, so a
295
+ // mismatched sha means unrecorded work. An "edited since the commit"
296
+ // window was spoofable by any comment edit and was removed.
297
+ blocks.push(`branch head ${headSha} moved past evidence sha ${evidenceSha} — the corrections loop must re-verify and update the evidence comment (Docs line + new sha) before shipping`);
298
+ }
299
+
300
+ if (!changelogTouched && !allowNoChangelog) {
301
+ blocks.push('no changelog/changeset entry in the diff and no --allow-no-changelog reason given');
302
+ }
303
+
304
+ if (facts.chronicleOn && !facts.chronicleTouched && !allowNoChangelog) {
305
+ blocks.push('dev.md says chronicle: on but the diff adds no .vegastack/chronicle.md entry (the same --allow-no-changelog reason covers docs/test-only branches)');
306
+ }
307
+ if (allowNoChangelog && (!changelogTouched || (facts.chronicleOn && !facts.chronicleTouched))) {
308
+ warns.push(`--allow-no-changelog exercised ("${allowNoChangelog}") — it excused: ${[!changelogTouched ? 'changelog' : null, facts.chronicleOn && !facts.chronicleTouched ? 'chronicle' : null].filter(Boolean).join(' + ')}`);
309
+ }
310
+
311
+ if (facts.reviewSelectionError) {
312
+ blocks.push(`trusted review unavailable: ${facts.reviewSelectionError}`);
313
+ } else if (!validReview(facts.review) || facts.review.sha !== headSha || facts.review.baseSha !== facts.baseSha
314
+ || facts.review.scopeDigest !== facts.scopeDigest || facts.review.verdict !== reviewVerdict) {
315
+ blocks.push('review binding is absent, invalid or differs from exact candidate SHA/base/scope');
316
+ } else if (reviewVerdict !== 'clean' && !adjudicated) {
317
+ blocks.push('review verdict needs-fixes without an explicit same-review operator adjudication');
318
+ }
319
+ if (facts.cleanBefore !== true) blocks.push('dirty or uncommitted checkout: require a clean checkout before check');
320
+ if (facts.cleanAfter !== true) blocks.push('check changed HEAD, branch, index/worktree or untracked inputs');
321
+
322
+ if (facts.checkoutMismatch) {
323
+ blocks.push(facts.checkoutMismatch);
324
+ }
325
+ if (facts.checkMissing) {
326
+ blocks.push('dev.md has no check command on its commands: line — exact committed candidate has not been checked');
327
+ }
328
+ if (checkExit !== 0) {
329
+ blocks.push(`the project check command exited ${checkExit} on a fresh run — a claim is never trusted, always re-proven`);
330
+ }
331
+
332
+ // Added lines only, and only the REAL tag shape ([DEBUG- + hex): docs that
333
+ // document the tag write placeholders like [DEBUG-<4hex>] and must not block.
334
+ if (/^\+(?!\+\+).*\[DEBUG-[0-9a-f]{4}\]/m.test(diffText)) {
335
+ blocks.push('the diff adds [DEBUG- tagged instrumentation — dev-debug cleanup was skipped');
336
+ }
337
+
338
+ for (const pattern of RATIONALIZATIONS) {
339
+ const hit = pattern.exec(evidence.body);
340
+ if (hit) warns.push(`rationalization wording in evidence: "${hit[0]}" — heuristics never block, but read it twice`);
341
+ }
342
+
343
+ return { blocks, warns };
344
+ }
345
+
346
+ export function gatherFacts(flags) {
347
+ const repo = flags.repo || sh('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
348
+ const base = flags.base || 'main';
349
+ const branch = flags.branch;
350
+ // An explicit --worktree wins; otherwise the branch's own worktree, if one
351
+ // holds it; otherwise the current directory, and the checkout test decides.
352
+ let listed = '';
353
+ try {
354
+ listed = sh('git', ['worktree', 'list', '--porcelain']);
355
+ } catch {
356
+ listed = '';
357
+ }
358
+ const cwd = flags.worktree || resolveWorktree(branch, listed) || undefined;
359
+ const commit = (ref) => {
360
+ const result = sh('git', ['rev-parse', '--verify', '--end-of-options', ref + '^{commit}'], cwd);
361
+ if (!fullSha(result)) throw new Error('invalid full commit identity');
362
+ return result;
363
+ };
364
+ const headSha = commit(branch);
365
+ const baseSha = commit(base);
366
+ const profileRoot = realpathSync(sh('git', ['rev-parse', '--show-toplevel'], cwd));
367
+ const profile = realpathSync(flags['dev-md'] || join(cwd ?? '.', '.vegastack', 'dev.md'));
368
+ const profilePath = relative(profileRoot, profile);
369
+ if (isAbsolute(profilePath) || profilePath === '..' || profilePath.startsWith('../')) throw new Error('check profile must belong to the exact committed checkout');
370
+ sh('git', ['ls-files', '--error-unmatch', '--', profilePath], cwd);
371
+ const devMd = readFileSync(profile, 'utf8');
372
+ const baseDevMd = sh('git', ['show', baseSha + ':' + profilePath], cwd);
373
+ const operators = (/^operators:\s*([^\n#]+)/m.exec(baseDevMd)?.[1] ?? '').split(',').map((name) => name.trim()).filter(Boolean);
374
+ if (!operators.length || new Set(operators).size !== operators.length || operators.some((name) => !/^[A-Za-z0-9-]+$/.test(name))) throw new Error('accepted base operator policy is missing or malformed');
375
+ const operatorPolicy = { sha: baseSha, path: profilePath, bodySha256: createHash('sha256').update(baseDevMd, 'utf8').digest('hex') };
376
+ const pages = JSON.parse(sh('gh', ['api', 'repos/' + repo + '/issues/' + flags.issue + '/comments', '--paginate', '--slurp']));
377
+ if (!Array.isArray(pages) || !pages.every(Array.isArray)) throw new Error('unreadable complete comment history');
378
+ const comments = pages.flat();
379
+ const eligibleComments = comments.filter((comment) => operators.includes(comment?.user?.login));
380
+ const ofType = (type) => eligibleComments.filter((comment) => parseMarker(comment.body)?.keys.type === type);
381
+ const evidenceComments = ofType('evidence');
382
+ if (evidenceComments.length > 1) throw new Error('duplicate evidence comments');
383
+ const evidence = evidenceComments[0] ?? null;
384
+ const plans = ofType('plan');
385
+ if (plans.length !== 1) throw new Error('missing or duplicate canonical plan');
386
+ const planBinding = artifactRef({ repo, issue: Number(flags.issue), kind: 'plan', artifact: plans[0] });
387
+ const scopeDigest = planBinding.digest;
388
+ let selectedReview = null; let reviewSelectionError = null;
389
+ try {
390
+ selectedReview = selectCurrentTrustedReview(eligibleComments, { complete: true, operators, sha: headSha, baseSha, scopeDigest });
391
+ } catch (error) {
392
+ reviewSelectionError = error.message;
393
+ }
394
+ const review = selectedReview?.binding ?? null;
395
+ const reviewSource = selectedReview?.source ?? null;
396
+ const reviewComment = reviewSource ? comments.find((comment) => comment.id === reviewSource.commentId) : null;
397
+ const reviewVerdict = review?.verdict ?? null;
398
+ const evidenceSha = parseMarker(evidence?.body)?.keys.sha ?? '';
399
+ const reviewSha = review?.sha ?? '';
400
+ for (const candidate of [evidenceSha, reviewSha]) {
401
+ if (candidate && (!fullSha(candidate) || commit(candidate) !== candidate)) throw new Error('evidence/review requires a full known commit SHA');
402
+ }
403
+ const diffText = sh('git', ['diff', baseSha + '...' + headSha], cwd);
404
+ const snapshot = () => ({ head: commit('HEAD'), branch: commit(branch), base: commit(base),
405
+ index: sh('git', ['write-tree'], cwd), indexFlags: sh('git', ['ls-files', '-v'], cwd), status: sh('git', ['status', '--porcelain=v1', '--untracked-files=all', '--ignore-submodules=none'], cwd) });
406
+ const before = snapshot();
407
+ const cleanBefore = before.status === '' && before.head === headSha && before.branch === headSha && before.indexFlags.split('\n').every((line) => !line || line.startsWith('H '));
408
+ const checkoutMismatch = before.head === headSha ? null
409
+ : 'the current checkout is not the branch under review and no worktree holds it — pass --worktree';
410
+
411
+ // dev.md is read from the worktree too: the knobs that gate this branch are
412
+ // the ones on this branch, not whatever the main checkout happens to hold.
413
+ const changelogKnob = (/^changelog:\s*(\S+)/m.exec(devMd) || [])[1] ?? 'none';
414
+ // Added files/lines only — a deleted changeset or the +++ diff header must
415
+ // not count as an entry.
416
+ const changelogTouched = changelogKnob === 'none'
417
+ ? true
418
+ : changelogKnob === 'changesets'
419
+ ? /^\+\+\+ b\/\.changeset\/(?!config)/m.test(diffText) || changesetChangelogEntryAdded(diffText)
420
+ : /^\+(?!\+\+)[^\n]*\S/m.test(sh('git', ['diff', baseSha + '...' + headSha, '--', 'CHANGELOG.md'], cwd) || '');
421
+
422
+ const chronicleOn = /^chronicle:\s*on\s*(#|$)/m.test(devMd);
423
+ const chronicleTouched = chronicleEntryAdded(sh('git', ['diff', baseSha + '...' + headSha, '--', '.vegastack/chronicle.md'], cwd) || '');
424
+
425
+ let checkExit = null;
426
+ const checkCmd = (/^commands:.*?check\s+`([^`]+)`/m.exec(devMd) || [])[1];
427
+ const checkMissing = !checkCmd;
428
+ if (checkCmd && cleanBefore) {
429
+ try {
430
+ execFileSync('sh', ['-c', checkCmd], { stdio: [DISCARD, 'pipe', 'pipe'], cwd, maxBuffer: LARGE_OUTPUT });
431
+ checkExit = 0;
432
+ } catch (error) {
433
+ checkExit = error.status ?? 1;
434
+ }
435
+ }
436
+
437
+ const after = snapshot();
438
+ const cleanAfter = cleanBefore && JSON.stringify(before) === JSON.stringify(after);
439
+ const decision = typedSection(evidence?.body, 'adjudication');
440
+ let sourceComment;
441
+ if (decision?.source?.kind === 'github-comment') {
442
+ const locator = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/[1-9]\d*#issuecomment-([1-9]\d*)$/.exec(decision.source.ref);
443
+ if (locator) sourceComment = JSON.parse(sh('gh', ['api', 'repos/' + locator[1] + '/issues/comments/' + locator[2]]));
444
+ }
445
+ const adjudicated = reviewAdjudicated(evidence?.body, { review, reviewCommentId: reviewComment?.id,
446
+ operators, publisher: evidence?.user?.login, sourceComment });
447
+ return {
448
+ evidence, review, reviewSource, reviewSelectionError, reviewVerdict, adjudicated, headSha, baseSha, operatorPolicy, reviewSha, evidenceSha, scopeDigest, planBinding, cleanBefore, cleanAfter, diffText,
449
+ checkCommand: checkCmd ?? null, environment: { runtime: process.version, platform: process.platform, arch: process.arch, git: sh('git', ['--version'], cwd) },
450
+ changelogTouched, chronicleOn, chronicleTouched,
451
+ allowNoChangelog: flags['allow-no-changelog'], checkExit, checkMissing, checkoutMismatch,
452
+ };
453
+ }
454
+
455
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
456
+ if (invokedDirectly) {
457
+ const argv = process.argv.slice(2);
458
+ const json = argv.includes('--json');
459
+ const get = (flag) => { const i = argv.indexOf(flag); return i === -1 ? undefined : argv[i + 1]; };
460
+ const flags = {
461
+ issue: get('--issue'), branch: get('--branch'), repo: get('--repo'), base: get('--base'),
462
+ 'dev-md': get('--dev-md'), worktree: get('--worktree'), 'allow-no-changelog': get('--allow-no-changelog'), json,
463
+ };
464
+ let outcome;
465
+ if (!flags.issue || !flags.branch) {
466
+ outcome = { blocks: ['usage: ship-gate.mjs --issue <n> --branch <name> [--json]'], warns: [] };
467
+ } else {
468
+ try {
469
+ const facts = gatherFacts(flags);
470
+ outcome = { ...evaluateShipGate(facts), candidate: { headSha: facts.headSha, baseSha: facts.baseSha, operatorPolicy: facts.operatorPolicy, reviewSha: facts.reviewSha, review: facts.review, reviewSource: facts.reviewSource, evidenceSha: facts.evidenceSha, scopeDigest: facts.scopeDigest, planBinding: facts.planBinding, cleanBefore: facts.cleanBefore, cleanAfter: facts.cleanAfter, checkExit: facts.checkExit, checkCommand: facts.checkCommand, environment: facts.environment } };
471
+ } catch (error) {
472
+ outcome = { blocks: [`cannot verify: ${error.message}`], warns: [] };
473
+ }
474
+ }
475
+ const ok = outcome.blocks.length === 0;
476
+ const exitCode = ok ? (outcome.warns.length ? 1 : 0) : 2;
477
+ if (json) {
478
+ console.log(JSON.stringify({ guard: 'ship-gate', ok, ...outcome }, null, 2));
479
+ } else {
480
+ console.log(`ship-gate: ${ok ? (outcome.warns.length ? 'pass with warnings' : 'pass') : 'BLOCKED'}`);
481
+ for (const b of outcome.blocks) console.log(` block: ${b}`);
482
+ for (const w of outcome.warns) console.log(` warn: ${w}`);
483
+ }
484
+ process.exit(exitCode);
485
+ }
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: dev-status
3
+ description: The operator's board — whose move is it, across every issue in the dev workflow. Use when asked "status", "what needs me", "where are we", "what's in flight", "anything stale?", "what should I look at next", or for a board overview of needs-operator / needs-plan / ready / working / for-operator issues. Not for the project's history ("catch me up" is dev-chronicle), implementing or reviewing anything, or repo bootstrap (dev-setup).
4
+ ---
5
+
6
+ # dev-status
7
+
8
+ Act: answer whose move it is from the script's data, and label everything else as judgment.
9
+
10
+ One question, answered from deterministic data: whose move is it? The bundled script gathers; this skill orders and narrates, and an unverifiable board is reported as exactly that, because a rendered guess reads as a fact.
11
+
12
+ Nearest neighbors: `dev-chronicle` answers "how did we get here"; this skill answers "what needs whom right now".
13
+
14
+ ## Gather
15
+
16
+ ```
17
+ node <path-to-this-skill>/scripts/status.mjs --orphan-hours 6 --json
18
+ node <path-to-this-skill>/scripts/status.mjs --orphan-hours 6 --all --json # the whole team's board
19
+ ```
20
+
21
+ Read-only; it returns the board (open issues per state label with age, scope, risky), task progress `x/y` from plan-comment checkboxes, ledger movement for `working` issues in hours (`possiblyOrphaned` = the ledger has been silent past `--orphan-hours`, default 6, or has not been written yet — the claim's heartbeat has stopped), open PRs with check state, pending unrecorded `Decision:` proposals, and the last chronicle entry. It also returns who you are (`viewer`), the `operators:` list, each issue's `assignees` and its resolved `operator`, and two derived arrays — `needsYou` (the human-state issues assigned to you; every one of them under `--all`) and `unowned` (human-state issues nobody is assigned). Exit 2 = cannot verify (offline, unauthenticated) — report the gap plainly and stop, because a guessed board sends the operator to the wrong issue.
22
+
23
+ ## Render — names, with numbers inside the links
24
+
25
+ ```markdown
26
+ ## Status — <repo> · DD-MM
27
+
28
+ Needs you (N):
29
+ - <linked title> — <state> <age>d: <one line: what it waits for and the word needed>
30
+ Unowned (N): - <linked title> — <state> <age>d, nobody assigned → assign <operator>
31
+ Waiting on plan (N): - <linked title> — needs-plan <age>d
32
+ Ready to build (N): - <linked title> — <scope>
33
+ In flight (N): - <linked title> — working, task <x>/<y>, ledger moved <n>h ago
34
+ Possibly orphaned (N): - <linked title> — working <age>d, ledger silent <n>h → heartbeat stopped; check, resume, or reclaim (`reclaim.mjs --issue <n>`)
35
+ Open PRs (N): - <linked title> — checks <green|pending-or-red|no-checks>
36
+ Control room: <n> knob(s) moved since this profile was drafted (<sha7> → <sha7>): <knob> <repo value> → <control-room value> — propose the edit, never make it
37
+ Pending decisions (N): "<gist-plain>" (<linked issue>) — records at that issue's merge
38
+ Last chronicle chapter: <date> — <title-plain>
39
+ Next: <the single most valuable operator action, and why>
40
+ ```
41
+
42
+ - **Needs you** first (the script's `needsYou`, oldest first) — it's your queue by assignment, not by guesswork; everything else is context. `--all` widens it to every human-state issue and is what a second operator asks for.
43
+ - **Unowned** is a human-state issue with no assignee — a flip that lost its assignment or an issue filed outside the workflow. Name the `operator` the script resolved and the one-line `gh issue edit <n> --add-assignee <operator>` that fixes it; the assignment is the operator's to make.
44
+ - Sections with zero entries are omitted, not rendered empty. A completely quiet board is one line: "Nothing needs you — <n> issues ready for agents, nothing in flight."
45
+ - `risky` issues get their flag shown inline wherever they appear.
46
+ - **Next** is one line, chosen not computed-looking: the action that unblocks the most (a plan approval blocking several ready issues beats a lone review).
47
+ - **Possibly orphaned** is the ledger heartbeat gone silent past the orphan window (or not yet started) — likely a dead session, not certainly one. Surface it with the `reclaim.mjs` command inline; the operator decides (check the session, hand it to a resume, or release the claim). A long-running task that keeps checkpointing stays out of this section, because its heartbeat is alive.
48
+ - <linked title> means a markdown link this report builds around the issue/PR title and its URL; numbers ride inside the link, because a bare number means nothing in a terminal. That governs the references the board itself makes.
49
+ - **Control room** is a proposal, never an edit: dev.md hand edits outrank the org and group defaults, so a differing knob is shown with both values and the operator decides; no clone yet, or a sync that failed, is reported as "control room not synced — run `vegafactory sync`" rather than as agreement
50
+ - `<title-plain>` / `<gist-plain>` are the script's `titlePlain` / `gistPlain` fields — text quoted from elsewhere (a chronicle title, a decision gist) may arrive carrying markdown links, and raw bracket-and-parenthesis markup means nothing in a terminal, so it is quoted with the markup removed rather than relinked.
51
+
52
+ The board is one screen: one line per issue, one Next line.
53
+
54
+ ## Honesty rules
55
+
56
+ **Data comes only from the script** — ordering, the wait-reason one-liners and Next are the skill's judgment, labelled as judgment, because a judgment dressed as data is the one the operator cannot question; assignment and the operator resolution are data from the script, never inferred from who spoke last in the thread. A possibly-orphaned `working` issue is a fact to surface, not an accusation — the ledger heartbeat went silent, which is likely but not certainly a dead session: "check, resume, or reclaim" is the operator's call (a takeover still needs their explicit handover, and `reclaim.mjs` is theirs to run, per dev-implement). Standalone, the report is the closing recap; add one only when invoked inside a larger run.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "dev-status"
3
+ short_description: "The operator's board — whose move is it"
4
+ default_prompt: "Use $dev-status to show what needs me."