opencode-overclock 0.4.0 → 0.5.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.
Files changed (50) hide show
  1. package/README.md +72 -17
  2. package/package.json +5 -3
  3. package/skills/codebase-design/DEEPENING.md +35 -0
  4. package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
  5. package/skills/codebase-design/SKILL.md +93 -0
  6. package/skills/diagnosing-bugs/SKILL.md +123 -0
  7. package/skills/domain-modeling/ADR-FORMAT.md +55 -0
  8. package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
  9. package/skills/domain-modeling/SKILL.md +102 -0
  10. package/skills/doubt/SKILL.md +80 -0
  11. package/skills/grilling/SKILL.md +96 -0
  12. package/skills/source-discipline/SKILL.md +78 -0
  13. package/skills/tdd/SKILL.md +87 -0
  14. package/skills/to-spec/SKILL.md +69 -0
  15. package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
  16. package/skills/to-tickets/SKILL.md +74 -0
  17. package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
  18. package/src/core/lifecycle.ts +18 -4
  19. package/src/core/types.ts +29 -0
  20. package/src/features/guard.ts +258 -12
  21. package/src/features/index.ts +13 -1
  22. package/src/features/recovery.ts +13 -3
  23. package/src/features/safety.ts +147 -0
  24. package/src/features/sched.ts +46 -10
  25. package/src/features/tasks.ts +87 -20
  26. package/src/features/truncator.ts +26 -9
  27. package/src/features/usage.ts +20 -0
  28. package/src/features/workflow.ts +270 -0
  29. package/src/lib/exec.ts +7 -1
  30. package/src/platform/process/exec.ts +252 -11
  31. package/src/platform/session/inject.ts +8 -1
  32. package/src/platform/storage/state.ts +26 -4
  33. package/src/v2/host.ts +4 -1
  34. package/src/workflow/agents/codebase-researcher.ts +27 -0
  35. package/src/workflow/agents/craftsman.ts +26 -0
  36. package/src/workflow/agents/design-explorer.ts +33 -0
  37. package/src/workflow/agents/doc-writer.ts +24 -0
  38. package/src/workflow/agents/doubt-reviewer.ts +26 -0
  39. package/src/workflow/agents/engineering-coach.ts +23 -0
  40. package/src/workflow/agents/performance-auditor.ts +29 -0
  41. package/src/workflow/agents/security-auditor.ts +23 -0
  42. package/src/workflow/agents/spec-reviewer.ts +15 -0
  43. package/src/workflow/agents/standards-reviewer.ts +24 -0
  44. package/src/workflow/agents/test-engineer.ts +28 -0
  45. package/src/workflow/catalog.ts +210 -0
  46. package/src/workflow/templates/build.ts +47 -0
  47. package/src/workflow/templates/define.ts +45 -0
  48. package/src/workflow/templates/diagnose.ts +58 -0
  49. package/src/workflow/templates/plan.ts +52 -0
  50. package/src/workflow/templates/ship.ts +64 -0
@@ -4,7 +4,7 @@ import type { FeatureModule } from "../types.ts"
4
4
  import { ensureStateDir, shellQuote, writeJson } from "../lib/state.ts"
5
5
  import { taskStore, type TaskMirrorEntry } from "../lib/mirror.ts"
6
6
  import { inject, toast } from "../lib/inject.ts"
7
- import { NON_INTERACTIVE_ENV } from "../lib/exec.ts"
7
+ import { killProcessTree, NON_INTERACTIVE_ENV, redactSensitiveOutput, sanitizeEnv } from "../lib/exec.ts"
8
8
  import { spawnTaskPane, type TmuxPane } from "../lib/tmux.ts"
9
9
 
10
10
  const z = tool.schema
@@ -127,12 +127,27 @@ function startStallWatchdog(
127
127
  }, checkIntervalMs)
128
128
  }
129
129
 
130
+ export async function readLogTail(logPath: string, tailLines = 50): Promise<string> {
131
+ const file = Bun.file(logPath)
132
+ if (!(await file.exists())) return "(no output)"
133
+ const s = await stat(logPath).catch(() => null)
134
+ if (!s || s.size === 0) return "(no output)"
135
+
136
+ const effectiveTail = Math.max(1, tailLines)
137
+ // Cap read window to last 512KB to prevent memory exhaustion on giant log files
138
+ const maxBytes = 512 * 1024
139
+ const start = Math.max(0, s.size - maxBytes)
140
+ const text = await (start > 0 ? file.slice(start).text() : file.text())
141
+ const lines = text.trimEnd().split("\n")
142
+ return lines.slice(-effectiveTail).join("\n")
143
+ }
144
+
130
145
  /** Exported for tests. onExit fires after status/exitCode settled. */
