@phuetz/code-buddy 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +3 -2
  2. package/dist/agent/codebuddy-agent.js +33 -0
  3. package/dist/agent/lesson-auto-proposer.js +10 -0
  4. package/dist/agent/middleware/session-duration.d.ts +36 -0
  5. package/dist/agent/middleware/session-duration.js +78 -0
  6. package/dist/agent/session-end-flush.d.ts +66 -0
  7. package/dist/agent/session-end-flush.js +217 -0
  8. package/dist/commands/cli/native-engine-commands.js +6 -1
  9. package/dist/commands/enhanced-command-handler.js +5 -0
  10. package/dist/commands/goal-cli.d.ts +41 -0
  11. package/dist/commands/goal-cli.js +97 -0
  12. package/dist/commands/handlers/goal-handler.d.ts +27 -0
  13. package/dist/commands/handlers/goal-handler.js +128 -0
  14. package/dist/commands/handlers/index.d.ts +1 -0
  15. package/dist/commands/handlers/index.js +2 -0
  16. package/dist/commands/slash/builtin-commands.js +20 -0
  17. package/dist/config/env-schema.js +14 -0
  18. package/dist/config/feature-flags.js +7 -0
  19. package/dist/context/context-manager-v2.d.ts +39 -0
  20. package/dist/context/context-manager-v2.js +90 -0
  21. package/dist/daemon/agent-task-executor.js +12 -1
  22. package/dist/daemon/autonomous-daemon.js +2 -0
  23. package/dist/daemon/autonomous-loop.d.ts +21 -1
  24. package/dist/daemon/autonomous-loop.js +60 -0
  25. package/dist/daemon/colab-goal.d.ts +38 -0
  26. package/dist/daemon/colab-goal.js +73 -0
  27. package/dist/fleet/colab-store.d.ts +21 -0
  28. package/dist/fleet/colab-store.js +16 -0
  29. package/dist/fleet/peer-session-bridge.d.ts +1 -1
  30. package/dist/fleet/peer-session-bridge.js +205 -2
  31. package/dist/fleet/peer-session-store.d.ts +3 -0
  32. package/dist/fleet/privacy-lint.d.ts +8 -0
  33. package/dist/fleet/privacy-lint.js +22 -0
  34. package/dist/goals/goal-judge.d.ts +36 -0
  35. package/dist/goals/goal-judge.js +129 -0
  36. package/dist/goals/goal-loop.d.ts +23 -0
  37. package/dist/goals/goal-loop.js +56 -0
  38. package/dist/goals/goal-manager.d.ts +71 -0
  39. package/dist/goals/goal-manager.js +236 -0
  40. package/dist/goals/goal-state.d.ts +86 -0
  41. package/dist/goals/goal-state.js +245 -0
  42. package/dist/goals/goal-store.d.ts +25 -0
  43. package/dist/goals/goal-store.js +71 -0
  44. package/dist/goals/index.d.ts +5 -0
  45. package/dist/goals/index.js +6 -0
  46. package/dist/hooks/use-input-handler.js +36 -1
  47. package/dist/index.js +44 -2
  48. package/dist/observability/run-store.d.ts +1 -1
  49. package/dist/server/websocket/fleet-bridge.d.ts +13 -1
  50. package/dist/server/websocket/fleet-bridge.js +11 -0
  51. package/package.json +1 -1
package/README.md CHANGED
@@ -193,7 +193,7 @@ From source, Cowork requires Node.js `>=22` in `cowork/`; the root CLI still sup
193
193
  |:---------|:-----------|:-----|
194
194
  | **AI Providers** | 15 providers (Grok, Claude, GPT, Gemini, Ollama, LM Studio, AWS Bedrock, Azure, Groq, Together, Fireworks, OpenRouter, vLLM, Copilot, Mistral), circuit breaker, model pairs | [providers.md](docs/providers.md) |
195
195
  | **Tools** | ~110 tools with RAG selection, multi-strategy edit matching, Codex-style apply_patch, streaming, BM25 tool search, code exec sandbox | [tools-reference.md](docs/tools-reference.md) |
196
- | **Commands** | 190+ slash commands, CLI subcommands (`/dev`, `/pr`, `/lint`, `/switch`, `/think`, `/batch`, `/watch`, `/conflicts`, `/vulns`, `/replace`) | [commands.md](docs/commands.md) |
196
+ | **Commands** | 190+ slash commands, CLI subcommands (`/goal`, `/dev`, `/pr`, `/lint`, `/switch`, `/think`, `/batch`, `/watch`, `/conflicts`, `/vulns`, `/replace`) | [commands.md](docs/commands.md) |
197
197
  | **Cowork Desktop** | Electron cockpit, embedded Code Buddy engine, backend health/start controls, model settings, permission rules, visual workflows, traces, artifacts, MCP/skills/plugin management | [Cowork Desktop](docs/cowork.md), [cowork/readme.md](cowork/readme.md), [cowork/ARCHITECTURE.md](cowork/ARCHITECTURE.md) |
