From e4123b42941a168254002ca96a6fcee11ea89a6f Mon Sep 17 00:00:00 2001 From: Sean Robbins Date: Sun, 6 Sep 2026 22:00:10 -0400 Subject: [PATCH 1/2] Add agent save/resume without a human confirm step Agents persist and load working state with notch save / notch resume and MCP save_working_state / resume_working_state. SessionStart injects the latest continuation body. Same-repo continuity is in scope. --- AGENTS.md | 10 +- CHANGELOG.md | 7 + README.md | 17 +- docs/README.md | 2 +- docs/active-plans/v4/v4-implementation-log.md | 4 + docs/guides/continuation-checkpoints.md | 25 ++- docs/guides/mcp-setup.md | 3 +- .../continuation-checkpoints/index.html | 6 +- src/cli/commands/onboard.ts | 1 + src/cli/commands/resume.ts | 36 ++++ src/cli/commands/save.ts | 86 ++++++++ src/cli/program.ts | 8 +- src/cli/prompts/claude-code.ts | 2 +- src/cli/prompts/codex.ts | 2 +- src/cli/prompts/cursor.ts | 2 +- src/cli/prompts/shared.ts | 9 +- src/core/continuation-service.ts | 197 +++++++++++++++--- src/mcp/server.ts | 37 +++- src/schemas/config.schema.json | 3 +- src/schemas/mcp-tools.schema.json | 17 ++ tests/cli/help.test.ts | 2 + tests/cli/prompt.test.ts | 2 + tests/cli/save-resume.test.ts | 109 ++++++++++ tests/e2e/agent-resume-smoke.test.ts | 76 +++++++ .../continuation-checkpoints-smoke.test.ts | 22 +- tests/helpers/store-fixtures.ts | 1 + tests/mcp/server-start.test.ts | 2 + tests/mcp/working-state-tools.test.ts | 100 +++++++++ tests/schema/mcp-tool-schemas.test.ts | 14 ++ tests/unit/continuation-service.test.ts | 67 +++++- tests/unit/no-deferred-commands.test.ts | 2 + 31 files changed, 806 insertions(+), 65 deletions(-) create mode 100644 src/cli/commands/resume.ts create mode 100644 src/cli/commands/save.ts create mode 100644 tests/cli/save-resume.test.ts create mode 100644 tests/e2e/agent-resume-smoke.test.ts create mode 100644 tests/mcp/working-state-tools.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6f0be8b..1383c8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Purpose -3Notch is a local-first CLI and MCP server for moving project context across boundaries that built-in AI tooling cannot cross — across repos, across AI work surfaces, web chats, and into new projects. V3 is the current shipped surface: packet transfer with artifact bundles, private context seeding, targeted briefs, self-addressed marks, typed packet replies, web-chat stdin intake, relationship indexing, deterministic corpus checks, and `.notchpkt` pack/unpack for cross-machine transport. Supporting commands: `onboard`, `status`, `doctor`, `mcp serve`. +3Notch is a local-first CLI and MCP server for automated agentic workflows: agents save and resume working state without a human in the loop, and they move selected context across repos, AI work surfaces, web chats, and new projects. V3 remains the packet-transfer surface. Same-repo continuity is in scope: wrap-up is `notch save`, session start is `notch resume`. Cross-boundary packets stay; they are not the only loop. Supporting commands: `onboard`, `status`, `doctor`, `mcp serve`. ## Read First @@ -32,6 +32,8 @@ During an active implementation run, append concise progress entries to the acti CLI commands: - `notch onboard` +- `notch save` +- `notch resume` - `notch mark` - `notch reply ` - `notch brief`, `notch brief create`, `notch brief list`, `notch brief show ` @@ -50,6 +52,7 @@ CLI commands: MCP tools: - `get_brief`, `create_brief`, `list_briefs`, `get_targeted_brief` - `create_packet`, `import_packet`, `list_packets`, `get_packet` +- `save_working_state`, `resume_working_state` - `create_seed_packet`, `import_seed_packet` - `create_mark`, `create_reply`, `check_store` - `get_status`, `run_doctor` @@ -57,7 +60,7 @@ MCP tools: ## Deferred (do not implement) -These are deliberately out of scope. Same-repo same-tool continuity is solved by CLAUDE.md, native tool memory, and `git commit`. 3Notch's wedge is cross-boundary transport. +These are deliberately out of scope. Cross-boundary packets stay; they are not the only loop. Same-repo agent save/resume is in scope — do not defer it to git or CLAUDE.md. - `notch pass`, MCP `create_pass`/`get_latest_pass`/`get_recent_passes` - `notch decision *`, MCP `record_decision`/`get_decisions` @@ -70,7 +73,8 @@ A regression-guard test (`tests/unit/no-deferred-commands.test.ts`) prevents acc ## Boundaries - When the user presents a product idea or asks "is this the right path?", discuss and pressure-test it before changing specs, plans, or repo files. -- Treat cross-repo packets, cross-tool handoff, and private context seeding as the core product loops. +- Treat same-repo agent save/resume, cross-repo packets, cross-tool handoff, and private context seeding as the core product loops. +- Wrap-up is `notch save`; session start is `notch resume`. Do not ask a human to confirm loading a continuation in the happy path. Codex, Grok, and Cursor should run those two commands. Do not invent a daemon. - No telemetry, cloud sync, hosted service, dashboard, vector database, or SQLite/native DB. - Durable inbox remains a dependency-free local filesystem spool. Do not turn it into chat, a daemon, hosted identity, background sync, or model invocation. - Do not build hidden chat or project scraping. MCP tools write selected or summarized context the user or agent explicitly supplies during a session. diff --git a/CHANGELOG.md b/CHANGELOG.md index f60dbfc..f2acc4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added +- `notch save` / `notch resume` and MCP `save_working_state` / `resume_working_state` persist and load working state for agentic loops without a human confirm step. +- Claude Code SessionStart injects the latest continuation body into `additionalContext`. + +### Changed +- Same-repo continuity is in scope. Wrap-up is `notch save`; session start is `notch resume`. + ## [0.7.2] - 2026-08-11 ### Changed diff --git a/README.md b/README.md index bbe8bfa..7435b18 100644 --- a/README.md +++ b/README.md @@ -49,15 +49,18 @@ Agent prompt (optional): ## Common flows -### Resume after a failed session +### Save and resume working state -With [continuation checkpoints](docs/guides/continuation-checkpoints.md) enabled for Claude Code, 3Notch can write a fallback from task state and git when the session hits a rate limit, model-down failure, or compaction. The next session may offer that checkpoint once; you approve before it is loaded. +Wrap-up: `notch save`. Session start: `notch resume`. No confirmation prompt. Codex, Grok, and Cursor should run those two commands; there is no 3Notch daemon. ```bash -notch packet list -notch packet preview +notch save --summary "Auth validation done" --next-steps "Implement session store" +notch resume +notch resume --json ``` +Claude Code SessionStart injects the latest checkpoint body so the next agent can continue without calling `get_packet` after a human yes. Private packets stay hidden unless you pass `--include-private`. See [Continuation checkpoints](docs/guides/continuation-checkpoints.md). + ### Hand off between tools ```bash @@ -133,6 +136,8 @@ Targeting fields (`--to-agent`, `--to-repo`) are intent metadata. Bytes move via ```text notch onboard initialize .notch/ and MCP setup +notch save write a continuation from git snapshot + summary +notch resume print the latest continuation (no prompt) notch packet create create a packet (--file, --ref, --next-steps) notch packet import import into .notch/inbox/ notch packet preview show what an agent will read @@ -160,8 +165,8 @@ notch mcp serve local stdio MCP server | | Tools | | --- | --- | -| **Read** | `get_brief`, `list_briefs`, `get_targeted_brief`, `get_packet`, `list_packets`, `list_inbox`, `get_inbox_delivery`, `get_status`, `check_store`, `run_doctor` | -| **Write** | `create_brief`, `create_packet`, `create_mark`, `create_reply`, `create_seed_packet`, `import_packet`, `import_seed_packet`, `inbox_init`, `send_packet`, `pull_inbox_packet`, `ack_inbox_delivery` | +| **Read** | `get_brief`, `list_briefs`, `get_targeted_brief`, `get_packet`, `list_packets`, `resume_working_state`, `list_inbox`, `get_inbox_delivery`, `get_status`, `check_store`, `run_doctor` | +| **Write** | `create_brief`, `create_packet`, `save_working_state`, `create_mark`, `create_reply`, `create_seed_packet`, `import_packet`, `import_seed_packet`, `inbox_init`, `send_packet`, `pull_inbox_packet`, `ack_inbox_delivery` | Private records under `.notch/private/` stay hidden unless the server starts with `--include-private`. Client setup: [docs/guides/mcp-setup.md](docs/guides/mcp-setup.md). diff --git a/docs/README.md b/docs/README.md index 72688a3..7f6081e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ Task-shaped how-tos for the main flows. - [Private context seeding](guides/private-context-seeding.md) — carrying reviewed preferences into a new repo. - [Targeted brief workflow](guides/targeted-brief-workflow.md) — scoped task briefs vs cross-boundary packets. - [MCP setup](guides/mcp-setup.md) — server flags, available tools, agent instruction packs. -- [Continuation checkpoints](guides/continuation-checkpoints.md) — opt-in Claude task aggregation, compaction fallback, model-down StopFailure recovery, and resume confirmation. +- [Continuation checkpoints](guides/continuation-checkpoints.md) — `notch save` / `notch resume`, opt-in Claude task aggregation, compaction fallback, and model-down StopFailure recovery. - [Release](guides/release.md) — maintainer version surfaces, publish recipe, and release-check. ## Reference diff --git a/docs/active-plans/v4/v4-implementation-log.md b/docs/active-plans/v4/v4-implementation-log.md index a9b1d58..90837ec 100644 --- a/docs/active-plans/v4/v4-implementation-log.md +++ b/docs/active-plans/v4/v4-implementation-log.md @@ -14,6 +14,10 @@ - Final review hardened malformed-input handling, Stop removal, per-stream state isolation, concurrent checkpoint serialization and IDs, long branch names, high-volume predecessor lookup, ignored local settings/backups, task-description preservation, and private MCP resume access. - Verification passed: lint, type-check, build, 194 unit/integration tests, 20 e2e tests, built-CLI help/version/hook smoke, a packed tarball installation exercising onboarding plus real task/post-compaction stdin events, zero installed-package audit findings, and Claude Code `doctor` against the generated project configuration. +## 2026-09-06 + +- **Issue #18:** `notch save` / `notch resume` and MCP `save_working_state` / `resume_working_state`. SessionStart injects the checkpoint body. No confirm prompt on the happy path. Same-repo continuity is in scope. + ## 2026-08-09 - **PR #14 (0.7.1):** widened default StopFailure matcher and runtime gate to recoverable model-down errors: `rate_limit|overloaded|server_error|max_output_tokens|unknown`. Auth/billing failures still excluded. Legacy `rate_limit`-only matchers rewritten on onboard resync. Source tags avoid secret-scanner false positives (`max-output` not `max-output-tokens` in titles). diff --git a/docs/guides/continuation-checkpoints.md b/docs/guides/continuation-checkpoints.md index 20595c9..b78fc2c 100644 --- a/docs/guides/continuation-checkpoints.md +++ b/docs/guides/continuation-checkpoints.md @@ -2,6 +2,25 @@ Continuation checkpoints preserve enough selected project state to resume after a rate limit, context compaction, or an intentional agent switch. They are ordinary immutable packets, not transcripts or hidden memory. +## Agent save and resume + +Wrap-up: + +```bash +notch save --summary "Auth validation done" --next-steps "Implement session store" +``` + +Session start: + +```bash +notch resume +notch resume --json +``` + +No confirmation prompt. Empty store is a quiet success; `--json` returns `"checkpoint": null`. Codex, Grok, and Cursor should run those two commands. There is no 3Notch daemon. Claude Code SessionStart puts the latest checkpoint body in `additionalContext` so the next agent continues without calling `get_packet` after a human yes. + +`--summary` and `--next-steps` are optional (stdin is accepted). Secret scan still blocks dirty writes. Private packets stay hidden from `notch resume` and MCP `resume_working_state` unless `--include-private` is set. + ## Enable Claude Code Checkpoints ```bash @@ -49,9 +68,9 @@ The default triggers are a meaningful milestone, an intentional agent/model swit ## Streams and Resume -The stream is the configured override, current Git branch, detached commit, or `default` for a non-Git project. Continuations use tags such as `continuation`, `stream-feature-auth`, and `source-post-compact`. +The stream is the configured override, current Git branch, detached commit, or `default` for a non-Git project. Continuations use tags such as `continuation`, `stream-feature-auth`, and `source-save` or `source-post-compact`. -At the next session start, 3Notch offers the latest matching checkpoint once. It supplies only packet metadata. Claude must not call `get_packet` or load the packet body until you confirm. +`notch save` / `save_working_state` write a continuation tagged for the current stream and supersede the latest same-stream checkpoint. `notch resume` / `resume_working_state` print the latest continuation for this store. Claude Code SessionStart injects that body automatically. ## Privacy Boundary @@ -59,7 +78,7 @@ At the next session start, 3Notch offers the latest matching checkpoint once. It - Onboarding adds `.claude/settings.local.json` to the project `.gitignore`; backups are kept under ignored `.notch/index/` state. - `.notch/index/continuation/` is local derived state and gitignored. - Project checkpoints land in `.notch/outbox/` and may appear in Git; choose `private` sensitivity to use ignored `.notch/private/outbox/`. -- Private mode intentionally adds `--include-private` to this project's 3Notch MCP server so the agent can list and load a checkpoint after you confirm. That server can read other private packets in the same local store through approved MCP calls. +- Private mode intentionally adds `--include-private` to this project's 3Notch MCP server so `resume_working_state` can return a private checkpoint. That server can read other private packets in the same local store through approved MCP calls. - A model-down StopFailure fallback cannot recover reasoning that was never reflected in a task, Git state, compact summary, final assistant message, or prior curated checkpoint. - Existing projects with a legacy `StopFailure` matcher of only `rate_limit` should rerun `notch onboard --yes --mcp claude-code --checkpoints ` so Claude Code wires the full recoverable matcher. - 3Notch never commits, pushes, imports, or transports a checkpoint automatically. diff --git a/docs/guides/mcp-setup.md b/docs/guides/mcp-setup.md index b738201..99caa78 100644 --- a/docs/guides/mcp-setup.md +++ b/docs/guides/mcp-setup.md @@ -42,7 +42,7 @@ notch mcp serve --default-actor "Claude Desktop" Read-only: - `get_brief`, `list_briefs`, `get_targeted_brief` -- `get_packet`, `list_packets` +- `get_packet`, `list_packets`, `resume_working_state` - `get_status`, `check_store`, `run_doctor` - `list_inbox` — list pending or retained durable deliveries - `get_inbox_delivery` — read pending, pulled, acknowledged, or rejected status for one delivery @@ -51,6 +51,7 @@ Write tools (require client tool-permission grant): - `create_brief` - `create_packet` (accepts `files`, `refs`, `nextSteps`, `tags`, and `supersedes`) +- `save_working_state` — wrap-up continuation from git snapshot plus optional summary/nextSteps - `create_mark` — self-addressed private capture - `create_reply` — typed packet reply - `create_seed_packet` diff --git a/site/public/docs/guides/continuation-checkpoints/index.html b/site/public/docs/guides/continuation-checkpoints/index.html index d01ad5c..edb7f4b 100644 --- a/site/public/docs/guides/continuation-checkpoints/index.html +++ b/site/public/docs/guides/continuation-checkpoints/index.html @@ -87,8 +87,10 @@

