fix(config): promote hashline_edit to top-level flag

Move hashline_edit out of experimental so it is a stable top-level config with default-on runtime behavior and explicit disable support. Add migration and tests to preserve existing experimental.hashline_edit users without breaking configs.
This commit is contained in:
YeonGyu-Kim 2026-02-20 11:12:33 +09:00
parent b8a6f10f70
commit 52b2afb6b0
8 changed files with 117 additions and 60 deletions

View File

@ -128,6 +128,9 @@
"type": "string" "type": "string"
} }
}, },
"hashline_edit": {
"type": "boolean"
},
"agents": { "agents": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -3141,9 +3144,6 @@
}, },
"disable_omo_env": { "disable_omo_env": {
"type": "boolean" "type": "boolean"
},
"hashline_edit": {
"type": "boolean"
} }
}, },
"additionalProperties": false "additionalProperties": false

View File

@ -644,6 +644,55 @@ describe("OhMyOpenCodeConfigSchema - browser_automation_engine", () => {
}) })
}) })
describe("OhMyOpenCodeConfigSchema - hashline_edit", () => {
test("accepts hashline_edit as true", () => {
//#given
const input = { hashline_edit: true }
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
expect(result.data?.hashline_edit).toBe(true)
})
test("accepts hashline_edit as false", () => {
//#given
const input = { hashline_edit: false }
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
expect(result.data?.hashline_edit).toBe(false)
})
test("hashline_edit is optional", () => {
//#given
const input = { auto_update: true }
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
expect(result.data?.hashline_edit).toBeUndefined()
})
test("rejects non-boolean hashline_edit", () => {
//#given
const input = { hashline_edit: "true" }
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(input)
//#then
expect(result.success).toBe(false)
})
})
describe("ExperimentalConfigSchema feature flags", () => { describe("ExperimentalConfigSchema feature flags", () => {
test("accepts plugin_load_timeout_ms as number", () => { test("accepts plugin_load_timeout_ms as number", () => {
//#given //#given
@ -699,48 +748,6 @@ describe("ExperimentalConfigSchema feature flags", () => {
} }
}) })
test("accepts hashline_edit as true", () => {
//#given
const config = { hashline_edit: true }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.hashline_edit).toBe(true)
}
})
test("accepts hashline_edit as false", () => {
//#given
const config = { hashline_edit: false }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.hashline_edit).toBe(false)
}
})
test("hashline_edit is optional", () => {
//#given
const config = { safe_hook_creation: true }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.hashline_edit).toBeUndefined()
}
})
test("accepts disable_omo_env as true", () => { test("accepts disable_omo_env as true", () => {
//#given //#given
const config = { disable_omo_env: true } const config = { disable_omo_env: true }
@ -794,16 +801,6 @@ describe("ExperimentalConfigSchema feature flags", () => {
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
test("rejects non-boolean hashline_edit", () => {
//#given
const config = { hashline_edit: "true" }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
}) })
describe("GitMasterConfigSchema", () => { describe("GitMasterConfigSchema", () => {

View File

@ -17,8 +17,6 @@ export const ExperimentalConfigSchema = z.object({
safe_hook_creation: z.boolean().optional(), safe_hook_creation: z.boolean().optional(),
/** Disable auto-injected <omo-env> context in prompts (experimental) */ /** Disable auto-injected <omo-env> context in prompts (experimental) */
disable_omo_env: z.boolean().optional(), disable_omo_env: z.boolean().optional(),
/** Enable hashline_edit tool for improved file editing with hash-based line anchors */
hashline_edit: z.boolean().optional(),
}) })
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema> export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>

View File

@ -33,6 +33,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_commands: z.array(BuiltinCommandNameSchema).optional(), disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */ /** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
disabled_tools: z.array(z.string()).optional(), disabled_tools: z.array(z.string()).optional(),
/** Enable hashline_edit tool/hook integrations (default: true at call site) */
hashline_edit: z.boolean().optional(),
agents: AgentOverridesSchema.optional(), agents: AgentOverridesSchema.optional(),
categories: CategoriesConfigSchema.optional(), categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(),

View File

@ -98,11 +98,11 @@ export function createToolGuardHooks(args: {
: null : null
const hashlineReadEnhancer = isHookEnabled("hashline-read-enhancer") const hashlineReadEnhancer = isHookEnabled("hashline-read-enhancer")
? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? true } })) ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
: null : null
const hashlineEditDiffEnhancer = isHookEnabled("hashline-edit-diff-enhancer") const hashlineEditDiffEnhancer = isHookEnabled("hashline-edit-diff-enhancer")
? safeHook("hashline-edit-diff-enhancer", () => createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? true } })) ? safeHook("hashline-edit-diff-enhancer", () => createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
: null : null
return { return {

View File

@ -113,7 +113,7 @@ export function createToolRegistry(args: {
} }
: {} : {}
const hashlineEnabled = pluginConfig.experimental?.hashline_edit ?? true const hashlineEnabled = pluginConfig.hashline_edit ?? true
const hashlineToolsRecord: Record<string, ToolDefinition> = hashlineEnabled const hashlineToolsRecord: Record<string, ToolDefinition> = hashlineEnabled
? { edit: createHashlineEditTool() } ? { edit: createHashlineEditTool() }
: {} : {}

View File

@ -306,6 +306,52 @@ describe("migrateHookNames", () => {
describe("migrateConfigFile", () => { describe("migrateConfigFile", () => {
const testConfigPath = "/tmp/nonexistent-path-for-test.json" const testConfigPath = "/tmp/nonexistent-path-for-test.json"
test("migrates experimental.hashline_edit to top-level hashline_edit", () => {
// given: Config with legacy experimental.hashline_edit
const rawConfig: Record<string, unknown> = {
experimental: { hashline_edit: false, safe_hook_creation: true },
}
// when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
// then: hashline_edit should move to top-level and be removed from experimental
expect(needsWrite).toBe(true)
expect(rawConfig.hashline_edit).toBe(false)
expect(rawConfig.experimental).toEqual({ safe_hook_creation: true })
})
test("migrates and removes empty experimental object", () => {
// given: Config with only experimental.hashline_edit
const rawConfig: Record<string, unknown> = {
experimental: { hashline_edit: true },
}
// when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
// then: hashline_edit moves top-level and empty experimental is removed
expect(needsWrite).toBe(true)
expect(rawConfig.hashline_edit).toBe(true)
expect(rawConfig.experimental).toBeUndefined()
})
test("does not overwrite top-level hashline_edit when already set", () => {
// given: Config with both top-level and legacy location
const rawConfig: Record<string, unknown> = {
hashline_edit: false,
experimental: { hashline_edit: true },
}
// when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
// then: top-level value wins, legacy key removed
expect(needsWrite).toBe(true)
expect(rawConfig.hashline_edit).toBe(false)
expect(rawConfig.experimental).toBeUndefined()
})
test("migrates omo_agent to sisyphus_agent", () => { test("migrates omo_agent to sisyphus_agent", () => {
// given: Config with legacy omo_agent key // given: Config with legacy omo_agent key
const rawConfig: Record<string, unknown> = { const rawConfig: Record<string, unknown> = {

View File

@ -67,6 +67,20 @@ export function migrateConfigFile(
needsWrite = true needsWrite = true
} }
if (copy.experimental && typeof copy.experimental === "object") {
const experimental = copy.experimental as Record<string, unknown>
if ("hashline_edit" in experimental) {
if (copy.hashline_edit === undefined) {
copy.hashline_edit = experimental.hashline_edit
}
delete experimental.hashline_edit
if (Object.keys(experimental).length === 0) {
delete copy.experimental
}
needsWrite = true
}
}
if (copy.disabled_agents && Array.isArray(copy.disabled_agents)) { if (copy.disabled_agents && Array.isArray(copy.disabled_agents)) {
const migrated: string[] = [] const migrated: string[] = []
let changed = false let changed = false