198
198
  | **Buddy Companion** | ChatGPT-backed identity, voice/TTS, proactive check-ins, self-evaluation, competitive radar, mission board, learned routines, safety ledger, local percept journal | [commands.md](docs/commands.md) |
199
199
  | **Vision & Presence** | Opt-in webcam snapshots, MediaPipe face/hand/pose/finger-tip analysis, local face enrollment, presence state for the agent | [cowork/ARCHITECTURE.md](cowork/ARCHITECTURE.md) |
@@ -202,7 +202,7 @@ From source, Cowork requires Node.js `>=22` in `cowork/`; the root CLI still sup
202
202
  | **Security** | Guardian Agent (AI risk scoring), OS/Docker/OpenShell sandbox, SSRF guard, secrets vault, write policy, exec policy, loop detection, omission detection, output sanitizer | [security.md](docs/security.md) |
203
203
  | **Channels** | 20+ messaging channels (Telegram, Discord, Slack, WhatsApp, Signal, Teams, Matrix, IRC, and more), DM pairing, send policy | [channels.md](docs/channels.md) |
204
204
  | **Context Engine** | Smart compression, tool output masking, image pruning, transcript repair, pre-compaction flush, restorable compression, JIT context, importance-weighted window | [context-engine.md](docs/context-engine.md) |
205
- | **Fleet & Autonomy** | Peer-to-peer hub (`peer.chat` / `peer.tool.invoke` / `peer_delegate`), A2A + ACP + MCP interop, 24/7 autonomous service (`buddy autonomy install`), event-driven daemon, claim TTL/lease, DAG task dependencies, workers→verifier→synthesizer swarm, free-first local→Tailscale→paid model tier | [fleet-guide.md](docs/fleet-guide.md) |
205
+ | **Fleet & Autonomy** | Peer-to-peer hub (`peer.chat` / `peer.tool.invoke` / `peer_delegate`), A2A + ACP + MCP interop, 24/7 autonomous service (`buddy autonomy install`), event-driven daemon, claim TTL/lease, DAG task dependencies, workers→verifier→synthesizer swarm, free-first local→Tailscale→paid model tier, judge-gated goal loops (`/goal` Ralph loop, `buddy goal`, board goal-mode, peer-session goals) | [fleet-guide.md](docs/fleet-guide.md) |
206
206
  | **Infrastructure** | HTTP server (OpenAI-compatible), WebSocket gateway, daemon mode, cron, device nodes, canvas/A2UI, 6 cloud deploy configs, MCP, plugins | [infrastructure.md](docs/infrastructure.md) |
207
207
  | **Configuration** | Env vars, TOML config with profiles, model-aware limits, per-agent params, i18n (6 locales), personas | [configuration.md](docs/configuration.md) |
208
208
  | **Development** | TypeScript strict, Vitest (27,334 tests), ESM, middleware pipeline, facade architecture | [development.md](docs/development.md) |
@@ -214,6 +214,7 @@ From source, Cowork requires Node.js `>=22` in `cowork/`; the root CLI still sup
214
214
  - **Vision**: local camera snapshots, MediaPipe face/hand/pose/finger-tip percepts, face enrollment, and presence state for context-aware collaboration
215
215
  - **Memory**: Persistent + semantic + prospective + decision + coding style memory, ICM cross-session memory
216
216
  - **Knowledge**: Knowledge base injection, 40 bundled skills, self-authoring skills at runtime
217
+ - **Goal Loops (Ralph loop)**: `/goal <text>` + `/subgoal` — a judge model re-checks completion after every turn and auto-continues until done (turn budget, pause/resume, fail-open judge); also headless (`buddy goal`), on fleet board tasks (`--goal-mode`, blocks for human review when the budget is spent), and on peer sessions (`peer.chat-session.goal`)
217
218
  - **Git Workflow**: Auto-commit (Aider-style), `/pr` creation, merge conflict resolver, ghost snapshots
218
219
  - **Code Intelligence**: LSP rename/refactor, auto-import, bug finder (25+ patterns, 6 langs), OpenAPI generator, log analyzer
219
220
  - **IDE Integration**: VS Code extension (diff view, inline edit, model switch), JetBrains plugin, LSP server