131
146
  export function createTaskManager(opts: {
132
147
  logDir: string
133
148
  /** mirror JSON path, written on every state change (spawn/exit/kill); omit to disable */
134
149
  mirrorPath?: string
135
- onExit?: (task: TaskRecord) => void
150
+ onExit?: (task: TaskRecord) => void | Promise<void>
136
151
  /** enables the stall watchdog; absent -> no polling at all */
137
152
  onStall?: (task: TaskRecord, tail: string) => void
138
153
  stallCheckIntervalMs?: number
@@ -140,9 +155,27 @@ export function createTaskManager(opts: {
140
155
  stallTailBytes?: number
141
156
  /** spawn a tmux split pane to tail task logs (only if TMUX is active) */
142
157
  tmux?: boolean
158
+ sanitizeEnv?: boolean
159
+ envAllowlist?: string[]
160
+ maxTasks?: number
143
161
  }): TaskManager {
144
162
  const tasks = new Map<string, TaskEntry>()
145
163
  let counter = 0
164
+ const maxRetainedTasks = opts.maxTasks ?? 100
165
+
166
+ function pruneFinishedTasks(): void {
167
+ if (tasks.size <= maxRetainedTasks) return
168
+ const finished: string[] = []
169
+ for (const [id, entry] of tasks) {
170
+ if (entry.status !== "running") {
171
+ finished.push(id)
172
+ }
173
+ }
174
+ const toRemove = tasks.size - maxRetainedTasks
175
+ for (let i = 0; i < Math.min(toRemove, finished.length); i++) {
176
+ tasks.delete(finished[i]!)
177
+ }
178
+ }
146
179
 
147
180
  function persistMirror(): void {
148
181
  if (!opts.mirrorPath) return
@@ -165,14 +198,38 @@ export function createTaskManager(opts: {
165
198
  }): TaskRecord {
166
199
  const id = `t${(++counter).toString(36)}-${crypto.randomUUID().slice(0, 6)}`
167
200
  const logPath = `${opts.logDir}/${id}.log`
168
- // shell-level redirection: no piping code, survives plugin restart losing streams
169
- const proc = Bun.spawn(["bash", "-c", `(${input.command}) >> ${shellQuote(logPath)} 2>&1`], {
170
- cwd: input.cwd,
171
- env: {
172
- ...process.env,
173
- ...NON_INTERACTIVE_ENV,
174
- },
175
- })
201
+
202
+ let proc: Bun.Subprocess
203
+ try {
204
+ proc = Bun.spawn(["bash", "-c", `(${input.command}) >> ${shellQuote(logPath)} 2>&1`], {
205
+ cwd: input.cwd,
206
+ env: {
207
+ ...(opts.sanitizeEnv === false ? process.env : sanitizeEnv(process.env, opts.envAllowlist)),
208
+ ...NON_INTERACTIVE_ENV,
209
+ },
210
+ })
211
+ } catch (e) {
212
+ console.warn(`[overclock] failed to spawn task ${id}: ${e}`)
213
+ const failedEntry: TaskEntry = {
214
+ id,
215
+ description: input.description,
216
+ command: input.command,
217
+ cwd: input.cwd,
218
+ sessionID: input.sessionID,
219
+ status: "exited",
220
+ exitCode: 1,
221
+ logPath,
222
+ proc: null as any,
223
+ stallNotified: false,
224
+ startedAt: Date.now(),
225
+ }
226
+ tasks.set(id, failedEntry)
227
+ pruneFinishedTasks()
228
+ persistMirror()
229
+ opts.onExit?.(strip(failedEntry))
230
+ return strip(failedEntry)
231
+ }
232
+
176
233
  const entry: TaskEntry = {
177
234
  id,
178
235
  description: input.description,
@@ -219,7 +276,12 @@ export function createTaskManager(opts: {
219
276
  if (entry.status === "running") entry.status = "exited"
220
277
  entry.exitCode = code
221
278
  persistMirror()
222
- opts.onExit?.(strip(entry))
279
+ try {
280
+ await opts.onExit?.(strip(entry))
281
+ } finally {
282
+ pruneFinishedTasks()
283
+ persistMirror()
284
+ }
223
285
  })
224
286
  return strip(entry)
225
287
  }
@@ -228,13 +290,16 @@ export function createTaskManager(opts: {
228
290
  const entry = tasks.get(id)
229
291
  if (!entry || entry.status !== "running") return false
230
292
  entry.status = "killed"
293
+ pruneFinishedTasks()
231
294
  persistMirror()
232
295
  if (entry.stallTimer) clearInterval(entry.stallTimer)
233
296
  entry.stallTimer = undefined
234
297
  if (entry.tmuxPane) void entry.tmuxPane.close()
235
- entry.proc.kill("SIGTERM")
236
- const hard = setTimeout(() => entry.proc.kill("SIGKILL"), 3000)
237
- entry.proc.exited.then(() => clearTimeout(hard))
298
+ if (entry.proc) {
299
+ void killProcessTree(entry.proc, "SIGTERM")
300
+ const hard = setTimeout(() => void killProcessTree(entry.proc, "SIGKILL"), 3000)
301
+ entry.proc.exited.then(() => clearTimeout(hard))
302
+ }
238
303
  return true
239
304
  }
240
305
 
@@ -249,10 +314,7 @@ export function createTaskManager(opts: {
249
314
  output: async (id, tailLines = 50) => {
250
315
  const e = tasks.get(id)
251
316
  if (!e) return `no task ${id}`
252
- const file = Bun.file(e.logPath)
253
- if (!(await file.exists())) return "(no output)"
254
- const lines = (await file.text()).split("\n")
255
- return lines.slice(-tailLines - 1).join("\n")
317
+ return readLogTail(e.logPath, tailLines)
256
318
  },
257
319
  killAll: () => {
258
320
  for (const id of tasks.keys()) kill(id)
@@ -285,9 +347,13 @@ export const tasks: FeatureModule = {
285
347
  logDir,
286
348
  mirrorPath: taskStore.path(ctx.directory),
287
349
  tmux: options.tmux === true,
350
+ sanitizeEnv: options.sanitizeEnv !== false,
351
+ envAllowlist: Array.isArray(options.envAllowlist) ? (options.envAllowlist as string[]) : undefined,
352
+ maxTasks: typeof options.maxTasks === "number" ? options.maxTasks : 100,
288
353
  onExit: async (task) => {
289
354
  if (task.status === "killed") return
290
- const tail = await manager.output(task.id, 20)
355
+ const rawTail = await readLogTail(task.logPath, 20)
356
+ const tail = redactSensitiveOutput(rawTail)
291
357
  const ok = task.exitCode === 0
292
358
  await toast(
293
359
  ctx.client,
@@ -305,12 +371,13 @@ export const tasks: FeatureModule = {
305
371
  stallThresholdMs,
306
372
  stallCheckIntervalMs,
307
373
  onStall: async (task: TaskRecord, tail: string) => {
374
+ const safeTail = redactSensitiveOutput(tail.trimEnd())
308
375
  await toast(ctx.client, `task ${task.id} looks stalled (waiting for input?)`, "warning")
309
376
  await inject(
310
377
  ctx.client,
311
378
  task.sessionID,
312
379
  `[background task ${task.id} "${task.description}" appears to be waiting for interactive input]\n` +
313
- `last output:\n${tail.trimEnd()}\n\n` +
380
+ `last output:\n${safeTail}\n\n` +
314
381
  `The command is likely blocked on a prompt. Kill it with ${shared.toolName("task_kill")} and re-run non-interactively ` +
315
382
  `(e.g. pipe input like \`echo y | cmd\`, or pass a --yes/--force flag).`,
316
383
  )
@@ -15,6 +15,7 @@ export interface TruncateResult {
15
15
 
16
16
  /**
17
17
  * Smartly truncate output preserving top context and bottom tail.
18
+ * Enforces strict character and line bounds so oversized lines never overflow context.
18
19
  */
19
20
  export function truncateOutput(
20
21
  content: string,
@@ -22,16 +23,23 @@ export function truncateOutput(
22
23
  headLinesCount = DEFAULT_HEAD_LINES,
23
24
  tailLinesCount = DEFAULT_TAIL_LINES,
24
25
  ): TruncateResult {
25
- if (content.length <= maxChars) {
26
+ const effectiveMax = Math.max(100, maxChars)
27
+ if (content.length <= effectiveMax) {
26
28
  return { text: content, truncated: false, omittedLines: 0, omittedChars: 0 }
27
29
  }
28
30
 
31
+ const headCount = Math.max(0, headLinesCount)
32
+ const tailCount = Math.max(0, tailLinesCount)
29
33
  const lines = content.split("\n")
30
- if (lines.length <= headLinesCount + tailLinesCount) {
34
+
35
+ const maxHeadChars = Math.floor(effectiveMax * 0.3)
36
+ const maxTailChars = Math.floor(effectiveMax * 0.7)
37
+
38
+ if (lines.length <= headCount + tailCount) {
31
39
  // If few lines but very long strings, hard slice
32
- const head = content.slice(0, Math.floor(maxChars * 0.3))
33
- const tail = content.slice(-Math.floor(maxChars * 0.7))
34
- const omittedChars = content.length - head.length - tail.length
40
+ const head = content.slice(0, maxHeadChars)
41
+ const tail = maxTailChars > 0 ? content.slice(-maxTailChars) : ""
42
+ const omittedChars = Math.max(0, content.length - head.length - tail.length)
35
43
  return {
36
44
  text: `${head}\n\n[... truncated ${omittedChars} characters to stay within context limits ...]\n\n${tail}`,
37
45
  truncated: true,
@@ -40,10 +48,19 @@ export function truncateOutput(
40
48
  }
41
49
  }
42
50
 
43
- const head = lines.slice(0, headLinesCount).join("\n")
44
- const tail = lines.slice(-tailLinesCount).join("\n")
45
- const omittedLines = lines.length - headLinesCount - tailLinesCount
46
- const omittedChars = content.length - head.length - tail.length
51
+ let head = headCount > 0 ? lines.slice(0, headCount).join("\n") : ""
52
+ let tail = tailCount > 0 ? lines.slice(-tailCount).join("\n") : ""
53
+
54
+ // Guard against giant single lines in head or tail violating maxChars limit
55
+ if (head.length > maxHeadChars) {
56
+ head = head.slice(0, maxHeadChars) + "\n... [line truncated]"
57
+ }
58
+ if (tail.length > maxTailChars) {
59
+ tail = "[line truncated] ...\n" + (maxTailChars > 0 ? tail.slice(-maxTailChars) : "")
60
+ }
61
+
62
+ const omittedLines = Math.max(0, lines.length - headCount - tailCount)
63
+ const omittedChars = Math.max(0, content.length - head.length - tail.length)
47
64
 
48
65
  const text = `${head}\n\n[... truncated ${omittedLines} lines (${omittedChars} chars) to stay within context limits ...]\n\n${tail}`
49
66
  return {
@@ -12,6 +12,8 @@ export interface SessionUsage {
12
12
  }
13
13
 
14
14
  const RETENTION_DAYS = 60
15
+ const MAX_SEEN_PER_DAY = 1000
16
+ const MAX_TRACKED_SESSIONS = 200
15
17
 
16
18
  function zeroTokens(): UsageTokens {
17
19
  return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
@@ -73,6 +75,7 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
73
75
  }
74
76
 
75
77
  async function flush(): Promise<void> {
78
+ pruneOldDays(state, Date.now())
76
79
  await writeJson(opts.statePath, state)
77
80
  }
78
81
 
@@ -93,6 +96,13 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
93
96
 
94
97
  function onEvent(event: { type: string; properties?: unknown }): void {
95
98
  try {
99
+ if (event.type === "session.deleted") {
100
+ const props = event.properties as { sessionID?: string; info?: { id?: string } } | undefined
101
+ const id = props?.sessionID ?? props?.info?.id
102
+ if (id) sessions.delete(id)
103
+ return
104
+ }
105
+
96
106
  if (event.type !== "message.updated") return
97
107
  const info = (event.properties as any)?.info
98
108
  if (!info || info.role !== "assistant" || !info.time?.completed) return
@@ -103,6 +113,10 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
103
113
  const bucket = state.days[day] ?? emptyBucket()
104
114
  state.days[day] = bucket
105
115
  if (bucket.seen.includes(info.id)) return
116
+
117
+ if (bucket.seen.length >= MAX_SEEN_PER_DAY) {
118
+ bucket.seen = bucket.seen.slice(-MAX_SEEN_PER_DAY + 1)
119
+ }
106
120
  bucket.seen.push(info.id)
107
121
 
108
122
  const cost = info.cost ?? 0
@@ -126,6 +140,12 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
126
140
  sess.tokens.output += tok.output
127
141
  sessions.set(info.sessionID, sess)
128
142
 
143
+ if (sessions.size > MAX_TRACKED_SESSIONS) {
144
+ // Evict oldest session to bound memory
145
+ const oldest = sessions.keys().next().value
146
+ if (oldest) sessions.delete(oldest)
147
+ }
148
+
129
149
  scheduleFlush()
130
150
  } catch (e) {
131
151
  console.warn(`[overclock] usage: event handling failed: ${e}`)
@@ -0,0 +1,270 @@
1
+ import { existsSync } from "node:fs"
2
+ import { resolve, dirname } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import type { FeatureModule, WorkflowOptions } from "../core/types.ts"
5
+ import { DEFINE_TEMPLATE } from "../workflow/templates/define.ts"
6
+ import { PLAN_TEMPLATE } from "../workflow/templates/plan.ts"
7
+ import { BUILD_TEMPLATE } from "../workflow/templates/build.ts"
8
+ import { DIAGNOSE_TEMPLATE } from "../workflow/templates/diagnose.ts"
9
+ import { SHIP_TEMPLATE } from "../workflow/templates/ship.ts"
10
+ import { STANDARDS_REVIEWER_PROMPT } from "../workflow/agents/standards-reviewer.ts"
11
+ import { SPEC_REVIEWER_PROMPT } from "../workflow/agents/spec-reviewer.ts"
12
+ import { SECURITY_AUDITOR_PROMPT } from "../workflow/agents/security-auditor.ts"
13
+ import { TEST_ENGINEER_PROMPT } from "../workflow/agents/test-engineer.ts"
14
+ import { PERFORMANCE_AUDITOR_PROMPT } from "../workflow/agents/performance-auditor.ts"
15
+ import { DOUBT_REVIEWER_PROMPT } from "../workflow/agents/doubt-reviewer.ts"
16
+ import { CODEBASE_RESEARCHER_PROMPT } from "../workflow/agents/codebase-researcher.ts"
17
+ import { DESIGN_EXPLORER_PROMPT } from "../workflow/agents/design-explorer.ts"
18
+ import { ENGINEERING_COACH_PROMPT } from "../workflow/agents/engineering-coach.ts"
19
+ import { CRAFTSMAN_PROMPT } from "../workflow/agents/craftsman.ts"
20
+ import { DOC_WRITER_PROMPT } from "../workflow/agents/doc-writer.ts"
21
+
22
+ function getBundledSkillsDir(customPath?: string): string {
23
+ if (customPath) return customPath
24
+ const currentDir =
25
+ typeof import.meta.dir === "string" ? import.meta.dir : dirname(fileURLToPath(import.meta.url))
26
+ return resolve(currentDir, "../../skills")
27
+ }
28
+
29
+ export const WORKFLOW_COMMANDS = {
30
+ define: {
31
+ description: "Interrogate requirements and draft SPEC.md with recommended defaults",
32
+ template: DEFINE_TEMPLATE,
33
+ },
34
+ plan: {
35
+ description: "Decompose spec into vertical tracer-bullet tasks in tasks/plan.md",
36
+ template: PLAN_TEMPLATE,
37
+ },
38
+ build: {
39
+ description: "Autonomous TDD implementation with tripwires and atomic commits",
40
+ template: BUILD_TEMPLATE,
41
+ },
42
+ diagnose: {
43
+ description: "Disciplined bug reproduction and isolation loop ([DEBUG-xxxx] tags)",
44
+ template: DIAGNOSE_TEMPLATE,
45
+ },
46
+ ship: {
47
+ description: "3-way parallel review (Standards, Spec, Security) with GO/NO-GO verdict",
48
+ template: SHIP_TEMPLATE,
49
+ },
50
+ }
51
+
52
+ export const WORKFLOW_AGENTS = {
53
+ "standards-reviewer": {
54
+ mode: "subagent" as const,
55
+ description: "Senior Staff Engineer auditing diffs for repo conventions and code smells",
56
+ prompt: STANDARDS_REVIEWER_PROMPT,
57
+ tools: {
58
+ write: false,
59
+ edit: false,
60
+ },
61
+ permission: {
62
+ edit: "deny" as const,
63
+ },
64
+ },
65
+ "spec-reviewer": {
66
+ mode: "subagent" as const,
67
+ description: "Product Engineer auditing diffs strictly against originating specifications",
68
+ prompt: SPEC_REVIEWER_PROMPT,
69
+ tools: {
70
+ write: false,
71
+ edit: false,
72
+ },
73
+ permission: {
74
+ edit: "deny" as const,
75
+ },
76
+ },
77
+ "security-auditor": {
78
+ mode: "subagent" as const,
79
+ description: "Adversarial Security Engineer auditing diffs for OWASP vulnerabilities and secrets",
80
+ prompt: SECURITY_AUDITOR_PROMPT,
81
+ tools: {
82
+ write: false,
83
+ edit: false,
84
+ },
85
+ permission: {
86
+ edit: "deny" as const,
87
+ },
88
+ },
89
+ "test-engineer": {
90
+ mode: "subagent" as const,
91
+ description: "QA Engineer auditing test strategy, coverage gaps, and Prove-It verification",
92
+ prompt: TEST_ENGINEER_PROMPT,
93
+ tools: {
94
+ write: false,
95
+ edit: false,
96
+ },
97
+ permission: {
98
+ edit: "deny" as const,
99
+ },
100
+ },
101
+ "performance-auditor": {
102
+ mode: "subagent" as const,
103
+ description: "Senior Performance Engineer auditing latency, N+1 queries, and resource leaks",
104
+ prompt: PERFORMANCE_AUDITOR_PROMPT,
105
+ tools: {
106
+ write: false,
107
+ edit: false,
108
+ },
109
+ permission: {
110
+ edit: "deny" as const,
111
+ },
112
+ },
113
+ "doubt-reviewer": {
114
+ mode: "all" as const,
115
+ description: "Adversarial Verification Engineer evaluating artifacts without author bias",
116
+ prompt: DOUBT_REVIEWER_PROMPT,
117
+ tools: {
118
+ write: false,
119
+ edit: false,
120
+ },
121
+ permission: {
122
+ edit: "deny" as const,
123
+ },
124
+ },
125
+ "codebase-researcher": {
126
+ mode: "all" as const,
127
+ description: "Scout Agent tracing seams, dependencies, and call graphs without polluting context",
128
+ prompt: CODEBASE_RESEARCHER_PROMPT,
129
+ tools: {
130
+ write: false,
131
+ edit: false,
132
+ },
133
+ permission: {
134
+ edit: "deny" as const,
135
+ },
136
+ },
137
+ "design-explorer": {
138
+ mode: "all" as const,
139
+ description: "Principal Architect producing contrasting 'Design It Twice' interface proposals",
140
+ prompt: DESIGN_EXPLORER_PROMPT,
141
+ tools: {
142
+ write: false,
143
+ edit: false,
144
+ },
145
+ permission: {
146
+ edit: "deny" as const,
147
+ },
148
+ },
149
+ "engineering-coach": {
150
+ mode: "all" as const,
151
+ description: "Elite Staff Mentor providing Socratic debugging guidance and design critique",
152
+ prompt: ENGINEERING_COACH_PROMPT,
153
+ tools: {
154
+ write: false,
155
+ edit: false,
156
+ },
157
+ permission: {
158
+ edit: "deny" as const,
159
+ },
160
+ },
161
+ craftsman: {
162
+ mode: "all" as const,
163
+ description:
164
+ "Disciplined software craftsman enforcing TDD, minimal vertical slices, and clean architecture",
165
+ prompt: CRAFTSMAN_PROMPT,
166
+ },
167
+ "doc-writer": {
168
+ mode: "all" as const,
169
+ description:
170
+ "Technical writer synthesizing accurate documentation, API references, and architecture records from code",
171
+ prompt: DOC_WRITER_PROMPT,
172
+ },
173
+ }
174
+
175
+ export const workflow: FeatureModule = {
176
+ name: "workflow",
177
+ defaultEnabled: true,
178
+ tools: [],
179
+ async init(_ctx, options) {
180
+ const opts = (options ?? {}) as WorkflowOptions
181
+ if (opts.enabled === false) {
182
+ return {}
183
+ }
184
+
185
+ const skillsPath = getBundledSkillsDir(opts.skillsPath)
186
+
187
+ return {
188
+ config: async (cfg: any) => {
189
+ if (opts.commands !== false) {
190
+ cfg.command = {
191
+ ...WORKFLOW_COMMANDS,
192
+ ...(cfg.command ?? {}),
193
+ }
194
+ }
195
+
196
+ if (opts.subagents !== false) {
197
+ cfg.agent = {
198
+ ...WORKFLOW_AGENTS,
199
+ ...(cfg.agent ?? {}),
200
+ }
201
+ }
202
+
203
+ if (existsSync(skillsPath)) {
204
+ cfg.skills = typeof cfg.skills === "object" && cfg.skills !== null ? cfg.skills : {}
205
+ if (!Array.isArray(cfg.skills.paths)) {
206
+ cfg.skills.paths = []
207
+ }
208
+ if (!cfg.skills.paths.includes(skillsPath)) {
209
+ cfg.skills.paths.push(skillsPath)
210
+ }
211
+ }
212
+ },
213
+ }
214
+ },
215
+
216
+ setup: async (v2Context, options) => {
217
+ const opts = (options ?? {}) as WorkflowOptions
218
+ if (opts.enabled === false) return
219
+
220
+ if (opts.commands !== false && v2Context.command?.transform) {
221
+ await v2Context.command.transform(async (draft) => {
222
+ for (const [name, cmd] of Object.entries(WORKFLOW_COMMANDS)) {
223
+ draft.update(name, (current) => {
224
+ current.name = current.name ?? name
225
+ current.description = current.description ?? cmd.description
226
+ current.template = current.template ?? cmd.template
227
+ })
228
+ }
229
+ })
230
+ }
231
+
232
+ if (opts.subagents !== false && v2Context.agent?.transform) {
233
+ await v2Context.agent.transform(async (draft) => {
234
+ for (const [id, ag] of Object.entries(WORKFLOW_AGENTS)) {
235
+ draft.update(id, (current) => {
236
+ current.mode = current.mode ?? ag.mode
237
+ current.description = current.description ?? ag.description
238
+ current.system = current.system ?? ag.prompt
239
+ if ("permission" in ag && ag.permission?.edit === "deny") {
240
+ const perms = (current.permissions as any[]) ?? []
241
+ const hasDenyEdit = perms.some((p: any) => p.action === "edit" && p.effect === "deny")
242
+ if (!hasDenyEdit) {
243
+ perms.push({
244
+ action: "edit",
245
+ resource: "*",
246
+ effect: "deny",
247
+ })
248
+ current.permissions = perms as any
249
+ }
250
+ }
251
+ })
252
+ }
253
+ })
254
+ }
255
+
256
+ const skillsPath = getBundledSkillsDir(opts.skillsPath)
257
+ if (existsSync(skillsPath) && v2Context.skill?.transform) {
258
+ await v2Context.skill.transform(async (draft) => {
259
+ const existing = draft.list?.() ?? []
260
+ const alreadyAdded = existing.some((s: any) => s.type === "directory" && s.path === skillsPath)
261
+ if (!alreadyAdded) {
262
+ draft.source({
263
+ type: "directory",
264
+ path: skillsPath,
265
+ } as any)
266
+ }
267
+ })
268
+ }
269
+ },
270
+ }
package/src/lib/exec.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  export {
2
- NON_INTERACTIVE_ENV,
3
2
  execBash,
3
+ killProcessTree,
4
4
  shellQuote,
5
+ sanitizeEnv,
6
+ redactSensitiveOutput,
7
+ NON_INTERACTIVE_ENV,
8
+ DEFAULT_PRESERVED_ENV,
9
+ SENSITIVE_ENV_PATTERN,
10
+ SENSITIVE_OUTPUT_PATTERNS,
5
11
  type ExecBashOptions,
6
12
  type ExecBashResult,
7
13
  } from "../platform/process/exec.ts"