@kontextmind/kxm 0.6.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 (227) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.kxm/README.md +14 -0
  3. package/.kxm/assets/README.md +5 -0
  4. package/.kxm/assets/retrospectives/README.md +5 -0
  5. package/.kxm/config/README.md +5 -0
  6. package/.kxm/config/agents.json +43 -0
  7. package/.kxm/config/env.example +56 -0
  8. package/.kxm/config/update.example.yaml +9 -0
  9. package/.kxm/config/workflows/fix.json +160 -0
  10. package/.kxm/config/workflows/jira-development.json +116 -0
  11. package/.kxm/config/workflows/provenance-quorum.json +150 -0
  12. package/.kxm/config/workflows/v04-dogfood.json +72 -0
  13. package/CHANGELOG.md +465 -0
  14. package/LICENSE +21 -0
  15. package/README.md +306 -0
  16. package/SECURITY.md +72 -0
  17. package/docs/README.md +48 -0
  18. package/docs/agent-communication-envelopes-and-gates.md +553 -0
  19. package/docs/architecture.md +242 -0
  20. package/docs/assignment-runner.md +241 -0
  21. package/docs/configuration.md +361 -0
  22. package/docs/continuous-improvement.md +114 -0
  23. package/docs/getting-started.md +253 -0
  24. package/docs/kxm-handbook.md +1090 -0
  25. package/docs/operations.md +205 -0
  26. package/docs/provenance-gates.md +291 -0
  27. package/docs/skills.md +45 -0
  28. package/docs/templates/README.md +95 -0
  29. package/docs/templates/adr.md +88 -0
  30. package/docs/templates/architecture.md +120 -0
  31. package/docs/templates/bug-fix.md +109 -0
  32. package/docs/templates/feature.md +108 -0
  33. package/docs/templates/handoff.md +72 -0
  34. package/docs/templates/postmortem.md +77 -0
  35. package/docs/templates/research.md +100 -0
  36. package/docs/templates/review.md +85 -0
  37. package/docs/templates/runbook.md +73 -0
  38. package/docs/templates/test-plan.md +87 -0
  39. package/docs/templates/test-report.md +72 -0
  40. package/docs/test-matrix.md +121 -0
  41. package/docs/troubleshooting.md +249 -0
  42. package/docs/vnext/README.md +62 -0
  43. package/docs/vnext/architecture.md +185 -0
  44. package/docs/vnext/effects-and-recovery.md +172 -0
  45. package/docs/vnext/lifecycles.md +235 -0
  46. package/docs/vnext/migration.md +220 -0
  47. package/docs/vnext/routing.md +184 -0
  48. package/docs/vnext/synchronization.md +172 -0
  49. package/docs/vnext/terminology.md +240 -0
  50. package/docs/vnext/validation.md +335 -0
  51. package/docs/webhook-workflows.md +240 -0
  52. package/docs/workflow-guide.md +1150 -0
  53. package/examples/README.md +102 -0
  54. package/examples/provenance-workflow.json +40 -0
  55. package/examples/requester.ts +30 -0
  56. package/examples/reviewer-agent.ts +29 -0
  57. package/examples/roundtrip.ts +46 -0
  58. package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
  59. package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
  60. package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
  61. package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
  62. package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
  63. package/examples/vnext/.kxm/agents/planner.yaml +13 -0
  64. package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
  65. package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
  66. package/examples/vnext/.kxm/gates.yaml +8 -0
  67. package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
  68. package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
  69. package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
  70. package/examples/vnext/.kxm/models/implementation.yaml +14 -0
  71. package/examples/vnext/.kxm/models/primary.yaml +17 -0
  72. package/examples/vnext/.kxm/prices.yaml +111 -0
  73. package/examples/vnext/.kxm/project/env.yaml +7 -0
  74. package/examples/vnext/.kxm/project.yaml +32 -0
  75. package/examples/vnext/.kxm/repo/repo.yaml +8 -0
  76. package/examples/vnext/.kxm/workflows/default.yaml +92 -0
  77. package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
  78. package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
  79. package/examples/vnext/README.md +53 -0
  80. package/examples/vnext/records/assignment-result-recorded.json +63 -0
  81. package/examples/vnext/records/assignment-result.json +46 -0
  82. package/examples/vnext/records/context-candidate.json +42 -0
  83. package/examples/vnext/records/delivery-manifest.json +66 -0
  84. package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
  85. package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
  86. package/examples/vnext/records/run-created.json +54 -0
  87. package/examples/vnext/records/sync-event.json +65 -0
  88. package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
  89. package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
  90. package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
  91. package/examples/workflow-signal.ts +63 -0
  92. package/package.json +129 -0
  93. package/plugins/kxm/.claude-plugin/plugin.json +73 -0
  94. package/plugins/kxm/.mcp.json +19 -0
  95. package/plugins/kxm/README.md +93 -0
  96. package/plugins/kxm/dist/cli.js +42853 -0
  97. package/plugins/kxm/dist/client.js +416 -0
  98. package/plugins/kxm/dist/core.js +1823 -0
  99. package/plugins/kxm/dist/extension.js +3797 -0
  100. package/plugins/kxm/dist/mcp-server.js +17104 -0
  101. package/plugins/kxm/dist/runtime.js +23361 -0
  102. package/plugins/kxm/dist/server.js +13640 -0
  103. package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
  104. package/plugins/kxm/package.json +12 -0
  105. package/plugins/kxm/skills/kxm/SKILL.md +97 -0
  106. package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
  107. package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
  108. package/plugins/kxm/src/arbiter.ts +355 -0
  109. package/plugins/kxm/src/artifacts-exist.ts +62 -0
  110. package/plugins/kxm/src/autocomplete.ts +236 -0
  111. package/plugins/kxm/src/cli.ts +3707 -0
  112. package/plugins/kxm/src/client.ts +614 -0
  113. package/plugins/kxm/src/commands.ts +1063 -0
  114. package/plugins/kxm/src/config.ts +290 -0
  115. package/plugins/kxm/src/context/providers.ts +101 -0
  116. package/plugins/kxm/src/context-packet.ts +332 -0
  117. package/plugins/kxm/src/context.ts +499 -0
  118. package/plugins/kxm/src/core.ts +6 -0
  119. package/plugins/kxm/src/database.ts +563 -0
  120. package/plugins/kxm/src/diagnostics.ts +184 -0
  121. package/plugins/kxm/src/envelope.ts +118 -0
  122. package/plugins/kxm/src/extension.ts +895 -0
  123. package/plugins/kxm/src/external-effects.ts +299 -0
  124. package/plugins/kxm/src/github-watch.ts +255 -0
  125. package/plugins/kxm/src/hub-binding.ts +160 -0
  126. package/plugins/kxm/src/hub.ts +2502 -0
  127. package/plugins/kxm/src/improve.ts +383 -0
  128. package/plugins/kxm/src/inbox.ts +10 -0
  129. package/plugins/kxm/src/kxm-install-kind.ts +113 -0
  130. package/plugins/kxm/src/kxm-update-config.ts +39 -0
  131. package/plugins/kxm/src/kxm-update.ts +238 -0
  132. package/plugins/kxm/src/local-snapshot.ts +406 -0
  133. package/plugins/kxm/src/logger.ts +198 -0
  134. package/plugins/kxm/src/mcp-server.ts +143 -0
  135. package/plugins/kxm/src/memory.ts +385 -0
  136. package/plugins/kxm/src/nous-pi.ts +287 -0
  137. package/plugins/kxm/src/nous-provider.ts +729 -0
  138. package/plugins/kxm/src/price-calc.ts +87 -0
  139. package/plugins/kxm/src/prices.ts +121 -0
  140. package/plugins/kxm/src/protocol.ts +172 -0
  141. package/plugins/kxm/src/recovery.ts +211 -0
  142. package/plugins/kxm/src/redact.ts +26 -0
  143. package/plugins/kxm/src/retrospective.ts +400 -0
  144. package/plugins/kxm/src/routing.ts +830 -0
  145. package/plugins/kxm/src/runtime.ts +9 -0
  146. package/plugins/kxm/src/server.ts +117 -0
  147. package/plugins/kxm/src/session-work.ts +571 -0
  148. package/plugins/kxm/src/session.ts +184 -0
  149. package/plugins/kxm/src/skills.ts +535 -0
  150. package/plugins/kxm/src/state.ts +326 -0
  151. package/plugins/kxm/src/store.ts +637 -0
  152. package/plugins/kxm/src/studio-layout.ts +268 -0
  153. package/plugins/kxm/src/suggest.ts +162 -0
  154. package/plugins/kxm/src/task-manager.ts +244 -0
  155. package/plugins/kxm/src/telemetry.ts +116 -0
  156. package/plugins/kxm/src/tui.ts +1046 -0
  157. package/plugins/kxm/src/vnext-bindings.ts +403 -0
  158. package/plugins/kxm/src/vnext-config.ts +1646 -0
  159. package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
  160. package/plugins/kxm/src/vnext-engine-command.ts +533 -0
  161. package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
  162. package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
  163. package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
  164. package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
  165. package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
  166. package/plugins/kxm/src/vnext-engine.ts +2458 -0
  167. package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
  168. package/plugins/kxm/src/vnext-harness.ts +1142 -0
  169. package/plugins/kxm/src/vnext-init.ts +430 -0
  170. package/plugins/kxm/src/vnext-migrate.ts +1848 -0
  171. package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
  172. package/plugins/kxm/src/vnext-permission.ts +936 -0
  173. package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
  174. package/plugins/kxm/src/vnext-repair.ts +1094 -0
  175. package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
  176. package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
  177. package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
  178. package/plugins/kxm/src/vnext-runtime.ts +663 -0
  179. package/plugins/kxm/src/vnext-template.ts +247 -0
  180. package/plugins/kxm/src/wiki.ts +313 -0
  181. package/plugins/kxm/src/workflow.ts +1548 -0
  182. package/schemas/vnext/README.md +46 -0
  183. package/schemas/vnext/agent.schema.json +40 -0
  184. package/schemas/vnext/assignment-result.schema.json +66 -0
  185. package/schemas/vnext/backup-manifest.schema.json +89 -0
  186. package/schemas/vnext/candidate.schema.json +109 -0
  187. package/schemas/vnext/common.schema.json +422 -0
  188. package/schemas/vnext/context-candidate.schema.json +76 -0
  189. package/schemas/vnext/context-packet.schema.json +192 -0
  190. package/schemas/vnext/delivery-manifest.schema.json +159 -0
  191. package/schemas/vnext/environment.schema.json +66 -0
  192. package/schemas/vnext/gate-registry.schema.json +109 -0
  193. package/schemas/vnext/handoff-manifest.schema.json +146 -0
  194. package/schemas/vnext/init-operation.schema.json +61 -0
  195. package/schemas/vnext/local-repository-bindings.schema.json +30 -0
  196. package/schemas/vnext/memory-record.schema.json +45 -0
  197. package/schemas/vnext/migration-decision.schema.json +26 -0
  198. package/schemas/vnext/migration-plan.schema.json +123 -0
  199. package/schemas/vnext/migration-receipt.schema.json +52 -0
  200. package/schemas/vnext/model.schema.json +42 -0
  201. package/schemas/vnext/permission-diff.schema.json +57 -0
  202. package/schemas/vnext/prices.schema.json +115 -0
  203. package/schemas/vnext/project.schema.json +85 -0
  204. package/schemas/vnext/repository.schema.json +24 -0
  205. package/schemas/vnext/run-event.schema.json +460 -0
  206. package/schemas/vnext/session-brief.schema.json +153 -0
  207. package/schemas/vnext/sync-event.schema.json +234 -0
  208. package/schemas/vnext/template-provenance.schema.json +38 -0
  209. package/schemas/vnext/workflow.schema.json +248 -0
  210. package/scripts/assignment-run.d.mts +354 -0
  211. package/scripts/assignment-run.mjs +4451 -0
  212. package/scripts/build-runtime.mjs +56 -0
  213. package/scripts/check-generated.mjs +77 -0
  214. package/scripts/check-versions.mjs +34 -0
  215. package/scripts/emit-codex-artifacts.d.mts +9 -0
  216. package/scripts/emit-codex-artifacts.mjs +91 -0
  217. package/scripts/harness-run.d.mts +83 -0
  218. package/scripts/harness-run.mjs +2095 -0
  219. package/scripts/kxm-hub.mjs +105 -0
  220. package/scripts/kxm-publish-npm.mjs +327 -0
  221. package/scripts/kxm-release-github.mjs +472 -0
  222. package/scripts/kxm-runtime-supervisor.mjs +7 -0
  223. package/scripts/kxm-worker.mjs +1127 -0
  224. package/scripts/kxm.mjs +27 -0
  225. package/scripts/roster-policy.d.mts +20 -0
  226. package/scripts/roster-policy.mjs +161 -0
  227. package/scripts/smoke-multi-pi.mjs +479 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * KXM External Side-Effect Idempotency (Slice C)
