add-coder 0.3.25 → 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 +114 -33
  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 +123 -41
  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 +121 -40
  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 +122 -40
  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 +114 -33
  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 +122 -40
  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,62 @@
1
+ import { randomUUID } from "crypto"
2
+ import * as z from "zod/v4"
3
+ import type { RuntimeContextKey } from "./runtime-context.js"
4
+
5
+ export const PLAN_LIFECYCLE_CHANNEL = "add_plan_lifecycle_changed_v1"
6
+
7
+ export const PlanLifecycleChangedEnvelopeSchema = z.object({
8
+ schemaVersion: z.literal(1),
9
+ eventId: z.string().min(1),
10
+ projectKey: z.string().min(1),
11
+ adapterKey: z.string().min(1),
12
+ planId: z.string().min(1),
13
+ revision: z.number().int().nonnegative(),
14
+ })
15
+
16
+ export type PlanLifecycleChangedEnvelope = z.infer<typeof PlanLifecycleChangedEnvelopeSchema>
17
+
18
+ export interface PrismaNotifyTransaction {
19
+ $executeRaw(query: TemplateStringsArray, ...values: unknown[]): Promise<number>
20
+ }
21
+
22
+ export function createPlanLifecycleChangedEnvelope(input: {
23
+ context: RuntimeContextKey
24
+ planId: string
25
+ revision: number
26
+ eventId?: string
27
+ }): PlanLifecycleChangedEnvelope {
28
+ return {
29
+ schemaVersion: 1,
30
+ eventId: input.eventId ?? randomUUID(),
31
+ projectKey: input.context.projectKey,
32
+ adapterKey: input.context.adapterKey,
33
+ planId: input.planId,
34
+ revision: input.revision,
35
+ }
36
+ }
37
+
38
+ export function parsePlanLifecycleChangedEnvelope(payload: string | undefined): PlanLifecycleChangedEnvelope | null {
39
+ if (!payload) return null
40
+ try {
41
+ const parsed: unknown = JSON.parse(payload)
42
+ const result = PlanLifecycleChangedEnvelopeSchema.safeParse(parsed)
43
+ return result.success ? result.data : null
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ export function isLifecycleEventForContext(
50
+ envelope: PlanLifecycleChangedEnvelope,
51
+ context: RuntimeContextKey,
52
+ ): boolean {
53
+ return envelope.projectKey === context.projectKey && envelope.adapterKey === context.adapterKey
54
+ }
55
+
56
+ export async function publishPlanLifecycleChanged(
57
+ tx: PrismaNotifyTransaction,
58
+ envelope: PlanLifecycleChangedEnvelope,
59
+ ): Promise<void> {
60
+ const payload = JSON.stringify(envelope)
61
+ await tx.$executeRaw`SELECT pg_notify(${PLAN_LIFECYCLE_CHANNEL}, ${payload})`
62
+ }
@@ -0,0 +1,78 @@
1
+ import type { RuntimeContextKey } from "./runtime-context.js"
2
+ import { assertLifecycleTransition, type PlanLifecycleStatus } from "./plan-lifecycle.js"
3
+ import {
4
+ createPlanLifecycleChangedEnvelope,
5
+ publishPlanLifecycleChanged,
6
+ type PrismaNotifyTransaction,
7
+ } from "./plan-lifecycle-events.js"
8
+
9
+ export interface PlanLifecycleMutationRow {
10
+ id: string
11
+ projectKey: string
12
+ adapterKey: string
13
+ planName: string
14
+ lifecycle: PlanLifecycleStatus
15
+ revision: number
16
+ }
17
+
18
+ export interface PlanLifecycleTransaction extends PrismaNotifyTransaction {
19
+ planRecord: {
20
+ findFirst(args: { where: Record<string, unknown> }): Promise<PlanLifecycleMutationRow | null>
21
+ update(args: {
22
+ where: Record<string, unknown>
23
+ data: { lifecycle: PlanLifecycleStatus; revision: { increment: 1 } }
24
+ }): Promise<PlanLifecycleMutationRow>
25
+ }
26
+ }
27
+
28
+ export interface PlanLifecycleDatabase {
29
+ $transaction<T>(work: (tx: PlanLifecycleTransaction) => Promise<T>): Promise<T>
30
+ }
31
+
32
+ export interface TransitionPlanLifecycleInput {
33
+ context: RuntimeContextKey
34
+ planName: string
35
+ to: PlanLifecycleStatus
36
+ expectedRevision?: number
37
+ }
38
+
39
+ export async function transitionPlanLifecycle(
40
+ database: PlanLifecycleDatabase,
41
+ input: TransitionPlanLifecycleInput,
42
+ ): Promise<PlanLifecycleMutationRow> {
43
+ return database.$transaction(async (tx) => {
44
+ const current = await tx.planRecord.findFirst({
45
+ where: {
46
+ projectKey: input.context.projectKey,
47
+ adapterKey: input.context.adapterKey,
48
+ planName: input.planName,
49
+ },
50
+ })
51
+ if (!current) {
52
+ throw new Error(`当前 RuntimeContextKey 下不存在 Plan: ${input.planName}`)
53
+ }
54
+ if (current.projectKey !== input.context.projectKey || current.adapterKey !== input.context.adapterKey) {
55
+ throw new Error("拒绝迁移 scope 外 Plan lifecycle")
56
+ }
57
+ if (input.expectedRevision !== undefined && current.revision !== input.expectedRevision) {
58
+ throw new Error(`Plan lifecycle revision 冲突: expected=${input.expectedRevision}, actual=${current.revision}`)
59
+ }
60
+ assertLifecycleTransition(current.lifecycle, input.to)
61
+ if (current.lifecycle === input.to) return current
62
+
63
+ const updated = await tx.planRecord.update({
64
+ where: { id: current.id, revision: current.revision },
65
+ data: { lifecycle: input.to, revision: { increment: 1 } },
66
+ })
67
+ if (updated.revision !== current.revision + 1) {
68
+ throw new Error(`Plan lifecycle revision 未按预期递增: ${current.revision} → ${updated.revision}`)
69
+ }
70
+ const envelope = createPlanLifecycleChangedEnvelope({
71
+ context: input.context,
72
+ planId: updated.id,
73
+ revision: updated.revision,
74
+ })
75
+ await publishPlanLifecycleChanged(tx, envelope)
76
+ return updated
77
+ })
78
+ }
@@ -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
+ }