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,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
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { RuntimeContextKey } from "./runtime-context.js"
|
|
2
|
+
import {
|
|
3
|
+
createPlanLifecycleChangedEnvelope,
|
|
4
|
+
publishPlanLifecycleChanged,
|
|
5
|
+
type PrismaNotifyTransaction,
|
|
6
|
+
} from "./plan-lifecycle-events.js"
|
|
7
|
+
import type { PlanLifecycleStatus } from "./plan-lifecycle.js"
|
|
8
|
+
|
|
9
|
+
export interface TrackedPlanRow {
|
|
10
|
+
id: string
|
|
11
|
+
projectKey: string
|
|
12
|
+
adapterKey: string
|
|
13
|
+
planName: string
|
|
14
|
+
lifecycle: PlanLifecycleStatus
|
|
15
|
+
revision: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PlanTrackingTransaction extends PrismaNotifyTransaction {
|
|
19
|
+
planRecord: {
|
|
20
|
+
findFirst(args: { where: Record<string, unknown> }): Promise<TrackedPlanRow | null>
|
|
21
|
+
create(args: { data: Record<string, unknown> }): Promise<TrackedPlanRow>
|
|
22
|
+
update(args: { where: Record<string, unknown>; data: Record<string, unknown> }): Promise<TrackedPlanRow>
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PlanTrackingDatabase {
|
|
27
|
+
$transaction<T>(work: (tx: PlanTrackingTransaction) => Promise<T>): Promise<T>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function trackPlanAndPublish(
|
|
31
|
+
database: PlanTrackingDatabase,
|
|
32
|
+
input: {
|
|
33
|
+
context: RuntimeContextKey
|
|
34
|
+
planName: string
|
|
35
|
+
planPath: string
|
|
36
|
+
projection: Record<string, unknown>
|
|
37
|
+
},
|
|
38
|
+
): Promise<{ created: boolean; plan: TrackedPlanRow }> {
|
|
39
|
+
return database.$transaction(async (tx) => {
|
|
40
|
+
const scope = {
|
|
41
|
+
projectKey: input.context.projectKey,
|
|
42
|
+
adapterKey: input.context.adapterKey,
|
|
43
|
+
planName: input.planName,
|
|
44
|
+
}
|
|
45
|
+
const existing = await tx.planRecord.findFirst({ where: scope })
|
|
46
|
+
const plan = existing
|
|
47
|
+
? await tx.planRecord.update({
|
|
48
|
+
where: { id: existing.id, revision: existing.revision },
|
|
49
|
+
data: { ...input.projection, planPath: input.planPath, revision: { increment: 1 } },
|
|
50
|
+
})
|
|
51
|
+
: await tx.planRecord.create({
|
|
52
|
+
data: {
|
|
53
|
+
...scope,
|
|
54
|
+
...input.projection,
|
|
55
|
+
planPath: input.planPath,
|
|
56
|
+
lifecycle: "DRAFT",
|
|
57
|
+
revision: 0,
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
const expectedRevision = existing ? existing.revision + 1 : 0
|
|
61
|
+
if (plan.revision !== expectedRevision) {
|
|
62
|
+
throw new Error(`Plan track revision 未按预期变化: expected=${expectedRevision}, actual=${plan.revision}`)
|
|
63
|
+
}
|
|
64
|
+
await publishPlanLifecycleChanged(tx, createPlanLifecycleChangedEnvelope({
|
|
65
|
+
context: input.context,
|
|
66
|
+
planId: plan.id,
|
|
67
|
+
revision: plan.revision,
|
|
68
|
+
}))
|
|
69
|
+
return { created: existing === null, plan }
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { DATABASE_URL, PROJECT_ROOT } from "./env.js"
|
|
2
|
-
import { join, dirname
|
|
1
|
+
import { DATABASE_URL, PROJECT_ROOT, MAGIC_DIR } from "./env.js"
|
|
2
|
+
import { join, dirname } from "path"
|
|
3
3
|
import { existsSync } from "fs"
|
|
4
|
-
import { fileURLToPath } from "url"
|
|
5
4
|
import { createRequire } from "module"
|
|
5
|
+
import { findUpSync } from "find-up"
|
|
6
6
|
|
|
7
7
|
const require = createRequire(import.meta.url)
|
|
8
8
|
|
|
@@ -11,14 +11,20 @@ const require = createRequire(import.meta.url)
|
|
|
11
11
|
// 双库分离项目(add.prisma=postgres + 业务 schema=mysql)需显式设置环境变量
|
|
12
12
|
// PRISMA_CLIENT_DIR=add-prisma,否则会加载业务库 client 导致 provider mismatch
|
|
13
13
|
const clientDir = process.env.PRISMA_CLIENT_DIR || "prisma"
|
|
14
|
+
// 锚点查找兜底(find-up 包):magicDir 向上查找项目根,替代手算层级(轮次 3)
|
|
15
|
+
const anchorRoot = (() => {
|
|
16
|
+
const hit = findUpSync(MAGIC_DIR, { cwd: import.meta.dirname, type: "directory" });
|
|
17
|
+
return hit ? dirname(hit) : null;
|
|
18
|
+
})();
|
|
14
19
|
const candidates = [
|
|
15
20
|
join(PROJECT_ROOT, `src/generated/${clientDir}/client.ts`),
|
|
16
21
|
join(PROJECT_ROOT, `src/generated/${clientDir}/client.js`),
|
|
17
22
|
join(process.cwd(), `src/generated/${clientDir}/client.ts`),
|
|
18
23
|
join(process.cwd(), `src/generated/${clientDir}/client.js`),
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
...(anchorRoot ? [
|
|
25
|
+
join(anchorRoot, `src/generated/${clientDir}/client.ts`),
|
|
26
|
+
join(anchorRoot, `src/generated/${clientDir}/client.js`),
|
|
27
|
+
] : []),
|
|
22
28
|
]
|
|
23
29
|
// 双库分离项目提示:检测到 add-prisma 客户端但未显式选择时提醒用户自行决策
|
|
24
30
|
if (clientDir === "prisma" && existsSync(join(PROJECT_ROOT, "src/generated/add-prisma/client.ts"))) {
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// >>> CAIJUE GENERATED START >>>
|
|
5
5
|
export const PROJECT_ROOT_PRIORITY = ["env_var", "dirname_fallback", "cwd_fallback"] as const;
|
|
6
6
|
export type ProjectRootTier = (typeof PROJECT_ROOT_PRIORITY)[number];
|
|
7
|
-
// <<< CAIJUE GENERATED END
|
|
8
|
-
// >>> USER CODE >>>
|
|
7
|
+
// <<< CAIJUE GENERATED END <<<// >>> USER CODE >>>
|
|
9
8
|
import { resolve, dirname } from "path"
|
|
10
9
|
import { existsSync } from "fs"
|
|
11
10
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// ⚠️ 由 caijuehub/transcribe.ts 自动生成,不要手动编辑!
|
|
2
|
+
// 改 *-rules.toml 后重新运行: add-coder generate
|
|
3
|
+
|
|
4
|
+
// >>> CAIJUE GENERATED START >>>
|
|
5
|
+
export const QUANT_RECHECK_CONFIG = {
|
|
6
|
+
ANCHOR_WEIGHT: 3,
|
|
7
|
+
IDF_MIN_DOCS: 5,
|
|
8
|
+
RRF_K: 10,
|
|
9
|
+
MIN_ABS_SIMILARITY: 0.25,
|
|
10
|
+
RRF_MIN_CANDIDATES: 2,
|
|
11
|
+
EWMA_LAMBDA: 0.3,
|
|
12
|
+
EWMA_K: 1.5,
|
|
13
|
+
EWMA_COLD_START: 10,
|
|
14
|
+
EWMA_BASELINE: 0.16,
|
|
15
|
+
} as const;
|
|
16
|
+
// <<< CAIJUE GENERATED END <<<
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @Author : xiaomingming wujixmm@gmail.com
|
|
3
|
+
* @Date : 2026-08-10 18:41:34
|
|
4
|
+
* @LastEditors : xiaomingming wujixmm@gmail.com
|
|
5
|
+
* @LastEditTime : 2026-08-10 18:41:35
|
|
6
|
+
* @FilePath : /farm-agent/home/xmm/ai/add-coder/templates/core/scripts/mcp-server/shared/redact.ts
|
|
7
|
+
* @Description :
|
|
8
|
+
*/
|
|
9
|
+
// 敏感信息脱敏(进程层并发契约 v2:日志/错误不泄露 DATABASE_URL/密码)
|
|
10
|
+
// 统一入口:response.ts 的 textResponse/errorResponse 内部调用,一处封装全覆盖所有工具
|
|
11
|
+
|
|
12
|
+
const CONNECTION_URL_RE =
|
|
13
|
+
/(postgres(?:ql)?|mysql|redis|mongodb(?:\+srv)?):\/\/[^\s"'`]+/gi
|
|
14
|
+
|
|
15
|
+
export function redactUrl(url: string): string {
|
|
16
|
+
// postgres://user:pass@host:port/db → postgres://user:****@host:port/db
|
|
17
|
+
return url.replace(/(:\/\/[^:/\s]+:)([^@/\s]+)(@)/, "$1****$3")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function redact(text: string): string {
|
|
21
|
+
return text.replace(CONNECTION_URL_RE, (m) => {
|
|
22
|
+
try {
|
|
23
|
+
return redactUrl(m)
|
|
24
|
+
} catch {
|
|
25
|
+
return m
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ToolResponse } from "../types.js"
|
|
2
|
+
import { redact } from "./redact.js"
|
|
2
3
|
|
|
3
4
|
export function textResponse(text: string): { content: ToolResponse } {
|
|
4
|
-
return { content: [{ type: "text", text }] }
|
|
5
|
+
return { content: [{ type: "text", text: redact(text) }] }
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export function errorResponse(message: string): { content: ToolResponse; isError: boolean } {
|
|
8
|
-
return { content: [{ type: "text", text: message }], isError: true }
|
|
9
|
+
return { content: [{ type: "text", text: redact(message) }], isError: true }
|
|
9
10
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from "crypto"
|
|
2
|
+
import { realpathSync } from "fs"
|
|
3
|
+
import { isAbsolute, relative, resolve } from "path"
|
|
4
|
+
|
|
5
|
+
export const ADAPTER_MAGIC_DIR = Object.freeze({
|
|
6
|
+
add: ".add",
|
|
7
|
+
claude: ".claude",
|
|
8
|
+
codex: ".codex",
|
|
9
|
+
qoder: ".qoder",
|
|
10
|
+
trae: ".trae",
|
|
11
|
+
vscode: ".vscode",
|
|
12
|
+
} as const)
|
|
13
|
+
|
|
14
|
+
export type AdapterKey = keyof typeof ADAPTER_MAGIC_DIR
|
|
15
|
+
export type AdapterMagicDir = (typeof ADAPTER_MAGIC_DIR)[AdapterKey]
|
|
16
|
+
|
|
17
|
+
export interface RuntimeContextKey {
|
|
18
|
+
projectKey: string
|
|
19
|
+
adapterKey: AdapterKey
|
|
20
|
+
projectRoot: string
|
|
21
|
+
magicDir: AdapterMagicDir
|
|
22
|
+
contextId: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAGIC_DIR_ADAPTER = new Map<AdapterMagicDir, AdapterKey>(
|
|
26
|
+
Object.entries(ADAPTER_MAGIC_DIR).map(([adapterKey, magicDir]) => [magicDir, adapterKey as AdapterKey]),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
export function adapterKeyFromMagicDir(magicDir: string): AdapterKey {
|
|
30
|
+
const adapterKey = MAGIC_DIR_ADAPTER.get(magicDir as AdapterMagicDir)
|
|
31
|
+
if (!adapterKey) {
|
|
32
|
+
throw new Error(`未知 ADD adapter magicDir: ${magicDir}`)
|
|
33
|
+
}
|
|
34
|
+
return adapterKey
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function canonicalProjectRoot(projectRoot: string): string {
|
|
38
|
+
if (!projectRoot.trim()) throw new Error("PROJECT_ROOT 不能为空")
|
|
39
|
+
return realpathSync.native(resolve(projectRoot))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function projectKeyFromRoot(projectRoot: string): string {
|
|
43
|
+
const canonicalRoot = canonicalProjectRoot(projectRoot)
|
|
44
|
+
return createHash("sha256").update(`add-project\0${canonicalRoot}`).digest("hex")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createRuntimeContext(projectRoot: string, magicDir: string): Readonly<RuntimeContextKey> {
|
|
48
|
+
const adapterKey = adapterKeyFromMagicDir(magicDir)
|
|
49
|
+
const canonicalRoot = canonicalProjectRoot(projectRoot)
|
|
50
|
+
const projectKey = projectKeyFromRoot(canonicalRoot)
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
projectKey,
|
|
53
|
+
adapterKey,
|
|
54
|
+
projectRoot: canonicalRoot,
|
|
55
|
+
magicDir: ADAPTER_MAGIC_DIR[adapterKey],
|
|
56
|
+
contextId: `${projectKey}:${adapterKey}`,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function runtimeScopeRoot(context: RuntimeContextKey): string {
|
|
61
|
+
return resolve(context.projectRoot, context.magicDir)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isPathInRuntimeScope(context: RuntimeContextKey, candidatePath: string): boolean {
|
|
65
|
+
const scopeRoot = runtimeScopeRoot(context)
|
|
66
|
+
const absoluteCandidate = isAbsolute(candidatePath) ? resolve(candidatePath) : resolve(context.projectRoot, candidatePath)
|
|
67
|
+
const scopedRelative = relative(scopeRoot, absoluteCandidate)
|
|
68
|
+
return scopedRelative === "" || (!scopedRelative.startsWith("..") && !isAbsolute(scopedRelative))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function assertPathInRuntimeScope(context: RuntimeContextKey, candidatePath: string): string {
|
|
72
|
+
const absoluteCandidate = isAbsolute(candidatePath) ? resolve(candidatePath) : resolve(context.projectRoot, candidatePath)
|
|
73
|
+
if (!isPathInRuntimeScope(context, absoluteCandidate)) {
|
|
74
|
+
throw new Error(`路径越出当前 ADD adapter scope: ${absoluteCandidate}(scope=${runtimeScopeRoot(context)})`)
|
|
75
|
+
}
|
|
76
|
+
return absoluteCandidate
|
|
77
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readdir, readFile } from "fs/promises"
|
|
2
|
+
import { basename, join, relative } from "path"
|
|
3
|
+
|
|
4
|
+
export type SchemaView = "business" | "add" | "all"
|
|
5
|
+
export type SchemaTopologyMode = "single" | "split"
|
|
6
|
+
export type SchemaBoundary = "shared" | "business" | "add"
|
|
7
|
+
|
|
8
|
+
export interface PrismaDeclaration {
|
|
9
|
+
kind: "model" | "enum"
|
|
10
|
+
name: string
|
|
11
|
+
sourcePath: string
|
|
12
|
+
sourceFile: string
|
|
13
|
+
boundary: SchemaBoundary
|
|
14
|
+
body: string
|
|
15
|
+
fieldCount: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PrismaSchemaFile {
|
|
19
|
+
path: string
|
|
20
|
+
file: string
|
|
21
|
+
boundary: SchemaBoundary
|
|
22
|
+
declarations: PrismaDeclaration[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PrismaSchemaTopology {
|
|
26
|
+
mode: SchemaTopologyMode
|
|
27
|
+
view: SchemaView
|
|
28
|
+
schemaRoot: string
|
|
29
|
+
files: PrismaSchemaFile[]
|
|
30
|
+
models: PrismaDeclaration[]
|
|
31
|
+
enums: PrismaDeclaration[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractBlock(source: string, braceIndex: number): string {
|
|
35
|
+
let depth = 1
|
|
36
|
+
let cursor = braceIndex + 1
|
|
37
|
+
while (depth > 0 && cursor < source.length) {
|
|
38
|
+
if (source[cursor] === "{") depth += 1
|
|
39
|
+
if (source[cursor] === "}") depth -= 1
|
|
40
|
+
cursor += 1
|
|
41
|
+
}
|
|
42
|
+
if (depth !== 0) throw new Error("Prisma declaration 花括号未闭合")
|
|
43
|
+
return source.slice(braceIndex, cursor)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parsePrismaDeclarations(
|
|
47
|
+
source: string,
|
|
48
|
+
sourcePath: string,
|
|
49
|
+
boundary: SchemaBoundary,
|
|
50
|
+
): PrismaDeclaration[] {
|
|
51
|
+
const declarations: PrismaDeclaration[] = []
|
|
52
|
+
const declarationRegex = /^\s*(model|enum)\s+(\w+)\s*\{/gm
|
|
53
|
+
let match: RegExpExecArray | null
|
|
54
|
+
while ((match = declarationRegex.exec(source)) !== null) {
|
|
55
|
+
const braceIndex = source.indexOf("{", match.index)
|
|
56
|
+
const body = extractBlock(source, braceIndex)
|
|
57
|
+
const kind = match[1] as "model" | "enum"
|
|
58
|
+
declarations.push({
|
|
59
|
+
kind,
|
|
60
|
+
name: match[2],
|
|
61
|
+
sourcePath,
|
|
62
|
+
sourceFile: basename(sourcePath),
|
|
63
|
+
boundary,
|
|
64
|
+
body,
|
|
65
|
+
fieldCount: kind === "model"
|
|
66
|
+
? body.split("\n").filter(line => {
|
|
67
|
+
const trimmed = line.trim()
|
|
68
|
+
return trimmed.length > 0 && trimmed !== "{" && trimmed !== "}" && !trimmed.startsWith("//") && !trimmed.startsWith("@@")
|
|
69
|
+
}).length
|
|
70
|
+
: 0,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
return declarations
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function boundaryFor(file: string, mode: SchemaTopologyMode): SchemaBoundary {
|
|
77
|
+
if (mode === "single") return "shared"
|
|
78
|
+
return file === "add.prisma" ? "add" : "business"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function includedInView(file: string, view: SchemaView): boolean {
|
|
82
|
+
if (view === "all") return true
|
|
83
|
+
return view === "add" ? file === "add.prisma" : file !== "add.prisma"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function loadPrismaSchemaTopology(input: {
|
|
87
|
+
projectRoot: string
|
|
88
|
+
splitDatabase: boolean
|
|
89
|
+
view?: SchemaView
|
|
90
|
+
}): Promise<PrismaSchemaTopology> {
|
|
91
|
+
const schemaRoot = join(input.projectRoot, "prisma")
|
|
92
|
+
const mode: SchemaTopologyMode = input.splitDatabase ? "split" : "single"
|
|
93
|
+
const view = input.view ?? "all"
|
|
94
|
+
let names: string[]
|
|
95
|
+
try {
|
|
96
|
+
names = (await readdir(schemaRoot)).filter(file => file.endsWith(".prisma")).sort()
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(`未找到 Prisma schema 目录: ${schemaRoot}`)
|
|
99
|
+
}
|
|
100
|
+
if (names.length === 0) throw new Error(`Prisma schema 目录中没有 *.prisma: ${schemaRoot}`)
|
|
101
|
+
|
|
102
|
+
const selected = names.filter(file => includedInView(file, view))
|
|
103
|
+
if (selected.length === 0) {
|
|
104
|
+
throw new Error(`Schema view=${view} 没有可扫描文件;mode=${mode};全部文件=${names.join(", ")}`)
|
|
105
|
+
}
|
|
106
|
+
const files: PrismaSchemaFile[] = []
|
|
107
|
+
for (const file of selected) {
|
|
108
|
+
const path = join(schemaRoot, file)
|
|
109
|
+
const boundary = boundaryFor(file, mode)
|
|
110
|
+
const source = await readFile(path, "utf8")
|
|
111
|
+
files.push({ path, file, boundary, declarations: parsePrismaDeclarations(source, path, boundary) })
|
|
112
|
+
}
|
|
113
|
+
const declarations = files.flatMap(file => file.declarations)
|
|
114
|
+
const models = declarations.filter(item => item.kind === "model")
|
|
115
|
+
const enums = declarations.filter(item => item.kind === "enum")
|
|
116
|
+
if (models.length === 0) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Schema 诊断: 检测到 ${files.length} 个 Prisma 文件但 view=${view} 解析为 0 个模型;` +
|
|
119
|
+
`mode=${mode};扫描=${files.map(file => relative(input.projectRoot, file.path)).join(", ")}`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const byName = new Map<string, PrismaDeclaration[]>()
|
|
124
|
+
for (const model of models) {
|
|
125
|
+
const key = model.name.toLowerCase()
|
|
126
|
+
byName.set(key, [...(byName.get(key) ?? []), model])
|
|
127
|
+
}
|
|
128
|
+
const conflicts = [...byName.entries()].filter(([, entries]) => entries.length > 1)
|
|
129
|
+
if (conflicts.length > 0) {
|
|
130
|
+
const details = conflicts.map(([name, entries]) =>
|
|
131
|
+
`${name}: ${entries.map(entry => `${relative(input.projectRoot, entry.sourcePath)}[${entry.boundary}]`).join(", ")}`,
|
|
132
|
+
)
|
|
133
|
+
throw new Error(`Schema 模型冲突(fail closed): ${details.join("; ")}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { mode, view, schemaRoot, files, models, enums }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatSchemaTopology(topology: PrismaSchemaTopology, projectRoot: string): string {
|
|
140
|
+
const lines = [
|
|
141
|
+
"=== Prisma Schema 拓扑 ===",
|
|
142
|
+
`mode: ${topology.mode}`,
|
|
143
|
+
`view: ${topology.view}`,
|
|
144
|
+
`databaseBoundaries: ${topology.mode === "single" ? "shared (business + ADD 同库)" : "business, add (分库;all 仅汇总展示,不代表可跨库 join)"}`,
|
|
145
|
+
`scannedFiles: ${topology.files.length}`,
|
|
146
|
+
]
|
|
147
|
+
for (const file of topology.files) {
|
|
148
|
+
lines.push(` - ${relative(projectRoot, file.path)} [${file.boundary}] models=${file.declarations.filter(item => item.kind === "model").length}`)
|
|
149
|
+
}
|
|
150
|
+
lines.push("", `模型 (${topology.models.length} 个):`)
|
|
151
|
+
for (const model of topology.models) {
|
|
152
|
+
lines.push(` ${model.name} (${model.fieldCount} 字段) — ${relative(projectRoot, model.sourcePath)} [${model.boundary}]`)
|
|
153
|
+
}
|
|
154
|
+
if (topology.enums.length > 0) {
|
|
155
|
+
lines.push("", `枚举 (${topology.enums.length} 个):`)
|
|
156
|
+
for (const item of topology.enums) lines.push(` ${item.name} — ${relative(projectRoot, item.sourcePath)} [${item.boundary}]`)
|
|
157
|
+
}
|
|
158
|
+
return lines.join("\n")
|
|
159
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import * as z from "zod/v4"
|
|
2
|
+
import { createHash } from "crypto"
|
|
2
3
|
import type { ToolRegistrar } from "./registrar.js"
|
|
3
4
|
import { textResponse, errorResponse } from "../shared/response.js"
|
|
4
5
|
import { prisma } from "../shared/prisma.js"
|
|
5
|
-
import { PROJECT_ID, PROJECT_ROOT } from "../shared/env.js"
|
|
6
|
+
import { PROJECT_ID, PROJECT_ROOT, getRuntimeContext } from "../shared/env.js"
|
|
6
7
|
import type { AddUserRow, DevOperationRow } from "../shared/db-types.js"
|
|
7
8
|
import { AddUserRowSchema, DevOperationRowSchema, validatedDelegate } from "../shared/db-types.js"
|
|
8
9
|
|
|
9
10
|
export function registerAuditTools(server: ToolRegistrar) {
|
|
11
|
+
const runtimeContext = getRuntimeContext()
|
|
10
12
|
|
|
11
13
|
// 无类型边界单点(zod 托管):动态 client → 运行期校验的泛型委托
|
|
12
14
|
const devDb = validatedDelegate<DevOperationRow>(prisma.devOperation, DevOperationRowSchema, "DevOperation")
|
|
@@ -30,7 +32,10 @@ export function registerAuditTools(server: ToolRegistrar) {
|
|
|
30
32
|
}, async (args: Record<string, string | number | undefined>, _ctx: unknown) => {
|
|
31
33
|
try {
|
|
32
34
|
const { targetType, action, targetId, planKeyword, keyword, sinceMinutes, limit = 20 } = args
|
|
33
|
-
const where: Record<string, unknown> = {
|
|
35
|
+
const where: Record<string, unknown> = {
|
|
36
|
+
projectKey: runtimeContext.projectKey,
|
|
37
|
+
producerAdapterKey: runtimeContext.adapterKey,
|
|
38
|
+
}
|
|
34
39
|
if (sinceMinutes !== undefined) where.createdAt = { gte: new Date(Date.now() - Number(sinceMinutes) * 60 * 1000) }
|
|
35
40
|
if (targetType) where.targetType = s(targetType); if (action) where.action = s(action); if (targetId) where.targetId = s(targetId); if (planKeyword) where.planKeyword = s(planKeyword)
|
|
36
41
|
const kw = s(keyword)
|
|
@@ -44,6 +49,7 @@ export function registerAuditTools(server: ToolRegistrar) {
|
|
|
44
49
|
const lines = [`=== 开发操作审计日志 (条件: ${fl.length > 0 ? fl.join(", ") : "无过滤条件(最近全部)"}) ===`, `服务项目: ${PROJECT_ID} (${PROJECT_ROOT})`, `共 ${logs.length} 条记录`]
|
|
45
50
|
for (let i = 0; i < logs.length; i++) {
|
|
46
51
|
const l = logs[i]; lines.push(`[${i+1}] ${l.createdAt.toISOString()}`, ` action: ${l.action} | targetType: ${l.targetType} | targetId: ${l.targetId || "(无)"}`)
|
|
52
|
+
lines.push(` beforeState: ${JSON.stringify(l.beforeState)}`, ` afterState: ${JSON.stringify(l.afterState)}`)
|
|
47
53
|
if (l.reason) lines.push(` reason: ${l.reason}`); if (l.planKeyword) lines.push(` planKeyword: ${l.planKeyword}`)
|
|
48
54
|
}
|
|
49
55
|
if (planKeyword) { lines.push("=== Plan 分组 ===", `planKeyword=${planKeyword} 的操作链:`); for (const l of logs) lines.push(` ${l.createdAt.toISOString().slice(11,19)} ${l.action}`) }
|
|
@@ -60,22 +66,58 @@ export function registerAuditTools(server: ToolRegistrar) {
|
|
|
60
66
|
targetType: z.string().describe("目标类型: 'API_ROUTE', 'COMPONENT', 'SCHEMA', 'RULE', 'DOC', 'PLAN', 'SPEC', 'SKILL', 'AGENT' 等"),
|
|
61
67
|
targetId: z.string().optional().describe("目标标识(相对路径)"),
|
|
62
68
|
planKeyword: z.string().optional().describe("关联 Plan 的关键词"),
|
|
63
|
-
beforeState: z.string().describe("
|
|
64
|
-
afterState: z.string().describe("
|
|
69
|
+
beforeState: z.string().describe("操作前的状态(非 null JSON 对象或数组字符串)。必填,用于审计回溯。"),
|
|
70
|
+
afterState: z.string().describe("操作后的状态(非 null JSON 对象或数组字符串)。必填,用于审计回溯。"),
|
|
65
71
|
reason: z.string().optional().describe("操作原因"),
|
|
72
|
+
operationKey: z.string().optional().describe("调用方提供的幂等键;未提供时按本次结构化操作内容计算"),
|
|
66
73
|
}),
|
|
67
74
|
}, async (args: Record<string, string | number | undefined>, _ctx: unknown) => {
|
|
68
75
|
try {
|
|
69
|
-
const { action, targetType, targetId, planKeyword, beforeState, afterState, reason } = args
|
|
76
|
+
const { action, targetType, targetId, planKeyword, beforeState, afterState, reason, operationKey } = args
|
|
70
77
|
const tId = s(targetId)
|
|
71
78
|
const pathWarnings: string[] = []
|
|
72
79
|
if (tId && (tId.startsWith("/") || /^[A-Z]:\\/.test(tId))) pathWarnings.push(`⚠️ targetId 使用了绝对路径: "${tId}"。请使用相对路径。`)
|
|
80
|
+
if (!s(beforeState).trim() || !s(afterState).trim()) return errorResponse("beforeState/afterState 必须提供非空 JSON 字符串。")
|
|
73
81
|
let parsedBefore: unknown, parsedAfter: unknown
|
|
74
82
|
try { if (beforeState) parsedBefore = JSON.parse(s(beforeState)); if (afterState) parsedAfter = JSON.parse(s(afterState)) } catch { return errorResponse("beforeState/afterState 必须是有效的 JSON 字符串。") }
|
|
83
|
+
const isStructuredState = (value: unknown): value is Record<string, unknown> | unknown[] => typeof value === "object" && value !== null
|
|
84
|
+
if (!isStructuredState(parsedBefore) || !isStructuredState(parsedAfter)) return errorResponse("beforeState/afterState 必须是非 null 的 JSON 对象或数组。")
|
|
75
85
|
let systemUser = await userDb.findUnique({ where: { username: "ai-assistant" }, select: { id: true } })
|
|
76
86
|
if (!systemUser) systemUser = await userDb.create({ data: { id: "ai-assistant", username: "ai-assistant", email: "ai-assistant@internal" } })
|
|
77
|
-
const
|
|
78
|
-
|
|
87
|
+
const operationPayload = {
|
|
88
|
+
projectKey: runtimeContext.projectKey,
|
|
89
|
+
producerAdapterKey: runtimeContext.adapterKey,
|
|
90
|
+
action: s(action),
|
|
91
|
+
targetType: s(targetType),
|
|
92
|
+
targetId: tId || "unknown",
|
|
93
|
+
planKeyword: s(planKeyword) || "unknown",
|
|
94
|
+
beforeState: parsedBefore,
|
|
95
|
+
afterState: parsedAfter,
|
|
96
|
+
reason: s(reason) || null,
|
|
97
|
+
}
|
|
98
|
+
const resolvedOperationKey = s(operationKey).trim() || createHash("sha256")
|
|
99
|
+
.update(JSON.stringify(operationPayload))
|
|
100
|
+
.digest("hex")
|
|
101
|
+
const scopedData: Partial<DevOperationRow> = {
|
|
102
|
+
...operationPayload,
|
|
103
|
+
userId: systemUser.id,
|
|
104
|
+
contextId: runtimeContext.contextId,
|
|
105
|
+
toolName: "record_dev_operation",
|
|
106
|
+
operationKey: resolvedOperationKey,
|
|
107
|
+
}
|
|
108
|
+
const log = await devDb.upsert({
|
|
109
|
+
where: {
|
|
110
|
+
projectKey_producerAdapterKey_toolName_operationKey: {
|
|
111
|
+
projectKey: runtimeContext.projectKey,
|
|
112
|
+
producerAdapterKey: runtimeContext.adapterKey,
|
|
113
|
+
toolName: "record_dev_operation",
|
|
114
|
+
operationKey: resolvedOperationKey,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
create: scopedData,
|
|
118
|
+
update: {},
|
|
119
|
+
})
|
|
120
|
+
const lines = [`✅ 开发操作已记录`, ` 落库项目: ${PROJECT_ID} (${PROJECT_ROOT})`, ` ID: ${log.id}`, ` action: ${s(action)}`, ` targetType: ${s(targetType)}`, ` targetId: ${tId || "unknown"}`, ` planKeyword: ${s(planKeyword) || "unknown"}`, ` beforeState: ${JSON.stringify(log.beforeState)}`, ` afterState: ${JSON.stringify(log.afterState)}`, ` createdAt: ${log.createdAt.toISOString()}`]
|
|
79
121
|
if (pathWarnings.length > 0) { lines.push(""); lines.push(...pathWarnings) }
|
|
80
122
|
lines.push("", `📋 落库回查(必须执行):`, tId ? ` query_audit_logs({ targetId: "${tId}" }) — 确认本条记录已写入` : "")
|
|
81
123
|
return textResponse(lines.join("\n"))
|