@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,162 @@
1
+ # factory-board.yml.template — dev-setup writes this to .github/workflows/factory-board.yml
2
+ # on the operator's yes, with three placeholders bound and every line above the strip
3
+ # marker below dropped:
4
+ # {{runs-on}} the job's runner label or label array, e.g. ubuntu-latest
5
+ # {{profile}} path to the project's dev.md, e.g. .vegastack/dev.md
6
+ # {{product-revision}} immutable product commit supplying the packaged policy compiler
7
+ # Requirements the offer must state: the VegaFactory App installed for the org with
8
+ # VEGAFACTORY_APP_ID and VEGAFACTORY_APP_PRIVATE_KEY set (references/github-app.md), and
9
+ # gh 2.97.0 or newer on the runner for name-based project field edits.
10
+ # ---
11
+ # Board mirror — the five state labels drive the project board's Status field.
12
+ #
13
+ # One way only: this workflow reads labels and writes Status. Nothing reads the board.
14
+ # A card dragged on the board is cosmetic until the next label change overwrites it.
15
+ #
16
+ # Needs the VegaFactory App (org variable VEGAFACTORY_APP_ID, org secret
17
+ # VEGAFACTORY_APP_PRIVATE_KEY) because GITHUB_TOKEN cannot write Projects, and gh 2.97.0
18
+ # or newer on the runner for `--field Status --value <name>`.
19
+ name: Board mirror
20
+
21
+ on:
22
+ issues:
23
+ types: [labeled, unlabeled, opened, reopened]
24
+
25
+ permissions:
26
+ contents: read
27
+
28
+ concurrency:
29
+ group: board-mirror-${{ github.event.issue.number }}
30
+ cancel-in-progress: false
31
+
32
+ jobs:
33
+ mirror:
34
+ runs-on: {{runs-on}}
35
+ steps:
36
+ - name: Read the profile
37
+ uses: actions/checkout@v7
38
+ with:
39
+ # Its own directory, never the runner's shared checkout: a sparse checkout
40
+ # leaves git in sparse mode, and on a self-hosted runner the next job that
41
+ # reuses the same path would start from an almost-empty tree.
42
+ path: profile
43
+ sparse-checkout: "{{profile}}"
44
+ sparse-checkout-cone-mode: false
45
+
46
+ - name: Read the pinned policy compiler
47
+ uses: actions/checkout@v7
48
+ with:
49
+ repository: vegastack/vegafactory
50
+ ref: "{{product-revision}}"
51
+ path: .vegafactory-policy
52
+ sparse-checkout: "skills/dev/dev-setup/scripts/effective-policy.mjs"
53
+ sparse-checkout-cone-mode: false
54
+ persist-credentials: false
55
+
56
+ - name: Resolve the board and the state label
57
+ id: resolve
58
+ env:
59
+ PROFILE: "profile/{{profile}}"
60
+ POLICY_MODULE: ".vegafactory-policy/skills/dev/dev-setup/scripts/effective-policy.mjs"
61
+ LABELS: '${{ toJSON(github.event.issue.labels.*.name) }}'
62
+ APP_ID: "${{ vars.VEGAFACTORY_APP_ID }}"
63
+ run: |
64
+ set -eu
65
+
66
+ if [ ! -f "$PROFILE" ]; then
67
+ echo "board mirror: skipped - no such file $PROFILE in this repo"
68
+ echo 'decision=skip' >> "$GITHUB_OUTPUT"
69
+ exit 0
70
+ fi
71
+
72
+ board=$(sed -n 's/^board:[[:space:]]*\([^[:space:]#]*\).*/\1/p' "$PROFILE" | head -1)
73
+ if [ -z "$board" ] || [ "$board" = "none" ]; then
74
+ echo "board mirror: skipped - $PROFILE carries no board number"
75
+ echo 'decision=skip' >> "$GITHUB_OUTPUT"
76
+ exit 0
77
+ fi
78
+
79
+ if [ -z "${APP_ID:-}" ]; then
80
+ echo "board mirror: skipped - no VEGAFACTORY_APP_ID variable; the factory App is not installed for this org"
81
+ echo 'decision=skip' >> "$GITHUB_OUTPUT"
82
+ exit 0
83
+ fi
84
+
85
+ have=$(gh --version | sed -n '1s/^gh version \([0-9][0-9.]*\).*/\1/p')
86
+ have_n=$(awk -F. '{ printf "%d", $1 * 1000000 + $2 * 1000 + $3 }' <<<"$have")
87
+ if [ "${have_n:-0}" -lt 2097000 ]; then
88
+ echo "::error::board mirror needs gh 2.97.0 or newer for name-based project fields; this runner has ${have:-unknown}"
89
+ exit 1
90
+ fi
91
+
92
+ node --input-type=module <<'JS'
93
+ import { readFileSync, appendFileSync } from 'node:fs';
94
+ import { resolve } from 'node:path';
95
+ import { pathToFileURL } from 'node:url';
96
+ const { parsePolicy, readWorkflowLabels, resolveState } = await import(pathToFileURL(resolve(process.env.POLICY_MODULE)).href);
97
+ try {
98
+ const policy = parsePolicy(readFileSync(process.env.PROFILE, 'utf8'));
99
+ if (policy.blocks.length) throw new Error(policy.blocks.join('; '));
100
+ const map = readWorkflowLabels(readFileSync(process.env.PROFILE, 'utf8'));
101
+ const state = resolveState(JSON.parse(process.env.LABELS), map);
102
+ if (state.blocks.length) throw new Error(state.blocks.join('; '));
103
+ const board = policy.extensions.board?.split(/\s/)[0];
104
+ if (!/^[1-9][0-9]*$/.test(board ?? '')) throw new Error('invalid board number');
105
+ appendFileSync(process.env.GITHUB_OUTPUT, 'decision=sync\nboard=' + board + '\nstatus=' + map[state.state] + '\n');
106
+ } catch (error) {
107
+ console.log('board mirror: skipped - ' + error.message);
108
+ appendFileSync(process.env.GITHUB_OUTPUT, 'decision=skip\n');
109
+ }
110
+ JS
111
+
112
+ - name: Mint the App token
113
+ id: token
114
+ if: steps.resolve.outputs.decision == 'sync'
115
+ uses: actions/create-github-app-token@v3
116
+ with:
117
+ app-id: "${{ vars.VEGAFACTORY_APP_ID }}"
118
+ private-key: "${{ secrets.VEGAFACTORY_APP_PRIVATE_KEY }}"
119
+ # owner resolves the org installation; repositories narrows the token to this one
120
+ # repository — without it the token reaches every repository the installation covers.
121
+ owner: "${{ github.repository_owner }}"
122
+ repositories: "${{ github.event.repository.name }}"
123
+
124
+ - name: Write Status on the board
125
+ id: mirror
126
+ if: steps.resolve.outputs.decision == 'sync'
127
+ env:
128
+ GH_TOKEN: "${{ steps.token.outputs.token }}"
129
+ OWNER: "${{ github.repository_owner }}"
130
+ BOARD: "${{ steps.resolve.outputs.board }}"
131
+ STATUS: "${{ steps.resolve.outputs.status }}"
132
+ ISSUE_URL: "${{ github.event.issue.html_url }}"
133
+ run: |
134
+ set -eu
135
+ options=$(mktemp)
136
+ gh project field-list "$BOARD" --owner "$OWNER" --format json > "$options"
137
+ OPTIONS_FILE="$options" node --input-type=module <<'JS'
138
+ import { readFileSync } from 'node:fs';
139
+ const fields = JSON.parse(readFileSync(process.env.OPTIONS_FILE, 'utf8')).fields;
140
+ const status = Array.isArray(fields) ? fields.filter(field => field.name === 'Status') : [];
141
+ if (status.length !== 1 || !Array.isArray(status[0].options) || !status[0].options.some(option => option.name === process.env.STATUS)) {
142
+ console.error('board mirror: configured Status option unavailable; no board mutation');
143
+ process.exit(1);
144
+ }
145
+ JS
146
+ err=$(mktemp)
147
+ if gh project item-edit "$BOARD" --owner "$OWNER" --url "$ISSUE_URL" --field Status --value "$STATUS" 2>"$err"; then
148
+ echo "board mirror: Status=$STATUS on project $BOARD"
149
+ exit 0
150
+ fi
151
+ cat "$err" >&2
152
+ # Only the one failure that means "not on the board yet" earns an item-add; gh names
153
+ # it ("<url> is not an item in project <n>; add it first with `gh project item-add`").
154
+ # A permission, option or transport error is reported as itself and stops here.
155
+ if ! grep -q 'is not an item in project' "$err"; then
156
+ echo "::error::board mirror: item-edit failed for a reason other than a missing item (the message above) - not adding, not retrying"
157
+ exit 1
158
+ fi
159
+ echo "board mirror: item not on project $BOARD yet - adding it, then retrying once"
160
+ gh project item-add "$BOARD" --owner "$OWNER" --url "$ISSUE_URL"
161
+ gh project item-edit "$BOARD" --owner "$OWNER" --url "$ISSUE_URL" --field Status --value "$STATUS"
162
+ echo "board mirror: Status=$STATUS on project $BOARD"
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Decision reminders remain session prose. This compatibility Stop hook only
3
+ // requests the same bounded, deduplicated local flush as stop-heartbeat.
4
+ //
5
+ // Installed to .vegastack/hooks/decision-nudge.mjs and wired on the Stop event. It replaces
6
+ // the inline shell recipe this skill used to carry: Node is guaranteed by the installer while
7
+ // `jq` is not, and a hook that silently exits because `jq` is missing is a guard that is not
8
+ // there. The prose instruction in the AGENTS.md dev section remains the portable base; this
9
+ // is a deterministic nudge on top of it, not a replacement.
10
+
11
+ import { pathToFileURL } from 'node:url'
12
+
13
+ export const NUDGE_REASON =
14
+ 'Before finishing: if this session settled a directional choice (the Decisions test in .vegastack/dev.md), propose one dated register line and ask the user to confirm; otherwise finish.'
15
+
16
+ const DIRECTIONAL = /decided|chose|instead of|convention|from now on|standardi[sz]|switch(ed|ing)? to/i
17
+
18
+ export function isDirectional(message) {
19
+ return typeof message === 'string' && DIRECTIONAL.test(message)
20
+ }
21
+
22
+ // Directional changes remain proposals in the session, never a Stop continuation
23
+ // or an unchecked session ID used as a temporary-file destination.
24
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
25
+ try {
26
+ const { runAdvisoryHook } = await import('./session-start.mjs')
27
+ await runAdvisoryHook('Stop', process.argv.slice(2))
28
+ } catch { /* advisory only */ }
29
+ process.exit(0)
30
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // Codex prompt → skill mentions, recorded as a proxy.
3
+ //
4
+ // Installed to .vegastack/hooks/prompt-skill-mention.mjs and wired on Codex's UserPromptSubmit
5
+ // event. Codex exposes no skill-activation event, so a skill's name mentioned in the prompt is the best available
6
+ // signal — and the record says so: these land with `trigger: "mention"`, never with the confidence
7
+ // of Claude's real activation events. Proactive skill capture on Codex stays a documented gap
8
+ // rather than a number that looks precise and is not.
9
+
10
+ import { spawnSync } from 'node:child_process'
11
+ import { readFileSync } from 'node:fs'
12
+ import { pathToFileURL } from 'node:url'
13
+
14
+ function main() {
15
+ const raw = readFileSync(0, 'utf8')
16
+ spawnSync(process.env.VSK_VEGAFACTORY || 'vegafactory', ['stats', 'record', '--source', 'codex-prompt'], {
17
+ input: raw,
18
+ stdio: ['pipe', 'ignore', 'ignore'],
19
+ })
20
+ }
21
+
22
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
23
+ try {
24
+ main()
25
+ } catch {
26
+ // a prompt must never fail to submit because nobody could count it
27
+ }
28
+ process.exit(0)
29
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ // SessionEnd requests only a bounded local flush. Never block exit, continue a turn,
3
+ // forward transcript/tool payloads, or start detached delivery/model work.
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
7
+ try {
8
+ // The installer copies the shared SessionStart adapter with either consumer.
9
+ // A missing sibling is an advisory failure, not an exit-blocking module error.
10
+ const { runAdvisoryHook } = await import('./session-start.mjs')
11
+ await runAdvisoryHook('SessionEnd', process.argv.slice(2))
12
+ } catch { /* missing CLI/adapter, invalid input and timeout remain non-blocking */ }
13
+ process.exit(0)
14
+ }
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ // Bounded local advisory adapter shared by SessionStart, Stop and SessionEnd.
3
+ // Guard enforcement is separate. This adapter never reads transcripts or native memory,
4
+ // invents a write destination, contacts a network or asks a model to continue.
5
+
6
+ import { spawn } from 'node:child_process'
7
+ import { accessSync, constants, lstatSync, readFileSync, realpathSync } from 'node:fs'
8
+ import { delimiter, dirname, isAbsolute, join } from 'node:path'
9
+ import { fileURLToPath, pathToFileURL } from 'node:url'
10
+ import { createHash, randomUUID } from 'node:crypto'
11
+
12
+ export const MAX_HOOK_INPUT_BYTES = 64 * 1024
13
+ export const LOCAL_FLUSH_MS = 500
14
+ const INPUT_WAIT_MS = 350
15
+ const EVENTS = new Set(['SessionStart', 'Stop', 'SessionEnd'])
16
+ const identity = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value)
17
+
18
+ export function sanitizeHookInput(payload, harness, event) {
19
+ if (!['claude', 'codex'].includes(harness) || !EVENTS.has(event)) return null
20
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
21
+ if (payload.hook_event_name !== undefined && payload.hook_event_name !== event) return null
22
+ if (!identity(payload.session_id) || (payload.turn_id !== undefined && !identity(payload.turn_id))) return null
23
+ if (typeof payload.cwd !== 'string' || payload.cwd.length > 4096 || !isAbsolute(payload.cwd) || /[\0\r\n]/.test(payload.cwd)) return null
24
+ if (payload.stop_hook_active !== undefined && typeof payload.stop_hook_active !== 'boolean') return null
25
+ // These values are identifiers for the private CLI resolver, never filesystem destinations.
26
+ return {
27
+ harness, event, sessionId: payload.session_id,
28
+ ...(payload.turn_id === undefined ? {} : { turnId: payload.turn_id }),
29
+ cwd: payload.cwd, stopHookActive: payload.stop_hook_active ?? false,
30
+ }
31
+ }
32
+
33
+ export function readBoundedHookInput(stream = process.stdin) {
34
+ return new Promise(resolve => {
35
+ const chunks = []
36
+ let size = 0, finished = false
37
+ const done = value => {
38
+ if (finished) return
39
+ finished = true
40
+ clearTimeout(timer)
41
+ stream.removeListener('data', data)
42
+ stream.removeListener('end', end)
43
+ stream.removeListener('error', error)
44
+ stream.pause()
45
+ resolve(value)
46
+ }
47
+ const data = chunk => {
48
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
49
+ size += bytes.length
50
+ if (size > MAX_HOOK_INPUT_BYTES) return done(null)
51
+ chunks.push(bytes)
52
+ }
53
+ const end = () => {
54
+ try { done(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)))) }
55
+ catch { done(null) }
56
+ }
57
+ const error = () => done(null)
58
+ const timer = setTimeout(() => done(null), INPUT_WAIT_MS)
59
+ stream.on('data', data).once('end', end).once('error', error)
60
+ })
61
+ }
62
+
63
+ function localCli() {
64
+ const explicit = process.env.VSK_VEGAFACTORY
65
+ const candidates = explicit ? [explicit] : (process.env.PATH ?? '').split(delimiter).filter(isAbsolute).map(path => join(path, 'vegafactory'))
66
+ for (const candidate of candidates) {
67
+ try {
68
+ if (!isAbsolute(candidate)) continue
69
+ const path = realpathSync(candidate)
70
+ if (!lstatSync(path).isFile()) continue
71
+ accessSync(path, constants.R_OK)
72
+ const root = dirname(dirname(path))
73
+ const read = file => {
74
+ const stat = lstatSync(file)
75
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error('unsupported package metadata')
76
+ return readFileSync(file)
77
+ }
78
+ const pkg = JSON.parse(read(join(root, 'package.json')))
79
+ if (pkg.name !== '@vegastack/vegafactory' || pkg.bin?.vegafactory !== 'dist/index.js'
80
+ || path !== realpathSync(join(root, 'dist/index.js'))) continue
81
+ const manifest = JSON.parse(read(join(root, 'skill-integrity.json')))
82
+ const expected = manifest.schemaVersion === 2 && manifest.skills?.['dev-setup']?.files?.['assets/hooks/session-start.mjs']
83
+ if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected)) continue
84
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex')
85
+ if (hash(read(fileURLToPath(import.meta.url))) !== expected
86
+ || hash(read(join(root, 'skill/dev-setup/assets/hooks/session-start.mjs'))) !== expected) continue
87
+ return path
88
+ } catch { /* missing installed CLI is an advisory no-op */ }
89
+ }
90
+ return null
91
+ }
92
+
93
+ export function runLocalHookPhase(cli, command, input, entryAt = performance.now()) {
94
+ return new Promise(resolvePhase => {
95
+ const remaining = 1000 - (performance.now() - entryAt)
96
+ if (remaining <= 0) return resolvePhase({ completed: false, output: '', phaseMs: null, totalMs: performance.now() - entryAt })
97
+ const nonce = randomUUID()
98
+ let phase = 'waiting-ready', output = '', phaseAt = null, phaseTimer = null, settled = false
99
+ // Detached creates one owned POSIX process group; this child remains fully
100
+ // supervised and is never unref'd or left doing background delivery.
101
+ const child = spawn(process.execPath, [cli, ...command], {
102
+ detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'ignore', 'ipc'],
103
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
104
+ })
105
+ const kill = () => {
106
+ phase = 'failed'
107
+ try { if (child.pid && process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL') } catch { /* already absent */ }
108
+ }
109
+ const deadline = setTimeout(kill, remaining)
110
+ const cancelled = () => kill()
111
+ process.once('SIGTERM', cancelled); process.once('SIGINT', cancelled)
112
+ const done = completed => {
113
+ const now = performance.now()
114
+ completed = completed && (phaseAt === null ? output === '' : now - phaseAt <= LOCAL_FLUSH_MS) && now - entryAt <= 1000
115
+ if (settled) return
116
+ settled = true
117
+ clearTimeout(deadline); clearTimeout(phaseTimer)
118
+ process.removeListener('SIGTERM', cancelled); process.removeListener('SIGINT', cancelled)
119
+ resolvePhase({ completed, output: completed ? output : '', phaseMs: phaseAt === null ? null : performance.now() - phaseAt, totalMs: performance.now() - entryAt })
120
+ }
121
+ child.on('error', () => { kill(); done(false) })
122
+ child.stdin.on('error', kill)
123
+ child.stdout.setEncoding('utf8')
124
+ child.stdout.on('data', chunk => { output += chunk; if (phaseAt === null || Buffer.byteLength(output) > 4096) kill() })
125
+ child.on('message', message => {
126
+ const now = performance.now()
127
+ if (now - entryAt > 1000 || phaseAt !== null && now - phaseAt > LOCAL_FLUSH_MS) return kill()
128
+ if (!message || typeof message !== 'object' || Array.isArray(message) || message.vskManagedHook !== 1) return kill()
129
+ if (message.phase === 'ready' && Object.keys(message).sort().join(',') === 'phase,vskManagedHook' && phase === 'waiting-ready') {
130
+ phase = 'validating'
131
+ // Read-only registry, session and current-policy validation remains
132
+ // under the entry-relative deadline. It grants no local write.
133
+ child.send({ vskManagedHook: 1, phase: 'start', nonce }, error => { if (error) kill() })
134
+ child.stdin.end(JSON.stringify(input))
135
+ } else if (message.phase === 'validated' && Object.keys(message).sort().join(',') === 'nonce,phase,vskManagedHook' && message.nonce === nonce && phase === 'validating') {
136
+ phase = 'flushing'; phaseAt = performance.now()
137
+ // Exactly one grant covers every claim, write, release, ACK and lesson
138
+ // mutation. No message can renew either deadline.
139
+ phaseTimer = setTimeout(kill, Math.min(LOCAL_FLUSH_MS, 1000 - (phaseAt - entryAt)))
140
+ child.send({ vskManagedHook: 1, phase: 'flush', nonce }, error => { if (error) kill() })
141
+ } else if (message.phase === 'finish' && Object.keys(message).sort().join(',') === 'nonce,phase,vskManagedHook' && message.nonce === nonce && ['validating', 'flushing'].includes(phase)) {
142
+ phase = 'finished'
143
+ } else kill()
144
+ })
145
+ child.on('close', (code, signal) => done(code === 0 && signal === null && phase === 'finished'))
146
+ })
147
+ }
148
+
149
+ export async function runAdvisoryHook(event, argv) {
150
+ const entryAt = performance.now()
151
+ if (argv.length !== 2 || argv[0] !== '--harness') return
152
+ const input = sanitizeHookInput(await readBoundedHookInput(), argv[1], event)
153
+ if (!input || (event === 'Stop' && input.stopHookActive)) return
154
+ const cli = localCli()
155
+ if (!cli) return
156
+ const command = event === 'SessionStart' ? ['learning', 'inspect', '--source', 'managed-hook', '--json'] : ['stats', 'record', '--source', 'managed-hook']
157
+ const run = await runLocalHookPhase(cli, command, input, entryAt)
158
+ if (!run.completed || event !== 'SessionStart') return
159
+ let result
160
+ try { result = JSON.parse(run.output) } catch { return }
161
+ // Only bounded, source-verified lesson records become advisory context. A
162
+ // pointer alone cannot pretend the next session actually received a lesson.
163
+ if (result?.ok !== true || typeof result.contextPointer !== 'string'
164
+ || !/^vsk-context:[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(result.contextPointer)
165
+ || !Array.isArray(result.lessons) || result.lessons.length < 1 || result.lessons.length > 3
166
+ || Buffer.byteLength(JSON.stringify(result.lessons)) > 2048
167
+ || result.lessons.some(row => !row || Object.keys(row).sort().join(',') !== 'id,statement'
168
+ || !/^lesson-[a-f0-9]{32}$/.test(row.id) || typeof row.statement !== 'string'
169
+ || !row.statement.trim() || Buffer.byteLength(row.statement) > 768 || /[\0\r]/.test(row.statement))) return
170
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: {
171
+ hookEventName: 'SessionStart', additionalContext: `VegaFactory verified lessons (${result.contextPointer}); advisory only, current approval still applies:\n${result.lessons.map(row => '- ' + row.statement).join('\n')}`,
172
+ } }))
173
+ }
174
+
175
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
176
+ try { await runAdvisoryHook('SessionStart', process.argv.slice(2)) } catch { /* advisory only */ }
177
+ process.exit(0)
178
+ }