diff --git a/docs-site/content/docs/configuration/ktx-yaml.mdx b/docs-site/content/docs/configuration/ktx-yaml.mdx index db74ffa7..34bb31d5 100644 --- a/docs-site/content/docs/configuration/ktx-yaml.mdx +++ b/docs-site/content/docs/configuration/ktx-yaml.mdx @@ -360,7 +360,7 @@ storage: |-------|------|---------|---------| | `state` | `sqlite` \| `postgres` | `sqlite` | Backend for ktx state. `sqlite` uses `.ktx/db.sqlite`; `postgres` expects a configured Postgres connection. | | `search` | `sqlite-fts5` \| `postgres-hybrid` | `sqlite-fts5` | Backend for search indexes. `postgres-hybrid` combines lexical and vector search in Postgres. | -| `git.auto_commit` | `boolean` | `true` | When `true`, ktx auto-commits changes to the git-backed state store. | +| `git.auto_commit` | `boolean` | `true` | When `true`, a context-source ingest run commits its changes to the git-backed state store. When `false`, the changes are applied to the working tree and left staged for you to commit. | | `git.author` | `string` | `ktx ` | Git author identity for auto-commits. Standard `Name ` form. | ## `llm` @@ -619,7 +619,7 @@ memory: | Field | Type | Default | Purpose | |-------|------|---------|---------| -| `auto_commit` | `boolean` | `true` | When `true`, ktx auto-commits memory updates to the git-backed store. | +| `auto_commit` | `boolean` | `true` | When `true`, a memory/wiki ingest run commits its updates to the git-backed store. When `false`, the updates are applied to the working tree and left staged for you to commit. | ## A full example diff --git a/packages/cli/src/context/core/git.service.ts b/packages/cli/src/context/core/git.service.ts index cc88c4c9..3de1bfc1 100644 --- a/packages/cli/src/context/core/git.service.ts +++ b/packages/cli/src/context/core/git.service.ts @@ -31,6 +31,10 @@ export type SquashMergeResult = | { ok: true; squashSha: string; touchedPaths: string[] } | { ok: false; conflict: true; conflictPaths: string[] }; +export type StageSquashResult = + | { ok: true; touchedPaths: string[]; stagedTree: string } + | { ok: false; conflict: true; conflictPaths: string[] }; + function mergeErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -681,6 +685,53 @@ export class GitService { authorEmail: string, commitMessage: string, ): Promise { + const applied = await this.applySquashToIndex(branch); + if (!applied.ok) { + return applied; + } + if (applied.touchedPaths.length === 0) { + const head = (await this.git.revparse(['HEAD'])).trim(); + return { ok: true, squashSha: head, touchedPaths: [] }; + } + + await this.git.commit(commitMessage, { '--author': `${author} <${authorEmail}>` }); + const squashSha = (await this.git.revparse(['HEAD'])).trim(); + return { ok: true, squashSha, touchedPaths: applied.touchedPaths }; + } + + /** + * Like {@link squashMergeIntoMain} but stops before committing: applies `branch` onto the + * current branch's index + working tree and leaves the result staged for the user to commit. + * Returns the staged tree's SHA, which is a valid diff/read ref (`git diff A..`, + * `git show :path`) so callers can reconcile derived indexes without a commit. + * + * This backs the `auto_commit: false` ingest path: changes still reach the working tree (so + * the run is not silently discarded), they are just not committed automatically. + * + * Caller must hold the `config:repo` lock, as with {@link squashMergeIntoMain}. + */ + async stageSquashMergeIntoMain(branch: string): Promise { + return this.withMutationQueue(() => this.stageSquashMergeIntoMainUnlocked(branch)); + } + + private async stageSquashMergeIntoMainUnlocked(branch: string): Promise { + const applied = await this.applySquashToIndex(branch); + if (!applied.ok) { + return applied; + } + const stagedTree = (await this.git.raw(['write-tree'])).trim(); + return { ok: true, touchedPaths: applied.touchedPaths, stagedTree }; + } + + /** + * Shared core of the squash-merge paths: applies `branch` onto the current branch's index + + * working tree via `git merge --squash` WITHOUT committing, leaving the caller to either + * commit (auto-commit on) or stage (auto-commit off). Returns the touched paths, or conflict + * info after restoring a clean tree. + */ + private async applySquashToIndex( + branch: string, + ): Promise<{ ok: true; touchedPaths: string[] } | { ok: false; conflict: true; conflictPaths: string[] }> { // Diff of HEAD..branch (two dots) lists commits/files reachable from `branch` that // aren't on HEAD — i.e. exactly what the squash would apply. Three dots (HEAD...branch) // is symmetric difference and would mis-classify cases where main moved ahead. @@ -690,8 +741,7 @@ export class GitService { .map((l) => l.trim()) .filter(Boolean); if (touchedPaths.length === 0) { - const head = (await this.git.revparse(['HEAD'])).trim(); - return { ok: true, squashSha: head, touchedPaths: [] }; + return { ok: true, touchedPaths: [] }; } // `git merge --squash` may NOT throw on a textual conflict — it stages the clean @@ -724,9 +774,7 @@ export class GitService { return { ok: false, conflict: true, conflictPaths }; } - await this.git.commit(commitMessage, { '--author': `${author} <${authorEmail}>` }); - const squashSha = (await this.git.revparse(['HEAD'])).trim(); - return { ok: true, squashSha, touchedPaths }; + return { ok: true, touchedPaths }; } /** diff --git a/packages/cli/src/context/ingest/ingest-bundle.runner.ts b/packages/cli/src/context/ingest/ingest-bundle.runner.ts index 6f5372d2..440a248d 100644 --- a/packages/cli/src/context/ingest/ingest-bundle.runner.ts +++ b/packages/cli/src/context/ingest/ingest-bundle.runner.ts @@ -2677,29 +2677,51 @@ export class IngestBundleRunner { throw error; } const commitMessage = this.buildCommitMessage(job, syncId, diffSummary, failedWorkUnits); + // With auto-commit disabled, apply the run onto main's working tree and leave it staged + // rather than committing. The wiki index is reconciled from the staged tree (a valid + // diff/read ref), so search stays consistent with the staged files; only the git commit + // and its message-enhancement job are skipped. + const autoCommit = this.deps.storage.autoCommit; const squashResult = await this.deps.lockingService.withLock('config:repo', async () => { const preSquashSha = await this.deps.gitService.revParseHead(); - const merge = await this.deps.gitService.squashMergeIntoMain( - sessionWorktree.branch, - this.deps.storage.systemGitAuthor.name, - this.deps.storage.systemGitAuthor.email, - commitMessage, - ); - return { preSquashSha, merge }; + if (autoCommit) { + const merge = await this.deps.gitService.squashMergeIntoMain( + sessionWorktree.branch, + this.deps.storage.systemGitAuthor.name, + this.deps.storage.systemGitAuthor.email, + commitMessage, + ); + return { preSquashSha, committed: true as const, merge }; + } + const merge = await this.deps.gitService.stageSquashMergeIntoMain(sessionWorktree.branch); + return { preSquashSha, committed: false as const, merge }; }); - const mergeResult = squashResult.merge; - if (!mergeResult.ok) { + if (!squashResult.merge.ok) { await this.deps.runs.markFailed(runRow.id); - throw new Error(`squash merge conflict: ${mergeResult.conflictPaths.join(', ')}`); + throw new Error(`squash merge conflict: ${squashResult.merge.conflictPaths.join(', ')}`); + } + const touchedPaths = squashResult.merge.touchedPaths; + const hasChanges = touchedPaths.length > 0; + // `syncRef` is the tree-ish to diff/read when reconciling the wiki index: the new commit + // SHA when committed, the staged tree SHA when staging. `commitSha` is only set when an + // actual commit was created (it surfaces in the report and progress UI). + let commitSha: string | null = null; + let syncRef: string | null = null; + if (hasChanges) { + if (squashResult.committed) { + commitSha = squashResult.merge.squashSha; + syncRef = commitSha; + } else { + syncRef = squashResult.merge.stagedTree; + } } - const commitSha = mergeResult.touchedPaths.length === 0 ? null : mergeResult.squashSha; await runTrace.event( 'debug', 'squash', 'squash_finished', { commitSha, - touchedPaths: mergeResult.touchedPaths, + touchedPaths, }, undefined, Date.now() - squashStartedAt, @@ -2714,18 +2736,28 @@ export class IngestBundleRunner { wikiCount: countMemoryFlowActions(memoryFlowSavedActions, 'wiki'), slCount: countMemoryFlowActions(memoryFlowSavedActions, 'sl'), }); - await stage6?.updateProgress(1.0, commitSha ? `Saved changes (${commitSha.slice(0, 8)})` : 'No changes to save'); + await stage6?.updateProgress( + 1.0, + commitSha + ? `Saved changes (${commitSha.slice(0, 8)})` + : hasChanges + ? 'Staged changes (auto-commit disabled)' + : 'No changes to save', + ); // Sync the shared `knowledge` index from the squashed diff in a single // transaction. If this throws, the run fails and no partial index state // survives (thanks to the transactional upsert in applyDiffTransactional). - if (commitSha) { + // `syncRef` is the new commit when committed, or the staged tree when staging. + if (syncRef) { const indexSyncStartedAt = Date.now(); // Multi-file squash → omit path so the handler diffs the whole commit // (a comma-joined pathspec would match nothing and the job would no-op). - const pathFilter = mergeResult.touchedPaths.length === 1 ? mergeResult.touchedPaths[0] : ''; - await this.deps.commitMessages.enqueueForExternalCommit({ commitHash: commitSha }, commitMessage, pathFilter); - await this.deps.wikiService.syncFromCommit(squashResult.preSquashSha, commitSha, runRow.id); + const pathFilter = touchedPaths.length === 1 ? touchedPaths[0] : ''; + if (squashResult.committed) { + await this.deps.commitMessages.enqueueForExternalCommit({ commitHash: syncRef }, commitMessage, pathFilter); + } + await this.deps.wikiService.syncFromCommit(squashResult.preSquashSha, syncRef, runRow.id); await this.syncKnowledgeSlRefsFromActions(job.connectionId, memoryFlowSavedActions); const touchedConnections = [ ...new Set( diff --git a/packages/cli/src/context/ingest/local-bundle-runtime.ts b/packages/cli/src/context/ingest/local-bundle-runtime.ts index 69b9159a..3f6c3381 100644 --- a/packages/cli/src/context/ingest/local-bundle-runtime.ts +++ b/packages/cli/src/context/ingest/local-bundle-runtime.ts @@ -129,6 +129,10 @@ class LocalIngestStorage implements IngestStoragePort { this.homeDir = join(project.projectDir, '.ktx'); } + get autoCommit(): boolean { + return this.project.config.storage.git.auto_commit; + } + resolveUploadDir(uploadId: string): string { return join(this.project.projectDir, '.ktx/cache/local-ingest', uploadId, 'upload'); } diff --git a/packages/cli/src/context/ingest/ports.ts b/packages/cli/src/context/ingest/ports.ts index 88294f59..d6898478 100644 --- a/packages/cli/src/context/ingest/ports.ts +++ b/packages/cli/src/context/ingest/ports.ts @@ -159,6 +159,11 @@ interface IngestGitAuthor { export interface IngestStoragePort { homeDir: string; systemGitAuthor: IngestGitAuthor; + /** + * Mirror of config `storage.git.auto_commit`. When false, an ingest run applies its squash + * onto the project's working tree and leaves it staged instead of committing it. + */ + autoCommit: boolean; resolveUploadDir(uploadId: string): string; resolvePullDir(jobId: string): string; resolveTranscriptDir(jobId: string): string; diff --git a/packages/cli/src/context/memory/local-memory.ts b/packages/cli/src/context/memory/local-memory.ts index b72bc9ce..82805427 100644 --- a/packages/cli/src/context/memory/local-memory.ts +++ b/packages/cli/src/context/memory/local-memory.ts @@ -116,6 +116,7 @@ export function createLocalProjectMemoryIngest( knowledge: { userScopedKnowledgeEnabled: false }, slValidation: { probeRowCount: 0 }, llm: { memoryIngestionModel: project.config.llm.models.default ?? 'local-memory-model' }, + autoCommit: project.config.memory.auto_commit, }, promptService: new PromptService({ promptsDir, partials: [] }), skillsRegistry: new SkillsRegistryService({ skillsDir }), diff --git a/packages/cli/src/context/memory/memory-agent.service.ts b/packages/cli/src/context/memory/memory-agent.service.ts index 7f6438d3..0b8d28df 100644 --- a/packages/cli/src/context/memory/memory-agent.service.ts +++ b/packages/cli/src/context/memory/memory-agent.service.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import * as YAML from 'yaml'; import { z } from 'zod'; import { type KtxLogger, noopLogger } from '../../context/core/config.js'; +import type { SquashMergeResult, StageSquashResult } from '../../context/core/git.service.js'; import type { KtxRuntimeToolSet } from '../../context/llm/runtime-port.js'; import { revertSourceToPreHead, type SlValidationDeps } from '../../context/sl/tools/sl-warehouse-validation.js'; import type { SemanticLayerSource } from '../../context/sl/types.js'; @@ -253,13 +254,20 @@ export class MemoryAgentService { reconciledCrossRefs, gateRevertedSources, ); - const mergeResult = await this.deps.lockingService.withLock('config:repo', () => - this.deps.gitService.squashMergeIntoMain( - sessionWorktree.branch, - SYSTEM_GIT_AUTHOR.name, - SYSTEM_GIT_AUTHOR.email, - squashMessage, - ), + // With auto-commit disabled, apply the session to main's working tree and leave it + // staged rather than committing. The knowledge/SL DB is written eagerly during the + // session (and rolled back on conflict below), so search stays consistent with the + // staged files; we only skip the git commit and its message-enhancement job. + const autoCommit = this.deps.settings.autoCommit; + const mergeResult = await this.deps.lockingService.withLock('config:repo', () => + autoCommit + ? this.deps.gitService.squashMergeIntoMain( + sessionWorktree.branch, + SYSTEM_GIT_AUTHOR.name, + SYSTEM_GIT_AUTHOR.email, + squashMessage, + ) + : this.deps.gitService.stageSquashMergeIntoMain(sessionWorktree.branch), ); if (!mergeResult.ok) { @@ -269,17 +277,19 @@ export class MemoryAgentService { } else if (mergeResult.touchedPaths.length === 0) { sessionOutcome = 'empty'; } else { - squashSha = mergeResult.squashSha; touchedPaths = mergeResult.touchedPaths; - // Single-file commits: pass the path so the handler diff is path-scoped. - // Multi-file commits: omit path so the handler grabs the full commit diff - // (a comma-joined pathspec would match nothing). - const pathFilter = touchedPaths.length === 1 ? touchedPaths[0] : ''; - await this.deps.rootFileStore.enqueueCommitMessageJobForExternalCommit( - { commitHash: squashSha }, - squashMessage, - pathFilter, - ); + if ('squashSha' in mergeResult) { + squashSha = mergeResult.squashSha; + // Single-file commits: pass the path so the handler diff is path-scoped. + // Multi-file commits: omit path so the handler grabs the full commit diff + // (a comma-joined pathspec would match nothing). + const pathFilter = touchedPaths.length === 1 ? touchedPaths[0] : ''; + await this.deps.rootFileStore.enqueueCommitMessageJobForExternalCommit( + { commitHash: squashSha }, + squashMessage, + pathFilter, + ); + } } } catch (error) { sessionCrashed = true; diff --git a/packages/cli/src/context/memory/types.ts b/packages/cli/src/context/memory/types.ts index 5cc7dab2..90df9f58 100644 --- a/packages/cli/src/context/memory/types.ts +++ b/packages/cli/src/context/memory/types.ts @@ -74,6 +74,11 @@ interface MemoryAgentSettings { llm: { memoryIngestionModel: string; }; + /** + * When false (config `memory.auto_commit: false`), a completed session is applied to the + * project's working tree and left staged instead of committed, so the user commits it. + */ + autoCommit: boolean; } interface MemoryTelemetryPort { diff --git a/packages/cli/src/context/project/config.ts b/packages/cli/src/context/project/config.ts index fd7f482c..18c87626 100644 --- a/packages/cli/src/context/project/config.ts +++ b/packages/cli/src/context/project/config.ts @@ -230,7 +230,12 @@ const setupSchema = z const storageGitSchema = z .strictObject({ - auto_commit: z.boolean().default(true).describe('When true, KTX automatically commits state changes to the local Git-backed store.'), + auto_commit: z + .boolean() + .default(true) + .describe( + 'When true, a context-source ingest run (`ktx ingest `) commits its changes to the local Git-backed store. When false, the changes are applied to the working tree and left staged for you to commit.', + ), author: z .string() .min(1) @@ -278,7 +283,12 @@ const agentSchema = z const memorySchema = z .strictObject({ - auto_commit: z.boolean().default(true).describe('When true, KTX automatically commits memory updates to the Git-backed store.'), + auto_commit: z + .boolean() + .default(true) + .describe( + 'When true, a memory/wiki ingest run commits its updates to the Git-backed store. When false, the updates are applied to the working tree and left staged for you to commit.', + ), }) .describe('Memory subsystem configuration.'); diff --git a/packages/cli/test/context/core/git.service.test.ts b/packages/cli/test/context/core/git.service.test.ts index 4c06d070..d42d660a 100644 --- a/packages/cli/test/context/core/git.service.test.ts +++ b/packages/cli/test/context/core/git.service.test.ts @@ -400,6 +400,66 @@ describe('GitService', () => { }); }); + describe('stageSquashMergeIntoMain', () => { + it('applies the branch to main without committing, leaving the changes staged', async () => { + const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed'); + const parent = await realpath(join(tempDir, '..')); + const wtDir = join(parent, `wt-${Date.now()}-stage`); + await service.addWorktree(wtDir, 'session/stage', baseSha); + + const scoped = service.forWorktree(wtDir); + await writeFile(join(wtDir, 'a.yaml'), 'one: 1\n', 'utf-8'); + await scoped.commitFile('a.yaml', 'wip a', 'System User', 'system@example.com'); + + const result = await service.stageSquashMergeIntoMain('session/stage'); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error('unreachable'); + } + expect(result.touchedPaths).toEqual(['a.yaml']); + expect(result.stagedTree).toMatch(/^[0-9a-f]{40}$/); + + // HEAD did not advance: no commit was created. + expect(await service.revParseHead()).toBe(baseSha); + // The change is in main's working tree... + await expect(readFile(join(tempDir, 'a.yaml'), 'utf-8')).resolves.toBe('one: 1\n'); + // ...and staged in the index for the user to commit. + const stagedNames = await createSimpleGit(tempDir).raw(['diff', '--cached', '--name-only']); + expect(stagedNames).toContain('a.yaml'); + // The staged tree is usable as a diff/read ref for DB sync. + const treeListing = await createSimpleGit(tempDir).raw(['ls-tree', '-r', '--name-only', result.stagedTree]); + expect(treeListing).toContain('a.yaml'); + + await service.removeWorktree(wtDir).catch(() => undefined); + await rm(wtDir, { recursive: true, force: true }).catch(() => undefined); + }); + + it('reports conflicts without committing or mutating main', async () => { + const { commitHash: baseSha } = await writeAndCommit('conflict.md', 'base\n'); + const parent = await realpath(join(tempDir, '..')); + const wtDir = join(parent, `wt-${Date.now()}-stage-conflict`); + await service.addWorktree(wtDir, 'session/stage-conflict', baseSha); + const scoped = service.forWorktree(wtDir); + await writeFile(join(wtDir, 'conflict.md'), 'from-branch\n', 'utf-8'); + await scoped.commitFile('conflict.md', 'branch edit', 'System User', 'system@example.com'); + + // Move main ahead with a conflicting change. + await writeAndCommit('conflict.md', 'from-main\n'); + const mainHead = await service.revParseHead(); + + const result = await service.stageSquashMergeIntoMain('session/stage-conflict'); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error('unreachable'); + } + expect(result.conflictPaths).toContain('conflict.md'); + expect(await service.revParseHead()).toBe(mainHead); + + await service.removeWorktree(wtDir).catch(() => undefined); + await rm(wtDir, { recursive: true, force: true }).catch(() => undefined); + }); + }); + describe('squashMergeIntoMain', () => { it('merges a session branch as one commit on main, returning the new SHA + touched paths', async () => { const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed'); diff --git a/packages/cli/test/context/ingest/ingest-bundle.runner.isolated-diff.test.ts b/packages/cli/test/context/ingest/ingest-bundle.runner.isolated-diff.test.ts index bad40098..b4505c4b 100644 --- a/packages/cli/test/context/ingest/ingest-bundle.runner.isolated-diff.test.ts +++ b/packages/cli/test/context/ingest/ingest-bundle.runner.isolated-diff.test.ts @@ -199,6 +199,7 @@ function makeDeps( storage: { homeDir: join(runtime.configDir, '.ktx'), systemGitAuthor: { name: 'KTX Test', email: 'system@ktx.local' }, + autoCommit: true, resolveUploadDir: (id) => join(runtime.homeDir, 'upload', id), resolvePullDir: (id) => join(runtime.homeDir, 'pull', id), resolveTranscriptDir: (id) => join(runtime.configDir, '.ktx/ingest-transcripts', id), diff --git a/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts b/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts index 68814792..f22c53bc 100644 --- a/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts +++ b/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts @@ -262,6 +262,7 @@ const buildRunner = (deps: ReturnType = makeDeps(), overrides: storage: { homeDir: '/tmp/ktx-test', systemGitAuthor: { name: 'KTX Test', email: 'system@ktx.local' }, + autoCommit: true, resolveUploadDir: (uploadId) => `/tmp/ktx-test/ingest-uploads/${uploadId}`, resolvePullDir: (jobId) => `/tmp/ktx-test/ingest-pulls/${jobId}`, resolveTranscriptDir: (jobId) => `/tmp/ktx-test/run/wu-transcripts/${jobId}`, @@ -1519,6 +1520,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => { storage: { homeDir: tempRoot, systemGitAuthor: { name: 'KTX Test', email: 'system@ktx.local' }, + autoCommit: true, resolveUploadDir: (uploadId: string) => join(tempRoot, 'ingest-uploads', uploadId), resolvePullDir: (jobId: string) => join(tempRoot, 'ingest-pulls', jobId), resolveTranscriptDir: (jobId: string) => join(tempRoot, 'run', 'wu-transcripts', jobId), diff --git a/packages/cli/test/context/ingest/local-bundle-ingest.test.ts b/packages/cli/test/context/ingest/local-bundle-ingest.test.ts index 6140dd10..1d8819e2 100644 --- a/packages/cli/test/context/ingest/local-bundle-ingest.test.ts +++ b/packages/cli/test/context/ingest/local-bundle-ingest.test.ts @@ -424,6 +424,60 @@ describe('canonical local ingest', () => { } }); + it('with auto_commit disabled, stages ingest changes and indexes the wiki without committing', async () => { + const projectDir = join(tempDir, 'no-autocommit-project'); + await initKtxProject({ projectDir }); + await writeFile( + join(projectDir, 'ktx.yaml'), + [ + 'connections:', + ' warehouse:', + ' driver: postgres', + 'ingest:', + ' adapters:', + ' - fake', + ' embeddings:', + ' backend: none', + 'storage:', + ' git:', + ' auto_commit: false', + '', + ].join('\n'), + 'utf-8', + ); + const stagedProject = await loadKtxProject({ projectDir }); + const preHead = await stagedProject.git.revParseHead(); + + const sourceDir = join(tempDir, 'no-autocommit-source'); + await mkdir(join(sourceDir, 'orders'), { recursive: true }); + await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\n', 'utf-8'); + + const result = await runLocalIngest({ + project: stagedProject, + adapters: [new FakeSourceAdapter()], + adapter: 'fake', + connectionId: 'warehouse', + sourceDir, + jobId: 'wiki-staged-1', + agentRunner: new WikiWritingAgentRunner(), + }); + + expect(result.result.failedWorkUnits).toEqual([]); + // No commit was created: HEAD is unchanged. + expect(await stagedProject.git.revParseHead()).toBe(preHead); + // ...yet the wiki page is on disk (staged) and indexed for search, reconciled from the + // staged tree rather than a commit. + await expect(readFile(join(projectDir, 'wiki', 'global', 'orders_context.md'), 'utf-8')).resolves.toContain('Orders'); + const db = new Database(join(projectDir, '.ktx', 'db.sqlite'), { readonly: true }); + try { + expect(db.prepare('SELECT key, summary FROM knowledge_pages ORDER BY key').all()).toEqual([ + { key: 'orders_context', summary: 'Orders source context' }, + ]); + } finally { + db.close(); + } + }); + it('does not persist noop embedding vectors when local embeddings are disabled', async () => { await writeFile( join(project.projectDir, 'ktx.yaml'), diff --git a/packages/cli/test/context/memory/memory-agent.service.ingest.test.ts b/packages/cli/test/context/memory/memory-agent.service.ingest.test.ts index acb1c2f8..44a66362 100644 --- a/packages/cli/test/context/memory/memory-agent.service.ingest.test.ts +++ b/packages/cli/test/context/memory/memory-agent.service.ingest.test.ts @@ -40,6 +40,7 @@ interface BuiltMocks { slValidator: any; toolsetFactory: any; logger: any; + autoCommit: boolean; } const buildMocks = (overrides: Partial = {}): BuiltMocks => { @@ -111,6 +112,9 @@ const buildMocks = (overrides: Partial = {}): BuiltMocks => { gitService: { revParseHead: vi.fn().mockResolvedValue('basesha'), squashMergeIntoMain: vi.fn().mockResolvedValue({ ok: true, squashSha: 'cafebabe', touchedPaths: ['a.yaml'] }), + stageSquashMergeIntoMain: vi + .fn() + .mockResolvedValue({ ok: true, touchedPaths: ['a.yaml'], stagedTree: 'deadbeeftree' }), }, lockingService: { withLock: vi.fn().mockImplementation((_key: string, fn: () => Promise) => fn()), @@ -134,6 +138,7 @@ const buildMocks = (overrides: Partial = {}): BuiltMocks => { }), }, logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + autoCommit: true, }; return { ...defaults, ...overrides }; @@ -151,6 +156,7 @@ const buildService = (mocks: BuiltMocks): MemoryAgentService => llm: { memoryIngestionModel: mocks.appSettings.settings.llm.memoryIngestionModel, }, + autoCommit: mocks.autoCommit, }, promptService: mocks.prompt, skillsRegistry: mocks.skillsRegistry, @@ -242,6 +248,26 @@ describe('MemoryAgentService.ingest — session-branch orchestration', () => { expect(result.commitHash).toBe('cafebabe'); }); + it('with auto_commit disabled, stages the session on main without committing or enqueuing a note', async () => { + const mocks = buildMocks({ autoCommit: false }); + const svc = buildService(mocks); + + const result = await svc.ingest(baseInput); + + // Applied to main via the staging path, never the committing path. + expect(mocks.gitService.stageSquashMergeIntoMain).toHaveBeenCalledWith('session/chat-1'); + expect(mocks.gitService.squashMergeIntoMain).not.toHaveBeenCalled(); + // No commit means no commit-message enhancement job. + expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).not.toHaveBeenCalled(); + // The session still applied successfully; there is just no commit hash. + expect(result.commitHash).toBeNull(); + expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'chat-1' }), + 'success', + expect.any(Object), + ); + }); + it('normalizes load_skill output to markdown while preserving structured payload', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'ktx-memory-skill-')); const skillDir = join(tempDir, 'memory_agent'); diff --git a/packages/cli/test/context/memory/memory-agent.service.test.ts b/packages/cli/test/context/memory/memory-agent.service.test.ts index ba91444e..8e44f5fa 100644 --- a/packages/cli/test/context/memory/memory-agent.service.test.ts +++ b/packages/cli/test/context/memory/memory-agent.service.test.ts @@ -193,6 +193,7 @@ describe('MemoryAgentService.reconcileCrossRefs', () => { knowledge: { userScopedKnowledgeEnabled: false }, slValidation: { probeRowCount: 1 }, llm: { memoryIngestionModel: 'test-model' }, + autoCommit: true, }, promptService: undefined as never, skillsRegistry: undefined as never, @@ -369,6 +370,7 @@ describe('MemoryAgentService.gateRevertInvalidSources (J3)', () => { knowledge: { userScopedKnowledgeEnabled: false }, slValidation: { probeRowCount: 1 }, llm: { memoryIngestionModel: 'test-model' }, + autoCommit: true, }, promptService: undefined as never, skillsRegistry: undefined as never,