bingocode 1.1.183 → 1.1.185

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.
@@ -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
+ }