bingocode 1.1.183 → 1.1.184
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/.claude/scheduled_tasks.json +34 -0
- package/package.json +1 -1
- package/src/bootstrap/state.ts +61 -13
- package/src/hooks/useGoalAutoUpdater.ts +187 -0
- package/src/hooks/useGoalEvaluator.ts +29 -16
- package/src/query.ts +21 -0
- package/src/skills/bundled/goal.ts +41 -32
- package/src/types/goal.ts +322 -0
- package/src/utils/__tests__/goalDag.test.ts +177 -0
- package/src/utils/__tests__/goalDrift.test.ts +132 -0
- package/src/utils/__tests__/goalIntegration.test.ts +217 -0
- package/src/utils/__tests__/goalStore.test.ts +592 -0
- package/src/utils/goalDag.ts +408 -0
- package/src/utils/goalDrift.ts +243 -0
- package/src/utils/goalEvaluator.ts +708 -122
- package/src/utils/goalHelpers.ts +155 -0
- package/src/utils/goalPersistence.ts +206 -0
- package/src/utils/goalStore.ts +463 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal system helper utilities — shared between hooks and store.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - asSubGoalId: convert a string to a proper SubGoal identifier
|
|
6
|
+
* - classifyToolAction: map tool execution to goal-relevant action
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SubGoal, SubGoalStatus } from '../types/goal.js'
|
|
10
|
+
|
|
11
|
+
/** Convert a DAG node ID to a proper SubGoal identifier. */
|
|
12
|
+
export function asSubGoalId(id: string): SubGoal['id'] {
|
|
13
|
+
return id as SubGoal['id']
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Create a SubGoal from the goal store. Used by hooks to build
|
|
17
|
+
* SubGoal objects for DAG operations. */
|
|
18
|
+
export function createSubGoal(
|
|
19
|
+
id: string,
|
|
20
|
+
text: string,
|
|
21
|
+
dependencies: string[] = [],
|
|
22
|
+
parentId: string = '',
|
|
23
|
+
): SubGoal {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
parentId: parentId as any,
|
|
27
|
+
text,
|
|
28
|
+
status: 'pending',
|
|
29
|
+
dependencies,
|
|
30
|
+
progress: 0,
|
|
31
|
+
createdAt: Date.now(),
|
|
32
|
+
updatedAt: Date.now(),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Helper to add a new sub-goal to the DAG. */
|
|
37
|
+
export function addSubGoalToDag(
|
|
38
|
+
store: { dag: { nodes: Map<string, SubGoal> } },
|
|
39
|
+
text: string,
|
|
40
|
+
deps: string[] = [],
|
|
41
|
+
): SubGoal {
|
|
42
|
+
const id = generateSubGoalId()
|
|
43
|
+
const subGoal = createSubGoal(id, text, deps)
|
|
44
|
+
return subGoal
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let counter = 0
|
|
48
|
+
function generateSubGoalId(): string {
|
|
49
|
+
return `sg-${++counter}-${Date.now().toString(36)}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Get a sub-goal from the DAG by ID. Returns null if not found. */
|
|
53
|
+
export function getSubGoal(store: { dag: { nodes: Map<string, SubGoal> } }, id: string): SubGoal | null {
|
|
54
|
+
return store.dag.nodes.get(id) ?? null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Update a sub-goal's status in the DAG. */
|
|
58
|
+
export function updateSubGoal(
|
|
59
|
+
store: { dag: { nodes: Map<string, SubGoal> } },
|
|
60
|
+
id: string,
|
|
61
|
+
status: SubGoalStatus,
|
|
62
|
+
): void {
|
|
63
|
+
const node = store.dag.nodes.get(id)
|
|
64
|
+
if (node) {
|
|
65
|
+
node.status = status
|
|
66
|
+
node.updatedAt = Date.now()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Check if all dependencies are satisfied for a sub-goal. */
|
|
71
|
+
export function canStartSubGoal(
|
|
72
|
+
store: { dag: { nodes: Map<string, SubGoal> } },
|
|
73
|
+
id: string,
|
|
74
|
+
): boolean {
|
|
75
|
+
const node = store.dag.nodes.get(id)
|
|
76
|
+
if (!node) return false
|
|
77
|
+
return node.dependencies.every(depId => {
|
|
78
|
+
const dep = store.dag.nodes.get(depId)
|
|
79
|
+
return dep?.status === 'completed' || dep?.status === 'skipped'
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// SDK adapter — converts SDK tool result format to goal system types
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Convert Anthropic SDK tool execution results to the goal system's
|
|
89
|
+
* internal ToolResult format. Used at the PostToolUse hook point in
|
|
90
|
+
* query.ts to bridge between the SDK's message format and goal types.
|
|
91
|
+
*
|
|
92
|
+
* This avoids importing SDK types directly in hooks (keeps the boundary
|
|
93
|
+
* clean) while providing a single conversion point for the goal system.
|
|
94
|
+
*/
|
|
95
|
+
export function convertSDKToolResults(
|
|
96
|
+
toolUseBlocks: ReadonlyArray<{ id: string; name: string; input: Record<string, unknown> }>,
|
|
97
|
+
toolResults: ReadonlyArray<{ type: string; message: { content: { type: string; text?: string; tool_use_id?: string; content?: string }[] } }>,
|
|
98
|
+
): { toolName: string; toolInput: Record<string, unknown>; toolOutput: string; success: boolean; timestamp: number }[] {
|
|
99
|
+
const results: { toolName: string; toolInput: Record<string, unknown>; toolOutput: string; success: boolean; timestamp: number }[] = []
|
|
100
|
+
let unmatchedCount = 0
|
|
101
|
+
|
|
102
|
+
for (const block of toolUseBlocks) {
|
|
103
|
+
// Find matching result for this tool use block
|
|
104
|
+
const matched = toolResults.find(
|
|
105
|
+
result =>
|
|
106
|
+
result.type === 'user' &&
|
|
107
|
+
Array.isArray(result.message.content) &&
|
|
108
|
+
result.message.content.some(
|
|
109
|
+
content =>
|
|
110
|
+
content.type === 'tool_result' &&
|
|
111
|
+
content.tool_use_id === block.id,
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
let output = ''
|
|
116
|
+
let success = true
|
|
117
|
+
if (matched) {
|
|
118
|
+
const resultBlock = matched.message.content.find(
|
|
119
|
+
content => content.type === 'tool_result' && content.tool_use_id === block.id,
|
|
120
|
+
)
|
|
121
|
+
if (resultBlock) {
|
|
122
|
+
output = typeof resultBlock.content === 'string'
|
|
123
|
+
? resultBlock.content
|
|
124
|
+
: JSON.stringify(resultBlock.content ?? '')
|
|
125
|
+
success = !(resultBlock as any).is_error
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
unmatchedCount++
|
|
129
|
+
success = false
|
|
130
|
+
output = '(no result found)'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
results.push({
|
|
134
|
+
toolName: block.name,
|
|
135
|
+
toolInput: block.input as Record<string, unknown>,
|
|
136
|
+
toolOutput: output,
|
|
137
|
+
success,
|
|
138
|
+
timestamp: Date.now(),
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Warn on unmapped results — may indicate SDK protocol change or
|
|
143
|
+
// tool results arriving outside the expected message order.
|
|
144
|
+
// One-off mismatches are normal (e.g., parallel tool calls), but
|
|
145
|
+
// consistent failures across all tools indicate a real problem.
|
|
146
|
+
if (unmatchedCount > 0) {
|
|
147
|
+
console.warn(
|
|
148
|
+
`[GoalAdapter] ${unmatchedCount}/${toolUseBlocks.length} tool result(s) unmatched. ` +
|
|
149
|
+
`Tool result count: ${toolResults.length}, tool use blocks: ${toolUseBlocks.length}. ` +
|
|
150
|
+
`If all results are missing, check SDK message format compatibility.`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return results
|
|
155
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal persistence layer — file-based save/load for goal state.
|
|
3
|
+
*
|
|
4
|
+
* Stores goal state in .claude/goals/<goalId>.json. Independent of the
|
|
5
|
+
* memory system (which is for long-term knowledge, not task tracking).
|
|
6
|
+
*
|
|
7
|
+
* Thread-safe: writes use rename-overwrite to avoid corruption on crash.
|
|
8
|
+
* Small files (<10KB) so synchronous IO is acceptable — no async needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, existsSync, readdirSync } from 'fs'
|
|
12
|
+
import { join, dirname } from 'path'
|
|
13
|
+
import { getProjectRoot } from '../bootstrap/state.js'
|
|
14
|
+
import type { GoalState, GoalId } from '../types/goal.js'
|
|
15
|
+
|
|
16
|
+
const GOALS_DIR_NAME = '.claude/goals'
|
|
17
|
+
const ARCHIVE_DIR_NAME = 'archive'
|
|
18
|
+
|
|
19
|
+
/** Resolve the absolute path to the goals directory. */
|
|
20
|
+
function getGoalsDir(): string {
|
|
21
|
+
const projectRoot = getProjectRoot()
|
|
22
|
+
return join(projectRoot, GOALS_DIR_NAME)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve the absolute path to a specific goal file. */
|
|
26
|
+
export function getGoalFilePath(id: GoalId): string {
|
|
27
|
+
return join(getGoalsDir(), `${id}.json`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Ensure the goals directory exists. Idempotent — safe to call repeatedly. */
|
|
31
|
+
export function ensureGoalsDir(): void {
|
|
32
|
+
const dir = getGoalsDir()
|
|
33
|
+
mkdirSync(dir, { recursive: true })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Ensure the archive subdirectory exists. */
|
|
37
|
+
export function ensureArchiveDir(): void {
|
|
38
|
+
const dir = join(getGoalsDir(), ARCHIVE_DIR_NAME)
|
|
39
|
+
mkdirSync(dir, { recursive: true })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Serialized format — wraps GoalState with version metadata
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
/** On-disk format: versioned envelope around the raw state.
|
|
47
|
+
* Version is incremented on every save; readers can detect conflicts. */
|
|
48
|
+
interface VersionedGoalFile {
|
|
49
|
+
version: number
|
|
50
|
+
state: GoalState
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Save goal state to disk atomically with version tracking.
|
|
55
|
+
* Uses write-then-rename pattern: write to a temp file first, then
|
|
56
|
+
* rename into place. This prevents partial writes on crash.
|
|
57
|
+
*
|
|
58
|
+
* The version field is incremented on every write. Callers can use
|
|
59
|
+
* the returned version for optimistic concurrency control.
|
|
60
|
+
*/
|
|
61
|
+
export function saveGoalState(state: GoalState): number {
|
|
62
|
+
if (!state.activeGoalId) {
|
|
63
|
+
throw new Error('Cannot save goal state without active goal ID')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ensureGoalsDir()
|
|
67
|
+
|
|
68
|
+
const targetPath = getGoalFilePath(state.activeGoalId)
|
|
69
|
+
const tmpPath = `${targetPath}.tmp`
|
|
70
|
+
|
|
71
|
+
// Read current version, increment for this write
|
|
72
|
+
const currentVersion = readVersion(state.activeGoalId)
|
|
73
|
+
const newVersion = currentVersion + 1
|
|
74
|
+
|
|
75
|
+
const envelope: VersionedGoalFile = { version: newVersion, state }
|
|
76
|
+
const json = JSON.stringify(envelope, null, 2)
|
|
77
|
+
writeFileSync(tmpPath, json, 'utf8')
|
|
78
|
+
renameSync(tmpPath, targetPath)
|
|
79
|
+
|
|
80
|
+
return newVersion
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load goal state from disk. Returns null if the file does not exist
|
|
85
|
+
* or is corrupted (invalid JSON).
|
|
86
|
+
*
|
|
87
|
+
* Reads both the state and its version number from the versioned
|
|
88
|
+
* envelope format.
|
|
89
|
+
*/
|
|
90
|
+
export function loadGoalState(id: GoalId): GoalState | null {
|
|
91
|
+
const path = getGoalFilePath(id)
|
|
92
|
+
if (!existsSync(path)) return null
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const raw = readFileSync(path, 'utf8')
|
|
96
|
+
const envelope = JSON.parse(raw) as VersionedGoalFile
|
|
97
|
+
|
|
98
|
+
// Basic structural validation (on the inner state)
|
|
99
|
+
validateGoalStateShape(envelope.state)
|
|
100
|
+
|
|
101
|
+
return envelope.state as GoalState
|
|
102
|
+
} catch {
|
|
103
|
+
// Corrupted JSON or validation failure — return null
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read just the version number from a goal file (without parsing the full state).
|
|
109
|
+
* Returns 0 if the file doesn't exist yet (brand-new goal). */
|
|
110
|
+
export function readVersion(id: GoalId): number {
|
|
111
|
+
const path = getGoalFilePath(id)
|
|
112
|
+
if (!existsSync(path)) return 0
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const raw = readFileSync(path, 'utf8')
|
|
116
|
+
const envelope = JSON.parse(raw) as VersionedGoalFile
|
|
117
|
+
return envelope.version ?? 0
|
|
118
|
+
} catch {
|
|
119
|
+
return 0
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Save state only if the current on-disk version matches the expected version.
|
|
125
|
+
* This provides optimistic concurrency control — if another writer has modified
|
|
126
|
+
* the file since we last read it, the save is rejected (returns false).
|
|
127
|
+
*
|
|
128
|
+
* Returns the new version number on success, or -1 on conflict.
|
|
129
|
+
*/
|
|
130
|
+
export function saveIfUnchanged(expectedVersion: number, state: GoalState): number {
|
|
131
|
+
if (!state.activeGoalId) {
|
|
132
|
+
throw new Error('Cannot save goal state without active goal ID')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
ensureGoalsDir()
|
|
136
|
+
|
|
137
|
+
const targetPath = getGoalFilePath(state.activeGoalId)
|
|
138
|
+
const currentVersion = readVersion(state.activeGoalId)
|
|
139
|
+
|
|
140
|
+
if (currentVersion !== expectedVersion && currentVersion !== 0) {
|
|
141
|
+
// Conflict: another writer changed the file since we last read it
|
|
142
|
+
return -1
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const newVersion = currentVersion + 1
|
|
146
|
+
const envelope: VersionedGoalFile = { version: newVersion, state }
|
|
147
|
+
const json = JSON.stringify(envelope, null, 2)
|
|
148
|
+
|
|
149
|
+
const tmpPath = `${targetPath}.tmp`
|
|
150
|
+
writeFileSync(tmpPath, json, 'utf8')
|
|
151
|
+
renameSync(tmpPath, targetPath)
|
|
152
|
+
|
|
153
|
+
return newVersion
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** List all saved goal files (return their IDs). */
|
|
157
|
+
export function listGoalFiles(): GoalId[] {
|
|
158
|
+
const dir = getGoalsDir()
|
|
159
|
+
if (!existsSync(dir)) return []
|
|
160
|
+
|
|
161
|
+
return readdirSync(dir)
|
|
162
|
+
.filter(f => f.endsWith('.json') && !f.startsWith('.'))
|
|
163
|
+
.map(f => f.replace('.json', '') as GoalId)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Move a goal file to the archive subdirectory. */
|
|
167
|
+
export function archiveGoal(id: GoalId): void {
|
|
168
|
+
ensureArchiveDir()
|
|
169
|
+
const src = getGoalFilePath(id)
|
|
170
|
+
const dst = join(getGoalsDir(), ARCHIVE_DIR_NAME, `${id}.json`)
|
|
171
|
+
|
|
172
|
+
if (!existsSync(src)) return
|
|
173
|
+
renameSync(src, dst)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Delete a goal file from disk. Irreversible. */
|
|
177
|
+
export function deleteGoal(id: GoalId): void {
|
|
178
|
+
const path = getGoalFilePath(id)
|
|
179
|
+
if (existsSync(path)) {
|
|
180
|
+
unlinkSync(path)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Basic structural validation for loaded JSON.
|
|
186
|
+
* Checks that required fields exist and have correct types.
|
|
187
|
+
* Does NOT validate all nested fields — just enough to reject
|
|
188
|
+
* obviously broken files.
|
|
189
|
+
*/
|
|
190
|
+
function validateGoalStateShape(parsed: unknown): void {
|
|
191
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
192
|
+
throw new Error('Invalid goal state: not an object')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const obj = parsed as Record<string, unknown>
|
|
196
|
+
|
|
197
|
+
if (typeof obj.activeGoalId !== 'string' && obj.activeGoalId !== null) {
|
|
198
|
+
throw new Error('Invalid goal state: activeGoalId must be string or null')
|
|
199
|
+
}
|
|
200
|
+
if (typeof obj.progress !== 'number') {
|
|
201
|
+
throw new Error('Invalid goal state: progress must be number')
|
|
202
|
+
}
|
|
203
|
+
if (!Array.isArray(obj.artifacts)) {
|
|
204
|
+
throw new Error('Invalid goal state: artifacts must be array')
|
|
205
|
+
}
|
|
206
|
+
}
|