3
+ * Ensures deterministic branching, preflight CAS checks, and immutable receipts for external mutations.
4
+ */
5
+
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { createHash } from "node:crypto";
8
+ import { mkdirSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+
11
+ export const EXTERNAL_EFFECT_SCHEMA = "kxm.external-effect-receipt.v1" as const;
12
+
13
+ export type ExternalActionKind =
14
+ | "git-branch"
15
+ | "git-commit"
16
+ | "git-push"
17
+ | "pr-create"
18
+ | "tracker-issue"
19
+ | "webhook";
20
+
21
+ export type ExternalEffectStatus = "in-flight" | "committed" | "failed" | "aborted";
22
+
23
+ export interface ExternalEffectReceipt {
24
+ schema: typeof EXTERNAL_EFFECT_SCHEMA;
25
+ effectKey: string;
26
+ runId: string;
27
+ stepId: string;
28
+ attemptId: string;
29
+ actionKind: ExternalActionKind;
30
+ targetRef: string;
31
+ status: ExternalEffectStatus;
32
+ payloadHash: string;
33
+ receiptPayload: Record<string, unknown>;
34
+ executedAt: string;
35
+ completedAt?: string | undefined;
36
+ }
37
+
38
+ export interface RunBranchOptions {
39
+ description?: string | undefined;
40
+ workflowId?: string | undefined;
41
+ issueKey?: string | undefined;
42
+ format?: "prefix-run" | "run-suffix" | undefined;
43
+ }
44
+
45
+ export function slugifyBranchPart(text: string, maxLength: number = 40): string {
46
+ const cleaned = text
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9]+/g, "-")
49
+ .replace(/^-+|-+$/g, "");
50
+ if (cleaned.length <= maxLength) return cleaned;
51
+ return cleaned.slice(0, maxLength).replace(/-+$/, "");
52
+ }
53
+
54
+ export function deterministicRunBranch(
55
+ runId: string,
56
+ descriptionOrOptions?: string | RunBranchOptions,
57
+ ): string {
58
+ const cleanId = runId.replace(/^run_/, "");
59
+ if (!descriptionOrOptions) {
60
+ return `kxm/run-${cleanId}`;
61
+ }
62
+
63
+ const options: RunBranchOptions =
64
+ typeof descriptionOrOptions === "string"
65
+ ? { description: descriptionOrOptions }
66
+ : descriptionOrOptions;
67
+
68
+ const parts: string[] = [];
69
+ if (options.workflowId) {
70
+ const wf = slugifyBranchPart(options.workflowId, 25);
71
+ if (wf) parts.push(wf);
72
+ }
73
+ if (options.issueKey) {
74
+ const issue = slugifyBranchPart(options.issueKey, 20);
75
+ if (issue && !parts.some((p) => p.includes(issue))) parts.push(issue);
76
+ }
77
+ if (options.description) {
78
+ const desc = slugifyBranchPart(options.description, 35);
79
+ if (desc && !parts.some((p) => p.includes(desc))) parts.push(desc);
80
+ }
81
+
82
+ const slug = parts.join("-").slice(0, 50).replace(/-+$/, "");
83
+ if (!slug) {
84
+ return `kxm/run-${cleanId}`;
85
+ }
86
+
87
+ if (options.format === "prefix-run") {
88
+ return `kxm/${slug}-run-${cleanId}`;
89
+ }
90
+ return `kxm/run-${cleanId}-${slug}`;
91
+ }
92
+
93
+ export function computeEffectKey(
94
+ runId: string,
95
+ stepId: string,
96
+ actionKind: ExternalActionKind,
97
+ targetRef: string,
98
+ ): string {
99
+ const raw = `${runId}:${stepId}:${actionKind}:${targetRef}`;
100
+ return `eff_${createHash("sha256").update(raw).digest("hex").slice(0, 16)}`;
101
+ }
102
+
103
+ export class ExternalEffectsLedger {
104
+ private db: DatabaseSync;
105
+
106
+ constructor(dbPath: string = ":memory:") {
107
+ if (dbPath !== ":memory:") {
108
+ mkdirSync(dirname(dbPath), { recursive: true });
109
+ }
110
+ this.db = new DatabaseSync(dbPath);
111
+ this.initSchema();
112
+ }
113
+
114
+ private initSchema(): void {
115
+ this.db.exec(`
116
+ CREATE TABLE IF NOT EXISTS external_effects (
117
+ effect_key TEXT PRIMARY KEY,
118
+ run_id TEXT NOT NULL,
119
+ step_id TEXT NOT NULL,
120
+ attempt_id TEXT NOT NULL,
121
+ action_kind TEXT NOT NULL,
122
+ target_ref TEXT NOT NULL,
123
+ status TEXT NOT NULL,
124
+ payload_hash TEXT NOT NULL,
125
+ receipt_payload TEXT NOT NULL,
126
+ executed_at TEXT NOT NULL,
127
+ completed_at TEXT
128
+ );
129
+ CREATE INDEX IF NOT EXISTS idx_ext_effects_run ON external_effects(run_id);
130
+ `);
131
+ }
132
+
133
+ /**
134
+ * Preflight Check-And-Set (CAS):
135
+ * Guarantees that an external mutation is only initiated if not already committed or currently in-flight.
136
+ */
137
+ claimEffect(input: {
138
+ runId: string;
139
+ stepId: string;
140
+ attemptId: string;
141
+ actionKind: ExternalActionKind;
142
+ targetRef: string;
143
+ payload?: Record<string, unknown> | undefined;
144
+ timeoutMs?: number | undefined;
145
+ }): { ok: true; effectKey: string } | { ok: false; error: string; existing?: ExternalEffectReceipt } {
146
+ const effectKey = computeEffectKey(input.runId, input.stepId, input.actionKind, input.targetRef);
147
+ const now = new Date().toISOString();
148
+ const payloadStr = JSON.stringify(input.payload ?? {});
149
+ const payloadHash = createHash("sha256").update(payloadStr).digest("hex");
150
+
151
+ const existing = this.getReceipt(effectKey);
152
+ if (existing) {
153
+ if (existing.status === "committed") {
154
+ return {
155
+ ok: false,
156
+ error: `effect_already_committed: ${input.actionKind} on ${input.targetRef} was already committed`,
157
+ existing,
158
+ };
159
+ }
160
+ if (existing.status === "in-flight") {
161
+ const timeout = input.timeoutMs ?? 60000;
162
+ const elapsed = Date.now() - Date.parse(existing.executedAt);
163
+ if (elapsed < timeout) {
164
+ return {
165
+ ok: false,
166
+ error: `effect_in_flight: ${input.actionKind} on ${input.targetRef} is currently executing`,
167
+ existing,
168
+ };
169
+ }
170
+ // If timed out, allow reclaim by updating status
171
+ }
172
+ }
173
+
174
+ const stmt = this.db.prepare(`
175
+ INSERT INTO external_effects (
176
+ effect_key, run_id, step_id, attempt_id, action_kind, target_ref,
177
+ status, payload_hash, receipt_payload, executed_at
178
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
179
+ ON CONFLICT(effect_key) DO UPDATE SET
180
+ attempt_id = excluded.attempt_id,
181
+ status = 'in-flight',
182
+ executed_at = excluded.executed_at,
183
+ payload_hash = excluded.payload_hash,
184
+ receipt_payload = excluded.receipt_payload
185
+ `);
186
+
187
+ stmt.run(
188
+ effectKey,
189
+ input.runId,
190
+ input.stepId,
191
+ input.attemptId,
192
+ input.actionKind,
193
+ input.targetRef,
194
+ "in-flight",
195
+ payloadHash,
196
+ payloadStr,
197
+ now,
198
+ );
199
+
200
+ return { ok: true, effectKey };
201
+ }
202
+
203
+ commitEffect(
204
+ effectKey: string,
205
+ receiptPayload: Record<string, unknown>,
206
+ ): void {
207
+ const now = new Date().toISOString();
208
+ const stmt = this.db.prepare(`
209
+ UPDATE external_effects
210
+ SET status = 'committed', receipt_payload = ?, completed_at = ?
211
+ WHERE effect_key = ?
212
+ `);
213
+ stmt.run(JSON.stringify(receiptPayload), now, effectKey);
214
+ }
215
+
216
+ abortEffect(effectKey: string, reason: string): void {
217
+ const now = new Date().toISOString();
218
+ const stmt = this.db.prepare(`
219
+ UPDATE external_effects
220
+ SET status = 'aborted', receipt_payload = ?, completed_at = ?
221
+ WHERE effect_key = ?
222
+ `);
223
+ stmt.run(JSON.stringify({ error: reason }), now, effectKey);
224
+ }
225
+
226
+ getReceipt(effectKey: string): ExternalEffectReceipt | undefined {
227
+ const stmt = this.db.prepare(`
228
+ SELECT * FROM external_effects WHERE effect_key = ?
229
+ `);
230
+ const row = stmt.get(effectKey) as {
231
+ effect_key: string;
232
+ run_id: string;
233
+ step_id: string;
234
+ attempt_id: string;
235
+ action_kind: ExternalActionKind;
236
+ target_ref: string;
237
+ status: ExternalEffectStatus;
238
+ payload_hash: string;
239
+ receipt_payload: string;
240
+ executed_at: string;
241
+ completed_at: string | null;
242
+ } | undefined;
243
+
244
+ if (!row) return undefined;
245
+
246
+ return {
247
+ schema: EXTERNAL_EFFECT_SCHEMA,
248
+ effectKey: row.effect_key,
249
+ runId: row.run_id,
250
+ stepId: row.step_id,
251
+ attemptId: row.attempt_id,
252
+ actionKind: row.action_kind,
253
+ targetRef: row.target_ref,
254
+ status: row.status,
255
+ payloadHash: row.payload_hash,
256
+ receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>,
257
+ executedAt: row.executed_at,
258
+ completedAt: row.completed_at ?? undefined,
259
+ };
260
+ }
261
+
262
+ listRunEffects(runId: string): ExternalEffectReceipt[] {
263
+ const stmt = this.db.prepare(`
264
+ SELECT * FROM external_effects WHERE run_id = ? ORDER BY executed_at ASC
265
+ `);
266
+ const rows = stmt.all(runId) as Array<{
267
+ effect_key: string;
268
+ run_id: string;
269
+ step_id: string;
270
+ attempt_id: string;
271
+ action_kind: ExternalActionKind;
272
+ target_ref: string;
273
+ status: ExternalEffectStatus;
274
+ payload_hash: string;
275
+ receipt_payload: string;
276
+ executed_at: string;
277
+ completed_at: string | null;
278
+ }>;
279
+
280
+ return rows.map((row) => ({
281
+ schema: EXTERNAL_EFFECT_SCHEMA,
282
+ effectKey: row.effect_key,
283
+ runId: row.run_id,
284
+ stepId: row.step_id,
285
+ attemptId: row.attempt_id,
286
+ actionKind: row.action_kind,
287
+ targetRef: row.target_ref,
288
+ status: row.status,
289
+ payloadHash: row.payload_hash,
290
+ receiptPayload: JSON.parse(row.receipt_payload) as Record<string, unknown>,
291
+ executedAt: row.executed_at,
292
+ completedAt: row.completed_at ?? undefined,
293
+ }));
294
+ }
295
+
296
+ close(): void {
297
+ this.db.close();
298
+ }
299
+ }
@@ -0,0 +1,255 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ import { redactSecrets } from "./redact.ts";
3
+ import type { WorkflowEvidenceInput } from "./workflow.ts";
4
+
5
+ export type WatchStatus = "passed" | "failed" | "warning";
6
+
7
+ export interface GithubCheckRun {
8
+ name: string;
9
+ status: string;
10
+ conclusion?: string | null;
11
+ html_url?: string;
12
+ completed_at?: string | null;
13
+ }
14
+
15
+ export interface GithubWatchInput {
16
+ serverUrl: string;
17
+ definitionId: string;
18
+ signalSecret: string;
19
+ runId: string;
20
+ stageId: string;
21
+ signalKey: string;
22
+ repo: string;
23
+ pr: number;
24
+ required?: string[];
25
+ timeoutMs: number;
26
+ intervalMs: number;
27
+ token?: string;
28
+ deliveryId?: string;
29
+ dryRun?: boolean;
30
+ now?: () => number;
31
+ sleep?: (ms: number) => Promise<void>;
32
+ fetchImpl?: typeof fetch;
33
+ }
34
+
35
+ export interface GithubWatchResult {
36
+ exitCode: number;
37
+ posted: boolean;
38
+ duplicate?: boolean;
39
+ status?: WatchStatus;
40
+ summary: string;
41
+ evidence: WorkflowEvidenceInput;
42
+ deliveryId?: string;
43
+ skipped?: boolean;
44
+ }
45
+
46
+ async function fetchWithTimeout(fetchImpl: typeof fetch, input: string, init: RequestInit, timeoutMs: number): Promise<Response> {
47
+ const controller = new AbortController();
48
+ const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
49
+ try {
50
+ return await fetchImpl(input, { ...init, signal: controller.signal });
51
+ } finally {
52
+ clearTimeout(timer);
53
+ }
54
+ }
55
+
56
+ function requiredToken(token: string | undefined): string | undefined {
57
+ const value = token?.trim();
58
+ return value || undefined;
59
+ }
60
+
61
+ const FAILED_CONCLUSIONS = new Set([
62
+ "failure",
63
+ "cancelled",
64
+ "timed_out",
65
+ "action_required",
66
+ "stale",
67
+ "startup_failure",
68
+ ]);
69
+ const SUCCESS_CONCLUSIONS = new Set(["success"]);
70
+ const CHECK_RUNS_PER_PAGE = 100;
71
+ const MAX_CHECK_RUN_PAGES = 100;
72
+
73
+ export function mapCheckConclusion(
74
+ runs: GithubCheckRun[],
75
+ required: string[] = [],
76
+ ): { status: WatchStatus | "pending"; evidence: WorkflowEvidenceInput } {
77
+ const names = required.length > 0 ? required : [...new Set(runs.map((run) => run.name))];
78
+ const interesting = names.map((name) => runs.find((run) => run.name === name));
79
+ const evidence = Object.fromEntries(interesting.slice(0, 32).map((run, index) => {
80
+ const name = names[index] ?? "unknown";
81
+ const conclusion = run?.conclusion ?? run?.status ?? "missing";
82
+ const url = run?.html_url ? ` url:${run.html_url}` : "";
83
+ const completed = run?.completed_at ? ` at:${run.completed_at}` : "";
84
+ return [`github.check:${name}`, redactSecrets(`conclusion:${conclusion}${url}${completed}`).slice(0, 500)];
85
+ }));
86
+ if (names.length === 0 || interesting.some((run) => !run || run.status !== "completed")) {
87
+ return { status: "pending", evidence };
88
+ }
89
+ if (interesting.some((run) => FAILED_CONCLUSIONS.has(run?.conclusion ?? ""))) {
90
+ return { status: "failed", evidence };
91
+ }
92
+ if (interesting.every((run) => SUCCESS_CONCLUSIONS.has(run?.conclusion ?? ""))) {
93
+ return { status: "passed", evidence };
94
+ }
95
+ return { status: "pending", evidence };
96
+ }
97
+
98
+ export async function postWorkflowSignal(input: {
99
+ serverUrl: string;
100
+ definitionId: string;
101
+ signalSecret: string;
102
+ runId: string;
103
+ signalKey: string;
104
+ status: WatchStatus;
105
+ summary: string;
106
+ evidence: WorkflowEvidenceInput;
107
+ deliveryId: string;
108
+ timeoutMs?: number;
109
+ fetchImpl?: typeof fetch;
110
+ }): Promise<{ httpStatus: number; duplicate: boolean }> {
111
+ const body = JSON.stringify({ status: input.status, summary: input.summary, evidence: input.evidence });
112
+ const signature = `sha256=${createHmac("sha256", input.signalSecret).update(body).digest("hex")}`;
113
+ const endpoint = [
114
+ input.serverUrl.replace(/\/$/, ""),
115
+ "v1/webhooks",
116
+ encodeURIComponent(input.definitionId),
117
+ "runs",
118
+ encodeURIComponent(input.runId),
119
+ "signals",
120
+ encodeURIComponent(input.signalKey),
121
+ ].join("/");
122
+ const response = await fetchWithTimeout(input.fetchImpl ?? fetch, endpoint, {
123
+ method: "POST",
124
+ headers: {
125
+ "content-type": "application/json",
126
+ "x-hub-signature-256": signature,
127
+ "x-kxm-delivery-id": input.deliveryId,
128
+ },
129
+ body,
130
+ }, input.timeoutMs ?? 15_000);
131
+ const text = await response.text();
132
+ let duplicate = false;
133
+ try {
134
+ const parsed = JSON.parse(text) as { duplicate?: boolean };
135
+ duplicate = parsed.duplicate === true;
136
+ } catch {
137
+ // Non-JSON responses are treated as adapter failures below.
138
+ }
139
+ if (!response.ok && response.status !== 200) {
140
+ throw new Error(`signal_http_${response.status}`);
141
+ }
142
+ return { httpStatus: response.status, duplicate };
143
+ }
144
+
145
+ export async function watchGithubChecks(input: GithubWatchInput): Promise<GithubWatchResult> {
146
+ const token = requiredToken(input.token);
147
+ if (!token) {
148
+ return { exitCode: 1, posted: false, skipped: true, summary: "github_auth_unavailable", evidence: {} };
149
+ }
150
+ const fetchImpl = input.fetchImpl ?? fetch;
151
+ const now = input.now ?? Date.now;
152
+ const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
153
+ const deadline = now() + input.timeoutMs;
154
+ const headers = {
155
+ authorization: `Bearer ${token}`,
156
+ accept: "application/vnd.github+json",
157
+ "user-agent": "kxm-github-watch",
158
+ };
159
+ const contextEvidence: WorkflowEvidenceInput = {
160
+ "workflow.run": input.runId,
161
+ "workflow.stage": input.stageId,
162
+ "workflow.signal": input.signalKey,
163
+ };
164
+ const explicitDeliveryId = input.deliveryId?.trim();
165
+ const deliveryGeneration = randomUUID();
166
+ let deliveryId = explicitDeliveryId || `github-watch:${deliveryGeneration}:pr-${input.pr}`;
167
+ const deliver = async (
168
+ status: WatchStatus,
169
+ summary: string,
170
+ evidence: WorkflowEvidenceInput,
171
+ ): Promise<GithubWatchResult> => {
172
+ const boundedEvidence = Object.fromEntries(Object.entries({ ...contextEvidence, ...evidence }).slice(0, 64));
173
+ if (input.dryRun) return { exitCode: status === "failed" && summary === "github_watch_timeout" ? 4 : 0, posted: false, status, summary, evidence: boundedEvidence, deliveryId };
174
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
175
+ try {
176
+ const posted = await postWorkflowSignal({ serverUrl: input.serverUrl, definitionId: input.definitionId, signalSecret: input.signalSecret, runId: input.runId, signalKey: input.signalKey, status, summary, evidence: boundedEvidence, deliveryId, timeoutMs: Math.min(15_000, Math.max(1_000, input.intervalMs)), fetchImpl });
177
+ return { exitCode: status === "failed" && summary === "github_watch_timeout" ? 4 : 0, posted: true, duplicate: posted.duplicate, status, summary, evidence: boundedEvidence, deliveryId };
178
+ } catch (error) {
179
+ const message = error instanceof Error ? error.message : "signal_failed";
180
+ if (/signal_http_(404|409)\b/.test(message)) return { exitCode: 1, posted: false, summary: "workflow_not_waiting", evidence: boundedEvidence, deliveryId };
181
+ const transient = /signal_http_(429|5\d\d)\b/.test(message) || message === "signal_failed" || /abort|timeout|fetch/i.test(message);
182
+ if (!transient || attempt === 3) return { exitCode: 1, posted: false, summary: "signal_failed", evidence: boundedEvidence, deliveryId };
183
+ await sleep(Math.min(250 * (2 ** (attempt - 1)), 1_000));
184
+ }
185
+ }
186
+ return { exitCode: 1, posted: false, summary: "signal_failed", evidence: boundedEvidence, deliveryId };
187
+ };
188
+ const githubGet = async (url: string): Promise<Response | undefined> => {
189
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
190
+ const remaining = deadline - now();
191
+ if (remaining <= 0) return undefined;
192
+ try {
193
+ const response = await fetchWithTimeout(fetchImpl, url, { headers }, Math.min(15_000, remaining));
194
+ if (response.status !== 429 && response.status < 500) return response;
195
+ if (attempt === 3) return response;
196
+ } catch {
197
+ if (attempt === 3 || now() >= deadline) return undefined;
198
+ }
199
+ await sleep(Math.min(250 * (2 ** (attempt - 1)), Math.max(1, deadline - now())));
200
+ }
201
+ return undefined;
202
+ };
203
+ const prResponse = await githubGet(`https://api.github.com/repos/${input.repo}/pulls/${input.pr}`);
204
+ if (!prResponse) return deliver("failed", "github_watch_timeout", {});
205
+ if (!prResponse.ok) {
206
+ return { exitCode: 1, posted: false, summary: "github_pr_unavailable", evidence: { "github.http": String(prResponse.status) } };
207
+ }
208
+ const pull = await prResponse.json() as { head?: { sha?: string } };
209
+ const headSha = pull.head?.sha;
210
+ if (!headSha) {
211
+ return { exitCode: 1, posted: false, summary: "github_head_unavailable", evidence: {} };
212
+ }
213
+ if (!explicitDeliveryId) deliveryId = `github-watch:${deliveryGeneration}:${headSha.slice(0, 40)}`;
214
+ let lastEvidence: WorkflowEvidenceInput = {};
215
+ while (now() <= deadline) {
216
+ const checkRuns: GithubCheckRun[] = [];
217
+ for (let page = 1; page <= MAX_CHECK_RUN_PAGES; page += 1) {
218
+ const checksUrl = new URL(`https://api.github.com/repos/${input.repo}/commits/${headSha}/check-runs`);
219
+ checksUrl.searchParams.set("per_page", String(CHECK_RUNS_PER_PAGE));
220
+ checksUrl.searchParams.set("page", String(page));
221
+ const checksResponse = await githubGet(checksUrl.toString());
222
+ if (!checksResponse) return deliver("failed", "github_watch_timeout", lastEvidence);
223
+ if (!checksResponse.ok) {
224
+ return { exitCode: 1, posted: false, summary: "github_checks_unavailable", evidence: { "github.http": String(checksResponse.status) } };
225
+ }
226
+ const payload = await checksResponse.json() as { total_count?: number; check_runs?: GithubCheckRun[] };
227
+ const pageRuns = Array.isArray(payload.check_runs) ? payload.check_runs : [];
228
+ checkRuns.push(...pageRuns);
229
+ const totalCount = Number.isInteger(payload.total_count) && payload.total_count! >= 0
230
+ ? payload.total_count!
231
+ : undefined;
232
+ const complete = totalCount === undefined
233
+ ? pageRuns.length < CHECK_RUNS_PER_PAGE
234
+ : checkRuns.length >= totalCount;
235
+ if (complete) break;
236
+ if (pageRuns.length === 0 || page === MAX_CHECK_RUN_PAGES) {
237
+ return {
238
+ exitCode: 1,
239
+ posted: false,
240
+ summary: "github_checks_unavailable",
241
+ evidence: { "github.pagination": pageRuns.length === 0 ? "incomplete" : "limit_exceeded" },
242
+ };
243
+ }
244
+ }
245
+ const mapped = mapCheckConclusion(checkRuns, input.required ?? []);
246
+ lastEvidence = mapped.evidence;
247
+ if (mapped.status !== "pending") {
248
+ const summary = mapped.status === "passed" ? "required GitHub checks passed" : "required GitHub checks failed";
249
+ return deliver(mapped.status, summary, mapped.evidence);
250
+ }
251
+ if (now() + input.intervalMs > deadline) break;
252
+ await sleep(input.intervalMs);
253
+ }
254
+ return deliver("failed", "github_watch_timeout", lastEvidence);
255
+ }