What the recovery layer records

Fallbacks include branch, short commit, dirty state, and changed-file names. They do not copy file contents or artifacts automatically.

-

Resume safely

-

At the next session start, 3Notch offers the latest matching checkpoint once. The agent must not load the packet body until you confirm. Every automatic or agent-authored checkpoint is unreviewed and targets next-agent.

+

Agent save and resume

+
$ notch save --summary "Auth validation done" --next-steps "Implement session store"
+$ notch resume
+

No confirmation prompt. Codex, Grok, and Cursor should run those two commands; there is no 3Notch daemon. Claude Code SessionStart injects the latest checkpoint body so the next agent continues without calling get_packet after a human yes. Private packets stay hidden unless --include-private is set.

Streams resolve from an explicit override, the current Git branch, a detached commit, or default. Each new checkpoint explicitly supersedes the latest checkpoint in that stream.

Privacy boundary

diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index 4f97eb8..b0feda4 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -416,6 +416,7 @@ function defaultConfig(projectName: string, projectRoot: string): NotchConfig { 'send_packet', 'pull_inbox_packet', 'ack_inbox_delivery', + 'save_working_state', ], }, }; diff --git a/src/cli/commands/resume.ts b/src/cli/commands/resume.ts new file mode 100644 index 0000000..34bdd56 --- /dev/null +++ b/src/cli/commands/resume.ts @@ -0,0 +1,36 @@ +import type { Command } from 'commander'; + +import { getCliContext } from '../context.js'; +import { printJson } from '../output.js'; +import { loadConfig } from '../../core/config-service.js'; +import { resumeWorkingState } from '../../core/continuation-service.js'; + +type ResumeOptions = { + includePrivate?: boolean; +}; + +export function registerResumeCommand(program: Command): void { + program + .command('resume') + .description('print the latest continuation for this store without prompting') + .option('--include-private', 'include private continuation packets') + .action(async (options: ResumeOptions, command: Command) => { + const context = getCliContext(command); + const loaded = await loadConfig({ + ...(context.cwd ? { cwd: context.cwd } : {}), + ...(context.store ? { store: context.store } : {}), + }); + const result = await resumeWorkingState(loaded, { + includePrivate: Boolean(options.includePrivate), + }); + + if (context.output.json) { + printJson(result); + return; + } + + if (result.checkpoint) { + process.stdout.write(`${result.checkpoint.markdown}\n`); + } + }); +} diff --git a/src/cli/commands/save.ts b/src/cli/commands/save.ts new file mode 100644 index 0000000..e3bae4e --- /dev/null +++ b/src/cli/commands/save.ts @@ -0,0 +1,86 @@ +import type { Command } from 'commander'; + +import { getCliContext } from '../context.js'; +import { printInfo, printJson } from '../output.js'; +import { printPrivatePacketHint } from '../private-hints.js'; +import { loadConfig } from '../../core/config-service.js'; +import { saveWorkingState } from '../../core/continuation-service.js'; +import { NotchException } from '../../types/errors.js'; + +type SaveOptions = { + nextSteps?: string; + private?: boolean; + summary?: string; +}; + +export function registerSaveCommand(program: Command): void { + program + .command('save') + .description('write a continuation packet for this store from git snapshot and optional summary') + .option('--summary ', 'working-state summary; pass - to read from stdin') + .option('--next-steps ', 'what the next agent should do; pass - to read from stdin') + .option('--private', 'write a private continuation packet') + .action(async (options: SaveOptions, command: Command) => { + const context = getCliContext(command); + const loaded = await loadConfig({ + ...(context.cwd ? { cwd: context.cwd } : {}), + ...(context.store ? { store: context.store } : {}), + }); + const fields = await resolveSaveFields(options); + const result = await saveWorkingState(loaded, { + ...(context.actor ? { actor: context.actor } : {}), + ...(context.agent ? { agent: context.agent } : {}), + ...(fields.nextSteps ? { nextSteps: fields.nextSteps } : {}), + ...(options.private ? { private: true } : {}), + ...(context.sourceTool ? { sourceTool: context.sourceTool } : {}), + ...(fields.summary ? { summary: fields.summary } : {}), + }); + + if (context.output.json) { + printJson(result); + return; + } + + printInfo(`Saved continuation ${result.packet.id}`, context.output); + printInfo(result.outboxPath, context.output); + printPrivatePacketHint(result.packet, context.output); + }); +} + +async function resolveSaveFields(options: SaveOptions): Promise<{ nextSteps?: string; summary?: string }> { + if (options.summary === '-' && options.nextSteps === '-') { + throw new NotchException({ + code: 'NOTCH_SAVE_STDIN_CONFLICT', + message: 'Only one of --summary or --next-steps can read stdin.', + recovery: 'Pass text for one flag and - for the other, or pipe a summary with no - flags.', + severity: 'error', + exitCode: 1, + }); + } + + const needsStdin = options.summary === '-' || options.nextSteps === '-' || !options.summary; + const stdin = needsStdin && (options.summary === '-' || options.nextSteps === '-' || !process.stdin.isTTY) + ? (await readStdin()).trim() + : ''; + const summary = options.summary === '-' + ? stdin || undefined + : options.summary ?? (options.nextSteps === '-' ? undefined : stdin || undefined); + const nextSteps = options.nextSteps === '-' + ? stdin || undefined + : options.nextSteps; + + return { + ...(nextSteps ? { nextSteps } : {}), + ...(summary ? { summary } : {}), + }; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 357ba5f..647cf91 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -12,6 +12,8 @@ import { registerOnboardCommand } from './commands/onboard.js'; import { registerPacketCommand } from './commands/packet.js'; import { registerPromptCommand } from './commands/prompt.js'; import { registerReplyCommand } from './commands/reply.js'; +import { registerResumeCommand } from './commands/resume.js'; +import { registerSaveCommand } from './commands/save.js'; import { registerScanCommand } from './commands/scan.js'; import { registerSeedCommand } from './commands/seed.js'; import { registerStatusCommand } from './commands/status.js'; @@ -19,7 +21,7 @@ import { registerStatusCommand } from './commands/status.js'; export function createProgram(): Command { const program = new Command() .name('notch') - .description('Local-first private context packets across repos and AI tools.') + .description('Local-first working-state checkpoints and portable context packets.') .version(VERSION, '-v, --version', 'print the 3Notch version') .option('--cwd ', 'run 3Notch as if started from another project directory') .option('--store ', 'use a specific .notch store path') @@ -35,6 +37,8 @@ export function createProgram(): Command { ` Examples: $ notch onboard --yes + $ notch save --summary "Auth validation done" --next-steps "Implement session store" + $ notch resume $ notch prompt --client claude-code $ notch mark --summary "Remember this project decision" $ notch reply --type question --summary "Can you clarify this?" @@ -51,6 +55,8 @@ Examples: ); registerOnboardCommand(program); + registerSaveCommand(program); + registerResumeCommand(program); registerPromptCommand(program); registerMarkCommand(program); registerReplyCommand(program); diff --git a/src/cli/prompts/claude-code.ts b/src/cli/prompts/claude-code.ts index fced6d9..a650837 100644 --- a/src/cli/prompts/claude-code.ts +++ b/src/cli/prompts/claude-code.ts @@ -4,5 +4,5 @@ export const claudeCodePromptClient: PromptClient = { id: 'claude-code', label: 'Claude Code', destination: 'CLAUDE.md in this repository', - setupHint: 'Claude Code should use the project-local .mcp.json created by notch onboard --mcp claude-code.', + setupHint: 'Claude Code should use the project-local .mcp.json created by notch onboard --mcp claude-code. SessionStart injects the latest continuation body; still run notch save at wrap-up. Do not wait for a human to confirm loading a continuation.', }; diff --git a/src/cli/prompts/codex.ts b/src/cli/prompts/codex.ts index 7e15b0a..38e8d60 100644 --- a/src/cli/prompts/codex.ts +++ b/src/cli/prompts/codex.ts @@ -4,5 +4,5 @@ export const codexPromptClient: PromptClient = { id: 'codex', label: 'Codex', destination: 'AGENTS.md in this repository', - setupHint: 'Codex should use the configured 3notch MCP server for explicit packet and brief operations.', + setupHint: 'Codex should use the configured 3notch MCP server. At wrap-up run notch save; at session start run notch resume. Do not wait for a human to confirm loading a continuation.', }; diff --git a/src/cli/prompts/cursor.ts b/src/cli/prompts/cursor.ts index e532415..1ac233c 100644 --- a/src/cli/prompts/cursor.ts +++ b/src/cli/prompts/cursor.ts @@ -4,5 +4,5 @@ export const cursorPromptClient: PromptClient = { id: 'cursor', label: 'Cursor', destination: '.cursor/rules/3notch.mdc or the project rules file you already use', - setupHint: 'Cursor should use the project MCP server definition that points at this .notch store.', + setupHint: 'Cursor should use the project MCP server definition that points at this .notch store. At wrap-up run notch save; at session start run notch resume. Do not wait for a human to confirm loading a continuation.', }; diff --git a/src/cli/prompts/shared.ts b/src/cli/prompts/shared.ts index 6bcd567..600c10a 100644 --- a/src/cli/prompts/shared.ts +++ b/src/cli/prompts/shared.ts @@ -29,6 +29,8 @@ export const v1McpToolNames = [ 'check_store', 'get_status', 'run_doctor', + 'save_working_state', + 'resume_working_state', ] as const; export function renderAgentPrompt(client: PromptClient): string { @@ -44,6 +46,8 @@ ${client.setupHint} When the user asks to package, transfer, or hand off project context, use the local 3Notch MCP server. Work from explicit user-provided context, selected files, source links, and summaries. Do not claim access to hidden chat history or private files the user did not provide. +Same-repo continuity uses save and resume. At wrap-up, run notch save or call save_working_state with a short summary and next steps. At session start, run notch resume or call resume_working_state and continue from that checkpoint immediately. Do not ask the user to confirm loading it. Codex, Grok, and Cursor should run those two commands; there is no 3Notch daemon. + Use packets for cross-tool or cross-repo handoff: - Call create_packet when the user wants context prepared for another agent, repo, or person. - Include a concise summary, recipient metadata, sourceLinks, exclusions, and a nextSteps field describing what the receiving agent should do - short, imperative, file paths included where relevant. @@ -77,10 +81,11 @@ Use durable inbox for asynchronous handoff after both stores have intentionally If .notch/config.json enables continuation checkpoints: - Follow the configured prompt or auto mode only at the listed semantic triggers; do not change that policy unless the user explicitly asks. -- A continuation is an ordinary handoff packet targeted to next-agent, tagged continuation, stream-, and source-agent. +- A continuation is an ordinary handoff packet targeted to next-agent, tagged continuation, stream-, and source-save or source-agent. +- Prefer save_working_state / notch save over hand-built create_packet calls. - Supersede only the latest continuation in the same stream. - Preserve the objective, completed work, decisions, constraints, verification, blockers, relevant files, exact next action, and what not to redo. -- Hook-generated fallback packets are unreviewed. Offer a matching checkpoint once, and call get_packet only after the user confirms. +- Claude Code SessionStart injects the latest checkpoint body. Do not wait for a human to approve get_packet. - Never read a Claude transcript path or claim that a checkpoint contains the complete conversation. Available 3Notch MCP tools: diff --git a/src/core/continuation-service.ts b/src/core/continuation-service.ts index a0b12e3..67d23ae 100644 --- a/src/core/continuation-service.ts +++ b/src/core/continuation-service.ts @@ -7,6 +7,8 @@ import { readGitSnapshot, type GitSnapshot } from './git-service.js'; import { toSlug } from './id-service.js'; import { createPacket, listPackets } from './packet-service.js'; import { atomicWriteFile, ensureDir } from './store-service.js'; +import type { NotchError } from '../types/errors.js'; +import type { NotchPacket, SourceToolName } from '../types/records.js'; export type ClaudeHookEvent = | 'SessionStart' @@ -62,6 +64,40 @@ export type ClaudeHookOutput = { }; }; +export type SaveWorkingStateInput = { + actor?: string | undefined; + agent?: string | undefined; + mcp?: boolean | undefined; + nextSteps?: string | undefined; + private?: boolean | undefined; + sourceTool?: SourceToolName | undefined; + summary?: string | undefined; +}; + +export type ListedContinuation = { + direction: 'inbox' | 'outbox'; + markdownPath: string; + packet: NotchPacket; + path: string; + rootPath: string; +}; + +export type SaveWorkingStateResult = { + outboxPath: string; + packet: NotchPacket; + stream: string; + warnings: NotchError[]; +}; + +export type ResumeWorkingStateOptions = { + includePrivate?: boolean | undefined; +}; + +export type ResumeWorkingStateResult = { + checkpoint: (ListedContinuation & { markdown: string }) | null; + stream: string; +}; + const supportedEvents = new Set([ 'SessionStart', 'TaskCreated', @@ -172,6 +208,67 @@ export function resolveContinuationStream(override: string | undefined, git: Git return 'default'; } +export async function saveWorkingState( + context: LoadedConfig, + input: SaveWorkingStateInput = {}, +): Promise { + const git = excludeStoreChanges(context, await readGitSnapshot(context.projectRoot)); + const stream = resolveContinuationStream(context.config.continuation?.streamOverride, git); + const sensitivity = input.private || context.config.continuation?.sensitivity === 'private' + ? 'private' + : 'project'; + + return await withCheckpointWriteLock(context, async () => { + const latest = await latestStreamCheckpoint(context, stream, true); + const result = await createPacket(context, { + ...(input.actor ? { actor: input.actor } : {}), + ...(input.agent ? { agent: input.agent } : {}), + importNotes: 'Created from notch save. Git snapshot is included; no transcript was read.', + ...(input.mcp ? { mcp: true } : {}), + nextSteps: input.nextSteps?.trim() || 'Continue from this working-state checkpoint.', + purpose: 'handoff', + sensitivity, + ...(input.sourceTool ? { sourceTool: input.sourceTool } : {}), + summary: renderSaveSummary(input.summary, git), + ...(latest ? { supersedes: latest.packet.id } : {}), + tags: ['continuation', `stream-${stream}`, 'source-save'], + title: continuationTitle(context.config.project.name, stream, 'save'), + toAgent: 'next-agent', + }); + + return { + outboxPath: result.outboxPath, + packet: result.packet, + stream, + warnings: result.warnings, + }; + }); +} + +export async function resumeWorkingState( + context: LoadedConfig, + options: ResumeWorkingStateOptions = {}, +): Promise { + const git = excludeStoreChanges(context, await readGitSnapshot(context.projectRoot)); + const stream = resolveContinuationStream(context.config.continuation?.streamOverride, git); + const latest = await findLatestContinuation(context, { + includePrivate: Boolean(options.includePrivate), + stream, + }); + + if (!latest) { + return { checkpoint: null, stream }; + } + + return { + checkpoint: { + ...latest, + markdown: await readFile(latest.markdownPath, 'utf8'), + }, + stream, + }; +} + function validateHookInput(value: unknown): ClaudeHookInput { if (!value || typeof value !== 'object') { throw new Error('Claude hook input must be a JSON object.'); @@ -230,7 +327,7 @@ async function handleEvent( ): Promise { switch (input.hook_event_name) { case 'SessionStart': - return await handleSessionStart(context, input, state, stream); + return await handleSessionStart(context, state, stream); case 'TaskCreated': updateTask(state, input, 'pending'); await writeSessionState(context, state); @@ -274,40 +371,33 @@ async function handleEvent( async function handleSessionStart( context: LoadedConfig, - input: ClaudeHookInput, state: ContinuationSessionState, stream: string, ): Promise { const agentPolicy = renderAgentCheckpointPolicy(context, stream); + const latest = await loadLatestStreamCheckpoint(context, stream); - if (input.source === 'compact') { - await writeSessionState(context, state); - return agentPolicy - ? sessionStartContext(agentPolicy) - : {}; + if (latest) { + state.offeredCheckpointId = latest.packet.id; } - const latest = await latestStreamCheckpoint(context, stream); + await writeSessionState(context, state); - if (!latest || state.offeredCheckpointId === latest.packet.id) { - await writeSessionState(context, state); + if (!latest) { return agentPolicy ? sessionStartContext(agentPolicy) : {}; } - state.offeredCheckpointId = latest.packet.id; - await writeSessionState(context, state); - const privateRead = latest.packet.sensitivity === 'private' - ? ' After confirmation, call get_packet with includePrivate true.' - : ''; - const offer = `3Notch found continuation checkpoint ${latest.packet.id} (${latest.packet.title}) for stream ${stream}. Offer it to the user once. Do not call get_packet or load its contents unless the user confirms.${privateRead}`; + const loaded = `3Notch continuation checkpoint ${latest.packet.id} (${latest.packet.title}) for stream ${stream}. Continue from this checkpoint. Do not ask the user to confirm loading it. + +${latest.markdown}`; return { - systemMessage: `3Notch continuation available for ${stream}: ${latest.packet.title}.`, + systemMessage: `3Notch loaded continuation for ${stream}: ${latest.packet.title}.`, hookSpecificOutput: { hookEventName: 'SessionStart', - additionalContext: [agentPolicy, offer].filter(Boolean).join('\n\n'), + additionalContext: [agentPolicy, loaded].filter(Boolean).join('\n\n'), }, }; } @@ -324,17 +414,15 @@ function renderAgentCheckpointPolicy(context: LoadedConfig, stream: string): str : 'At each configured semantic trigger, create a continuation checkpoint automatically without interrupting the workflow.'; const triggers = continuation.semanticTriggers.map((trigger) => `- ${trigger}`).join('\n'); - const privateListInstruction = continuation.sensitivity === 'private' - ? ' with includePrivate true' - : ''; - return `3Notch semantic continuation policy (${continuation.mode} mode). ${behavior} Configured triggers: ${triggers || '- None.'} -For an approved checkpoint, use list_packets${privateListInstruction} with tags continuation and stream-${stream} to find the latest predecessor, then call create_packet with: +Wrap-up: call save_working_state (or run notch save) with a concise summary and next steps. Session start already includes the latest continuation body when one exists; do not ask the user to confirm loading it. Prefer resume_working_state or notch resume in clients without this hook. + +For a new checkpoint, prefer save_working_state. If you must call create_packet: - recipient next-agent - sensitivity ${continuation.sensitivity} - tags continuation, stream-${stream}, source-agent @@ -526,18 +614,73 @@ function hasMaterialRecoveryState(state: ContinuationSessionState, git: GitSnaps return Object.keys(state.tasks).length > 0 || git.dirty || git.commit !== state.startGit.commit || git.branch !== state.startGit.branch; } -async function latestStreamCheckpoint(context: LoadedConfig, stream: string) { - const sensitivity = context.config.continuation?.sensitivity ?? 'project'; +async function latestStreamCheckpoint( + context: LoadedConfig, + stream: string, + includePrivate = true, +): Promise { const packets = await listPackets(context, { - includePrivate: true, + includePrivate, limit: 1, - sensitivity, tags: ['continuation', `stream-${stream}`], }); return packets[0]; } +async function loadLatestStreamCheckpoint( + context: LoadedConfig, + stream: string, +): Promise<(ListedContinuation & { markdown: string }) | undefined> { + const latest = await latestStreamCheckpoint(context, stream, true); + + if (!latest) { + return undefined; + } + + return { + ...latest, + markdown: await readFile(latest.markdownPath, 'utf8'), + }; +} + +async function findLatestContinuation( + context: LoadedConfig, + options: { includePrivate: boolean; stream: string }, +): Promise { + const streamMatch = await latestStreamCheckpoint(context, options.stream, options.includePrivate); + + if (streamMatch) { + return streamMatch; + } + + const any = await listPackets(context, { + includePrivate: options.includePrivate, + limit: 1, + tags: ['continuation'], + }); + + return any[0]; +} + +function renderSaveSummary(primary: string | undefined, git: GitSnapshot): string { + const changedFiles = git.changedFiles.length > 0 + ? git.changedFiles.map((file) => `- ${file}`).join('\n') + : '- None detected.'; + const head = primary?.trim() || 'Working state saved from the current Git snapshot.'; + + return `${head} + +### Repository State + +- Branch: ${git.branch ?? 'unknown'} +- Commit: ${git.commit ?? 'unknown'} +- Dirty: ${git.dirty ? 'yes' : 'no'} + +Changed files: +${changedFiles}`; +} + async function readSessionState( context: LoadedConfig, sessionId: string, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2cea164..b45eb29 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -11,6 +11,7 @@ import { checkStore } from '../core/check-service.js'; import { loadConfig } from '../core/config-service.js'; import { runDoctor } from '../core/doctor-service.js'; import { ackInboxDelivery, getInboxDeliveryStatus, initializeDurableInbox, listInboxDeliveries, pullInboxDelivery, sendInboxPacket } from '../core/inbox-service.js'; +import { resumeWorkingState, saveWorkingState } from '../core/continuation-service.js'; import { createMark, createPacket, createReply, getPacket, listPackets } from '../core/packet-service.js'; import { createSeedPacket, importSeedPacket } from '../core/seed-service.js'; import { getStatus } from '../core/status-service.js'; @@ -91,6 +92,18 @@ const toolDefinitions: ToolDefinition[] = [ readOnly: true, }, { name: 'get_packet', description: 'Read a packet by ID or slug.', readOnly: true }, + { + name: 'save_working_state', + title: 'Save working state', + description: 'Use at wrap-up to persist this session so another agent can continue without a human: writes a continuation packet from the current Git snapshot plus the optional summary and nextSteps you supply. Secret-scanned before write. Does not ask for confirmation.', + readOnly: false, + }, + { + name: 'resume_working_state', + title: 'Resume working state', + description: 'Use at session start to load the latest continuation for this store and continue immediately. Returns the checkpoint body; no confirmation step. Empty store returns checkpoint null. Private packets stay hidden unless the server was started with --include-private.', + readOnly: true, + }, { name: 'create_seed_packet', title: 'Create private seed packet', @@ -175,7 +188,7 @@ export function createNotchMcpServer(options: NotchMcpServerOptions = {}): Serve { name: '3notch', version: VERSION }, { capabilities: { tools: {} }, - instructions: '3Notch exposes local, explicit, reviewable packets. For async handoff: create and pack a project packet, send_packet to a registered local: address, forward the returned delivery notice, list_inbox at the receiver, pull_inbox_packet with import=true, review, ack_inbox_delivery, and use get_inbox_delivery when the sender needs status. Durable inbox labels are not authenticated identities; private/seed packets are blocked. 3Notch does not scrape chats, invoke other models, run shells, host a relay, or delete acknowledged packets.', + instructions: '3Notch exposes local, explicit, reviewable packets. Wrap-up: save_working_state. Session start: resume_working_state — continue from the returned body, do not ask the user to confirm loading it. For async handoff: create and pack a project packet, send_packet to a registered local: address, forward the returned delivery notice, list_inbox at the receiver, pull_inbox_packet with import=true, review, ack_inbox_delivery, and use get_inbox_delivery when the sender needs status. Durable inbox labels are not authenticated identities; private/seed packets are blocked. 3Notch does not scrape chats, invoke other models, run shells, host a relay, or delete acknowledged packets.', }, ); @@ -471,6 +484,28 @@ async function executeTool( strict: Boolean(args.strict), }) as unknown as Record; } + case 'save_working_state': { + return await saveWorkingState(context, { + actor: mcpActorName(args, options), + mcp: true, + ...(stringArg(args.nextSteps) ? { nextSteps: stringArg(args.nextSteps) } : {}), + private: Boolean(args.private), + sourceTool: 'notch-mcp', + ...(stringArg(args.summary) ? { summary: stringArg(args.summary) } : {}), + }) as unknown as Record; + } + case 'resume_working_state': { + const includePrivateRequested = Boolean(args.includePrivate); + const result = await resumeWorkingState(context, { + includePrivate: includePrivateRequested && Boolean(options.includePrivate), + }); + return { + ...result, + warnings: includePrivateRequested && !options.includePrivate + ? [{ code: 'NOTCH_PRIVATE_HIDDEN', message: 'Private packets require --include-private.', severity: 'warn' }] + : [], + }; + } } throw new Error(`Unhandled 3Notch MCP tool: ${toolName}`); diff --git a/src/schemas/config.schema.json b/src/schemas/config.schema.json index 9f7e038..ed0ce4a 100644 --- a/src/schemas/config.schema.json +++ b/src/schemas/config.schema.json @@ -76,7 +76,8 @@ "inbox_init", "send_packet", "pull_inbox_packet", - "ack_inbox_delivery" + "ack_inbox_delivery", + "save_working_state" ] }, "uniqueItems": true diff --git a/src/schemas/mcp-tools.schema.json b/src/schemas/mcp-tools.schema.json index 7066b61..71435e3 100644 --- a/src/schemas/mcp-tools.schema.json +++ b/src/schemas/mcp-tools.schema.json @@ -289,6 +289,23 @@ "strict": { "type": "boolean", "description": "When true, escalate warnings — missing .notch/.gitignore entries and Claude Code hook drift — to errors. Default false." }, "fixDerivedState": { "type": "boolean", "description": "When true, create missing store directories, add the required .notch/.gitignore entries, and rebuild the derived index. The only mutating option here; refused when the server runs in read-only mode. Default false." } } + }, + "save_working_state": { + "type": "object", + "additionalProperties": false, + "properties": { + "actorName": { "type": "string", "minLength": 1, "description": "Name recorded as the author of this write; defaults to the server's configured actor, or 'mcp-client'." }, + "summary": { "type": "string", "minLength": 1, "description": "Working-state summary to persist. When omitted, the packet is built from the current Git snapshot (branch, commit, dirty files)." }, + "nextSteps": { "type": "string", "minLength": 1, "description": "What the next agent should do after loading this checkpoint." }, + "private": { "type": "boolean", "description": "When true, write a private continuation packet. Default false; also private when continuation.sensitivity is private." } + } + }, + "resume_working_state": { + "type": "object", + "additionalProperties": false, + "properties": { + "includePrivate": { "type": "boolean", "description": "Include private continuation packets. Honoured only when the server was started with --include-private; otherwise the latest visible checkpoint is returned and a NOTCH_PRIVATE_HIDDEN warning is added. Default false." } + } } }, "type": "object", diff --git a/tests/cli/help.test.ts b/tests/cli/help.test.ts index 6959e02..4aaeb78 100644 --- a/tests/cli/help.test.ts +++ b/tests/cli/help.test.ts @@ -64,6 +64,8 @@ describe('CLI skeleton', () => { expect(result.stdout).toContain('--cwd '); expect(result.stdout).toContain('--store '); expect(result.stdout).toContain('seed'); + expect(result.stdout).toContain('save'); + expect(result.stdout).toContain('resume'); expect(result.stdout).toContain('create, import, pack, preview, and manage context'); expect(result.stdout).toContain('mcp serve'); }); diff --git a/tests/cli/prompt.test.ts b/tests/cli/prompt.test.ts index 682766a..f40b9ef 100644 --- a/tests/cli/prompt.test.ts +++ b/tests/cli/prompt.test.ts @@ -25,6 +25,8 @@ const v2Tools = [ 'check_store', 'get_status', 'run_doctor', + 'save_working_state', + 'resume_working_state', ]; const deferredSurface = ['pass', 'conflict', 'stale']; diff --git a/tests/cli/save-resume.test.ts b/tests/cli/save-resume.test.ts new file mode 100644 index 0000000..c5ebe4f --- /dev/null +++ b/tests/cli/save-resume.test.ts @@ -0,0 +1,109 @@ +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { runCli } from '../helpers/run-cli.js'; +import { withTempProject } from '../helpers/temp-project.js'; + +describe('notch save / resume', () => { + it('saves a continuation from flags and prints it on resume without prompting', async () => { + await withTempProject({ git: true, prefix: 'notch-save-cli-' }, async (project) => { + const onboard = await runCli(['onboard', '--yes', '--name', 'save-cli'], { cwd: project.path }); + expect(onboard.exitCode).toBe(0); + await writeFile(path.join(project.path, 'src-change.ts'), 'export const changed = true;\n'); + + const saved = await runCli([ + '--json', + 'save', + '--summary', + 'Finished the parser slice.', + '--next-steps', + 'Write the resume tests.', + ], { cwd: project.path }); + + expect(saved.exitCode).toBe(0); + expect(saved.stderr).toBe(''); + const savedData = JSON.parse(saved.stdout) as { + packet: { id: string; tags: string[]; summary: string; nextSteps: string }; + stream: string; + }; + expect(savedData.packet.tags).toEqual(expect.arrayContaining(['continuation', 'source-save'])); + expect(savedData.packet.summary).toContain('Finished the parser slice.'); + expect(savedData.packet.summary).toContain('src-change.ts'); + expect(savedData.packet.nextSteps).toBe('Write the resume tests.'); + + const resumed = await runCli(['resume'], { cwd: project.path }); + expect(resumed.exitCode).toBe(0); + expect(resumed.stderr).toBe(''); + expect(resumed.stdout).toContain('Finished the parser slice.'); + expect(resumed.stdout).toContain('Write the resume tests.'); + expect(resumed.stdout.toLowerCase()).not.toContain('confirm'); + + const resumedJson = await runCli(['--json', 'resume'], { cwd: project.path }); + expect(JSON.parse(resumedJson.stdout)).toMatchObject({ + checkpoint: { packet: { id: savedData.packet.id } }, + }); + }); + }); + + it('reads summary from stdin and returns none JSON on an empty store', async () => { + await withTempProject({ git: true, prefix: 'notch-save-stdin-' }, async (project) => { + await runCli(['onboard', '--yes', '--name', 'save-stdin'], { cwd: project.path }); + + const empty = await runCli(['--json', 'resume'], { cwd: project.path }); + expect(empty.exitCode).toBe(0); + expect(JSON.parse(empty.stdout)).toMatchObject({ checkpoint: null }); + + const quiet = await runCli(['resume'], { cwd: project.path }); + expect(quiet.exitCode).toBe(0); + expect(quiet.stdout).toBe(''); + expect(quiet.stderr).toBe(''); + + const saved = await runCli(['--json', 'save'], { + cwd: project.path, + input: 'Stdin summary for the next agent.', + }); + expect(saved.exitCode).toBe(0); + expect(JSON.parse(saved.stdout)).toMatchObject({ + packet: { summary: expect.stringContaining('Stdin summary for the next agent.') }, + }); + }); + }); + + it('blocks secret-bearing save content and hides private packets by default', async () => { + await withTempProject({ git: true, prefix: 'notch-save-private-' }, async (project) => { + await runCli(['onboard', '--yes', '--name', 'save-private'], { cwd: project.path }); + + const blocked = await runCli([ + '--json', + 'save', + '--summary', + 'api_key=forbidden-value', + ], { cwd: project.path }); + expect(blocked.exitCode).toBe(5); + expect(JSON.parse(blocked.stderr)).toMatchObject({ + error: { code: 'NOTCH_SECRET_DETECTED' }, + }); + + const saved = await runCli([ + '--json', + 'save', + '--private', + '--summary', + 'Keep this checkpoint off git.', + ], { cwd: project.path }); + expect(saved.exitCode).toBe(0); + expect(JSON.parse(saved.stdout)).toMatchObject({ + packet: { sensitivity: 'private' }, + }); + + const hidden = await runCli(['--json', 'resume'], { cwd: project.path }); + expect(JSON.parse(hidden.stdout)).toMatchObject({ checkpoint: null }); + + const visible = await runCli(['--json', 'resume', '--include-private'], { cwd: project.path }); + expect(JSON.parse(visible.stdout)).toMatchObject({ + checkpoint: { packet: { sensitivity: 'private' } }, + }); + }); + }); +}); diff --git a/tests/e2e/agent-resume-smoke.test.ts b/tests/e2e/agent-resume-smoke.test.ts new file mode 100644 index 0000000..171f001 --- /dev/null +++ b/tests/e2e/agent-resume-smoke.test.ts @@ -0,0 +1,76 @@ +import { execFile } from 'node:child_process'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { createNotchMcpServer } from '../../src/mcp/server.js'; +import { createMcpHarness } from '../helpers/mcp-harness.js'; +import { runCli } from '../helpers/run-cli.js'; +import { withTempProject } from '../helpers/temp-project.js'; + +const execFileAsync = promisify(execFile); + +describe('agent save/resume e2e', () => { + it('saves working state, resumes it, and injects the body on SessionStart', async () => { + await withTempProject({ git: true, prefix: 'notch-agent-resume-' }, async (project) => { + await execFileAsync('git', ['checkout', '-b', 'feature/resume'], { cwd: project.path }); + const onboard = await runCli([ + 'onboard', '--yes', '--mcp', 'claude-code', '--checkpoints', 'script', '--name', 'resume-app', + ], { cwd: project.path }); + expect(onboard.exitCode).toBe(0); + + await writeFile(path.join(project.path, 'work.ts'), 'export const unfinished = true;\n'); + const saved = await runCli([ + '--json', + 'save', + '--summary', + 'Parser is in; resume tests remain.', + '--next-steps', + 'Run the remaining verification.', + ], { cwd: project.path }); + expect(saved.exitCode).toBe(0); + const savedData = JSON.parse(saved.stdout) as { packet: { id: string } }; + + const resumed = await runCli(['resume'], { cwd: project.path }); + expect(resumed.exitCode).toBe(0); + expect(resumed.stdout).toContain('Parser is in; resume tests remain.'); + expect(resumed.stdout).toContain('Run the remaining verification.'); + expect(resumed.stdout).toContain('work.ts'); + expect(resumed.stdout.toLowerCase()).not.toContain('confirm'); + + const hook = await runCli(['hook', 'claude-code'], { + cwd: project.path, + input: JSON.stringify({ + session_id: 'e2e-resume-reader', + cwd: project.path, + hook_event_name: 'SessionStart', + source: 'startup', + transcript_path: '/not-readable.jsonl', + }), + }); + expect(hook.exitCode).toBe(0); + const hookOutput = JSON.parse(hook.stdout) as { + hookSpecificOutput?: { additionalContext?: string }; + }; + expect(hookOutput.hookSpecificOutput?.additionalContext).toContain('Parser is in; resume tests remain.'); + expect(hookOutput.hookSpecificOutput?.additionalContext).toContain(savedData.packet.id); + expect(hookOutput.hookSpecificOutput?.additionalContext).not.toContain('Do not call get_packet'); + expect(hook.stdout).not.toContain('/not-readable.jsonl'); + + const harness = await createMcpHarness(createNotchMcpServer({ cwd: project.path })); + try { + const mcpResume = await harness.callTool('resume_working_state', {}) as { + structuredContent: { checkpoint: { packet: { id: string }; markdown: string } }; + }; + expect(mcpResume.structuredContent.checkpoint.packet.id).toBe(savedData.packet.id); + expect(mcpResume.structuredContent.checkpoint.markdown).toContain('Parser is in; resume tests remain.'); + } finally { + await harness.close(); + } + + const doctor = await runCli(['doctor'], { cwd: project.path }); + expect(doctor.exitCode).toBe(0); + }); + }); +}); diff --git a/tests/e2e/continuation-checkpoints-smoke.test.ts b/tests/e2e/continuation-checkpoints-smoke.test.ts index 19fbf37..7048e2d 100644 --- a/tests/e2e/continuation-checkpoints-smoke.test.ts +++ b/tests/e2e/continuation-checkpoints-smoke.test.ts @@ -86,13 +86,17 @@ describe('continuation checkpoint e2e', () => { }); const resumeOutput = JSON.parse(resume.stdout) as { hookSpecificOutput?: { additionalContext?: string }; + systemMessage?: string; }; - expect(resumeOutput.hookSpecificOutput?.additionalContext).toContain('Do not call get_packet'); - expect(resume.stdout).not.toContain('Core implementation is complete'); + expect(resumeOutput.hookSpecificOutput?.additionalContext).toContain('Core implementation is complete'); + expect(resumeOutput.hookSpecificOutput?.additionalContext).toContain('Do not ask the user to confirm loading it'); + expect(resumeOutput.hookSpecificOutput?.additionalContext).not.toContain('Do not call get_packet'); + expect(resumeOutput.systemMessage).toContain('loaded continuation'); + expect(resume.stdout).not.toContain('/not-readable.jsonl'); }); }); - it('wires private MCP access and supports confirmation-gated private resume', async () => { + it('wires private MCP access and injects private checkpoint bodies on SessionStart', async () => { await withTempProject({ git: true, prefix: 'ncp-' }, async (project) => { await execFileAsync('git', ['checkout', '-b', 'main'], { cwd: project.path }); await runCli([ @@ -141,8 +145,9 @@ describe('continuation checkpoint e2e', () => { transcript_path: '/never-read.jsonl', }), }); - expect(offer.stdout).toContain('includePrivate true'); - expect(offer.stdout).not.toContain('Private recovery state.'); + expect(offer.stdout).toContain('Private recovery state.'); + expect(offer.stdout).toContain('Do not ask the user to confirm loading it'); + expect(offer.stdout).not.toContain('includePrivate true'); const harness = await createMcpHarness(createNotchMcpServer({ cwd: project.path, @@ -157,11 +162,12 @@ describe('continuation checkpoint e2e', () => { expect(listed.structuredContent.packets).toHaveLength(1); expect(listed.structuredContent.packets[0]?.packet.sensitivity).toBe('private'); - await expect(harness.callTool('get_packet', { - id: listed.structuredContent.packets[0]?.packet.id, + await expect(harness.callTool('resume_working_state', { includePrivate: true, })).resolves.toMatchObject({ - structuredContent: { packet: { summary: expect.stringContaining('Private recovery state.') } }, + structuredContent: { + checkpoint: { packet: { id: listed.structuredContent.packets[0]?.packet.id } }, + }, }); } finally { await harness.close(); diff --git a/tests/helpers/store-fixtures.ts b/tests/helpers/store-fixtures.ts index 29e4d6b..e0f0999 100644 --- a/tests/helpers/store-fixtures.ts +++ b/tests/helpers/store-fixtures.ts @@ -53,6 +53,7 @@ export async function createBareStore( 'send_packet', 'pull_inbox_packet', 'ack_inbox_delivery', + 'save_working_state', ], }, }, diff --git a/tests/mcp/server-start.test.ts b/tests/mcp/server-start.test.ts index 4344735..3e186f8 100644 --- a/tests/mcp/server-start.test.ts +++ b/tests/mcp/server-start.test.ts @@ -43,7 +43,9 @@ describe('MCP server foundation', () => { 'list_inbox', 'list_packets', 'pull_inbox_packet', + 'resume_working_state', 'run_doctor', + 'save_working_state', 'send_packet', ].sort()); expect(tools).not.toContain('create_pass'); diff --git a/tests/mcp/working-state-tools.test.ts b/tests/mcp/working-state-tools.test.ts new file mode 100644 index 0000000..f9ed4dc --- /dev/null +++ b/tests/mcp/working-state-tools.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { createNotchMcpServer } from '../../src/mcp/server.js'; +import { createMcpHarness } from '../helpers/mcp-harness.js'; +import { createBareStore } from '../helpers/store-fixtures.js'; +import { withTempProject } from '../helpers/temp-project.js'; + +describe('MCP save_working_state / resume_working_state', () => { + it('saves and resumes working state without a confirmation step', async () => { + await withTempProject({ git: true, prefix: 'mcp-save-' }, async (project) => { + await createBareStore(project.path, { name: 'mcp-save-app' }); + const harness = await createMcpHarness(createNotchMcpServer({ cwd: project.path })); + + try { + const empty = await harness.callTool('resume_working_state', {}) as { + structuredContent: { checkpoint: null }; + }; + expect(empty.structuredContent.checkpoint).toBeNull(); + + const saved = await harness.callTool('save_working_state', { + summary: 'MCP wrap-up for the parser.', + nextSteps: 'Resume and write the remaining tests.', + }) as { structuredContent: { packet: { id: string; tags: string[] }; stream: string } }; + expect(saved.structuredContent.packet.tags).toEqual(expect.arrayContaining([ + 'continuation', + 'source-save', + ])); + + const resumed = await harness.callTool('resume_working_state', {}) as { + structuredContent: { checkpoint: { markdown: string; packet: { id: string } } }; + }; + expect(resumed.structuredContent.checkpoint.packet.id).toBe(saved.structuredContent.packet.id); + expect(resumed.structuredContent.checkpoint.markdown).toContain('MCP wrap-up for the parser.'); + expect(resumed.structuredContent.checkpoint.markdown).toContain('Resume and write the remaining tests.'); + } finally { + await harness.close(); + } + }); + }); + + it('hides private working state unless the server started with includePrivate', async () => { + await withTempProject({ git: true, prefix: 'mcp-save-private-' }, async (project) => { + await createBareStore(project.path, { name: 'mcp-save-private' }); + const writer = await createMcpHarness(createNotchMcpServer({ cwd: project.path })); + + try { + await writer.callTool('save_working_state', { + private: true, + summary: 'Private MCP working state.', + }); + } finally { + await writer.close(); + } + + const hidden = await createMcpHarness(createNotchMcpServer({ cwd: project.path })); + try { + await expect(hidden.callTool('resume_working_state', { includePrivate: true })).resolves.toMatchObject({ + structuredContent: { + checkpoint: null, + warnings: [expect.objectContaining({ code: 'NOTCH_PRIVATE_HIDDEN' })], + }, + }); + } finally { + await hidden.close(); + } + + const visible = await createMcpHarness(createNotchMcpServer({ + cwd: project.path, + includePrivate: true, + })); + try { + await expect(visible.callTool('resume_working_state', { includePrivate: true })).resolves.toMatchObject({ + structuredContent: { + checkpoint: { markdown: expect.stringContaining('Private MCP working state.') }, + }, + }); + } finally { + await visible.close(); + } + }); + }); + + it('rejects save_working_state in read-only mode', async () => { + await withTempProject({}, async (project) => { + await createBareStore(project.path, { name: 'mcp-save-readonly' }); + const harness = await createMcpHarness(createNotchMcpServer({ cwd: project.path, readOnly: true })); + + try { + await expect(harness.callTool('save_working_state', { + summary: 'Should not write.', + })).resolves.toMatchObject({ + isError: true, + structuredContent: { error: { code: 'NOTCH_MCP_READ_ONLY' } }, + }); + } finally { + await harness.close(); + } + }); + }); +}); diff --git a/tests/schema/mcp-tool-schemas.test.ts b/tests/schema/mcp-tool-schemas.test.ts index 3dfc727..4038b86 100644 --- a/tests/schema/mcp-tool-schemas.test.ts +++ b/tests/schema/mcp-tool-schemas.test.ts @@ -26,6 +26,8 @@ const expectedTools = [ 'ack_inbox_delivery', 'get_status', 'run_doctor', + 'save_working_state', + 'resume_working_state', ]; describe('MCP tool input schemas', () => { @@ -45,6 +47,18 @@ describe('MCP tool input schemas', () => { expect(validate({ limit: 0 })).toBe(false); }); + it('accepts empty save_working_state and resume_working_state arguments', () => { + const ajv = new Ajv2020({ strict: true }); + ajv.addSchema(sharedSchema); + const validateSave = ajv.compile(getMcpToolInputSchema('save_working_state')); + const validateResume = ajv.compile(getMcpToolInputSchema('resume_working_state')); + + expect(validateSave({})).toBe(true); + expect(validateSave({ summary: 'Wrap-up.', nextSteps: 'Continue.' })).toBe(true); + expect(validateResume({})).toBe(true); + expect(validateResume({ includePrivate: true })).toBe(true); + }); + it('accepts packet tags and supersedes fields', () => { const ajv = new Ajv2020({ strict: true }); ajv.addSchema(sharedSchema); diff --git a/tests/unit/continuation-service.test.ts b/tests/unit/continuation-service.test.ts index 41a64f2..4a5f538 100644 --- a/tests/unit/continuation-service.test.ts +++ b/tests/unit/continuation-service.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; -import { resolveContinuationStream, runClaudeCodeHook } from '../../src/core/continuation-service.js'; +import { resolveContinuationStream, resumeWorkingState, runClaudeCodeHook, saveWorkingState } from '../../src/core/continuation-service.js'; import { checkStore } from '../../src/core/check-service.js'; import { loadConfig } from '../../src/core/config-service.js'; import { listPackets } from '../../src/core/packet-service.js'; @@ -203,7 +203,7 @@ describe('continuation service', () => { }); }); - it('offers a matching checkpoint once without loading its content', async () => { + it('injects the matching checkpoint body on SessionStart without waiting for confirmation', async () => { await withContinuationProject(async (projectPath) => { await runClaudeCodeHook({ session_id: 'writer', @@ -218,24 +218,79 @@ describe('continuation service', () => { cwd: projectPath, hook_event_name: 'SessionStart', source: 'startup', + transcript_path: '/unreadable/transcript.jsonl', }); const second = await runClaudeCodeHook({ session_id: 'reader', cwd: projectPath, hook_event_name: 'SessionStart', source: 'resume', + transcript_path: '/unreadable/transcript.jsonl', }); const compact = await runClaudeCodeHook({ session_id: 'reader', cwd: projectPath, hook_event_name: 'SessionStart', source: 'compact', + transcript_path: '/unreadable/transcript.jsonl', }); - expect(first.hookSpecificOutput?.additionalContext).toContain('Offer it to the user once'); - expect(JSON.stringify(first)).not.toContain('Sensitive-to-this-test'); - expect(second).toEqual({}); - expect(compact).toEqual({}); + expect(first.hookSpecificOutput?.additionalContext).toContain('Sensitive-to-this-test continuation details.'); + expect(first.hookSpecificOutput?.additionalContext).toContain('Do not ask the user to confirm loading it'); + expect(first.hookSpecificOutput?.additionalContext).not.toContain('Offer it to the user once'); + expect(first.systemMessage).toContain('loaded continuation'); + expect(JSON.stringify(first)).not.toContain('transcript.jsonl'); + expect(second.hookSpecificOutput?.additionalContext).toContain('Sensitive-to-this-test continuation details.'); + expect(compact.hookSpecificOutput?.additionalContext).toContain('Sensitive-to-this-test continuation details.'); + }); + }); + + it('saves a continuation from git snapshot and resume returns it without prompting', async () => { + await withContinuationProject(async (projectPath) => { + await writeFile(path.join(projectPath, 'working.ts'), 'export const wip = true;\n'); + const context = await loadConfig({ cwd: projectPath }); + const saved = await saveWorkingState(context, { + nextSteps: 'Finish the working-state tests.', + summary: 'Auth validation is done; session store is next.', + }); + + expect(saved.packet.tags).toEqual(expect.arrayContaining([ + 'continuation', + 'stream-main', + 'source-save', + ])); + expect(saved.packet.summary).toContain('Auth validation is done'); + expect(saved.packet.summary).toContain('working.ts'); + expect(saved.packet.nextSteps).toBe('Finish the working-state tests.'); + expect(saved.stream).toBe('main'); + + const resumed = await resumeWorkingState(context); + expect(resumed.checkpoint?.packet.id).toBe(saved.packet.id); + expect(resumed.checkpoint?.markdown).toContain('Auth validation is done'); + expect(resumed.checkpoint?.markdown).toContain('Finish the working-state tests.'); + }); + }); + + it('hides private continuations from resume unless includePrivate is set', async () => { + await withContinuationProject(async (projectPath) => { + const context = await loadConfig({ cwd: projectPath }); + const saved = await saveWorkingState(context, { + private: true, + summary: 'Private working state for this store.', + }); + + expect(saved.packet.sensitivity).toBe('private'); + expect(await resumeWorkingState(context)).toMatchObject({ checkpoint: null, stream: 'main' }); + const visible = await resumeWorkingState(context, { includePrivate: true }); + expect(visible.checkpoint?.packet.id).toBe(saved.packet.id); + expect(visible.checkpoint?.markdown).toContain('Private working state for this store.'); + }); + }); + + it('returns none when the store has no continuation', async () => { + await withContinuationProject(async (projectPath) => { + const context = await loadConfig({ cwd: projectPath }); + expect(await resumeWorkingState(context)).toEqual({ checkpoint: null, stream: 'main' }); }); }); diff --git a/tests/unit/no-deferred-commands.test.ts b/tests/unit/no-deferred-commands.test.ts index a38e2eb..d723580 100644 --- a/tests/unit/no-deferred-commands.test.ts +++ b/tests/unit/no-deferred-commands.test.ts @@ -32,6 +32,8 @@ describe('deferred surface guard', () => { 'src/cli/commands/packet.ts', 'src/cli/commands/prompt.ts', 'src/cli/commands/reply.ts', + 'src/cli/commands/resume.ts', + 'src/cli/commands/save.ts', 'src/cli/commands/scan.ts', 'src/cli/commands/seed.ts', 'src/cli/commands/status.ts', From 4492d13ca12cfba87abb439035472741674b9cfd Mon Sep 17 00:00:00 2001 From: Sean Robbins Date: Sun, 6 Sep 2026 22:07:14 -0400 Subject: [PATCH 2/2] fix: do not auto-inject private continuations; drop stale confirm copy SessionStart follows continuation.sensitivity. Resume stays on the current stream. Onboard/site no longer say load waits for a human. --- site/public/index.html | 2 +- src/cli/commands/onboard.ts | 2 +- src/core/continuation-service.ts | 23 ++++------------------- tests/unit/continuation-service.test.ts | 9 +++++++++ 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/site/public/index.html b/site/public/index.html index 64c7a99..48981ce 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -1249,7 +1249,7 @@

Common questions.

-

With Claude Code continuation checkpoints enabled, 3Notch can write a fallback on rate limits, model-down failures (overload, server error, and related cases), and compaction. The next session may offer that checkpoint once; you approve before anything is loaded.

+

With Claude Code continuation checkpoints enabled, 3Notch writes a fallback on rate limits, model-down failures, and compaction. The next session injects that checkpoint automatically. Private continuations stay hidden unless the store is configured for private sensitivity or you pass --include-private.

diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index b0feda4..4328882 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -487,7 +487,7 @@ This project uses 3Notch for explicit, reviewable context handoffs between AI to ## Continuation Checkpoints -Claude Code checkpoints are opt-in through \`notch onboard --mcp claude-code --checkpoints \`. Script hooks aggregate structured task events and repository state without reading session transcripts. \`prompt\` asks before an agent drafts a semantic checkpoint; \`auto\` drafts it automatically. On resume, 3Notch offers a matching checkpoint but never loads it without user confirmation. +Claude Code checkpoints are opt-in through \`notch onboard --mcp claude-code --checkpoints \`. Script hooks aggregate structured task events and repository state without reading session transcripts. \`prompt\` asks before an agent drafts a semantic checkpoint; \`auto\` drafts it automatically. On SessionStart, 3Notch injects the latest matching continuation into additionalContext with no human confirm. Private continuations load only when continuation.sensitivity is private (or \`notch resume --include-private\` / MCP \`--include-private\`). ## Boundaries diff --git a/src/core/continuation-service.ts b/src/core/continuation-service.ts index 67d23ae..98ce254 100644 --- a/src/core/continuation-service.ts +++ b/src/core/continuation-service.ts @@ -377,10 +377,6 @@ async function handleSessionStart( const agentPolicy = renderAgentCheckpointPolicy(context, stream); const latest = await loadLatestStreamCheckpoint(context, stream); - if (latest) { - state.offeredCheckpointId = latest.packet.id; - } - await writeSessionState(context, state); if (!latest) { @@ -617,7 +613,7 @@ function hasMaterialRecoveryState(state: ContinuationSessionState, git: GitSnaps async function latestStreamCheckpoint( context: LoadedConfig, stream: string, - includePrivate = true, + includePrivate = false, ): Promise { const packets = await listPackets(context, { includePrivate, @@ -632,7 +628,8 @@ async function loadLatestStreamCheckpoint( context: LoadedConfig, stream: string, ): Promise<(ListedContinuation & { markdown: string }) | undefined> { - const latest = await latestStreamCheckpoint(context, stream, true); + const includePrivate = context.config.continuation?.sensitivity === 'private'; + const latest = await latestStreamCheckpoint(context, stream, includePrivate); if (!latest) { return undefined; @@ -648,19 +645,7 @@ async function findLatestContinuation( context: LoadedConfig, options: { includePrivate: boolean; stream: string }, ): Promise { - const streamMatch = await latestStreamCheckpoint(context, options.stream, options.includePrivate); - - if (streamMatch) { - return streamMatch; - } - - const any = await listPackets(context, { - includePrivate: options.includePrivate, - limit: 1, - tags: ['continuation'], - }); - - return any[0]; + return latestStreamCheckpoint(context, options.stream, options.includePrivate); } function renderSaveSummary(primary: string | undefined, git: GitSnapshot): string { diff --git a/tests/unit/continuation-service.test.ts b/tests/unit/continuation-service.test.ts index 4a5f538..bb66773 100644 --- a/tests/unit/continuation-service.test.ts +++ b/tests/unit/continuation-service.test.ts @@ -284,6 +284,15 @@ describe('continuation service', () => { const visible = await resumeWorkingState(context, { includePrivate: true }); expect(visible.checkpoint?.packet.id).toBe(saved.packet.id); expect(visible.checkpoint?.markdown).toContain('Private working state for this store.'); + + const start = await runClaudeCodeHook({ + session_id: 'session-private-default', + cwd: projectPath, + hook_event_name: 'SessionStart', + source: 'startup', + transcript_path: '/never-read.jsonl', + }); + expect(JSON.stringify(start)).not.toContain('Private working state for this store.'); }); });