@@ -121,6 +121,11 @@ export class CodeBuddyAgent extends BaseAgent {
121
121
  // This is safe because the singletons returned by getters are the concrete implementations
122
122
  this.tokenCounter = this.infrastructure.tokenCounter;
123
123
  this.contextManager = this.infrastructure.contextManager;
124
+ // WS3-T2 — periodic memory snapshot for very long sessions. Interval from
125
+ // CODEBUDDY_SNAPSHOT_INTERVAL_MIN (default 45 min, 0 disables); the timer
126
+ // is unref'd so it never keeps a finished process alive. Optional call:
127
+ // test doubles and custom context engines may not implement snapshots.
128
+ this.contextManager.startPeriodicSnapshot?.(() => this.historyManager.getMessagesRef());
124
129
  this.checkpointManager = this.infrastructure.checkpoints;
125
130
  this.sessionStore = this.infrastructure.sessions;
126
131
  this.modeManager = this.infrastructure.modeManager;
@@ -295,6 +300,20 @@ export class CodeBuddyAgent extends BaseAgent {
295
300
  catch (err) {
296
301
  logger.debug('Failed to register ContextWarningMiddleware (non-critical)', { error: err instanceof Error ? err.message : String(err) });
297
302
  }
303
+ // Session duration middleware (priority 35) — suggests a clean pause
304
+ // with a fresh resume point past CODEBUDDY_SESSION_PAUSE_HOURS (12 h)
305
+ try {
306
+ const { SessionDurationMiddleware } = await import('./middleware/session-duration.js');
307
+ pipeline.use(new SessionDurationMiddleware({
308
+ takeSnapshot: () => {
309
+ this.contextManager.takeSnapshot?.(this.historyManager.getMessagesRef());
310
+ },
311
+ }));
312
+ logger.debug('SessionDurationMiddleware registered in pipeline (priority 35)');
313
+ }
314
+ catch (err) {
315
+ logger.debug('Failed to register SessionDurationMiddleware (non-critical)', { error: err instanceof Error ? err.message : String(err) });
316
+ }
298
317
  // Reasoning middleware (priority 42) — auto-detects complex queries
299
318
  try {
300
319
  const { createReasoningMiddleware } = await import('./middleware/reasoning-middleware.js');
@@ -1637,6 +1656,19 @@ Look at the screenshot and find the element matching the user's intent. Output o
1637
1656
  });
1638
1657
  }
1639
1658
  }
1659
+ // WS3-T1 — session-end flush: handoff + review-gated lesson candidates.
1660
+ // Fire-and-forget like the dialectic above; callers that need the flush
1661
+ // to complete before process exit await runSessionEndFlush directly.
1662
+ {
1663
+ const chatHistory = this.historyManager.getChatHistory();
1664
+ if (chatHistory && chatHistory.length > 0) {
1665
+ import('./session-end-flush.js')
1666
+ .then(({ runSessionEndFlush }) => runSessionEndFlush({ chatHistory, client: this.codebuddyClient }))
1667
+ .catch((err) => {
1668
+ logger.debug('[session-end-flush] dispose-path flush failed', { error: String(err) });
1669
+ });
1670
+ }
1671
+ }
1640
1672
  // Remove only the forwarding listeners we attached (not other listeners)
1641
1673
  const { start, success, failed, error } = this.repairListeners;
1642
1674
  if (start) {
@@ -1661,6 +1693,7 @@ Look at the screenshot and find the element matching the user's intent. Output o
1661
1693
  error: err instanceof Error ? err.message : String(err),
1662
1694
  });
1663
1695
  });
1696
+ this.contextManager.stopPeriodicSnapshot?.();
1664
1697
  this.peerRoutingConfig = null;
1665
1698
  super.dispose();
1666
1699
  }
@@ -15,6 +15,7 @@
15
15
  import { CodeBuddyClient } from '../codebuddy/client.js';
16
16
  import { detectProviderFromEnv } from '../utils/provider-detector.js';
17
17
  import { getLessonCandidateQueue, } from './lesson-candidate-queue.js';
18
+ import { scanForSecrets } from '../fleet/privacy-lint.js';
18
19
  import { logger } from '../utils/logger.js';
19
20
  const VALID = ['PATTERN', 'RULE', 'CONTEXT', 'INSIGHT'];
