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,259 @@
1
+ /**
2
+ * Hashline edit tool (Wave 2, PR #46) — structural `hashline_edit` tool that
3
+ * anchors edits to stable sha256 content tags instead of fragile line numbers.
4
+ *
5
+ * Tool shape matches the delegation tools (`{description, args, execute}`,
6
+ * zod-validated — same import pattern as delegation.ts).
7
+ *
8
+ * Safety model (council-approved):
9
+ * 1. ALL refs are validated against the ORIGINAL file snapshot BEFORE any
10
+ * write — a mismatch returns an error-as-text with a re-tagged excerpt,
11
+ * `>>>` marker, and a Did-you-mean suggestion. NO partial write ever.
12
+ * 2. Edits are applied BOTTOM-UP (highest line first) so every ref resolves
13
+ * against the original numbering, even for adjacent edits.
14
+ *
15
+ * Ops:
16
+ * - replace: `ref` (or `ref`..`endRef` range) → replace with `lines`
17
+ * - append: insert `content` AFTER the anchor `ref` line
18
+ * - prepend: insert `content` BEFORE the anchor `ref` line
19
+ * - delete: remove the `ref` line (or `ref`..`endRef` range); no `lines`
20
+ *
21
+ * @module hashline/tool
22
+ */
23
+
24
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises'
25
+ import { isAbsolute, resolve, sep } from 'node:path'
26
+ import { z } from 'zod'
27
+
28
+ import { buildMismatchError, HASHLINE_REF_RE } from './core.ts'
29
+ import { hashTag } from './xxhash.ts'
30
+
31
+ // ─── Types ─────────────────────────────────────────────────────────────
32
+
33
+ /** Structural tool context (subset of the opencode ToolContext). */
34
+ export interface HashlineEditContext {
35
+ sessionID: string
36
+ directory?: string
37
+ worktree?: string
38
+ agent?: string
39
+ }
40
+
41
+ /** One hashline edit operation. */
42
+ export interface HashlineEditOp {
43
+ op: 'replace' | 'append' | 'prepend' | 'delete'
44
+ /** Anchor ref "LINE#TAG" against the ORIGINAL snapshot. */
45
+ ref: string
46
+ /** Optional range end for replace/delete ("LINE#TAG"). */
47
+ endRef?: string
48
+ /** Replacement lines (replace). */
49
+ lines?: string[]
50
+ /** Text to insert (append/prepend; split on newlines). */
51
+ content?: string
52
+ }
53
+
54
+ /** Tool result — string (error-as-text) or success with metadata. */
55
+ export type HashlineToolResult =
56
+ | string
57
+ | { title?: string; output: string; metadata: { firstChangedLine: number; changedCount: number } }
58
+
59
+ // ─── Args schema ───────────────────────────────────────────────────────
60
+
61
+ const hashlineEditOpArgs = {
62
+ op: z.enum(['replace', 'append', 'prepend', 'delete']),
63
+ ref: z.string().min(1),
64
+ endRef: z.string().optional(),
65
+ lines: z.array(z.string()).optional(),
66
+ content: z.string().optional(),
67
+ } satisfies z.ZodRawShape
68
+
69
+ const hashlineEditArgs = {
70
+ file: z.string().min(1),
71
+ edits: z.array(z.object(hashlineEditOpArgs)).min(1),
72
+ } satisfies z.ZodRawShape
73
+
74
+ /** A ref validated against the original snapshot. */
75
+ interface ResolvedRef {
76
+ line: number
77
+ tag: string
78
+ /** Ref string as given (for error messages). */
79
+ raw: string
80
+ }
81
+
82
+ /** A fully validated edit, ready to apply bottom-up. */
83
+ interface ResolvedEdit {
84
+ op: HashlineEditOp['op']
85
+ line: number
86
+ endLine: number
87
+ insert: string[]
88
+ }
89
+
90
+ // ─── Helpers ───────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Resolve the edit file to an absolute path, with a containment guard.
94
+ *
95
+ * Relative paths must stay inside `base` (worktree → directory → cwd):
96
+ * `../` traversal (`../../etc/passwd`) is rejected as error-as-text, so no
97
+ * read/write ever happens outside the worktree. Absolute paths are trusted
98
+ * as-is — hashline_edit carries the same privilege as edit/write/bash, so
99
+ * this is cheap defense-in-depth, not a security boundary.
100
+ */
101
+ function resolveFile(
102
+ file: string,
103
+ ctx: HashlineEditContext,
104
+ ): { ok: true; file: string } | { ok: false; error: string } {
105
+ if (isAbsolute(file)) return { ok: true, file: resolve(file) }
106
+ const base = resolve(ctx.worktree ?? ctx.directory ?? process.cwd())
107
+ const resolved = resolve(base, file)
108
+ // Prefix check with a path.sep boundary: `/tmp/x` must not match `/tmp2/x`.
109
+ if (resolved !== base && !resolved.startsWith(base + sep)) {
110
+ return {
111
+ ok: false,
112
+ error: `hashline_edit: file "${file}" escapes the worktree (resolved to "${resolved}") — path containment denied`,
113
+ }
114
+ }
115
+ return { ok: true, file: resolved }
116
+ }
117
+
118
+ /** Parse + validate a ref string against the ORIGINAL snapshot lines. */
119
+ function resolveRef(raw: string, file: string, lines: readonly string[]): ResolvedRef | string {
120
+ const m = HASHLINE_REF_RE.exec(raw)
121
+ if (!m) {
122
+ return `hashline_edit: invalid ref "${raw}" — expected "LINE#TAG" (e.g. "12#XJ")`
123
+ }
124
+ const line = Number(m[1])
125
+ const tag = m[2]
126
+ if (line < 1 || line > lines.length) {
127
+ return `hashline_edit: ref "${raw}" line ${line} out of range — file has ${lines.length} line(s)`
128
+ }
129
+ const actual = hashTag(lines[line - 1] ?? '', line)
130
+ if (actual !== tag) {
131
+ return buildMismatchError(file, raw, { line, tag: actual, content: lines[line - 1] }, lines)
132
+ }
133
+ return { line, tag, raw }
134
+ }
135
+
136
+ /**
137
+ * Validate ALL edits against the original snapshot; returns the resolved
138
+ * edit list or the error-as-text for the first failure.
139
+ */
140
+ function validateEdits(
141
+ args: z.infer<z.ZodObject<typeof hashlineEditArgs>>,
142
+ file: string,
143
+ lines: readonly string[],
144
+ ): ResolvedEdit[] | string {
145
+ const resolved: ResolvedEdit[] = []
146
+ for (const [i, e] of args.edits.entries()) {
147
+ const pos = resolveRef(e.ref, file, lines)
148
+ if (typeof pos === 'string') return pos
149
+ const edit: ResolvedEdit = { op: e.op, line: pos.line, endLine: pos.line, insert: [] }
150
+
151
+ if (e.op === 'delete' && e.lines) {
152
+ return `hashline_edit: delete edit (index ${i}) must not provide "lines"`
153
+ }
154
+ if (e.op === 'replace' && (!e.lines || e.lines.length === 0)) {
155
+ return `hashline_edit: replace edit (index ${i}) requires non-empty "lines"`
156
+ }
157
+ if ((e.op === 'append' || e.op === 'prepend') && typeof e.content !== 'string') {
158
+ return `hashline_edit: ${e.op} edit (index ${i}) requires "content"`
159
+ }
160
+
161
+ if (e.endRef) {
162
+ const end = resolveRef(e.endRef, file, lines)
163
+ if (typeof end === 'string') return end
164
+ if (end.line < edit.line) {
165
+ return `hashline_edit: endRef "${e.endRef}" is before ref "${e.ref}"`
166
+ }
167
+ edit.endLine = end.line
168
+ }
169
+
170
+ if (e.op === 'replace') {
171
+ edit.insert = e.lines ?? []
172
+ } else if (e.op === 'append' || e.op === 'prepend') {
173
+ edit.insert = (e.content ?? '').split('\n')
174
+ }
175
+ resolved.push(edit)
176
+ }
177
+ return resolved
178
+ }
179
+
180
+ // ─── Tool factory ──────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Build the structural `hashline_edit` tool. execute() returns error-as-text
184
+ * on any validation failure (never throws for user errors, never writes
185
+ * partially); on success returns the summary with `{firstChangedLine,
186
+ * changedCount}` metadata.
187
+ */
188
+ export function createHashlineEditTool() {
189
+ return {
190
+ description:
191
+ 'Edit a file anchored by hashline refs (LINE#TAG) instead of raw line numbers. ' +
192
+ 'Ops: replace (ref..endRef or single ref → lines), append/prepend (anchor ref → content), ' +
193
+ 'delete (ref..endRef). ALL refs must be validated against the ORIGINAL file before ' +
194
+ 'anything is written; on mismatch the tool returns an error with a re-tagged excerpt ' +
195
+ 'and a Did-you-mean suggestion, and nothing is modified.',
196
+ args: hashlineEditArgs,
197
+ execute: async (
198
+ args: z.infer<z.ZodObject<typeof hashlineEditArgs>>,
199
+ ctx: HashlineEditContext,
200
+ ): Promise<HashlineToolResult> => {
201
+ const resolved = resolveFile(args.file, ctx)
202
+ if (!resolved.ok) return resolved.error
203
+ const file = resolved.file
204
+
205
+ let original: string
206
+ try {
207
+ original = await readFile(file, 'utf8')
208
+ } catch {
209
+ return `hashline_edit: cannot read "${file}" — check the path and try again`
210
+ }
211
+
212
+ const hadTrailingNewline = original.endsWith('\n')
213
+ const lines = original.split('\n')
214
+
215
+ // Phase 1: validate every ref against the ORIGINAL snapshot — no writes.
216
+ const edits = validateEdits(args, file, lines)
217
+ if (typeof edits === 'string') return edits
218
+
219
+ // Phase 2: apply bottom-up (highest line first; ties: later edit first).
220
+ edits.sort((a, b) => b.line - a.line || b.endLine - a.endLine)
221
+ const working = [...lines]
222
+ for (const e of edits) {
223
+ const deleteCount = e.endLine - e.line + 1
224
+ if (e.op === 'replace') {
225
+ working.splice(e.line - 1, deleteCount, ...e.insert)
226
+ } else if (e.op === 'delete') {
227
+ working.splice(e.line - 1, deleteCount)
228
+ } else if (e.op === 'append') {
229
+ working.splice(e.line, 0, ...e.insert)
230
+ } else {
231
+ // prepend: insert before the anchor line.
232
+ working.splice(e.line - 1, 0, ...e.insert)
233
+ }
234
+ }
235
+
236
+ let out = working.join('\n')
237
+ if (hadTrailingNewline && !out.endsWith('\n')) out += '\n'
238
+ // Atomic write (tmp + rename — same pattern as file-persistence.ts
239
+ // writeState / background-job-board.ts writeSignal): a mid-write crash
240
+ // leaves at most a `.tmp-<pid>` file, never partial target content.
241
+ // The tmp file is best-effort cleaned up if the write or rename fails.
242
+ const tmpPath = `${file}.tmp-${process.pid}`
243
+ try {
244
+ await writeFile(tmpPath, out, 'utf8')
245
+ await rename(tmpPath, file)
246
+ } catch {
247
+ await rm(tmpPath, { force: true }).catch(() => {})
248
+ return `hashline_edit: cannot write "${file}" — check permissions and try again`
249
+ }
250
+
251
+ const firstChangedLine = Math.min(...edits.map((e) => e.line))
252
+ return {
253
+ title: 'hashline_edit',
254
+ output: `Updated ${file}: ${edits.length} edit(s) applied, first changed line ${firstChangedLine}.`,
255
+ metadata: { firstChangedLine, changedCount: edits.length },
256
+ }
257
+ },
258
+ }
259
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Hashline tag computation (Wave 2, PR #46) — sha256-truncated per-line tags.
3
+ *
4
+ * Pure `node:crypto` — zero vendored xxhash, zero deps. A 2-char tag is
5
+ * derived from the FIRST byte of the sha256 digest, mapped through a 16-char
6
+ * alphabet (16² = 256 possible tags — the same alphabet as the reference
7
+ * implementation for ecosystem familiarity).
8
+ *
9
+ * Seeding rule:
10
+ * - Lines containing at least one letter/digit (`/[\p{L}\p{N}]/u`) use
11
+ * seed 0 → the tag depends ONLY on the content, so identical content
12
+ * yields the same tag anywhere in the file (stable anchors for moving
13
+ * lines).
14
+ * - Blank / symbol-only lines use the LINE NUMBER as seed → otherwise all
15
+ * blank lines would share one tag and could never be anchored uniquely.
16
+ *
17
+ * @module hashline/xxhash
18
+ */
19
+
20
+ import { createHash } from 'node:crypto'
21
+
22
+ /** 16-char tag alphabet — same as the reference hashline (ecosystem familiarity). */
23
+ export const HASHLINE_DICT = 'ZPMQVRWSNKTXJBYH'
24
+
25
+ /** Lines that carry at least one letter or digit hash by content alone. */
26
+ const ALNUM_RE = /[\p{L}\p{N}]/u
27
+
28
+ /**
29
+ * Normalize a line before hashing: strip the trailing `\r` (CRLF inputs) and
30
+ * trim trailing whitespace so cosmetic edits do not change the tag.
31
+ */
32
+ export function normalizeLine(line: string): string {
33
+ return line.replace(/\r$/, '').trimEnd()
34
+ }
35
+
36
+ /**
37
+ * Compute the 2-char hashline tag for `line` at 1-based `lineNumber`.
38
+ *
39
+ * @param line Raw line content (may include trailing newline artifacts).
40
+ * @param lineNumber 1-based line number (seeds blank/symbol lines only).
41
+ * @returns Two uppercase chars from HASHLINE_DICT.
42
+ */
43
+ export function hashTag(line: string, lineNumber: number): string {
44
+ const normalized = normalizeLine(line)
45
+ const seed = ALNUM_RE.test(normalized) ? 0 : lineNumber
46
+ // The `\u0000` NUL between seed and content disambiguates the
47
+ // seed/content concatenation: without it, seed 1 + content "2" and
48
+ // seed 12 + content "" would collide into the same digest input. NUL
49
+ // never occurs in real source lines, so it is a safe separator.
50
+ const digest = createHash('sha256').update(`${seed}\u0000${normalized}`).digest()
51
+ const first = digest[0] ?? 0
52
+ return HASHLINE_DICT.charAt(first >> 4) + HASHLINE_DICT.charAt(first & 0x0f)
53
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Idle Continuation Dispatcher (Wave 3, PR #46) — routes `session.idle`
3
+ * events to the subsystem that owns the idle.
4
+ *
5
+ * Priority: an ACTIVE GOAL owns the idle — `GoalLoop.onIdle` continues the
6
+ * goal. Without an active goal, the TODO enforcer gets the idle (it
7
+ * self-guards on its own enabled flag + guards). Board-child sessions never
8
+ * reach the dispatcher — the plugin's event hook routes those to the
9
+ * delegation finalize path first (`handleDelegationEvent` returns true) and
10
+ * only calls the dispatcher on the `!delegated` branch.
11
+ *
12
+ * Independently testable — the loop and enforcer are structural interfaces;
13
+ * the real GoalLoop/TodoEnforcer satisfy them.
14
+ *
15
+ * Pure TypeScript — zero runtime dependencies.
16
+ *
17
+ * @module idle-continuation
18
+ */
19
+
20
+ /** Structural goal-loop surface the dispatcher needs. */
21
+ export interface GoalLoopLike {
22
+ hasActiveGoal(sessionID: string): Promise<boolean>
23
+ onIdle(sessionID: string): Promise<void>
24
+ }
25
+
26
+ /** Structural todo-enforcer surface the dispatcher needs. */
27
+ export interface TodoEnforcerLike {
28
+ onIdle(sessionID: string): Promise<void>
29
+ }
30
+
31
+ /** The dispatcher's onIdle surface (used by the plugin event hook). */
32
+ export interface IdleDispatcher {
33
+ onIdle(sessionID: string): Promise<void>
34
+ }
35
+
36
+ /**
37
+ * Build the idle router. The plugin event hook calls this only on the
38
+ * `!delegated` branch of `handleDelegationEvent`, so board children never
39
+ * arrive here.
40
+ */
41
+ export function createIdleDispatcher(deps: {
42
+ goalLoop: GoalLoopLike
43
+ todoEnforcer: TodoEnforcerLike
44
+ }): IdleDispatcher {
45
+ return {
46
+ onIdle: async (sessionID: string): Promise<void> => {
47
+ if (await deps.goalLoop.hasActiveGoal(sessionID)) {
48
+ await deps.goalLoop.onIdle(sessionID)
49
+ return
50
+ }
51
+ await deps.todoEnforcer.onIdle(sessionID)
52
+ },
53
+ }
54
+ }