add-coder 0.3.26 → 0.3.27

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 (132) hide show
  1. package/README.md +36 -10
  2. package/dist/index.js +481 -209
  3. package/package.json +14 -4
  4. package/templates/.add-coder-src-hash.json +129 -106
  5. package/templates/adapters/claude/hooks/doc-format-guard.sh +112 -31
  6. package/templates/adapters/claude/hooks/lib/common.sh +46 -87
  7. package/templates/adapters/claude/hooks/lib/context-inject.sh +2 -2
  8. package/templates/adapters/claude/hooks/lib/notify.sh +6 -4
  9. package/templates/adapters/claude/hooks/lib/preload-templates.sh +27 -2
  10. package/templates/adapters/claude/hooks/lib/state-detect.sh +8 -112
  11. package/templates/adapters/claude/hooks/lib/vocabulary.sh +13 -7
  12. package/templates/adapters/claude/hooks/prompt-submit.sh +1 -1
  13. package/templates/adapters/claude/hooks/stop-check.sh +9 -0
  14. package/templates/adapters/codex/config.toml.example +12 -0
  15. package/templates/adapters/codex/hooks/doc-format-guard.sh +121 -39
  16. package/templates/adapters/codex/hooks/lib/common.sh +47 -99
  17. package/templates/adapters/codex/hooks/lib/context-inject.sh +4 -4
  18. package/templates/adapters/codex/hooks/lib/notify.sh +6 -4
  19. package/templates/adapters/codex/hooks/lib/preload-templates.sh +27 -6
  20. package/templates/adapters/codex/hooks/lib/state-detect.sh +5 -114
  21. package/templates/adapters/codex/hooks/lib/vocabulary.sh +3 -8
  22. package/templates/adapters/codex/hooks/notification.sh +3 -18
  23. package/templates/adapters/codex/hooks/post-tool-use.sh +24 -119
  24. package/templates/adapters/codex/hooks/pre-tool-use.sh +97 -184
  25. package/templates/adapters/codex/hooks/prompt-submit.sh +8 -3
  26. package/templates/adapters/codex/hooks/session-start.sh +19 -9
  27. package/templates/adapters/codex/hooks/stop-check.sh +44 -18
  28. package/templates/adapters/codex/hooks.json +67 -1
  29. package/templates/adapters/qoder/hooks/doc-format-guard.sh +119 -38
  30. package/templates/adapters/qoder/hooks/lib/common.sh +49 -87
  31. package/templates/adapters/qoder/hooks/lib/context-inject.sh +2 -2
  32. package/templates/adapters/qoder/hooks/lib/notify.sh +6 -4
  33. package/templates/adapters/qoder/hooks/lib/preload-templates.sh +27 -2
  34. package/templates/adapters/qoder/hooks/lib/state-detect.sh +8 -112
  35. package/templates/adapters/qoder/hooks/lib/vocabulary.sh +13 -7
  36. package/templates/adapters/qoder/hooks/prompt-submit.sh +1 -1
  37. package/templates/adapters/qoder/hooks/session-start.sh +1 -1
  38. package/templates/adapters/qoder/hooks/stop-check.sh +11 -0
  39. package/templates/adapters/trae/hooks/doc-format-guard.sh +120 -38
  40. package/templates/adapters/trae/hooks/lib/common.sh +46 -87
  41. package/templates/adapters/trae/hooks/lib/context-inject.sh +2 -2
  42. package/templates/adapters/trae/hooks/lib/notify.sh +5 -3
  43. package/templates/adapters/trae/hooks/lib/preload-templates.sh +27 -2
  44. package/templates/adapters/trae/hooks/lib/state-detect.sh +8 -115
  45. package/templates/adapters/trae/hooks/lib/vocabulary.sh +13 -7
  46. package/templates/adapters/trae/hooks/pre-tool-use.sh +14 -8
  47. package/templates/adapters/trae/hooks/stop-check.sh +9 -0
  48. package/templates/adapters/vscode/hooks/doc-format-guard.sh +112 -31
  49. package/templates/adapters/vscode/hooks/lib/common.sh +46 -87
  50. package/templates/adapters/vscode/hooks/lib/context-inject.sh +2 -2
  51. package/templates/adapters/vscode/hooks/lib/notify.sh +6 -4
  52. package/templates/adapters/vscode/hooks/lib/preload-templates.sh +27 -2
  53. package/templates/adapters/vscode/hooks/lib/state-detect.sh +8 -112
  54. package/templates/adapters/vscode/hooks/lib/vocabulary.sh +13 -7
  55. package/templates/adapters/vscode/hooks/notification.sh +3 -5
  56. package/templates/adapters/vscode/hooks/prompt-submit.sh +1 -1
  57. package/templates/adapters/vscode/hooks/stop-check.sh +9 -0
  58. package/templates/core/docs/ADD-governance-claude-code.md +1 -1
  59. package/templates/core/docs/ADD-governance-codex.md +1 -1
  60. package/templates/core/docs/ADD-governance-qoder-cn.md +1 -1
  61. package/templates/core/docs/ADD-governance-trae.md +1 -1
  62. package/templates/core/docs/ADD-governance-vscode-copilot.md +1 -1
  63. package/templates/core/hooks/doc-format-guard.sh +120 -38
  64. package/templates/core/hooks/lib/common.sh +46 -87
  65. package/templates/core/hooks/lib/context-inject.sh +2 -2
  66. package/templates/core/hooks/lib/notify.sh +5 -3
  67. package/templates/core/hooks/lib/preload-templates.sh +27 -2
  68. package/templates/core/hooks/lib/state-detect.sh +8 -115
  69. package/templates/core/hooks/lib/vocabulary.sh +13 -7
  70. package/templates/core/hooks/pre-tool-use.sh +14 -8
  71. package/templates/core/hooks/stop-check.sh +9 -0
  72. package/templates/core/prisma/add.prisma +140 -82
  73. package/templates/core/rules/project_rules.md +27 -24
  74. package/templates/core/scripts/db-ensure.sh +8 -0
  75. package/templates/core/scripts/gen-plan-index.sh +4 -15
  76. package/templates/core/scripts/mcp-server/notifications/hitl.ts +9 -1
  77. package/templates/core/scripts/mcp-server/notifications/hook.ts +25 -9
  78. package/templates/core/scripts/mcp-server/resources/hitl-approval-widget.ts +34 -0
  79. package/templates/core/scripts/mcp-server/resources/hook-events-report.ts +14 -2
  80. package/templates/core/scripts/mcp-server/resources/index.ts +2 -0
  81. package/templates/core/scripts/mcp-server/sampling/review.ts +25 -10
  82. package/templates/core/scripts/mcp-server/shared/db-types.ts +33 -8
  83. package/templates/core/scripts/mcp-server/shared/dps-scoring.strategy.ts +1 -1
  84. package/templates/core/scripts/mcp-server/shared/env.ts +28 -8
  85. package/templates/core/scripts/mcp-server/shared/fs.ts +3 -0
  86. package/templates/core/scripts/mcp-server/shared/hitl-interaction.strategy.ts +2 -3
  87. package/templates/core/scripts/mcp-server/shared/hitl-lifecycle-mutation.ts +129 -0
  88. package/templates/core/scripts/mcp-server/shared/hitl-proposal-mutation.ts +98 -0
  89. package/templates/core/scripts/mcp-server/shared/hitl-ui.ts +3 -0
  90. package/templates/core/scripts/mcp-server/shared/plan-lifecycle-events.ts +62 -0
  91. package/templates/core/scripts/mcp-server/shared/plan-lifecycle-mutation.ts +78 -0
  92. package/templates/core/scripts/mcp-server/shared/plan-lifecycle-subscriber.ts +145 -0
  93. package/templates/core/scripts/mcp-server/shared/plan-lifecycle.ts +153 -0
  94. package/templates/core/scripts/mcp-server/shared/plan-round-events.ts +56 -0
  95. package/templates/core/scripts/mcp-server/shared/plan-round-mutation.ts +143 -0
  96. package/templates/core/scripts/mcp-server/shared/plan-round-store.ts +47 -0
  97. package/templates/core/scripts/mcp-server/shared/plan-round-subscriber.ts +99 -0
  98. package/templates/core/scripts/mcp-server/shared/plan-status-store.ts +37 -0
  99. package/templates/core/scripts/mcp-server/shared/plan-tracking-mutation.ts +71 -0
  100. package/templates/core/scripts/mcp-server/shared/prisma.ts +12 -6
  101. package/templates/core/scripts/mcp-server/shared/project-root-strategy.ts +1 -2
  102. package/templates/core/scripts/mcp-server/shared/quant-recheck.strategy.ts +16 -0
  103. package/templates/core/scripts/mcp-server/shared/redact.ts +28 -0
  104. package/templates/core/scripts/mcp-server/shared/response.ts +3 -2
  105. package/templates/core/scripts/mcp-server/shared/runtime-context.ts +77 -0
  106. package/templates/core/scripts/mcp-server/shared/schema-topology.ts +159 -0
  107. package/templates/core/scripts/mcp-server/tools/audit.ts +49 -7
  108. package/templates/core/scripts/mcp-server/tools/context.ts +117 -121
  109. package/templates/core/scripts/mcp-server/tools/contract.ts +34 -9
  110. package/templates/core/scripts/mcp-server/tools/gateway/check_add_route_status.ts +50 -3
  111. package/templates/core/scripts/mcp-server/tools/gateway/check_doc_similarity.ts +218 -0
  112. package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +22 -7
  113. package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +75 -4
  114. package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +19 -6
  115. package/templates/core/scripts/mcp-server/tools/gateway/index.ts +2 -0
  116. package/templates/core/scripts/mcp-server/tools/hitl.ts +160 -75
  117. package/templates/core/scripts/mcp-server/tools/hook-event-report.ts +9 -2
  118. package/templates/core/scripts/mcp-server/tools/index.ts +69 -3
  119. package/templates/core/scripts/mcp-server/tools/plan.ts +140 -15
  120. package/templates/core/scripts/mcp-server/tools/review.ts +44 -15
  121. package/templates/core/scripts/mcp-server.ts +60 -1
  122. package/templates/core/scripts/plan-status-bridge.ts +26 -0
  123. package/templates/core/skills/add-paradigm/SKILL.md +2 -2
  124. package/templates/core/templates/add-route-template.schema.json +24 -56
  125. package/templates/core/templates/collab-contract-template.md +1 -1
  126. package/templates/core/templates/handoff-multi-round-template.schema.json +48 -0
  127. package/templates/core/templates/handoff-single-round-template.schema.json +92 -0
  128. package/templates/core/templates/handoff-single-round.schema.json +1 -1
  129. package/templates/core/templates/hitl-approval-widget.html +222 -37
  130. package/templates/core/templates/spec-template.schema.json +9 -46
  131. package/templates/core/templates/standard-plan-template.schema.json +1 -1
  132. package/templates/core/templates/tasks-template.schema.json +13 -54
