iterate-plugin 2.12.2 → 3.2.1
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 +28 -12
- package/README.zh-CN.md +2 -1
- package/dist/approval-gate.js +16 -2
- package/dist/config-loader.js +5 -0
- package/dist/git-scope.js +61 -7
- package/dist/index.js +16 -6
- package/dist/session-hooks.js +36 -11
- package/dist/skill-prompt.js +3 -0
- package/dist/tools/decision-log.js +10 -1
- package/dist/tools/defense-events.js +260 -0
- package/dist/tools/defense-store.js +97 -0
- package/dist/tools/experience-bank.js +248 -0
- package/dist/tools/experience-store.js +132 -0
- package/dist/tools/quality-gate.js +180 -0
- package/dist/tools/quality-store.js +174 -0
- package/lib/client.js +662 -103
- package/lib/parse.js +93 -0
- package/package.json +7 -6
- package/src/approval-gate.ts +14 -2
- package/src/client/index.ts +542 -49
- package/src/config-loader.ts +5 -0
- package/src/git-scope.ts +48 -7
- package/src/index.ts +16 -6
- package/src/session-hooks.ts +33 -11
- package/src/skill-prompt.ts +3 -0
- package/src/tools/checkpoint.ts +1 -1
- package/src/tools/config.ts +1 -1
- package/src/tools/decision-log.ts +11 -2
- package/src/tools/defense-events.ts +295 -0
- package/src/tools/defense-store.ts +113 -0
- package/src/tools/experience-bank.ts +264 -0
- package/src/tools/experience-store.ts +160 -0
- package/src/tools/fix.ts +1 -1
- package/src/tools/history.ts +1 -1
- package/src/tools/prune.ts +1 -1
- package/src/tools/quality-gate.ts +193 -0
- package/src/tools/quality-store.ts +199 -0
- package/src/tools/review.ts +1 -1
- package/src/tools/transcript.ts +1 -1
- package/src/tools/triage.ts +1 -1
- package/src/types.ts +118 -0
package/src/config-loader.ts
CHANGED
|
@@ -207,6 +207,11 @@ export type ProjectRootResult = { ok: true; root: string } | { ok: false; reason
|
|
|
207
207
|
*/
|
|
208
208
|
export function resolveProjectRoot(input?: string, sessionCwd?: string): ProjectRootResult {
|
|
209
209
|
const raw = (input ?? '').trim()
|
|
210
|
+
// A NUL byte can never name a real path and makes `resolve()` (and every
|
|
211
|
+
// downstream fs call) throw — treat it as unsafe input, not a throw path.
|
|
212
|
+
if (raw.includes('\0')) {
|
|
213
|
+
return { ok: false, reason: 'Refusing project root containing NUL bytes.' }
|
|
214
|
+
}
|
|
210
215
|
const root = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd))
|
|
211
216
|
if (!root || root === sep) {
|
|
212
217
|
return { ok: false, reason: 'Refusing filesystem root as project root.' }
|
package/src/git-scope.ts
CHANGED
|
@@ -43,6 +43,51 @@ export interface GitScopeResult {
|
|
|
43
43
|
* NUL is present (callers that did not pass -z) fall back to newline-split
|
|
44
44
|
* with C-style quote/escape unescaping for core.quotePath output.
|
|
45
45
|
*/
|
|
46
|
+
/**
|
|
47
|
+
* Decode the quoted body of a git core.quotePath output line into the real
|
|
48
|
+
* filename bytes, then interpret them as UTF-8.
|
|
49
|
+
*
|
|
50
|
+
* Single-pass and escape-atomic: each `\` consumes exactly one escape (\" \\
|
|
51
|
+
* \t \n or a 3-digit octal for a raw byte), so a literal `\\303` in a filename
|
|
52
|
+
* (escaped backslash + literal "303") is decoded as the byte `\` followed by
|
|
53
|
+
* ASCII "303" rather than as the single byte 0xC3. Ordinary characters in the
|
|
54
|
+
* quoted body are ASCII (git always octal-escapes non-ASCII bytes), so they map
|
|
55
|
+
* 1:1 to bytes.
|
|
56
|
+
*/
|
|
57
|
+
function decodeQuotedPath(content: string): string {
|
|
58
|
+
const bytes: number[] = []
|
|
59
|
+
let i = 0
|
|
60
|
+
while (i < content.length) {
|
|
61
|
+
const ch = content[i]!
|
|
62
|
+
if (ch !== '\\') {
|
|
63
|
+
bytes.push(ch.charCodeAt(0))
|
|
64
|
+
i++
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const next = content[i + 1]
|
|
68
|
+
if (next === '"') { bytes.push(0x22); i += 2 }
|
|
69
|
+
else if (next === '\\') { bytes.push(0x5c); i += 2 }
|
|
70
|
+
else if (next === 't') { bytes.push(0x09); i += 2 }
|
|
71
|
+
else if (next === 'n') { bytes.push(0x0a); i += 2 }
|
|
72
|
+
else if (next !== undefined && next >= '0' && next <= '7') {
|
|
73
|
+
const oct = content.slice(i + 1, i + 4)
|
|
74
|
+
if (oct.length === 3 && /^[0-7]{3}$/.test(oct)) {
|
|
75
|
+
bytes.push(parseInt(oct, 8))
|
|
76
|
+
i += 4
|
|
77
|
+
} else {
|
|
78
|
+
// Malformed octal — keep the backslash literally.
|
|
79
|
+
bytes.push(0x5c)
|
|
80
|
+
i++
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Unknown escape — keep the backslash literally.
|
|
84
|
+
bytes.push(0x5c)
|
|
85
|
+
i++
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return Buffer.from(bytes).toString('utf-8')
|
|
89
|
+
}
|
|
90
|
+
|
|
46
91
|
export function parseChangedFiles(stdout: string): string[] {
|
|
47
92
|
if (stdout.includes('\0')) {
|
|
48
93
|
return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0)
|
|
@@ -52,15 +97,11 @@ export function parseChangedFiles(stdout: string): string[] {
|
|
|
52
97
|
.map((line) => {
|
|
53
98
|
const trimmed = line.trim()
|
|
54
99
|
// git core.quotePath wraps paths with special characters in "..."; the
|
|
55
|
-
// content uses C-style escapes (\" \\ \t \n and \ooo octal for
|
|
100
|
+
// content uses C-style escapes (\" \\ \t \n) and \ooo octal escapes for
|
|
101
|
+
// non-ASCII bytes (which are raw UTF-8 BYTES, not Latin-1 code points).
|
|
56
102
|
const quoted = trimmed.match(/^"(.*)"$/)
|
|
57
103
|
if (!quoted) return trimmed
|
|
58
|
-
return quoted[1]!
|
|
59
|
-
.replace(/\\"/g, '"')
|
|
60
|
-
.replace(/\\\\/g, '\\')
|
|
61
|
-
.replace(/\\t/g, '\t')
|
|
62
|
-
.replace(/\\n/g, '\n')
|
|
63
|
-
.replace(/\\([0-7]{3})/g, (_m, oct: string) => String.fromCharCode(parseInt(oct, 8)))
|
|
104
|
+
return decodeQuotedPath(quoted[1]!)
|
|
64
105
|
})
|
|
65
106
|
.filter((line) => line.length > 0)
|
|
66
107
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
* iterate-plugin — dsh plugin for the iterate autonomous closed-loop workflow
|
|
3
3
|
*
|
|
4
4
|
* Architecture:
|
|
5
|
-
* - The plugin registers
|
|
6
|
-
*
|
|
5
|
+
* - The plugin registers 17 tools (14 original + 3 v3.0 quality command center tools)
|
|
6
|
+
* Original: config, validate, decision-log, context, review, triage, fix, diff,
|
|
7
|
+
* rollback, checkpoint, status, history, prune, transcript
|
|
8
|
+
* v3.0: experience, quality_gate, defense_events
|
|
7
9
|
* - The plugin injects a system prompt section teaching the iterate workflow pattern
|
|
8
10
|
* - The model (prompted by the skill) writes a workflow script using dsh's `workflow` tool
|
|
9
11
|
* - The workflow script uses `agent()` / `parallel()` / `phase()` / `log()` to orchestrate
|
|
10
|
-
* - Subagents use the
|
|
11
|
-
* review, triage, apply/rollback/fixing, checkpoint, status, history, prune, transcript
|
|
12
|
+
* - Subagents use the 17 tools to do real work (read config, run validation, log decisions,
|
|
13
|
+
* review, triage, apply/rollback/fixing, checkpoint, status, history, prune, transcript,
|
|
14
|
+
* query experience bank, check quality gates, query defense events)
|
|
12
15
|
* - A `tools/pre-execute` hook gates destructive iterate calls behind human approval
|
|
13
16
|
* (F8 observatory approval policy: ask / deny / allow).
|
|
14
17
|
*
|
|
@@ -20,7 +23,7 @@
|
|
|
20
23
|
*
|
|
21
24
|
* Key files:
|
|
22
25
|
* - src/index.ts — Plugin entry: register tools + inject skill prompt
|
|
23
|
-
* - src/tools/ —
|
|
26
|
+
* - src/tools/ — 17 tool implementations (14 original + 3 v3.0)
|
|
24
27
|
* - src/config-loader.ts — YAML config loading
|
|
25
28
|
* - src/types.ts — Shared types
|
|
26
29
|
*/
|
|
@@ -37,6 +40,9 @@ import { registerCheckpointTool, registerStatusTool } from './tools/checkpoint.t
|
|
|
37
40
|
import { registerHistoryTool } from './tools/history.ts'
|
|
38
41
|
import { registerPruneTool } from './tools/prune.ts'
|
|
39
42
|
import { registerTranscriptTool } from './tools/transcript.ts'
|
|
43
|
+
import { registerExperienceBankTool } from './tools/experience-bank.ts'
|
|
44
|
+
import { registerQualityGateTool } from './tools/quality-gate.ts'
|
|
45
|
+
import { registerDefenseEventsTool } from './tools/defense-events.ts'
|
|
40
46
|
import { registerSessionHooks } from './session-hooks.ts'
|
|
41
47
|
import { registerLiveCapture } from './live.ts'
|
|
42
48
|
import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
|
|
@@ -45,7 +51,7 @@ export const name = 'iterate-plugin'
|
|
|
45
51
|
export const inject = ['tools', 'systemPrompt'] as const
|
|
46
52
|
|
|
47
53
|
export function apply(ctx: Context): void {
|
|
48
|
-
// 1. Register the 14
|
|
54
|
+
// 1. Register the 17 tools (14 original + 3 v3.0)
|
|
49
55
|
registerConfigTool(ctx)
|
|
50
56
|
registerValidateTool(ctx)
|
|
51
57
|
registerDecisionLogTool(ctx)
|
|
@@ -60,6 +66,10 @@ export function apply(ctx: Context): void {
|
|
|
60
66
|
registerHistoryTool(ctx)
|
|
61
67
|
registerPruneTool(ctx)
|
|
62
68
|
registerTranscriptTool(ctx)
|
|
69
|
+
// v3.0: Quality Command Center tools
|
|
70
|
+
registerExperienceBankTool(ctx)
|
|
71
|
+
registerQualityGateTool(ctx)
|
|
72
|
+
registerDefenseEventsTool(ctx)
|
|
63
73
|
|
|
64
74
|
// 2. Wire the observatory approval gate onto dsh's tools/pre-execute waterfall,
|
|
65
75
|
// and the live reviewer-activity feed onto tools/result.
|
package/src/session-hooks.ts
CHANGED
|
@@ -36,17 +36,33 @@ import type { ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
|
|
|
36
36
|
* Returns a dsh `PreToolDecision` so the caller can short-circuit the caller.
|
|
37
37
|
*/
|
|
38
38
|
export function gateDecision(exec: ToolExecution): PreToolDecision {
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
39
|
+
// Defensively read the tool name: an exec handed to the waterfall is an
|
|
40
|
+
// ordinary object, but a hostile/proxied exec must degrade to "not our tool"
|
|
41
|
+
// (allow) instead of throwing before classification. The gate only ever
|
|
42
|
+
// inspects iterate tools, so an unreadable name also must not alter
|
|
43
|
+
// unrelated tooling.
|
|
44
|
+
let name = ''
|
|
45
|
+
try {
|
|
46
|
+
name = exec?.name ?? ''
|
|
47
|
+
} catch {
|
|
48
|
+
name = ''
|
|
49
|
+
}
|
|
50
|
+
if (!isDestructiveIterateTool(name)) return { kind: 'allow' }
|
|
42
51
|
|
|
43
52
|
// Resolve the project root (use the call's own `path` arg, else the agent's
|
|
44
53
|
// session cwd) to read the effective observatory policy.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
let argPath: string | undefined
|
|
55
|
+
let sessionCwd: string | undefined
|
|
56
|
+
try {
|
|
57
|
+
const args = exec?.arguments
|
|
58
|
+
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
|
59
|
+
const p = (args as Record<string, unknown>).path
|
|
60
|
+
if (typeof p === 'string') argPath = p
|
|
61
|
+
}
|
|
62
|
+
sessionCwd = exec?.agent?.session?.header?.cwd
|
|
63
|
+
} catch {
|
|
64
|
+
// hostile/proxied exec — fall through with both undefined (defaults to ask)
|
|
65
|
+
}
|
|
50
66
|
const resolved = resolveProjectRoot(argPath, sessionCwd)
|
|
51
67
|
let policy: 'ask' | 'deny' | 'allow' = 'ask'
|
|
52
68
|
if (resolved.ok) {
|
|
@@ -69,12 +85,18 @@ export function gateDecision(exec: ToolExecution): PreToolDecision {
|
|
|
69
85
|
*/
|
|
70
86
|
export function registerSessionHooks(ctx: Context): void {
|
|
71
87
|
ctx.on('tools/pre-execute', (exec: ToolExecution, next: () => Promise<PreToolDecision>) => {
|
|
72
|
-
//
|
|
88
|
+
// Fail-safe: a throwing gate must never fail OPEN. Degrade to `ask` so a
|
|
89
|
+
// destructive call still routes through human consent instead of running
|
|
90
|
+
// via `next()`'s allow default (matches the header's documented contract).
|
|
73
91
|
let decision: PreToolDecision
|
|
74
92
|
try {
|
|
75
93
|
decision = gateDecision(exec)
|
|
76
|
-
} catch {
|
|
77
|
-
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.warn('[iterate] approval gate failed; degrading to ask.', err)
|
|
96
|
+
return Promise.resolve({
|
|
97
|
+
kind: 'ask',
|
|
98
|
+
reason: 'iterate approval gate unavailable — require consent',
|
|
99
|
+
})
|
|
78
100
|
}
|
|
79
101
|
if (decision.kind === 'ask') {
|
|
80
102
|
// Delegate the actual human-consent prompt + audit to dsh's approval
|
package/src/skill-prompt.ts
CHANGED
|
@@ -24,6 +24,9 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
24
24
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
25
25
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
26
26
|
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
27
|
+
- \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it.
|
|
28
|
+
- \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json\`.
|
|
29
|
+
- \`iterate_defense_events\` — defense event stream (\`.iterate/defense-events.json\`): \`list\`/\`counts\` review precondition failures, rollbacks, invariant violations, and falsified assumptions; \`record\` logs a new event when a defense fires. Human-readable labels follow the project \`language\` (en/zh).
|
|
27
30
|
|
|
28
31
|
### When to use
|
|
29
32
|
When the user asks to review or iterate on the project (e.g. "review this project", "iterate on error handling", "check the codebase for issues", "dry-run review", "反复审查"), run an iterate **workflow** by calling the \`workflow\` tool.
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
|
-
import type { JsonValue } from '@deepseek-ai/dsh-
|
|
14
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
15
15
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
16
16
|
import { checkpointPath, iterateDir } from '../paths.ts'
|
|
17
17
|
import { readRegistry } from './fix.ts'
|
package/src/tools/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
|
-
import type { JsonValue } from '@deepseek-ai/dsh-
|
|
3
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
4
|
import { loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
5
5
|
import {
|
|
6
6
|
applyConfigUpdates,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
-
import type { JsonValue } from '@deepseek-ai/dsh-
|
|
4
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
5
5
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
6
6
|
import type { DecisionLogEntry } from '../types.ts'
|
|
7
7
|
|
|
@@ -63,7 +63,7 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
|
|
|
63
63
|
const line = JSON.stringify(entry) + '\n'
|
|
64
64
|
appendFileSync(filePath, line, 'utf-8')
|
|
65
65
|
} catch (err) {
|
|
66
|
-
return { count:
|
|
66
|
+
return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
|
|
67
67
|
}
|
|
68
68
|
// Count entries
|
|
69
69
|
let count = 0
|
|
@@ -215,6 +215,15 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
const result = appendDecisionEntry(projectRoot, entry)
|
|
218
|
+
if (result.error) {
|
|
219
|
+
return {
|
|
220
|
+
operation: 'append',
|
|
221
|
+
success: false,
|
|
222
|
+
entryCount: 0,
|
|
223
|
+
logPath: result.path,
|
|
224
|
+
error: result.error,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
218
227
|
return {
|
|
219
228
|
operation: 'append',
|
|
220
229
|
success: true,
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/defense-events.ts — defense event stream query & record tool.
|
|
3
|
+
*
|
|
4
|
+
* iterate_defense_events — browse/search defense events from the current
|
|
5
|
+
* iteration, or record a new one.
|
|
6
|
+
*
|
|
7
|
+
* Defense events include: precondition failures, rollbacks, invariant violations,
|
|
8
|
+
* and assumption falsifications. Read operations give visibility into defensive
|
|
9
|
+
* actions; "record" persists a new event to .iterate/defense-events.json.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
13
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
14
|
+
import { resolveProjectRootForExec, loadEffectiveConfig } from '../config-loader.ts'
|
|
15
|
+
import { readDefenseEvents, writeDefenseEvents, addDefenseEvent } from './defense-store.ts'
|
|
16
|
+
import type { DefenseEvent, DefenseEventType } from '../types.ts'
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LIMIT = 50
|
|
19
|
+
const MAX_LIMIT = 100
|
|
20
|
+
|
|
21
|
+
const EVENT_TYPES: DefenseEventType[] = [
|
|
22
|
+
'precondition_failed',
|
|
23
|
+
'rollback',
|
|
24
|
+
'invariant_violated',
|
|
25
|
+
'assumption_falsified',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** Clamp a caller-supplied limit to a sane range. */
|
|
29
|
+
function clampLimit(limit: number | undefined): number {
|
|
30
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
|
|
31
|
+
return DEFAULT_LIMIT
|
|
32
|
+
}
|
|
33
|
+
return Math.min(limit, MAX_LIMIT)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Bilingual, config-driven human-readable labels for defense event types. */
|
|
37
|
+
const EVENT_TYPE_LABELS: Record<DefenseEventType, { zh: string; en: string }> = {
|
|
38
|
+
precondition_failed: { zh: '前置校验失败', en: 'precondition failed' },
|
|
39
|
+
rollback: { zh: '回滚', en: 'rollback' },
|
|
40
|
+
invariant_violated: { zh: '不变量违反', en: 'invariant violated' },
|
|
41
|
+
assumption_falsified: { zh: '假设被证伪', en: 'assumption falsified' },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Label for a defense event type in the requested language (fallback: English). */
|
|
45
|
+
function labelFor(type: DefenseEventType, language: 'zh' | 'en'): string {
|
|
46
|
+
const labels = EVENT_TYPE_LABELS[type]
|
|
47
|
+
return labels ? labels[language] : type
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Validate arguments for the record operation. */
|
|
51
|
+
function validateRecordInput(args: {
|
|
52
|
+
type?: unknown
|
|
53
|
+
round?: unknown
|
|
54
|
+
description?: unknown
|
|
55
|
+
defense?: unknown
|
|
56
|
+
outcome?: unknown
|
|
57
|
+
severity?: unknown
|
|
58
|
+
}): string[] {
|
|
59
|
+
const errors: string[] = []
|
|
60
|
+
if (typeof args.type !== 'string' || !EVENT_TYPES.includes(args.type as DefenseEventType)) {
|
|
61
|
+
errors.push(`type must be one of: ${EVENT_TYPES.join(', ')}`)
|
|
62
|
+
}
|
|
63
|
+
if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
|
|
64
|
+
errors.push('round must be a positive integer')
|
|
65
|
+
}
|
|
66
|
+
if (typeof args.description !== 'string' || !args.description.trim()) {
|
|
67
|
+
errors.push('description is required')
|
|
68
|
+
}
|
|
69
|
+
if (typeof args.defense !== 'string' || !args.defense.trim()) {
|
|
70
|
+
errors.push('defense is required')
|
|
71
|
+
}
|
|
72
|
+
if (typeof args.outcome !== 'string' || !args.outcome.trim()) {
|
|
73
|
+
errors.push('outcome is required')
|
|
74
|
+
}
|
|
75
|
+
const severity = args.severity
|
|
76
|
+
if (severity !== 'critical' && severity !== 'high' && severity !== 'medium' && severity !== 'low') {
|
|
77
|
+
errors.push('severity must be one of critical, high, medium, low')
|
|
78
|
+
}
|
|
79
|
+
return errors
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Register the `iterate_defense_events` tool.
|
|
84
|
+
* Queries defense events from the current iteration.
|
|
85
|
+
*/
|
|
86
|
+
export function registerDefenseEventsTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
87
|
+
ctx.tools.register(
|
|
88
|
+
defineTool({
|
|
89
|
+
name: 'iterate_defense_events',
|
|
90
|
+
description:
|
|
91
|
+
'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
|
|
92
|
+
'and assumption falsifications. ' +
|
|
93
|
+
'List/counts return events with descriptions, outcomes, and summary counts; ' +
|
|
94
|
+
'"record" persists a new event to .iterate/defense-events.json. ' +
|
|
95
|
+
'Use it to review defensive actions taken, or to log one when a defense fires.',
|
|
96
|
+
parameters: {
|
|
97
|
+
operation: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: 'Operation: list (browse all), counts (summary by type), record (log a new event). Default: list.',
|
|
100
|
+
enum: ['list', 'counts', 'record'],
|
|
101
|
+
},
|
|
102
|
+
type: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
description: 'Event type (filter for list; required for record): precondition_failed, rollback, invariant_violated, assumption_falsified.',
|
|
105
|
+
},
|
|
106
|
+
round: {
|
|
107
|
+
type: 'integer',
|
|
108
|
+
description: 'Round number (filter for list; required for record).',
|
|
109
|
+
},
|
|
110
|
+
severity: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
description: 'Severity (filter for list; required for record): critical, high, medium, low.',
|
|
113
|
+
},
|
|
114
|
+
description: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
description: 'What was being checked (required for record).',
|
|
117
|
+
},
|
|
118
|
+
defense: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
description: 'The defense that was triggered (required for record).',
|
|
121
|
+
},
|
|
122
|
+
outcome: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'Outcome: what was protected against (required for record).',
|
|
125
|
+
},
|
|
126
|
+
file: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
description: 'Optional file/location context (record).',
|
|
129
|
+
},
|
|
130
|
+
line: {
|
|
131
|
+
type: 'integer',
|
|
132
|
+
description: 'Optional line number context (record).',
|
|
133
|
+
},
|
|
134
|
+
language: {
|
|
135
|
+
type: 'string',
|
|
136
|
+
description: 'Label language for readable output: en (default) or zh. Falls back to the project config language.',
|
|
137
|
+
enum: ['en', 'zh'],
|
|
138
|
+
},
|
|
139
|
+
limit: {
|
|
140
|
+
type: 'integer',
|
|
141
|
+
description: `Max events to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}).`,
|
|
142
|
+
},
|
|
143
|
+
path: {
|
|
144
|
+
type: 'string',
|
|
145
|
+
description: 'Project root directory (default: current working directory).',
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
output: {
|
|
150
|
+
schema: {
|
|
151
|
+
type: 'object',
|
|
152
|
+
additionalProperties: false,
|
|
153
|
+
properties: {
|
|
154
|
+
ok: { type: 'boolean', required: true },
|
|
155
|
+
kind: { type: 'string' },
|
|
156
|
+
operation: { type: 'string' },
|
|
157
|
+
count: { type: 'integer' },
|
|
158
|
+
events: { type: 'json' },
|
|
159
|
+
counts: { type: 'json' },
|
|
160
|
+
event: { type: 'json' },
|
|
161
|
+
language: { type: 'string' },
|
|
162
|
+
errors: { type: 'json' },
|
|
163
|
+
error: { type: 'string' },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
render: (_args, value) => {
|
|
167
|
+
if (!value.ok) return [{ type: 'text', text: `defense events query failed: ${value.error}` }]
|
|
168
|
+
const language: 'zh' | 'en' = value.language === 'zh' ? 'zh' : 'en'
|
|
169
|
+
|
|
170
|
+
if (value.operation === 'counts' && value.counts) {
|
|
171
|
+
const counts = value.counts as Record<DefenseEventType, number>
|
|
172
|
+
const lines = [
|
|
173
|
+
'Defense Event Summary:',
|
|
174
|
+
...EVENT_TYPES.map((type) =>
|
|
175
|
+
` ${labelFor(type, language)}: ${counts[type] ?? 0}`
|
|
176
|
+
),
|
|
177
|
+
` Total: ${EVENT_TYPES.reduce((sum, type) => sum + (counts[type] ?? 0), 0)}`,
|
|
178
|
+
]
|
|
179
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (value.operation === 'record' && value.event) {
|
|
183
|
+
const e = value.event as unknown as DefenseEvent
|
|
184
|
+
return [{ type: 'text', text: [
|
|
185
|
+
`Recorded defense event: ${e.id}`,
|
|
186
|
+
` Round ${e.round} - ${labelFor(e.type, language)} (${e.severity})`,
|
|
187
|
+
` Check: ${e.description}`,
|
|
188
|
+
` Defense: ${e.defense}`,
|
|
189
|
+
` Outcome: ${e.outcome}`,
|
|
190
|
+
e.file ? ` File: ${e.file}${e.line ? `:${e.line}` : ''}` : '',
|
|
191
|
+
].filter(Boolean).join('\n') }]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const events = (value.events as DefenseEvent[] | undefined) ?? []
|
|
195
|
+
if (events.length === 0) {
|
|
196
|
+
return [{ type: 'text', text: 'No defense events recorded.' }]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const lines = [
|
|
200
|
+
`Defense Events (${value.count} total):`,
|
|
201
|
+
'',
|
|
202
|
+
...events.map((e) => {
|
|
203
|
+
const typeLabel = labelFor(e.type, language)
|
|
204
|
+
return `[${e.id}] Round ${e.round} - ${typeLabel}\n ${e.description}\n Outcome: ${e.outcome}`
|
|
205
|
+
}),
|
|
206
|
+
]
|
|
207
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async execute(args, exec) {
|
|
212
|
+
const resolved = resolveProjectRootForExec(exec, args.path)
|
|
213
|
+
if (!resolved.ok) return { ok: false, kind: 'defense_events', error: resolved.reason }
|
|
214
|
+
const projectRoot = resolved.root
|
|
215
|
+
|
|
216
|
+
const configLang = loadEffectiveConfig(projectRoot).config.language
|
|
217
|
+
const language: 'zh' | 'en' = args.language === 'zh' || args.language === 'en' ? args.language : configLang
|
|
218
|
+
|
|
219
|
+
const operation = typeof args.operation === 'string' ? args.operation : 'list'
|
|
220
|
+
const limit = clampLimit(args.limit as number | undefined)
|
|
221
|
+
|
|
222
|
+
if (operation === 'record') {
|
|
223
|
+
const errors = validateRecordInput(args)
|
|
224
|
+
if (errors.length > 0) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
kind: 'defense_events',
|
|
228
|
+
operation: 'record',
|
|
229
|
+
errors: errors as unknown as JsonValue,
|
|
230
|
+
error: `Invalid defense event: ${errors.join('; ')}`,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const stream = readDefenseEvents(projectRoot)
|
|
234
|
+
const next = addDefenseEvent(stream, {
|
|
235
|
+
round: args.round as number,
|
|
236
|
+
type: args.type as DefenseEventType,
|
|
237
|
+
description: args.description as string,
|
|
238
|
+
defense: args.defense as string,
|
|
239
|
+
outcome: args.outcome as string,
|
|
240
|
+
severity: args.severity as DefenseEvent['severity'],
|
|
241
|
+
...(typeof args.file === 'string' && args.file.length > 0 ? { file: args.file } : {}),
|
|
242
|
+
...(typeof args.line === 'number' ? { line: args.line } : {}),
|
|
243
|
+
})
|
|
244
|
+
writeDefenseEvents(projectRoot, next)
|
|
245
|
+
const event = next.events[next.events.length - 1]
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
kind: 'defense_events',
|
|
249
|
+
operation: 'record',
|
|
250
|
+
language,
|
|
251
|
+
event: event as unknown as JsonValue,
|
|
252
|
+
counts: next.counts as unknown as JsonValue,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const stream = readDefenseEvents(projectRoot)
|
|
257
|
+
|
|
258
|
+
if (operation === 'counts') {
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
kind: 'defense_events',
|
|
262
|
+
operation: 'counts',
|
|
263
|
+
language,
|
|
264
|
+
counts: stream.counts as unknown as JsonValue,
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Filter events
|
|
269
|
+
let events = stream.events
|
|
270
|
+
|
|
271
|
+
if (typeof args.type === 'string' && args.type) {
|
|
272
|
+
events = events.filter((e) => e.type === args.type)
|
|
273
|
+
}
|
|
274
|
+
if (typeof args.round === 'number') {
|
|
275
|
+
events = events.filter((e) => e.round === args.round)
|
|
276
|
+
}
|
|
277
|
+
if (typeof args.severity === 'string' && args.severity) {
|
|
278
|
+
events = events.filter((e) => e.severity === args.severity)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Sort by timestamp descending (newest first)
|
|
282
|
+
events.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
kind: 'defense_events',
|
|
287
|
+
operation: 'list',
|
|
288
|
+
language,
|
|
289
|
+
count: Math.min(events.length, limit),
|
|
290
|
+
events: events.slice(0, limit) as unknown as JsonValue,
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
295
|
+
}
|