pantheon-opencode 1.3.2 → 1.3.3

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,102 @@
1
+ /**
2
+ * Dispatch Guard (Wave 4, PR #46) — auto-retry-on-empty for background
3
+ * delegations. Classifies a dispatch result and redispatch once when it
4
+ * came back empty.
5
+ *
6
+ * Failure signature classification:
7
+ * - 'content' — text content present, nothing to do.
8
+ * - 'empty-mode1' — NO content AND NO tokens: the dispatch returned
9
+ * nothing at all (no reply / early abort).
10
+ * - 'empty-mode2' — no text part but tokens present: reasoning happened
11
+ * but the text part was lost — the themis Wave-2
12
+ * failure signature that triggered this guard.
13
+ *
14
+ * Retry cap is HARD at 1 (never 2x). The guard is stateful per instance:
15
+ * one retry budget total. It is a PURE library — it does NOT intercept
16
+ * task() (opencode 1.18.x cannot intercept task completion via hooks, so
17
+ * integration is manual-orchestration; see zeus.md "Empty-Result Retry").
18
+ *
19
+ * @module dispatch-guard
20
+ */
21
+
22
+ /** A dispatch result — the minimal shape needed to classify emptiness. */
23
+ export interface DispatchResultLike {
24
+ /** Text content of the result (task_result / report markdown). */
25
+ content?: string | null
26
+ /** Input tokens (present when the model actually reasoned). */
27
+ tokensInput?: number
28
+ /** Output tokens (present when the model actually reasoned). */
29
+ tokensOutput?: number
30
+ }
31
+
32
+ /** Classification of a dispatch result. */
33
+ export type DispatchClassification = 'content' | 'empty-mode1' | 'empty-mode2'
34
+
35
+ export interface DispatchGuardOptions {
36
+ /** Enable auto-retry on empty results. Default: true. */
37
+ retryOnEmpty?: boolean
38
+ /** Injectable logger (defaults to console.warn with a prefix). */
39
+ logger?: { warn: (message: string) => void }
40
+ }
41
+
42
+ export interface DispatchGuard {
43
+ /** Classify a result: content, empty-mode1 (nothing), empty-mode2 (reasoning, no text). */
44
+ classifyResult(result: DispatchResultLike): DispatchClassification
45
+ /**
46
+ * Redispatch ONCE when the result is empty and retryOnEmpty is enabled.
47
+ * A retried result that is STILL empty is returned as-is — the cap of 1
48
+ * is never exceeded. Never throws.
49
+ */
50
+ maybeRetry(
51
+ result: DispatchResultLike,
52
+ redispatch: () => Promise<DispatchResultLike>,
53
+ ): Promise<{
54
+ classification: DispatchClassification
55
+ retried: boolean
56
+ result: DispatchResultLike
57
+ }>
58
+ }
59
+
60
+ /** 1-based retry budget — the hard cap. Never 2x. */
61
+ const MAX_RETRIES = 1
62
+
63
+ /** Whether a result carries any text content (whitespace-only counts as empty). */
64
+ function hasContent(result: DispatchResultLike): boolean {
65
+ return typeof result.content === 'string' && result.content.trim() !== ''
66
+ }
67
+
68
+ /** Whether the result carries any token counts (i.e. the model actually ran). */
69
+ function hasTokens(result: DispatchResultLike): boolean {
70
+ return (result.tokensInput ?? 0) > 0 || (result.tokensOutput ?? 0) > 0
71
+ }
72
+
73
+ export function createDispatchGuard(options?: DispatchGuardOptions): DispatchGuard {
74
+ const retryOnEmpty = options?.retryOnEmpty ?? true
75
+ const warn =
76
+ options?.logger?.warn ??
77
+ ((message: string) => console.warn(`[pantheon-dispatch-guard] ${message}`))
78
+ // Per-instance retry budget — the "cap 1" enforcement point.
79
+ let retriesUsed = 0
80
+
81
+ return {
82
+ classifyResult(result: DispatchResultLike): DispatchClassification {
83
+ if (hasContent(result)) return 'content'
84
+ return hasTokens(result) ? 'empty-mode2' : 'empty-mode1'
85
+ },
86
+
87
+ async maybeRetry(result, redispatch) {
88
+ const classification = this.classifyResult(result)
89
+ if (!retryOnEmpty || classification === 'content' || retriesUsed >= MAX_RETRIES) {
90
+ return { classification, retried: false, result }
91
+ }
92
+ retriesUsed += 1
93
+ warn(`dispatch returned ${classification}; retrying once (cap ${MAX_RETRIES})`)
94
+ const retriedResult = await redispatch()
95
+ return {
96
+ classification: this.classifyResult(retriedResult),
97
+ retried: true,
98
+ result: retriedResult,
99
+ }
100
+ },
101
+ }
102
+ }
@@ -23,8 +23,9 @@ const DEFAULT_STATE_PATH = '.pantheon/board/state.json'
23
23
  * Writes are atomic: content is written to a `.tmp` file, then renamed.