@@ -0,0 +1,145 @@
1
+ import type { RuntimeContextKey } from "./runtime-context.js"
2
+ import type { PlanStatusResolution } from "./plan-lifecycle.js"
3
+ import {
4
+ isLifecycleEventForContext,
5
+ parsePlanLifecycleChangedEnvelope,
6
+ PLAN_LIFECYCLE_CHANNEL,
7
+ type PlanLifecycleChangedEnvelope,
8
+ } from "./plan-lifecycle-events.js"
9
+
10
+ export interface PgNotification {
11
+ channel: string
12
+ payload?: string
13
+ }
14
+
15
+ export interface LifecycleListenClient {
16
+ connect(): Promise<unknown>
17
+ query(sql: string): Promise<unknown>
18
+ end(): Promise<void>
19
+ on(event: "notification", listener: (message: PgNotification) => void): this
20
+ on(event: "error", listener: (error: Error) => void): this
21
+ on(event: "end", listener: () => void): this
22
+ removeAllListeners(): this
23
+ }
24
+
25
+ export type LifecycleListenClientFactory = () => LifecycleListenClient | Promise<LifecycleListenClient>
26
+
27
+ export interface PlanLifecycleSubscriberOptions {
28
+ context: RuntimeContextKey
29
+ clientFactory: LifecycleListenClientFactory
30
+ resolveStatus(input: {
31
+ context: RuntimeContextKey
32
+ planId?: string
33
+ reason: "initial" | "notification" | "reconnect"
34
+ }): Promise<PlanStatusResolution>
35
+ onSnapshot(snapshot: PlanStatusResolution, envelope?: PlanLifecycleChangedEnvelope): void | Promise<void>
36
+ onError?(error: Error): void
37
+ reconnectDelayMs?: number
38
+ }
39
+
40
+ export class PlanLifecycleSubscriber {
41
+ readonly #options: PlanLifecycleSubscriberOptions
42
+ #client: LifecycleListenClient | null = null
43
+ #stopping = false
44
+ #startedOnce = false
45
+ #reconnectTimer: ReturnType<typeof setTimeout> | null = null
46
+ #refreshQueue: Promise<void> = Promise.resolve()
47
+
48
+ constructor(options: PlanLifecycleSubscriberOptions) {
49
+ this.#options = options
50
+ }
51
+
52
+ async start(): Promise<void> {
53
+ if (this.#client) return
54
+ this.#stopping = false
55
+ try {
56
+ await this.#connectAndHydrate("initial")
57
+ } catch (error) {
58
+ this.#scheduleReconnect()
59
+ throw error
60
+ }
61
+ }
62
+
63
+ async stop(): Promise<void> {
64
+ this.#stopping = true
65
+ if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer)
66
+ this.#reconnectTimer = null
67
+ const client = this.#client
68
+ this.#client = null
69
+ if (!client) return
70
+ client.removeAllListeners()
71
+ try { await client.query(`UNLISTEN ${PLAN_LIFECYCLE_CHANNEL}`) } catch { /* connection may already be gone */ }
72
+ await client.end().catch(() => undefined)
73
+ await this.#refreshQueue
74
+ }
75
+
76
+ async #connectAndHydrate(reason: "initial" | "reconnect"): Promise<void> {
77
+ const client = await this.#options.clientFactory()
78
+ this.#client = client
79
+ client.on("notification", (message) => this.#handleNotification(message))
80
+ client.on("error", (error) => this.#handleDisconnect(client, error))
81
+ client.on("end", () => this.#handleDisconnect(client, new Error("PostgreSQL LISTEN session ended")))
82
+ try {
83
+ await client.connect()
84
+ await client.query(`LISTEN ${PLAN_LIFECYCLE_CHANNEL}`)
85
+ await this.#refresh(undefined, reason)
86
+ this.#startedOnce = true
87
+ } catch (error) {
88
+ if (this.#client === client) this.#client = null
89
+ client.removeAllListeners()
90
+ await client.end().catch(() => undefined)
91
+ const normalized = error instanceof Error ? error : new Error(String(error))
92
+ this.#options.onError?.(normalized)
93
+ throw normalized
94
+ }
95
+ }
96
+
97
+ #handleNotification(message: PgNotification): void {
98
+ if (this.#stopping || message.channel !== PLAN_LIFECYCLE_CHANNEL) return
99
+ const envelope = parsePlanLifecycleChangedEnvelope(message.payload)
100
+ if (!envelope || !isLifecycleEventForContext(envelope, this.#options.context)) return
101
+ this.#refreshQueue = this.#refreshQueue
102
+ .then(() => this.#refresh(envelope, "notification"))
103
+ .catch((error: unknown) => this.#options.onError?.(error instanceof Error ? error : new Error(String(error))))
104
+ }
105
+
106
+ #handleDisconnect(client: LifecycleListenClient, error: Error): void {
107
+ if (this.#stopping || this.#client !== client) return
108
+ this.#client = null
109
+ client.removeAllListeners()
110
+ this.#options.onError?.(error)
111
+ if (this.#reconnectTimer) return
112
+ this.#reconnectTimer = setTimeout(() => {
113
+ this.#reconnectTimer = null
114
+ if (this.#stopping) return
115
+ this.#connectAndHydrate(this.#startedOnce ? "reconnect" : "initial")
116
+ .catch(() => this.#scheduleReconnect())
117
+ }, this.#options.reconnectDelayMs ?? 1000)
118
+ }
119
+
120
+ #scheduleReconnect(): void {
121
+ if (this.#stopping || this.#reconnectTimer) return
122
+ this.#reconnectTimer = setTimeout(() => {
123
+ this.#reconnectTimer = null
124
+ this.#connectAndHydrate(this.#startedOnce ? "reconnect" : "initial")
125
+ .catch(() => this.#scheduleReconnect())
126
+ }, this.#options.reconnectDelayMs ?? 1000)
127
+ }
128
+
129
+ async #refresh(
130
+ envelope: PlanLifecycleChangedEnvelope | undefined,
131
+ reason: "initial" | "notification" | "reconnect",
132
+ ): Promise<void> {
133
+ const snapshot = await this.#options.resolveStatus({
134
+ context: this.#options.context,
135
+ planId: envelope?.planId,
136
+ reason,
137
+ })
138
+ await this.#options.onSnapshot(snapshot, envelope)
139
+ }
140
+ }
141
+
142
+ export async function createPgLifecycleListenClient(databaseUrl: string): Promise<LifecycleListenClient> {
143
+ const { Client } = await import("pg")
144
+ return new Client({ connectionString: databaseUrl })
145
+ }
@@ -0,0 +1,153 @@
1
+ import type { RuntimeContextKey } from "./runtime-context.js"
2
+
3
+ export const PLAN_LIFECYCLE_STATUSES = [
4
+ "DRAFT",
5
+ "ACTIVE",
6
+ "BLOCKED",
7
+ "REJECTED",
8
+ "CLOSED",
9
+ "ABANDONED",
10
+ ] as const
11
+
12
+ export type PlanLifecycleStatus = (typeof PLAN_LIFECYCLE_STATUSES)[number]
13
+ export type PlanApprovalStatus = "DRAFT" | "SUBMITTED" | "TONGYI" | "BOHUI"
14
+
15
+ export interface ScopedPlanStatusRow {
16
+ id: string
17
+ projectKey: string
18
+ adapterKey: string
19
+ planName: string
20
+ lifecycle: PlanLifecycleStatus
21
+ revision: number
22
+ doneTasks: number
23
+ totalTasks: number
24
+ checklistTDone: number
25
+ checklistT: number
26
+ }
27
+
28
+ export interface PlanStatusStore {
29
+ findPlan(input: {
30
+ context: RuntimeContextKey
31
+ planName?: string
32
+ planId?: string
33
+ activeOnly?: boolean
34
+ }): Promise<ScopedPlanStatusRow | null>
35
+ findLatestPlanApproval(input: {
36
+ context: RuntimeContextKey
37
+ planName: string
38
+ }): Promise<PlanApprovalStatus | null>
39
+ }
40
+
41
+ export interface PlanStatusSnapshot {
42
+ availability: "READY"
43
+ source: "database"
44
+ context: Pick<RuntimeContextKey, "projectKey" | "adapterKey" | "contextId">
45
+ planName: string
46
+ planId: string
47
+ lifecycle: PlanLifecycleStatus
48
+ revision: number
49
+ isActive: boolean
50
+ approvalStatus: PlanApprovalStatus | null
51
+ progress: {
52
+ doneTasks: number
53
+ totalTasks: number
54
+ checklistTDone: number
55
+ checklistT: number
56
+ }
57
+ }
58
+
59
+ export interface NoActivePlanSnapshot {
60
+ availability: "READY"
61
+ source: "database"
62
+ context: Pick<RuntimeContextKey, "projectKey" | "adapterKey" | "contextId">
63
+ planName: null
64
+ lifecycle: null
65
+ isActive: false
66
+ }
67
+
68
+ export interface PlanStatusUnavailable {
69
+ availability: "STATUS_UNAVAILABLE"
70
+ source: "database"
71
+ context: Pick<RuntimeContextKey, "projectKey" | "adapterKey" | "contextId">
72
+ reason: string
73
+ }
74
+
75
+ export type PlanStatusResolution = PlanStatusSnapshot | NoActivePlanSnapshot | PlanStatusUnavailable
76
+
77
+ const ALLOWED_TRANSITIONS: Readonly<Record<PlanLifecycleStatus, readonly PlanLifecycleStatus[]>> = {
78
+ DRAFT: ["ACTIVE", "REJECTED", "ABANDONED"],
79
+ ACTIVE: ["BLOCKED", "CLOSED", "ABANDONED"],
80
+ BLOCKED: ["ACTIVE", "CLOSED", "ABANDONED"],
81
+ REJECTED: ["DRAFT", "ACTIVE", "ABANDONED"],
82
+ CLOSED: [],
83
+ ABANDONED: [],
84
+ }
85
+
86
+ export function isActiveLifecycle(lifecycle: PlanLifecycleStatus): boolean {
87
+ return lifecycle === "ACTIVE" || lifecycle === "BLOCKED"
88
+ }
89
+
90
+ export function assertLifecycleTransition(from: PlanLifecycleStatus, to: PlanLifecycleStatus): void {
91
+ if (from === to) return
92
+ if (!ALLOWED_TRANSITIONS[from].includes(to)) {
93
+ throw new Error(`非法 Plan lifecycle 迁移: ${from} → ${to}`)
94
+ }
95
+ }
96
+
97
+ function snapshotContext(context: RuntimeContextKey) {
98
+ return { projectKey: context.projectKey, adapterKey: context.adapterKey, contextId: context.contextId }
99
+ }
100
+
101
+ export async function resolvePlanStatus(
102
+ store: PlanStatusStore,
103
+ context: RuntimeContextKey,
104
+ selector?: string | { planName?: string; planId?: string; activeOnly?: boolean },
105
+ ): Promise<PlanStatusResolution> {
106
+ try {
107
+ const selection = typeof selector === "string" ? { planName: selector } : (selector ?? {})
108
+ const plan = await store.findPlan({
109
+ context,
110
+ planName: selection.planName,
111
+ planId: selection.planId,
112
+ activeOnly: selection.activeOnly ?? (selection.planName === undefined && selection.planId === undefined),
113
+ })
114
+ if (!plan) {
115
+ return {
116
+ availability: "READY",
117
+ source: "database",
118
+ context: snapshotContext(context),
119
+ planName: null,
120
+ lifecycle: null,
121
+ isActive: false,
122
+ }
123
+ }
124
+ if (plan.projectKey !== context.projectKey || plan.adapterKey !== context.adapterKey) {
125
+ throw new Error("PlanStatusStore 返回了 scope 外记录")
126
+ }
127
+ const approvalStatus = await store.findLatestPlanApproval({ context, planName: plan.planName })
128
+ return {
129
+ availability: "READY",
130
+ source: "database",
131
+ context: snapshotContext(context),
132
+ planName: plan.planName,
133
+ planId: plan.id,
134
+ lifecycle: plan.lifecycle,
135
+ revision: plan.revision,
136
+ isActive: isActiveLifecycle(plan.lifecycle),
137
+ approvalStatus,
138
+ progress: {
139
+ doneTasks: plan.doneTasks,
140
+ totalTasks: plan.totalTasks,
141
+ checklistTDone: plan.checklistTDone,
142
+ checklistT: plan.checklistT,
143
+ },
144
+ }
145
+ } catch (error) {
146
+ return {
147
+ availability: "STATUS_UNAVAILABLE",
148
+ source: "database",
149
+ context: snapshotContext(context),
150
+ reason: error instanceof Error ? error.message : String(error),
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,56 @@
1
+ import { randomUUID } from "crypto"
2
+ import * as z from "zod/v4"
3
+ import type { RuntimeContextKey } from "./runtime-context.js"
4
+ import type { PrismaNotifyTransaction } from "./plan-lifecycle-events.js"
5
+
6
+ export const PLAN_ROUND_CHANNEL = "add_plan_round_changed_v1"
7
+
8
+ export const PlanRoundChangedEnvelopeSchema = z.object({
9
+ schemaVersion: z.literal(1),
10
+ eventId: z.string().min(1),
11
+ projectKey: z.string().min(1),
12
+ adapterKey: z.string().min(1),
13
+ planId: z.string().min(1),
14
+ })
15
+
16
+ export type PlanRoundChangedEnvelope = z.infer<typeof PlanRoundChangedEnvelopeSchema>
17
+
18
+ export function createPlanRoundChangedEnvelope(input: {
19
+ context: RuntimeContextKey
20
+ planId: string
21
+ eventId?: string
22
+ }): PlanRoundChangedEnvelope {
23
+ return {
24
+ schemaVersion: 1,
25
+ eventId: input.eventId ?? randomUUID(),
26
+ projectKey: input.context.projectKey,
27
+ adapterKey: input.context.adapterKey,
28
+ planId: input.planId,
29
+ }
30
+ }
31
+
32
+ export function parsePlanRoundChangedEnvelope(payload: string | undefined): PlanRoundChangedEnvelope | null {
33
+ if (!payload) return null
34
+ try {
35
+ const parsed: unknown = JSON.parse(payload)
36
+ const result = PlanRoundChangedEnvelopeSchema.safeParse(parsed)
37
+ return result.success ? result.data : null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ export function isPlanRoundEventForContext(
44
+ envelope: PlanRoundChangedEnvelope,
45
+ context: RuntimeContextKey,
46
+ ): boolean {
47
+ return envelope.projectKey === context.projectKey && envelope.adapterKey === context.adapterKey
48
+ }
49
+
50
+ export async function publishPlanRoundChanged(
51
+ tx: PrismaNotifyTransaction,
52
+ envelope: PlanRoundChangedEnvelope,
53
+ ): Promise<void> {
54
+ const payload = JSON.stringify(envelope)
55
+ await tx.$executeRaw`SELECT pg_notify(${PLAN_ROUND_CHANNEL}, ${payload})`
56
+ }
@@ -0,0 +1,143 @@
1
+ import { createHash } from "crypto"
2
+ import type { RuntimeContextKey } from "./runtime-context.js"
3
+ import {
4
+ createPlanRoundChangedEnvelope,
5
+ publishPlanRoundChanged,
6
+ } from "./plan-round-events.js"
7
+ import type { PrismaNotifyTransaction } from "./plan-lifecycle-events.js"
8
+
9
+ export interface PlanRoundPlanRow {
10
+ id: string
11
+ projectKey: string
12
+ adapterKey: string
13
+ planName: string
14
+ lifecycle: string
15
+ revision: number
16
+ }
17
+
18
+ export interface PlanRoundOperationRow {
19
+ id: string
20
+ projectKey: string
21
+ producerAdapterKey: string
22
+ contextId: string
23
+ toolName: string
24
+ operationKey: string
25
+ planKeyword: string
26
+ action: string
27
+ targetType: string
28
+ targetId: string
29
+ beforeState: unknown
30
+ afterState: unknown
31
+ reason: string | null
32
+ createdAt: Date
33
+ }
34
+
35
+ export interface PlanRoundTransaction extends PrismaNotifyTransaction {
36
+ planRecord: {
37
+ findFirst(args: { where: Record<string, unknown> }): Promise<PlanRoundPlanRow | null>
38
+ }
39
+ addUser: {
40
+ findUnique(args: { where: Record<string, unknown>; select: Record<string, unknown> }): Promise<{ id: string } | null>
41
+ create(args: { data: Record<string, unknown> }): Promise<{ id: string }>
42
+ }
43
+ devOperation: {
44
+ upsert(args: { where: Record<string, unknown>; create: Record<string, unknown>; update: Record<string, unknown> }): Promise<PlanRoundOperationRow>
45
+ }
46
+ }
47
+
48
+ export interface PlanRoundDatabase {
49
+ $transaction<T>(work: (tx: PlanRoundTransaction) => Promise<T>): Promise<T>
50
+ }
51
+
52
+ export interface ClosePlanRoundInput {
53
+ context: RuntimeContextKey
54
+ planName: string
55
+ round: number
56
+ beforeState: Record<string, unknown> | unknown[]
57
+ afterState: Record<string, unknown> | unknown[]
58
+ reason?: string
59
+ operationKey?: string
60
+ }
61
+
62
+ export interface ClosePlanRoundResult {
63
+ plan: PlanRoundPlanRow
64
+ operation: PlanRoundOperationRow
65
+ }
66
+
67
+ export function defaultPlanRoundOperationKey(input: {
68
+ context: RuntimeContextKey
69
+ planId: string
70
+ round: number
71
+ }): string {
72
+ return createHash("sha256")
73
+ .update(`${input.context.contextId}:${input.planId}:ROUND_CLOSED:${input.round}`)
74
+ .digest("hex")
75
+ }
76
+
77
+ export async function closePlanRoundAndPublish(
78
+ database: PlanRoundDatabase,
79
+ input: ClosePlanRoundInput,
80
+ ): Promise<ClosePlanRoundResult> {
81
+ if (!Number.isInteger(input.round) || input.round < 1) throw new Error("round 必须是大于 0 的整数")
82
+
83
+ return database.$transaction(async (tx) => {
84
+ const plan = await tx.planRecord.findFirst({
85
+ where: {
86
+ projectKey: input.context.projectKey,
87
+ adapterKey: input.context.adapterKey,
88
+ planName: input.planName,
89
+ },
90
+ })
91
+ if (!plan) throw new Error(`当前 RuntimeContextKey 下不存在 Plan: ${input.planName}`)
92
+ if (plan.projectKey !== input.context.projectKey || plan.adapterKey !== input.context.adapterKey) {
93
+ throw new Error("拒绝关闭 scope 外 PlanRound")
94
+ }
95
+
96
+ let user = await tx.addUser.findUnique({ where: { username: "ai-assistant" }, select: { id: true } })
97
+ if (!user) {
98
+ user = await tx.addUser.create({
99
+ data: { id: "ai-assistant", username: "ai-assistant", email: "ai-assistant@internal" },
100
+ })
101
+ }
102
+ const baseOperationKey = defaultPlanRoundOperationKey({
103
+ context: input.context,
104
+ planId: plan.id,
105
+ round: input.round,
106
+ })
107
+ const operationKey = input.operationKey?.trim()
108
+ ? createHash("sha256").update(`${baseOperationKey}:${input.operationKey.trim()}`).digest("hex")
109
+ : baseOperationKey
110
+ const operation = await tx.devOperation.upsert({
111
+ where: {
112
+ projectKey_producerAdapterKey_toolName_operationKey: {
113
+ projectKey: input.context.projectKey,
114
+ producerAdapterKey: input.context.adapterKey,
115
+ toolName: "plan_round_close",
116
+ operationKey,
117
+ },
118
+ },
119
+ create: {
120
+ userId: user.id,
121
+ projectKey: input.context.projectKey,
122
+ producerAdapterKey: input.context.adapterKey,
123
+ contextId: input.context.contextId,
124
+ toolName: "plan_round_close",
125
+ operationKey,
126
+ planKeyword: input.planName,
127
+ action: "ROUND_CLOSED",
128
+ targetType: "PLAN_ROUND",
129
+ targetId: `${input.planName}::round${input.round}`,
130
+ beforeState: input.beforeState,
131
+ afterState: input.afterState,
132
+ reason: input.reason ?? null,
133
+ },
134
+ update: {},
135
+ })
136
+
137
+ await publishPlanRoundChanged(tx, createPlanRoundChangedEnvelope({
138
+ context: input.context,
139
+ planId: plan.id,
140
+ }))
141
+ return { plan, operation }
142
+ })
143
+ }
@@ -0,0 +1,47 @@
1
+ import type { RuntimeContextKey } from "./runtime-context.js"
2
+ import type { PlanRoundOperationRow } from "./plan-round-mutation.js"
3
+
4
+ export interface PlanRoundReadClient {
5
+ planRecord: {
6
+ findFirst(args: { where: Record<string, unknown>; orderBy: Record<string, unknown> }): Promise<{ id: string; planName: string } | null>
7
+ }
8
+ devOperation: {
9
+ findMany(args: { where: Record<string, unknown>; orderBy: Record<string, unknown> }): Promise<PlanRoundOperationRow[]>
10
+ }
11
+ }
12
+
13
+ export interface PlanRoundSnapshot {
14
+ context: RuntimeContextKey
15
+ planId: string | null
16
+ planName: string | null
17
+ rounds: PlanRoundOperationRow[]
18
+ }
19
+
20
+ export async function queryPlanRounds(
21
+ client: PlanRoundReadClient,
22
+ input: { context: RuntimeContextKey; planName?: string; planId?: string; round?: number },
23
+ ): Promise<PlanRoundSnapshot> {
24
+ const explicitPlan = Boolean(input.planName || input.planId)
25
+ const plan = await client.planRecord.findFirst({
26
+ where: {
27
+ projectKey: input.context.projectKey,
28
+ adapterKey: input.context.adapterKey,
29
+ ...(input.planName ? { planName: input.planName } : {}),
30
+ ...(input.planId ? { id: input.planId } : {}),
31
+ ...(!explicitPlan ? { lifecycle: { in: ["ACTIVE", "BLOCKED"] } } : {}),
32
+ },
33
+ orderBy: { updatedAt: "desc" },
34
+ })
35
+ if (!plan) return { context: input.context, planId: null, planName: null, rounds: [] }
36
+
37
+ const where: Record<string, unknown> = {
38
+ projectKey: input.context.projectKey,
39
+ producerAdapterKey: input.context.adapterKey,
40
+ toolName: "plan_round_close",
41
+ action: "ROUND_CLOSED",
42
+ planKeyword: plan.planName,
43
+ }
44
+ if (input.round !== undefined) where.targetId = `${plan.planName}::round${input.round}`
45
+ const rounds = await client.devOperation.findMany({ where, orderBy: { createdAt: "asc" } })
46
+ return { context: input.context, planId: plan.id, planName: plan.planName, rounds }
47
+ }
@@ -0,0 +1,99 @@
1
+ import type { RuntimeContextKey } from "./runtime-context.js"
2
+ import type { LifecycleListenClient, LifecycleListenClientFactory, PgNotification } from "./plan-lifecycle-subscriber.js"
3
+ import type { PlanRoundSnapshot } from "./plan-round-store.js"
4
+ import {
5
+ isPlanRoundEventForContext,
6
+ parsePlanRoundChangedEnvelope,
7
+ PLAN_ROUND_CHANNEL,
8
+ type PlanRoundChangedEnvelope,
9
+ } from "./plan-round-events.js"
10
+
11
+ export interface PlanRoundSubscriberOptions {
12
+ context: RuntimeContextKey
13
+ clientFactory: LifecycleListenClientFactory
14
+ queryRounds(input: { context: RuntimeContextKey; planId?: string; reason: "initial" | "notification" | "reconnect" }): Promise<PlanRoundSnapshot>
15
+ onSnapshot(snapshot: PlanRoundSnapshot, envelope?: PlanRoundChangedEnvelope): void | Promise<void>
16
+ onError?(error: Error): void
17
+ reconnectDelayMs?: number
18
+ }
19
+
20
+ export class PlanRoundSubscriber {
21
+ readonly #options: PlanRoundSubscriberOptions
22
+ #client: LifecycleListenClient | null = null
23
+ #stopping = false
24
+ #startedOnce = false
25
+ #reconnectTimer: ReturnType<typeof setTimeout> | null = null
26
+ #refreshQueue: Promise<void> = Promise.resolve()
27
+
28
+ constructor(options: PlanRoundSubscriberOptions) { this.#options = options }
29
+
30
+ async start(): Promise<void> {
31
+ if (this.#client) return
32
+ this.#stopping = false
33
+ try { await this.#connectAndHydrate("initial") } catch (error) { this.#scheduleReconnect(); throw error }
34
+ }
35
+
36
+ async stop(): Promise<void> {
37
+ this.#stopping = true
38
+ if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer)
39
+ this.#reconnectTimer = null
40
+ const client = this.#client
41
+ this.#client = null
42
+ if (!client) return
43
+ client.removeAllListeners()
44
+ try { await client.query(`UNLISTEN ${PLAN_ROUND_CHANNEL}`) } catch { /* connection may already be gone */ }
45
+ await client.end().catch(() => undefined)
46
+ await this.#refreshQueue
47
+ }
48
+
49
+ async #connectAndHydrate(reason: "initial" | "reconnect"): Promise<void> {
50
+ const client = await this.#options.clientFactory()
51
+ this.#client = client
52
+ client.on("notification", (message: PgNotification) => this.#handleNotification(message))
53
+ client.on("error", (error: Error) => this.#handleDisconnect(client, error))
54
+ client.on("end", () => this.#handleDisconnect(client, new Error("PostgreSQL PlanRound LISTEN session ended")))
55
+ try {
56
+ await client.connect()
57
+ await client.query(`LISTEN ${PLAN_ROUND_CHANNEL}`)
58
+ await this.#refresh(undefined, reason)
59
+ this.#startedOnce = true
60
+ } catch (error) {
61
+ if (this.#client === client) this.#client = null
62
+ client.removeAllListeners()
63
+ await client.end().catch(() => undefined)
64
+ const normalized = error instanceof Error ? error : new Error(String(error))
65
+ this.#options.onError?.(normalized)
66
+ throw normalized
67
+ }
68
+ }
69
+
70
+ #handleNotification(message: PgNotification): void {
71
+ if (this.#stopping || message.channel !== PLAN_ROUND_CHANNEL) return
72
+ const envelope = parsePlanRoundChangedEnvelope(message.payload)
73
+ if (!envelope || !isPlanRoundEventForContext(envelope, this.#options.context)) return
74
+ this.#refreshQueue = this.#refreshQueue
75
+ .then(() => this.#refresh(envelope, "notification"))
76
+ .catch((error: unknown) => this.#options.onError?.(error instanceof Error ? error : new Error(String(error))))
77
+ }
78
+
79
+ #handleDisconnect(client: LifecycleListenClient, error: Error): void {
80
+ if (this.#stopping || this.#client !== client) return
81
+ this.#client = null
82
+ client.removeAllListeners()
83
+ this.#options.onError?.(error)
84
+ this.#scheduleReconnect()
85
+ }
86
+
87
+ #scheduleReconnect(): void {
88
+ if (this.#stopping || this.#reconnectTimer) return
89
+ this.#reconnectTimer = setTimeout(() => {
90
+ this.#reconnectTimer = null
91
+ this.#connectAndHydrate(this.#startedOnce ? "reconnect" : "initial").catch(() => this.#scheduleReconnect())
92
+ }, this.#options.reconnectDelayMs ?? 1000)
93
+ }
94
+
95
+ async #refresh(envelope: PlanRoundChangedEnvelope | undefined, reason: "initial" | "notification" | "reconnect"): Promise<void> {
96
+ const snapshot = await this.#options.queryRounds({ context: this.#options.context, planId: envelope?.planId, reason })
97
+ await this.#options.onSnapshot(snapshot, envelope)
98
+ }
99
+ }
@@ -0,0 +1,37 @@
1
+ import type { HitlRow, PlanRow } from "./db-types.js"
2
+ import { HitlRowSchema, PlanRowSchema, validatedDelegate } from "./db-types.js"
3
+ import type { PlanStatusStore } from "./plan-lifecycle.js"
4
+
5
+ /**
6
+ * 数据库是 lifecycle 真相源。所有查询都强制携带 RuntimeContextKey,禁止同名
7
+ * Plan 在 adapter 间串线;LISTEN/NOTIFY 只负责唤醒本 store 重新查询。
8
+ */
9
+ export function createPrismaPlanStatusStore(client: Record<string, unknown>): PlanStatusStore {
10
+ const plans = validatedDelegate<PlanRow>(client.planRecord, PlanRowSchema, "PlanRecord")
11
+ const hitls = validatedDelegate<HitlRow>(client.hitlRecord, HitlRowSchema, "HitlRecord")
12
+
13
+ return {
14
+ async findPlan({ context, planName, planId, activeOnly }) {
15
+ const where: Record<string, unknown> = {
16
+ projectKey: context.projectKey,
17
+ adapterKey: context.adapterKey,
18
+ }
19
+ if (planName) where.planName = planName
20
+ if (planId) where.id = planId
21
+ if (activeOnly) where.lifecycle = { in: ["ACTIVE", "BLOCKED"] }
22
+ return plans.findFirst({ where, orderBy: { updatedAt: "desc" } })
23
+ },
24
+ async findLatestPlanApproval({ context, planName }) {
25
+ const record = await hitls.findFirst({
26
+ where: {
27
+ projectKey: context.projectKey,
28
+ adapterKey: context.adapterKey,
29
+ planName,
30
+ type: "PLAN",
31
+ },
32
+ orderBy: { round: "desc" },
33
+ })
34
+ return record?.status ?? null
35
+ },
36
+ }
37
+ }