20
21
  const SYSTEM_PROMPT = `You extract REUSABLE PROCEDURAL LESSONS from a coding session that just completed.
@@ -98,6 +99,15 @@ export async function proposeLessonsFromSession(chatHistory, workDir = process.c
98
99
  const content = (cand.content || '').trim();
99
100
  if (!content || !VALID.includes(category))
100
101
  continue;
102
+ // WS3 guard-rail: never persist secrets/PII into memory — even into the
103
+ // review queue, where a reviewer could approve them into lessons.md.
104
+ const lint = scanForSecrets(`${content}\n${cand.context ?? ''}`);
105
+ if (lint.hasSecrets) {
106
+ logger.warn('[lesson-auto-proposer] candidate dropped: contains secret/PII material', {
107
+ kinds: lint.matches.map((m) => m.kind),
108
+ });
109
+ continue;
110
+ }
101
111
  try {
102
112
  const { candidate } = queue.propose({
103
113
  category,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Session Duration Middleware (WS3-T3 — auto-pause & resume)
3
+ *
4
+ * Very long sessions (12 h+) degrade context quality and pile up risk.
5
+ * Past a configurable threshold this middleware suggests a clean pause,
6
+ * takes a fresh context snapshot as the resume point (WS3-T2), and
7
+ * reminds periodically instead of nagging every turn.
8
+ *
9
+ * It never stops the loop — pausing stays the operator's decision; the
10
+ * autonomous runner has its own bounded budgets (WS1).
11
+ *
12
+ * @module agent/middleware
13
+ */
14
+ import { ConversationMiddleware, MiddlewareContext, MiddlewareResult } from './types.js';
15
+ export interface SessionDurationOptions {
16
+ /** Pause-suggestion threshold in ms. Default: CODEBUDDY_SESSION_PAUSE_HOURS (12 h); 0 disables. */
17
+ maxSessionMs?: number;
18
+ /** Re-warn cadence once past the threshold. Default 60 min. */
19
+ remindEveryMs?: number;
20
+ /** Injectable clock for tests. */
21
+ now?: () => number;
22
+ /** Take a fresh context snapshot when the pause is suggested (WS3-T2 hook). */
23
+ takeSnapshot?: () => void;
24
+ }
25
+ export declare class SessionDurationMiddleware implements ConversationMiddleware {
26
+ readonly name = "session-duration";
27
+ readonly priority = 35;
28
+ private readonly startedAt;
29
+ private readonly thresholdMs;
30
+ private readonly remindEveryMs;
31
+ private readonly now;
32
+ private readonly takeSnapshot;
33
+ private lastWarnedAt;
34
+ constructor(options?: SessionDurationOptions);
35
+ beforeTurn(_context: MiddlewareContext): MiddlewareResult;
36
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Session Duration Middleware (WS3-T3 — auto-pause & resume)
3
+ *
4
+ * Very long sessions (12 h+) degrade context quality and pile up risk.
5
+ * Past a configurable threshold this middleware suggests a clean pause,
6
+ * takes a fresh context snapshot as the resume point (WS3-T2), and
7
+ * reminds periodically instead of nagging every turn.
8
+ *
9
+ * It never stops the loop — pausing stays the operator's decision; the
10
+ * autonomous runner has its own bounded budgets (WS1).
11
+ *
12
+ * @module agent/middleware
13
+ */
14
+ import { RunStore } from '../../observability/run-store.js';
15
+ import { logger } from '../../utils/logger.js';
16
+ const HOUR_MS = 3_600_000;
17
+ function defaultThresholdMs() {
18
+ const hours = parseFloat(process.env.CODEBUDDY_SESSION_PAUSE_HOURS || '12');
19
+ if (!Number.isFinite(hours) || hours <= 0)
20
+ return 0; // 0/invalid → disabled
21
+ return hours * HOUR_MS;
22
+ }
23
+ export class SessionDurationMiddleware {
24
+ name = 'session-duration';
25
+ priority = 35;
26
+ startedAt;
27
+ thresholdMs;
28
+ remindEveryMs;
29
+ now;
30
+ takeSnapshot;
31
+ lastWarnedAt = null;
32
+ constructor(options = {}) {
33
+ this.now = options.now ?? Date.now;
34
+ this.startedAt = this.now();
35
+ this.thresholdMs = options.maxSessionMs ?? defaultThresholdMs();
36
+ this.remindEveryMs = options.remindEveryMs ?? HOUR_MS;
37
+ this.takeSnapshot = options.takeSnapshot;
38
+ }
39
+ beforeTurn(_context) {
40
+ if (this.thresholdMs <= 0)
41
+ return { action: 'continue' };
42
+ const elapsed = this.now() - this.startedAt;
43
+ if (elapsed < this.thresholdMs)
44
+ return { action: 'continue' };
45
+ if (this.lastWarnedAt !== null && this.now() - this.lastWarnedAt < this.remindEveryMs) {
46
+ return { action: 'continue' };
47
+ }
48
+ this.lastWarnedAt = this.now();
49
+ // Fresh resume point right when the pause is suggested.
50
+ try {
51
+ this.takeSnapshot?.();
52
+ }
53
+ catch (err) {
54
+ logger.debug('[session-duration] snapshot on pause suggestion failed', { error: String(err) });
55
+ }
56
+ try {
57
+ const runStore = RunStore.getInstance();
58
+ if (runStore.getCurrentRunId()) {
59
+ runStore.appendEvent('pause_suggested', {
60
+ elapsedMs: elapsed,
61
+ thresholdMs: this.thresholdMs,
62
+ });
63
+ }
64
+ }
65
+ catch {
66
+ // Observability must never break the loop.
67
+ }
68
+ const hours = (elapsed / HOUR_MS).toFixed(1);
69
+ return {
70
+ action: 'warn',
71
+ message: `⏸️ Session running for ${hours} h. Consider a clean pause: a fresh context ` +
72
+ `snapshot was just written (.codebuddy/context-snapshot.json), a handoff is ` +
73
+ `written on exit (.codebuddy/HANDOFF.md), and \`buddy --continue\` reloads the ` +
74
+ `session. Long sessions degrade context quality — \`/compact\` also helps.`,
75
+ };
76
+ }
77
+ }
78
+ //# sourceMappingURL=session-duration.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Session-end flush (WS3-T1 — Mémoire & continuité du run).
3
+ *
4
+ * When a session ends, persist what the next run needs to not start from
5
+ * zero:
6
+ * 1. a short HANDOFF file (`.codebuddy/HANDOFF.md`) — last goal, last
7
+ * state, files touched, open risks — written synchronously so it also
8
+ * works from process `exit` handlers;
9
+ * 2. reusable lesson candidates via the existing review-gated
10
+ * auto-proposer (PENDING only — a human approves them into lessons.md,
11
+ * which the per-turn `<lessons_context>` injection then re-injects on
12
+ * future runs).
13
+ *
14
+ * Guard-rails: trivial sessions are skipped (no LLM call, no file), and
15
+ * everything written here passes the privacy lint first (PII/secret spans
16
+ * are redacted from the handoff; tainted lesson candidates are dropped by
17
+ * the proposer itself).
18
+ *
19
+ * @module agent/session-end-flush
20
+ */
21
+ import type { CodeBuddyClient } from '../codebuddy/client.js';
22
+ import type { ChatEntry } from './types.js';
23
+ export interface SessionEndFlushInput {
24
+ chatHistory: ChatEntry[];
25
+ workDir?: string;
26
+ /** Reuse the session's LLM client for the lesson proposal (no re-auth). */
27
+ client?: CodeBuddyClient;
28
+ sessionId?: string;
29
+ /** Session start (ms epoch) — used for the duration line of the handoff. */
30
+ startedAt?: number;
31
+ }
32
+ export interface SessionEndFlushResult {
33
+ /** Lesson candidates enqueued for review (`buddy lessons` to approve). */
34
+ proposedLessons: number;
35
+ /** Absolute path of the handoff file, when one was (re)written. */
36
+ handoffPath?: string;
37
+ openRisks: string[];
38
+ skipped?: 'disabled' | 'trivial';
39
+ }
40
+ /**
41
+ * Heuristic open-risk extraction: error-ish tool results, deduped per tool,
42
+ * newest kept. No LLM involved — deterministic and free.
43
+ */
44
+ export declare function extractOpenRisks(history: ChatEntry[]): string[];
45
+ /** Files touched through write-ish tool calls, for the handoff. */
46
+ export declare function extractTouchedFiles(history: ChatEntry[]): string[];
47
+ export interface HandoffOptions {
48
+ sessionId?: string;
49
+ startedAt?: number;
50
+ /** Force a write even below the size threshold (used by tests). */
51
+ force?: boolean;
52
+ }
53
+ /**
54
+ * Write `.codebuddy/HANDOFF.md` synchronously. Returns the path when
55
+ * written, undefined when the session didn't warrant one. Idempotent per
56
+ * history array so the async flush and a process-exit fallback don't both
57
+ * write.
58
+ */
59
+ export declare function writeHandoffSync(history: ChatEntry[], workDir?: string, options?: HandoffOptions): string | undefined;
60
+ /**
61
+ * Run the complete session-end flush. Safe to call from any exit path:
62
+ * no-ops on trivial sessions, never throws.
63
+ */
64
+ export declare function runSessionEndFlush(input: SessionEndFlushInput): Promise<SessionEndFlushResult>;
65
+ /** Test hook: reset the per-history idempotence latch. */
66
+ export declare function resetSessionEndFlushState(): void;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Session-end flush (WS3-T1 — Mémoire & continuité du run).
3
+ *
4
+ * When a session ends, persist what the next run needs to not start from
5
+ * zero:
6
+ * 1. a short HANDOFF file (`.codebuddy/HANDOFF.md`) — last goal, last
7
+ * state, files touched, open risks — written synchronously so it also
8
+ * works from process `exit` handlers;
9
+ * 2. reusable lesson candidates via the existing review-gated
10
+ * auto-proposer (PENDING only — a human approves them into lessons.md,
11
+ * which the per-turn `<lessons_context>` injection then re-injects on
12
+ * future runs).
13
+ *
14
+ * Guard-rails: trivial sessions are skipped (no LLM call, no file), and
15
+ * everything written here passes the privacy lint first (PII/secret spans
16
+ * are redacted from the handoff; tainted lesson candidates are dropped by
17
+ * the proposer itself).
18
+ *
19
+ * @module agent/session-end-flush
20
+ */
21
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+ import { isFeatureEnabled } from '../config/feature-flags.js';
24
+ import { redactSecrets } from '../fleet/privacy-lint.js';
25
+ import { logger } from '../utils/logger.js';
26
+ // ============================================================================
27
+ // Gates & heuristics
28
+ // ============================================================================
29
+ /** Below this many assistant turns the session is too trivial to flush. */
30
+ const MIN_ASSISTANT_TURNS = 2;
31
+ /** Handoff is only worth writing past this transcript size (chars) … */
32
+ const HANDOFF_MIN_TRANSCRIPT_CHARS = 8_000;
33
+ /** … unless open risks were detected, which always deserve a handoff. */
34
+ const RISK_PATTERN = /\b(error|failed|failure|exception|denied|timeout|timed out|blocked|fatal|refused)\b/i;
35
+ const MAX_RISKS = 5;
36
+ const SNIPPET_MAX = 240;
37
+ function meaningfulTurns(history) {
38
+ let assistant = 0;
39
+ let tools = 0;
40
+ for (const e of history) {
41
+ if (e.type === 'assistant' && e.content.trim())
42
+ assistant++;
43
+ else if (e.type === 'tool_result')
44
+ tools++;
45
+ }
46
+ return { assistant, tools };
47
+ }
48
+ function firstLine(text) {
49
+ const line = text.trim().split('\n')[0] ?? '';
50
+ return line.length > SNIPPET_MAX ? `${line.slice(0, SNIPPET_MAX)}…` : line;
51
+ }
52
+ /**
53
+ * Heuristic open-risk extraction: error-ish tool results, deduped per tool,
54
+ * newest kept. No LLM involved — deterministic and free.
55
+ */
56
+ export function extractOpenRisks(history) {
57
+ const byKey = new Map();
58
+ for (const e of history) {
59
+ if (e.type !== 'tool_result')
60
+ continue;
61
+ if (!RISK_PATTERN.test(e.content))
62
+ continue;
63
+ const tool = e.toolCall?.function?.name || 'tool';
64
+ byKey.set(tool, `\`${tool}\`: ${firstLine(redactSecrets(e.content))}`);
65
+ }
66
+ return [...byKey.values()].slice(-MAX_RISKS);
67
+ }
68
+ /** Files touched through write-ish tool calls, for the handoff. */
69
+ export function extractTouchedFiles(history) {
70
+ const WRITE_TOOLS = new Set([
71
+ 'str_replace', 'str_replace_editor', 'create_file', 'write_file',
72
+ 'apply_patch', 'edit_file', 'text_editor',
73
+ ]);
74
+ const files = new Set();
75
+ for (const e of history) {
76
+ const calls = e.toolCalls ?? (e.toolCall ? [e.toolCall] : []);
77
+ for (const call of calls) {
78
+ if (!call?.function?.name || !WRITE_TOOLS.has(call.function.name))
79
+ continue;
80
+ try {
81
+ const args = JSON.parse(call.function.arguments || '{}');
82
+ const p = args.path ?? args.file_path ?? args.filePath;
83
+ if (typeof p === 'string' && p.trim())
84
+ files.add(p.trim());
85
+ }
86
+ catch { /* unparseable args — skip */ }
87
+ }
88
+ }
89
+ return [...files].slice(0, 20);
90
+ }
91
+ // ============================================================================
92
+ // Handoff (sync — usable from process exit handlers)
93
+ // ============================================================================
94
+ let handoffWrittenFor = null;
95
+ /**
96
+ * Write `.codebuddy/HANDOFF.md` synchronously. Returns the path when
97
+ * written, undefined when the session didn't warrant one. Idempotent per
98
+ * history array so the async flush and a process-exit fallback don't both
99
+ * write.
100
+ */
101
+ export function writeHandoffSync(history, workDir = process.cwd(), options = {}) {
102
+ if (handoffWrittenFor === history)
103
+ return undefined;
104
+ const { assistant } = meaningfulTurns(history);
105
+ if (assistant < MIN_ASSISTANT_TURNS)
106
+ return undefined;
107
+ const transcriptChars = history.reduce((n, e) => n + e.content.length, 0);
108
+ const risks = extractOpenRisks(history);
109
+ if (!options.force && transcriptChars < HANDOFF_MIN_TRANSCRIPT_CHARS && risks.length === 0) {
110
+ return undefined;
111
+ }
112
+ const lastUser = [...history].reverse().find((e) => e.type === 'user' && e.content.trim());
113
+ const lastAssistant = [...history].reverse().find((e) => e.type === 'assistant' && e.content.trim());
114
+ const touched = extractTouchedFiles(history);
115
+ const startedAt = options.startedAt;
116
+ const durationMin = startedAt ? Math.round((Date.now() - startedAt) / 60_000) : undefined;
117
+ const lines = [
118
+ '# Session Handoff',
119
+ '',
120
+ `> Auto-generated at session end — latest session wins. Read this before resuming work.`,
121
+ '',
122
+ `- Date: ${new Date().toISOString()}`,
123
+ ...(options.sessionId ? [`- Session: ${options.sessionId}`] : []),
124
+ ...(durationMin !== undefined ? [`- Durée: ~${durationMin} min`] : []),
125
+ `- Échanges: ${meaningfulTurns(history).assistant} réponses assistant, ${meaningfulTurns(history).tools} résultats d'outils`,
126
+ '',
127
+ '## Dernier objectif (utilisateur)',
128
+ '',
129
+ lastUser ? firstLine(redactSecrets(lastUser.content)) : '(aucun message utilisateur)',
130
+ '',
131
+ '## Dernier état (assistant)',
132
+ '',
133
+ lastAssistant ? redactSecrets(lastAssistant.content).trim().slice(0, 800) : '(aucune réponse)',
134
+ '',
135
+ ];
136
+ if (touched.length > 0) {
137
+ lines.push('## Fichiers touchés', '', ...touched.map((f) => `- \`${f}\``), '');
138
+ }
139
+ lines.push('## Risques ouverts', '');
140
+ if (risks.length > 0) {
141
+ lines.push(...risks.map((r) => `- ${r}`));
142
+ }
143
+ else {
144
+ lines.push('- Aucun détecté.');
145
+ }
146
+ lines.push('', '## Reprise', '', risks.length > 0
147
+ ? '- Commencer par lever les risques ouverts ci-dessus.'
148
+ : '- Continuer le dernier objectif, ou `buddy --continue` pour recharger la session.', '- Leçons en attente de revue : `buddy lessons` (candidats proposés en fin de session).', '');
149
+ const content = redactSecrets(lines.join('\n'));
150
+ const dir = path.join(workDir, '.codebuddy');
151
+ const target = path.join(dir, 'HANDOFF.md');
152
+ try {
153
+ fs.mkdirSync(dir, { recursive: true });
154
+ fs.writeFileSync(target, content, 'utf8');
155
+ handoffWrittenFor = history;
156
+ return target;
157
+ }
158
+ catch (err) {
159
+ logger.debug('[session-end-flush] handoff write failed', { err: String(err) });
160
+ return undefined;
161
+ }
162
+ }
163
+ // ============================================================================
164
+ // Full async flush (handoff + lesson proposal)
165
+ // ============================================================================
166
+ /**
167
+ * Run the complete session-end flush. Safe to call from any exit path:
168
+ * no-ops on trivial sessions, never throws.
169
+ */
170
+ export async function runSessionEndFlush(input) {
171
+ const empty = { proposedLessons: 0, openRisks: [] };
172
+ try {
173
+ if (!isFeatureEnabled('SESSION_END_FLUSH')) {
174
+ return { ...empty, skipped: 'disabled' };
175
+ }
176
+ const history = input.chatHistory ?? [];
177
+ const { assistant } = meaningfulTurns(history);
178
+ if (assistant < MIN_ASSISTANT_TURNS) {
179
+ return { ...empty, skipped: 'trivial' };
180
+ }
181
+ const workDir = input.workDir ?? process.cwd();
182
+ const openRisks = extractOpenRisks(history);
183
+ const handoffPath = writeHandoffSync(history, workDir, {
184
+ ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
185
+ ...(input.startedAt !== undefined ? { startedAt: input.startedAt } : {}),
186
+ });
187
+ // Lesson proposal last — it is the only step that may call an LLM, so a
188
+ // killed process still leaves the handoff behind.
189
+ let proposedLessons = 0;
190
+ try {
191
+ const { proposeLessonsFromSession } = await import('./lesson-auto-proposer.js');
192
+ const proposed = await proposeLessonsFromSession(history, workDir, input.client);
193
+ proposedLessons = proposed.length;
194
+ }
195
+ catch (err) {
196
+ logger.debug('[session-end-flush] lesson proposal failed', { err: String(err) });
197
+ }
198
+ if (proposedLessons > 0 || handoffPath) {
199
+ logger.info(`[session-end-flush] ${proposedLessons} lesson candidate(s) proposed` +
200
+ (handoffPath ? `, handoff written to ${path.relative(workDir, handoffPath)}` : ''));
201
+ }
202
+ return {
203
+ proposedLessons,
204
+ ...(handoffPath ? { handoffPath } : {}),
205
+ openRisks,
206
+ };
207
+ }
208
+ catch (err) {
209
+ logger.debug('[session-end-flush] flush failed', { err: String(err) });
210
+ return empty;
211
+ }
212
+ }
213
+ /** Test hook: reset the per-history idempotence latch. */
214
+ export function resetSessionEndFlushState() {
215
+ handoffWrittenFor = null;
216
+ }
217
+ //# sourceMappingURL=session-end-flush.js.map
@@ -817,6 +817,8 @@ export function registerFleetAutonomyCommands(program) {
817
817
  .option('--priority <p>', 'critical | high | medium | low', 'medium')
818
818
  .option('--depends-on <ids>', 'comma-separated task ids this task depends on')
819
819
  .option('--description <text>', 'task description')
820
+ .option('--goal-mode', 'judge-gated loop: the worker keeps going until an LLM judge confirms the task is done, then blocks for human review when the budget is spent')
821
+ .option('--goal-max-turns <n>', 'goal-mode turn budget (default 5)')
820
822
  .option('--dir <path>', 'colab dir')
821
823
  .option('--json', 'output JSON')
822
824
  .action(async (title, opts) => {
@@ -828,12 +830,15 @@ export function registerFleetAutonomyCommands(program) {
828
830
  priority,
829
831
  ...(opts.description ? { description: opts.description } : {}),
830
832
  ...(opts.dependsOn ? { dependsOn: opts.dependsOn.split(',').map((s) => s.trim()).filter(Boolean) } : {}),
833
+ ...(opts.goalMode ? { goalMode: true } : {}),
834
+ ...(opts.goalMaxTurns ? { goalMaxTurns: parseInt(opts.goalMaxTurns, 10) } : {}),
831
835
  });
832
836
  if (opts.json) {
833
837
  console.log(JSON.stringify({ task }, null, 2));
834
838
  return;
835
839
  }
836
- console.log(`Added task ${task.id} [${task.priority}]${task.dependsOn ? ` depends on ${task.dependsOn.join(', ')}` : ''}`);
840
+ const goalNote = task.goalMode ? ` goal-mode(${task.goalMaxTurns ?? 5} turns)` : '';
841
+ console.log(`Added task ${task.id} [${task.priority}]${goalNote}${task.dependsOn ? ` depends on ${task.dependsOn.join(', ')}` : ''}`);
837
842
  });
