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.
- package/README.md +36 -10
- package/dist/index.js +481 -209
- package/package.json +14 -4
- package/templates/.add-coder-src-hash.json +129 -106
- package/templates/adapters/claude/hooks/doc-format-guard.sh +114 -33
- package/templates/adapters/claude/hooks/lib/common.sh +46 -87
- package/templates/adapters/claude/hooks/lib/context-inject.sh +2 -2
- package/templates/adapters/claude/hooks/lib/notify.sh +6 -4
- package/templates/adapters/claude/hooks/lib/preload-templates.sh +27 -2
- package/templates/adapters/claude/hooks/lib/state-detect.sh +8 -112
- package/templates/adapters/claude/hooks/lib/vocabulary.sh +13 -7
- package/templates/adapters/claude/hooks/prompt-submit.sh +1 -1
- package/templates/adapters/claude/hooks/stop-check.sh +9 -0
- package/templates/adapters/codex/config.toml.example +12 -0
- package/templates/adapters/codex/hooks/doc-format-guard.sh +123 -41
- package/templates/adapters/codex/hooks/lib/common.sh +47 -99
- package/templates/adapters/codex/hooks/lib/context-inject.sh +4 -4
- package/templates/adapters/codex/hooks/lib/notify.sh +6 -4
- package/templates/adapters/codex/hooks/lib/preload-templates.sh +27 -6
- package/templates/adapters/codex/hooks/lib/state-detect.sh +5 -114
- package/templates/adapters/codex/hooks/lib/vocabulary.sh +3 -8
- package/templates/adapters/codex/hooks/notification.sh +3 -18
- package/templates/adapters/codex/hooks/post-tool-use.sh +24 -119
- package/templates/adapters/codex/hooks/pre-tool-use.sh +97 -184
- package/templates/adapters/codex/hooks/prompt-submit.sh +8 -3
- package/templates/adapters/codex/hooks/session-start.sh +19 -9
- package/templates/adapters/codex/hooks/stop-check.sh +44 -18
- package/templates/adapters/codex/hooks.json +67 -1
- package/templates/adapters/qoder/hooks/doc-format-guard.sh +121 -40
- package/templates/adapters/qoder/hooks/lib/common.sh +49 -87
- package/templates/adapters/qoder/hooks/lib/context-inject.sh +2 -2
- package/templates/adapters/qoder/hooks/lib/notify.sh +6 -4
- package/templates/adapters/qoder/hooks/lib/preload-templates.sh +27 -2
- package/templates/adapters/qoder/hooks/lib/state-detect.sh +8 -112
- package/templates/adapters/qoder/hooks/lib/vocabulary.sh +13 -7
- package/templates/adapters/qoder/hooks/prompt-submit.sh +1 -1
- package/templates/adapters/qoder/hooks/session-start.sh +1 -1
- package/templates/adapters/qoder/hooks/stop-check.sh +11 -0
- package/templates/adapters/trae/hooks/doc-format-guard.sh +122 -40
- package/templates/adapters/trae/hooks/lib/common.sh +46 -87
- package/templates/adapters/trae/hooks/lib/context-inject.sh +2 -2
- package/templates/adapters/trae/hooks/lib/notify.sh +5 -3
- package/templates/adapters/trae/hooks/lib/preload-templates.sh +27 -2
- package/templates/adapters/trae/hooks/lib/state-detect.sh +8 -115
- package/templates/adapters/trae/hooks/lib/vocabulary.sh +13 -7
- package/templates/adapters/trae/hooks/pre-tool-use.sh +14 -8
- package/templates/adapters/trae/hooks/stop-check.sh +9 -0
- package/templates/adapters/vscode/hooks/doc-format-guard.sh +114 -33
- package/templates/adapters/vscode/hooks/lib/common.sh +46 -87
- package/templates/adapters/vscode/hooks/lib/context-inject.sh +2 -2
- package/templates/adapters/vscode/hooks/lib/notify.sh +6 -4
- package/templates/adapters/vscode/hooks/lib/preload-templates.sh +27 -2
- package/templates/adapters/vscode/hooks/lib/state-detect.sh +8 -112
- package/templates/adapters/vscode/hooks/lib/vocabulary.sh +13 -7
- package/templates/adapters/vscode/hooks/notification.sh +3 -5
- package/templates/adapters/vscode/hooks/prompt-submit.sh +1 -1
- package/templates/adapters/vscode/hooks/stop-check.sh +9 -0
- package/templates/core/docs/ADD-governance-claude-code.md +1 -1
- package/templates/core/docs/ADD-governance-codex.md +1 -1
- package/templates/core/docs/ADD-governance-qoder-cn.md +1 -1
- package/templates/core/docs/ADD-governance-trae.md +1 -1
- package/templates/core/docs/ADD-governance-vscode-copilot.md +1 -1
- package/templates/core/hooks/doc-format-guard.sh +122 -40
- package/templates/core/hooks/lib/common.sh +46 -87
- package/templates/core/hooks/lib/context-inject.sh +2 -2
- package/templates/core/hooks/lib/notify.sh +5 -3
- package/templates/core/hooks/lib/preload-templates.sh +27 -2
- package/templates/core/hooks/lib/state-detect.sh +8 -115
- package/templates/core/hooks/lib/vocabulary.sh +13 -7
- package/templates/core/hooks/pre-tool-use.sh +14 -8
- package/templates/core/hooks/stop-check.sh +9 -0
- package/templates/core/prisma/add.prisma +140 -82
- package/templates/core/rules/project_rules.md +27 -24
- package/templates/core/scripts/db-ensure.sh +8 -0
- package/templates/core/scripts/gen-plan-index.sh +4 -15
- package/templates/core/scripts/mcp-server/notifications/hitl.ts +9 -1
- package/templates/core/scripts/mcp-server/notifications/hook.ts +25 -9
- package/templates/core/scripts/mcp-server/resources/hitl-approval-widget.ts +34 -0
- package/templates/core/scripts/mcp-server/resources/hook-events-report.ts +14 -2
- package/templates/core/scripts/mcp-server/resources/index.ts +2 -0
- package/templates/core/scripts/mcp-server/sampling/review.ts +25 -10
- package/templates/core/scripts/mcp-server/shared/db-types.ts +33 -8
- package/templates/core/scripts/mcp-server/shared/dps-scoring.strategy.ts +1 -1
- package/templates/core/scripts/mcp-server/shared/env.ts +28 -8
- package/templates/core/scripts/mcp-server/shared/fs.ts +3 -0
- package/templates/core/scripts/mcp-server/shared/hitl-interaction.strategy.ts +2 -3
- package/templates/core/scripts/mcp-server/shared/hitl-lifecycle-mutation.ts +129 -0
- package/templates/core/scripts/mcp-server/shared/hitl-proposal-mutation.ts +98 -0
- package/templates/core/scripts/mcp-server/shared/hitl-ui.ts +3 -0
- package/templates/core/scripts/mcp-server/shared/plan-lifecycle-events.ts +62 -0
- package/templates/core/scripts/mcp-server/shared/plan-lifecycle-mutation.ts +78 -0
- package/templates/core/scripts/mcp-server/shared/plan-lifecycle-subscriber.ts +145 -0
- package/templates/core/scripts/mcp-server/shared/plan-lifecycle.ts +153 -0
- package/templates/core/scripts/mcp-server/shared/plan-round-events.ts +56 -0
- package/templates/core/scripts/mcp-server/shared/plan-round-mutation.ts +143 -0
- package/templates/core/scripts/mcp-server/shared/plan-round-store.ts +47 -0
- package/templates/core/scripts/mcp-server/shared/plan-round-subscriber.ts +99 -0
- package/templates/core/scripts/mcp-server/shared/plan-status-store.ts +37 -0
- package/templates/core/scripts/mcp-server/shared/plan-tracking-mutation.ts +71 -0
- package/templates/core/scripts/mcp-server/shared/prisma.ts +12 -6
- package/templates/core/scripts/mcp-server/shared/project-root-strategy.ts +1 -2
- package/templates/core/scripts/mcp-server/shared/quant-recheck.strategy.ts +16 -0
- package/templates/core/scripts/mcp-server/shared/redact.ts +28 -0
- package/templates/core/scripts/mcp-server/shared/response.ts +3 -2
- package/templates/core/scripts/mcp-server/shared/runtime-context.ts +77 -0
- package/templates/core/scripts/mcp-server/shared/schema-topology.ts +159 -0
- package/templates/core/scripts/mcp-server/tools/audit.ts +49 -7
- package/templates/core/scripts/mcp-server/tools/context.ts +117 -121
- package/templates/core/scripts/mcp-server/tools/contract.ts +34 -9
- package/templates/core/scripts/mcp-server/tools/gateway/check_add_route_status.ts +50 -3
- package/templates/core/scripts/mcp-server/tools/gateway/check_doc_similarity.ts +218 -0
- package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +22 -7
- package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +75 -4
- package/templates/core/scripts/mcp-server/tools/gateway/check_spec_sync.ts +19 -6
- package/templates/core/scripts/mcp-server/tools/gateway/index.ts +2 -0
- package/templates/core/scripts/mcp-server/tools/hitl.ts +160 -75
- package/templates/core/scripts/mcp-server/tools/hook-event-report.ts +9 -2
- package/templates/core/scripts/mcp-server/tools/index.ts +69 -3
- package/templates/core/scripts/mcp-server/tools/plan.ts +140 -15
- package/templates/core/scripts/mcp-server/tools/review.ts +44 -15
- package/templates/core/scripts/mcp-server.ts +60 -1
- package/templates/core/scripts/plan-status-bridge.ts +26 -0
- package/templates/core/skills/add-paradigm/SKILL.md +2 -2
- package/templates/core/templates/add-route-template.schema.json +24 -56
- package/templates/core/templates/collab-contract-template.md +1 -1
- package/templates/core/templates/handoff-multi-round-template.schema.json +48 -0
- package/templates/core/templates/handoff-single-round-template.schema.json +92 -0
- package/templates/core/templates/handoff-single-round.schema.json +1 -1
- package/templates/core/templates/hitl-approval-widget.html +222 -37
- package/templates/core/templates/spec-template.schema.json +9 -46
- package/templates/core/templates/standard-plan-template.schema.json +1 -1
- 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
|
+
}
|