24
24
  *
25
25
  * The board's in-memory `this.jobs` Map is the source of truth —
26
- * this file is a crash-recovery snapshot only. `deleteJob()` is a no-op
27
- * because stale entries are harmless and overwritten on the next `saveJob()`.
26
+ * this file is a crash-recovery snapshot. `deleteJob()` removes the
27
+ * record from the snapshot so pruned/reconciled jobs don't resurface
28
+ * after a restart.
28
29
  */
29
30
  export class FilePersistenceAdapter implements PersistenceAdapter {
30
31
  private readonly statePath: string
@@ -87,9 +88,12 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
87
88
  return (await this.readState()) ?? []
88
89
  }
89
90
 
90
- async deleteJob(_taskID: string): Promise<void> {
91
- // No-op: the board's in-memory Map is the source of truth.
92
- // The file is a crash-recovery snapshot; stale entries are harmless
93
- // and will be overwritten on the next saveJob().
91
+ async deleteJob(taskID: string): Promise<void> {
92
+ const records = await this.readState()
93
+ if (!records) return
94
+ const idx = records.findIndex(r => r.taskID === taskID)
95
+ if (idx < 0) return
96
+ records.splice(idx, 1)
97
+ await this.writeState(records)
94
98
  }
95
99
  }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Full-auto Goal Loop (Wave 3, PR #46) — one active goal per session,
3
+ * continued automatically while idle. `pantheon_goal_create` starts a goal;
4
+ * `pantheon_goal_update` moves it pending → in_progress → done; while
5
+ * active, `GoalLoop.onIdle` re-injects the continuation prompt (objective
6
+ * restated from the store) until marked done. Council-approved cut: NO LLM
7
+ * completion-audit prompt — the state machine is the source of truth.
8
+ *
9
+ * Persistence: FILE-based GoalStore (`.pantheon/goals/<sessionID>.json`,
10
+ * atomic tmp+rename). Opt-in `full_auto.enabled: false` default. Guards:
11
+ * board-running skip, in-flight Set, cooldown, max_continuations cap (25).
12
+ * Injectable now() clock. Pure TypeScript — zero runtime deps.
13
+ *
14
+ * @module goal-loop
15
+ */
16
+
17
+ import { z } from 'zod'
18
+
19
+ import type { BackgroundJobBoard } from './background-job-board.ts'
20
+ import type { ToolContextLike } from './delegation.ts'
21
+ import { GoalStore } from './goal-store.ts'
22
+ import type { TodoEnforcerMessage } from './todo-enforcer.ts'
23
+
24
+ export type { Goal, GoalStatus } from './goal-store.ts'
25
+ export { GoalStore }
26
+
27
+ // ─── Constants ─────────────────────────────────────────────────────────
28
+
29
+ /** Continuation text injected into idle sessions. `{objective}` is substituted. */
30
+ export const GOAL_CONTINUATION_PROMPT =
31
+ "Active goal: {objective}. Continue working toward it. Do not stop until you update the goal to done. If you believe it's complete, call pantheon_goal_update with status done."
32
+
33
+ /** Defaults matching routing.yml `full_auto`. */
34
+ export const GOAL_LOOP_DEFAULTS = {
35
+ enabled: false,
36
+ cooldownMs: 5000,
37
+ maxContinuations: 25,
38
+ } as const
39
+
40
+ // ─── Types ─────────────────────────────────────────────────────────────
41
+
42
+ /** Structural client surface — the subset of the SDK the loop uses. */
43
+ export interface GoalLoopClient {
44
+ session: {
45
+ messages(input: { path: { id: string } }): Promise<TodoEnforcerMessage[]>
46
+ promptAsync(input: {
47
+ path: { id: string }
48
+ body: {
49
+ agent?: string
50
+ model?: { providerID: string; modelID: string }
51
+ parts: Array<{ type: 'text'; text: string }>
52
+ }
53
+ }): Promise<unknown>
54
+ }
55
+ }
56
+
57
+ /** Tunables (all optional — see GOAL_LOOP_DEFAULTS). */
58
+ export interface GoalLoopOptions {
59
+ enabled?: boolean
60
+ /** Minimum gap between continuations (ms). */
61
+ cooldownMs?: number
62
+ /** Hard cap on continuations per goal — after this the loop halts. */
63
+ maxContinuations?: number
64
+ /** Injectable clock (testable), defaults to `Date.now`. */
65
+ now?: () => number
66
+ }
67
+
68
+ /** Dependencies threaded to the loop (structural board — list only). */
69
+ export interface GoalLoopDeps {
70
+ store: GoalStore
71
+ client: GoalLoopClient
72
+ board: Pick<BackgroundJobBoard, 'list'>
73
+ options?: GoalLoopOptions
74
+ logger?: { warn: (message: string) => void }
75
+ }
76
+
77
+ type InheritedContext = {
78
+ agent?: string
79
+ model?: { providerID: string; modelID: string }
80
+ }
81
+
82
+ /** Render a tool failure as error text (tools return errors as TEXT). */
83
+ function toolFailure(prefix: string, err: unknown): string {
84
+ const reason = err instanceof Error ? err.message : String(err)
85
+ return `${prefix} failed: ${reason}`
86
+ }
87
+
88
+ // ─── Args schemas ──────────────────────────────────────────────────────
89
+
90
+ const goalCreateArgs = {
91
+ objective: z.string().min(1).describe('The goal objective the session keeps working toward.'),
92
+ } satisfies z.ZodRawShape
93
+
94
+ const goalUpdateArgs = {
95
+ status: z.enum(['pending', 'in_progress', 'done']).optional().describe('New goal status.'),
96
+ objective: z.string().min(1).optional().describe('New goal objective.'),
97
+ } satisfies z.ZodRawShape
98
+
99
+ const goalGetArgs = {} satisfies z.ZodRawShape
100
+
101
+ /** One structural goal tool: description + zod args shape + execute (delegation.ts shape). */
102
+ export type GoalTool<Args extends z.ZodRawShape> = {
103
+ description: string
104
+ args: Args
105
+ execute(args: z.infer<z.ZodObject<Args>>, ctx: ToolContextLike): Promise<string>
106
+ }
107
+
108
+ /** The three goal tools. */
109
+ export interface GoalToolset {
110
+ pantheon_goal_create: GoalTool<typeof goalCreateArgs>
111
+ pantheon_goal_update: GoalTool<typeof goalUpdateArgs>
112
+ pantheon_goal_get: GoalTool<typeof goalGetArgs>
113
+ }
114
+
115
+ // ─── GoalLoop ──────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Re-inject a continuation prompt into idle sessions with an active goal.
119
+ * Per-session state: last-continuation timestamp (cooldown) and an in-flight
120
+ * set (never concurrent). `onIdle` never throws — the event hook can never
121
+ * break the session.
122
+ */
123
+ export class GoalLoop {
124
+ private readonly store: GoalStore
125
+ private readonly client: GoalLoopClient
126
+ private readonly board: Pick<BackgroundJobBoard, 'list'>
127
+ private readonly options: Required<GoalLoopOptions>
128
+ private readonly warn: (message: string) => void
129
+
130
+ private readonly inFlight = new Set<string>()
131
+ private readonly lastContinuationAt = new Map<string, number>()
132
+
133
+ constructor(deps: GoalLoopDeps) {
134
+ this.store = deps.store
135
+ this.client = deps.client
136
+ this.board = deps.board
137
+ const opts = deps.options ?? {}
138
+ this.options = {
139
+ enabled: opts.enabled ?? GOAL_LOOP_DEFAULTS.enabled,
140
+ cooldownMs: opts.cooldownMs ?? GOAL_LOOP_DEFAULTS.cooldownMs,
141
+ maxContinuations: opts.maxContinuations ?? GOAL_LOOP_DEFAULTS.maxContinuations,
142
+ now: opts.now ?? Date.now,
143
+ }
144
+ this.warn =
145
+ deps.logger?.warn ?? ((message: string) => console.warn(`[pantheon-goal] ${message}`))
146
+ }
147
+
148
+ /** True when the session has an active (non-done) goal. Used by the idle dispatcher. */
149
+ async hasActiveGoal(sessionID: string): Promise<boolean> {
150
+ return this.store.hasActive(sessionID)
151
+ }
152
+
153
+ /** Handle a `session.idle` event for a session with an active goal. Never throws. */
154
+ async onIdle(sessionID: string): Promise<void> {
155
+ if (!this.options.enabled) return
156
+ if (this.inFlight.has(sessionID)) return
157
+ this.inFlight.add(sessionID)
158
+ try {
159
+ await this.handleIdle(sessionID)
160
+ } catch (err: unknown) {
161
+ const reason = err instanceof Error ? err.message : String(err)
162
+ this.warn(`goal continuation failed for session ${sessionID}: ${reason}`)
163
+ } finally {
164
+ this.inFlight.delete(sessionID)
165
+ }
166
+ }
167
+
168
+ private async handleIdle(sessionID: string): Promise<void> {
169
+ const goal = await this.store.load(sessionID)
170
+ // DONE (or absent) goal → nothing to continue.
171
+ if (goal === undefined || goal.status === 'done') return
172
+
173
+ // A session with a running background job is busy — skip.
174
+ if (this.board.list(sessionID).some((job) => job.state === 'running')) return
175
+
176
+ // Hard cap: stop the loop once the cap is reached.
177
+ if (goal.continuationCount >= this.options.maxContinuations) return
178
+
179
+ const now = this.options.now()
180
+ const last = this.lastContinuationAt.get(sessionID)
181
+ if (last !== undefined && now - last < this.options.cooldownMs) return
182
+
183
+ // Timestamp BEFORE the call — a failed injection still applies the
184
+ // cooldown (no hot retry loop).
185
+ this.lastContinuationAt.set(sessionID, now)
186
+ const inherited = await this.inheritAgentOrModel(sessionID)
187
+ const prompt = GOAL_CONTINUATION_PROMPT.replace('{objective}', goal.objective)
188
+ await this.client.session.promptAsync({
189
+ path: { id: sessionID },
190
+ body: {
191
+ ...(inherited.agent !== undefined ? { agent: inherited.agent } : {}),
192
+ ...(inherited.model !== undefined ? { model: inherited.model } : {}),
193
+ parts: [{ type: 'text', text: prompt }],
194
+ },
195
+ })
196
+ goal.continuationCount += 1
197
+ goal.updatedAt = now
198
+ await this.store.save(goal)
199
+ }
200
+
201
+ /**
202
+ * Inherit the agent (or model) from the LAST assistant message so the
203
+ * continuation runs in the same context as the session's last turn.
204
+ */
205
+ private async inheritAgentOrModel(sessionID: string): Promise<InheritedContext> {
206
+ const messages = await this.client.session.messages({ path: { id: sessionID } })
207
+ for (const msg of [...messages].reverse()) {
208
+ const info = msg.info
209
+ if (info === undefined || info.role !== 'assistant') continue
210
+ if (typeof info.agent === 'string' && info.agent !== '') {
211
+ return { agent: info.agent }
212
+ }
213
+ const providerID = info.model?.providerID ?? info.providerID
214
+ const modelID = info.model?.modelID ?? info.modelID
215
+ if (
216
+ typeof providerID === 'string' &&
217
+ providerID !== '' &&
218
+ typeof modelID === 'string' &&
219
+ modelID !== ''
220
+ ) {
221
+ return { model: { providerID, modelID } }
222
+ }
223
+ }
224
+ return {}
225
+ }
226
+
227
+ /** Build the three structural goal tools (single active goal per session). */
228
+ tools(): GoalToolset {
229
+ return {
230
+ pantheon_goal_create: {
231
+ description:
232
+ 'Create the single active goal for this session. The goal loop then continues the session automatically on idle until the goal is marked done.',
233
+ args: goalCreateArgs,
234
+ execute: async (args, ctx) => {
235
+ try {
236
+ this.store.sanitizeSessionID(ctx.sessionID)
237
+ const existing = await this.store.load(ctx.sessionID)
238
+ if (existing !== undefined && existing.status !== 'done') {
239
+ return `pantheon_goal_create failed: session ${ctx.sessionID} already has an active goal`
240
+ }
241
+ const now = this.options.now()
242
+ await this.store.save({
243
+ id: ctx.sessionID,
244
+ sessionID: ctx.sessionID,
245
+ objective: args.objective,
246
+ status: 'pending',
247
+ createdAt: now,
248
+ updatedAt: now,
249
+ continuationCount: 0,
250
+ })
251
+ return `Goal created (${ctx.sessionID}): ${args.objective}`
252
+ } catch (err: unknown) {
253
+ return toolFailure('pantheon_goal_create', err)
254
+ }
255
+ },
256
+ },
257
+ pantheon_goal_update: {
258
+ description:
259
+ 'Update the active goal: set status (pending/in_progress/done) and/or rewrite the objective. Setting status to done stops all further continuations.',
260
+ args: goalUpdateArgs,
261
+ execute: async (args, ctx) => {
262
+ try {
263
+ const goal = await this.store.load(ctx.sessionID)
264
+ if (goal === undefined) {
265
+ return `pantheon_goal_update failed: no active goal for session ${ctx.sessionID}`
266
+ }
267
+ if (args.status === undefined && args.objective === undefined) {
268
+ return 'pantheon_goal_update failed: provide status and/or objective'
269
+ }
270
+ if (args.objective !== undefined) goal.objective = args.objective
271
+ if (args.status !== undefined) goal.status = args.status
272
+ goal.updatedAt = this.options.now()
273
+ await this.store.save(goal)
274
+ return `Goal updated: status=${goal.status}, objective=${goal.objective}`
275
+ } catch (err: unknown) {
276
+ return toolFailure('pantheon_goal_update', err)
277
+ }
278
+ },
279
+ },
280
+ pantheon_goal_get: {
281
+ description: 'Return the active goal for this session (status, objective, continuations).',
282
+ args: goalGetArgs,
283
+ execute: async (_args, ctx) => {
284
+ try {
285
+ const goal = await this.store.load(ctx.sessionID)
286
+ if (goal === undefined) {
287
+ return `No active goal for session ${ctx.sessionID}`
288
+ }
289
+ return `Goal (${goal.id}): status=${goal.status}, objective=${goal.objective}, continuations=${goal.continuationCount}`
290
+ } catch (err: unknown) {
291
+ return toolFailure('pantheon_goal_get', err)
292
+ }
293
+ },
294
+ },
295
+ }
296
+ }
297
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * File-based goal persistence for the full-auto Goal Loop (Wave 3, PR #46).
3
+ *
4
+ * One JSON file per session (`.pantheon/goals/<sessionID>.json`), written
5
+ * atomically (tmp + rename) so a crash never leaves a partial goal. The
6
+ * plugin layer cannot reach MCP KV servers, so goals live on disk.
7
+ * Session IDs are sanitized before path construction (path-traversal guard,
8
+ * same pattern as assertSafeParentSessionID).
9
+ *
10
+ * Pure TypeScript — zero runtime deps beyond node:fs.
11
+ *
12
+ * @module goal-store
13
+ */
14
+
15
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
16
+ import { join } from 'node:path'
17
+
18
+ import { assertSafeParentSessionID } from './delegation-finalize.ts'
19
+
20
+ export type GoalStatus = 'pending' | 'in_progress' | 'done'
21
+
22
+ /** One goal persisted at `.pantheon/goals/<sessionID>.json`. */
23
+ export interface Goal {
24
+ id: string
25
+ sessionID: string
26
+ objective: string
27
+ status: GoalStatus
28
+ createdAt: number
29
+ updatedAt: number
30
+ continuationCount: number
31
+ }
32
+
33
+ const DEFAULT_GOAL_DIR = '.pantheon/goals'
34
+
35
+ /**
36
+ * File-based goal persistence. `save` writes atomically (tmp + rename);
37
+ * `load` returns undefined when no goal exists yet.
38
+ */
39
+ export class GoalStore {
40
+ private readonly dir: string
41
+
42
+ constructor(opts: { dir?: string } = {}) {
43
+ this.dir = opts.dir ?? DEFAULT_GOAL_DIR
44
+ }
45
+
46
+ /**
47
+ * Path-traversal guard for session IDs embedded in file paths (same
48
+ * pattern as assertSafeParentSessionID). Throws on `..`, `/`, `\`.
49
+ */
50
+ sanitizeSessionID(sessionID: string): string {
51
+ assertSafeParentSessionID(sessionID)
52
+ return sessionID
53
+ }
54
+
55
+ private pathFor(sessionID: string): string {
56
+ return join(this.dir, `${this.sanitizeSessionID(sessionID)}.json`)
57
+ }
58
+
59
+ /** Load the session's goal; undefined when none exists yet. */
60
+ async load(sessionID: string): Promise<Goal | undefined> {
61
+ const path = this.pathFor(sessionID)
62
+ try {
63
+ const content = await readFile(path, 'utf-8')
64
+ return JSON.parse(content) as Goal
65
+ } catch (err: unknown) {
66
+ if (
67
+ err instanceof Error &&
68
+ 'code' in err &&
69
+ (err as NodeJS.ErrnoException).code === 'ENOENT'
70
+ ) {
71
+ return undefined
72
+ }
73
+ throw err
74
+ }
75
+ }
76
+
77
+ /** Persist a goal atomically (tmp + rename). */
78
+ async save(goal: Goal): Promise<void> {
79
+ const path = this.pathFor(goal.sessionID)
80
+ const tmpPath = `${path}.tmp`
81
+ await mkdir(this.dir, { recursive: true })
82
+ await writeFile(tmpPath, JSON.stringify(goal, null, 2), 'utf-8')
83
+ await rename(tmpPath, path)
84
+ }
85
+
86
+ /** True when the session has a goal that is not `done`. */
87
+ async hasActive(sessionID: string): Promise<boolean> {
88
+ const goal = await this.load(sessionID)
89
+ return goal !== undefined && goal.status !== 'done'
90
+ }
91
+
92
+ /** List the session's goals — at most one (YAGNI: single active goal). */
93
+ async list(sessionID: string): Promise<Goal[]> {
94
+ const goal = await this.load(sessionID)
95
+ return goal === undefined ? [] : [goal]
96
+ }
97
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Hashline core (Wave 2, PR #46) — tagged-line formatting, ref parsing, and
3
+ * mismatch diagnostics shared by the read enhancer and the hashline_edit tool.
4
+ *
5
+ * A "ref" is the stable anchor used by hashline_edit: `{line}#{tag}` where
6
+ * the tag is the 2-char sha256 hash of the line content. Ref format:
7
+ * ^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$
8
+ *
9
+ * @module hashline/core
10
+ */
11
+
12
+ import { HASHLINE_DICT, hashTag } from './xxhash.ts'
13
+
14
+ /** A ref string: line number + 2-char tag (e.g. "12#XJ"). */
15
+ export const HASHLINE_REF_RE = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
16
+
17
+ /** Matches an already-tagged line (`12#XJ|content`) — idempotency guard. */
18
+ const TAGGED_LINE_RE = new RegExp(`^\\s*[0-9]+#[${HASHLINE_DICT}]{2}\\|`)
19
+
20
+ /** A resolved line match used in mismatch diagnostics. */
21
+ export interface LineMatch {
22
+ /** 1-based line number. */
23
+ line: number
24
+ /** The tag ACTUALLY computed for the line content. */
25
+ tag: string
26
+ /** The raw line content (for the excerpt). */
27
+ content?: string | undefined
28
+ }
29
+
30
+ /**
31
+ * Format a line as a hashline-tagged line: `{lineNo}#{tag}|{content}`.
32
+ * This is the format the read enhancer emits and the edit tool consumes.
33
+ */
34
+ export function formatTaggedLine(lineNo: number, content: string): string {
35
+ return `${lineNo}#${hashTag(content, lineNo)}|${content}`
36
+ }
37
+
38
+ /**
39
+ * Whether a line is already hashline-tagged (idempotency check). Used by the
40
+ * read enhancer to avoid re-tagging and by tests to verify the format.
41
+ */
42
+ export function isTaggedLine(line: string): boolean {
43
+ return TAGGED_LINE_RE.test(line)
44
+ }
45
+
46
+ /**
47
+ * Build the "Did you mean" suggestion for a mismatched ref: the corrected
48
+ * ref is the ACTUAL line number + ACTUAL tag, e.g. `Did you mean "12#XJ"?`.
49
+ */
50
+ export function suggestLineForHash(lineNo: number, tag: string): string {
51
+ return `Did you mean "${lineNo}#${tag}"?`
52
+ }
53
+
54
+ /**
55
+ * Build the full mismatch error text for the hashline_edit tool (returned
56
+ * as error-as-text, never thrown, never partially written):
57
+ *
58
+ * hashline_edit: ref "12#QQ" does not match /path/file at line 12 (tag "XJ")
59
+ *
60
+ * >>>12#XJ|actual line content
61
+ * ... 13#AB|next line
62
+ *
63
+ * Did you mean "12#XJ"?
64
+ *
65
+ * The excerpt is a RE-TAGGED view of the original `lines` around the target
66
+ * line (±2 lines of context), with the mismatched line marked `>>>` and
67
+ * carrying its ACTUAL tag.
68
+ */
69
+ export function buildMismatchError(
70
+ file: string,
71
+ ref: string,
72
+ actual: LineMatch,
73
+ lines: readonly string[],
74
+ ): string {
75
+ const start = Math.max(1, actual.line - 2)
76
+ const end = Math.min(lines.length, actual.line + 2)
77
+ const excerpt: string[] = []
78
+ for (let i = start; i <= end; i++) {
79
+ const content = lines[i - 1] ?? ''
80
+ const tag = i === actual.line ? actual.tag : hashTag(content, i)
81
+ const marker = i === actual.line ? '>>>' : ' '
82
+ excerpt.push(`${marker}${i}#${tag}|${content}`)
83
+ }
84
+ return [
85
+ `hashline_edit: ref "${ref}" does not match ${file} at line ${actual.line} (tag "${actual.tag}")`,
86
+ '',
87
+ excerpt.join('\n'),
88
+ '',
89
+ suggestLineForHash(actual.line, actual.tag),
90
+ ].join('\n')
91
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Read enhancer (Wave 2, PR #46) — augments the `read` tool output with
3
+ * per-line hashline tags so the model can anchor hashline_edit refs.
4
+ *
5
+ * Wired as the plugin's `tool.execute.after` hook (purely additive — the
6
+ * plugin has no `tool.execute.after` yet; pantheon-hooks.ts is a SEPARATE
7
+ * plugin instance, so no key collision). Only `input.tool === 'read'` is
8
+ * touched; every other tool passes through byte-for-byte.
9
+ *
10
+ * Transform: lines matching `^\s*(\d+): ?(.*)$` or `^\s*(\d+)\| ?(.*)$` are
11
+ * rewritten as `{line}#{tag}|{content}`. Already-tagged lines are skipped
12
+ * (idempotent — safe across multiple hook invocations). Lines that do not
13
+ * carry a line-number prefix (truncation markers like `... 500 more lines`,
14
+ * bare text) are left untouched.
15
+ *
16
+ * @module hashline/read-enhancer
17
+ */
18
+
19
+ import { isTaggedLine } from './core.ts'
20
+ import { hashTag } from './xxhash.ts'
21
+
22
+ /** Input shape of the opencode `tool.execute.after` hook. */
23
+ export interface ToolExecuteAfterInput {
24
+ tool: string
25
+ sessionID: string
26
+ callID: string
27
+ args: unknown
28
+ }
29
+
30
+ /** Output shape of the opencode `tool.execute.after` hook (mutable). */
31
+ export interface ToolExecuteAfterOutput {
32
+ title?: string
33
+ output: string
34
+ metadata?: Record<string, unknown>
35
+ }
36
+
37
+ /** The `tool.execute.after` handler shape. */
38
+ export type ToolExecuteAfterHandler = (
39
+ input: ToolExecuteAfterInput,
40
+ output: ToolExecuteAfterOutput,
41
+ ) => Promise<void>
42
+
43
+ /** Read-output line prefixes: `N: content`, `N| content`, `N|content`. */
44
+ const LINE_PREFIX_RE = /^\s*([0-9]+): ?(.*)$|^\s*([0-9]+)\| ?(.*)$/
45
+
46
+ /**
47
+ * Build the read enhancer handler. Throws nothing — any unexpected state is
48
+ * a no-op so the hook can never break the tool result.
49
+ */
50
+ export function createReadEnhancer(): ToolExecuteAfterHandler {
51
+ return async (input: ToolExecuteAfterInput, output: ToolExecuteAfterOutput): Promise<void> => {
52
+ if (input.tool !== 'read') return
53
+ if (typeof output.output !== 'string') return
54
+
55
+ const tagged = output.output.split('\n').map((line: string) => {
56
+ if (isTaggedLine(line)) return line
57
+ const m = LINE_PREFIX_RE.exec(line)
58
+ if (!m) return line
59
+ const lineNo = Number(m[1] ?? m[3])
60
+ const content = m[2] ?? m[4] ?? ''
61
+ return `${lineNo}#${hashTag(content, lineNo)}|${content}`
62
+ })
63
+
64
+ output.output = tagged.join('\n')
65
+ }
66
+ }