838
843
  fleet
839
844
  .command('swarm <goal>')
@@ -62,6 +62,8 @@ handleFastMode,
62
62
  handleBtw, setBtwClient,
63
63
  // Heartbeat handler (V4.x — wire user-facing activation of HeartbeatEngine)
64
64
  handleHeartbeat,
65
+ // Goal handler (Hermes Agent parity — Ralph loop)
66
+ handleGoal, handleSubgoal,
65
67
  // Daily reset handler (audit OpenClaw heritage activation)
66
68
  handleDailyReset,
67
69
  // Team session handler — slash /share (audit OpenClaw heritage activation, TeamSessionManager wake)
@@ -337,6 +339,9 @@ export class EnhancedCommandHandler {
337
339
  ['__BTW__', (args) => handleBtw(args)],
338
340
  // Heartbeat engine activation (fleet AUTONOMOUS-FLEET-PROTOCOL v0.1)
339
341
  ['__HEARTBEAT__', (args) => handleHeartbeat(args)],
342
+ // Standing goal — judge + auto-continue loop (Hermes Agent parity)
343
+ ['__GOAL__', (args) => handleGoal(args)],
344
+ ['__SUBGOAL__', (args) => handleSubgoal(args)],
340
345
  // Daily reset scheduler (audit OpenClaw heritage activation)
341
346
  ['__DAILY_RESET__', (args) => handleDailyReset(args)],
342
347
  // Team session manager — slash /share (audit OpenClaw heritage, TeamSessionManager wake)
@@ -0,0 +1,41 @@
1
+ /**
2
+ * buddy goal — headless Ralph loop.
3
+ *
4
+ * Runs the full agentic loop toward a standing goal: each turn the agent
5
+ * works with tools, then the goal judge decides done/continue. Continuation
6
+ * prompts are fed back in-process until the goal is achieved, the turn
7
+ * budget is exhausted, or the judge auto-pauses.
8
+ *
9
+ * Usage:
10
+ * buddy goal "Fix every failing test in tests/auth/"
11
+ * buddy goal "Ship the feature" --max-turns 10 --judge-model qwen3:8b
12
+ *
13
+ * Exit codes: 0 = goal done, 1 = paused (budget/judge) or error.
14
+ */
15
+ import { Command } from 'commander';
16
+ import type { ChatEntry } from '../agent/codebuddy-agent.js';
17
+ import type { CodeBuddyClient } from '../codebuddy/client.js';
18
+ import { GoalStatus } from '../goals/goal-state.js';
19
+ /** The slice of CodeBuddyAgent the loop needs — injectable for tests. */
20
+ export interface GoalLoopAgent {
21
+ processUserMessage(input: string): Promise<ChatEntry[]>;
22
+ getClient(): CodeBuddyClient;
23
+ }
24
+ export interface GoalLoopRunOptions {
25
+ maxTurns?: number;
26
+ /** Progress sink (status lines ⊙/↻/✓/⏸). Defaults to silent. */
27
+ onMessage?: (text: string) => void;
28
+ }
29
+ export interface GoalLoopRunResult {
30
+ status: GoalStatus | 'unknown';
31
+ turnsUsed: number;
32
+ lastReason?: string;
33
+ }
34
+ /**
35
+ * Drive the goal loop headlessly on an in-process agent. Sets the goal,
36
+ * runs the first turn with the goal text (mirroring the interactive
37
+ * `/goal <text>` kick-off), then follows judge verdicts until the loop
38
+ * stops continuing.
39
+ */
40
+ export declare function runGoalLoop(agent: GoalLoopAgent, goalText: string, options?: GoalLoopRunOptions): Promise<GoalLoopRunResult>;
41
+ export declare function createGoalCommand(): Command;