@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
@@ -0,0 +1,97 @@
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 { maybeContinueGoalAfterTurn } from '../goals/goal-loop.js';
17
+ import { getGoalManager } from '../goals/goal-manager.js';
18
+ import { resolveCommandProvider } from './llm-provider-resolution.js';
19
+ /**
20
+ * Drive the goal loop headlessly on an in-process agent. Sets the goal,
21
+ * runs the first turn with the goal text (mirroring the interactive
22
+ * `/goal <text>` kick-off), then follows judge verdicts until the loop
23
+ * stops continuing.
24
+ */
25
+ export async function runGoalLoop(agent, goalText, options = {}) {
26
+ const manager = getGoalManager();
27
+ const state = manager.set(goalText, options.maxTurns ? { maxTurns: options.maxTurns } : {});
28
+ const emit = options.onMessage ?? (() => { });
29
+ emit(`⊙ Goal set (${state.maxTurns}-turn budget): ${state.goal}`);
30
+ let prompt = state.goal;
31
+ // Hard backstop on top of the manager's own budget/auto-pause guards.
32
+ const maxIterations = state.maxTurns + 1;
33
+ for (let i = 0; i < maxIterations; i++) {
34
+ const entries = await agent.processUserMessage(prompt);
35
+ const lastResponse = entries
36
+ .filter(entry => entry.type === 'assistant' && entry.content)
37
+ .map(entry => entry.content)
38
+ .join('\n');
39
+ const outcome = await maybeContinueGoalAfterTurn({
40
+ client: agent.getClient(),
41
+ lastResponse,
42
+ interrupted: false,
43
+ });
44
+ if (outcome?.message)
45
+ emit(outcome.message);
46
+ if (!outcome?.continuationPrompt)
47
+ break;
48
+ prompt = outcome.continuationPrompt;
49
+ }
50
+ const final = manager.state;
51
+ return {
52
+ status: final?.status ?? 'unknown',
53
+ turnsUsed: final?.turnsUsed ?? 0,
54
+ ...(final?.lastReason ? { lastReason: final.lastReason } : {}),
55
+ };
56
+ }
57
+ export function createGoalCommand() {
58
+ const cmd = new Command('goal')
59
+ .description('Run the agent toward a standing goal until a judge model confirms it is done (Ralph loop)')
60
+ .argument('<goal>', 'The goal to pursue')
61
+ .option('--max-turns <n>', 'Turn budget (default 20, or goals.maxTurns from settings)')
62
+ .option('--judge-model <model>', 'Model for the goal judge (default: session model)')
63
+ .option('-m, --model <model>', 'Override the agent model for this run')
64
+ .option('--max-tool-rounds <n>', 'Max tool rounds per turn', '50')
65
+ .action(async (goal, options, command) => {
66
+ const modelOverride = options.model ?? command?.optsWithGlobals?.()?.model;
67
+ const resolved = resolveCommandProvider({ explicitModel: modelOverride });
68
+ if (!resolved) {
69
+ console.error('Error: No provider available — set an API key, run `buddy onboard`, or point CODEBUDDY_PROVIDER=ollama at a local Ollama.');
70
+ process.exit(1);
71
+ }
72
+ if (options.judgeModel) {
73
+ process.env.CODEBUDDY_GOAL_JUDGE_MODEL = options.judgeModel;
74
+ }
75
+ process.env.CODEBUDDY_DISABLE_MCP = process.env.CODEBUDDY_DISABLE_MCP ?? 'true';
76
+ process.env.CODEBUDDY_HEADLESS = 'true';
77
+ try {
78
+ const { CodeBuddyAgent } = await import('../agent/codebuddy-agent.js');
79
+ const { ConfirmationService } = await import('../utils/confirmation-service.js');
80
+ ConfirmationService.getInstance().setSessionFlag('allOperations', true);
81
+ const agent = new CodeBuddyAgent(resolved.apiKey, resolved.baseURL, resolved.model, parseInt(options.maxToolRounds, 10));
82
+ await agent.systemPromptReady;
83
+ const result = await runGoalLoop(agent, goal, {
84
+ ...(options.maxTurns ? { maxTurns: parseInt(options.maxTurns, 10) } : {}),
85
+ onMessage: text => console.log(`\n${text}`),
86
+ });
87
+ agent.dispose?.();
88
+ process.exit(result.status === 'done' ? 0 : 1);
89
+ }
90
+ catch (err) {
91
+ console.error('Goal error:', err instanceof Error ? err.message : err);
92
+ process.exit(1);
93
+ }
94
+ });
95
+ return cmd;
96
+ }
97
+ //# sourceMappingURL=goal-cli.js.map
@@ -0,0 +1,27 @@
1
+ import { CommandHandlerResult } from './branch-handlers.js';
2
+ /**
3
+ * /goal — standing goal with judge + auto-continue loop (the Ralph loop,
4
+ * ported from Hermes Agent).
5
+ *
6
+ * Forms:
7
+ * /goal <text> set a new goal (replaces any old one) and start turn 1
8
+ * /goal | status show current state
9
+ * /goal pause halt auto-continuation, keep the goal
10
+ * /goal resume restart the loop (resets the turn budget)
11
+ * /goal clear discard the goal (aliases: stop, done)
12
+ */
13
+ export declare function handleGoal(args: string[]): Promise<CommandHandlerResult>;
14
+ /**
15
+ * /subgoal — extra acceptance criteria added mid-loop.
16
+ *
17
+ * Forms:
18
+ * /subgoal show current subgoals
19
+ * /subgoal <text> append a criterion
20
+ * /subgoal remove <n> drop subgoal n (1-based)
21
+ * /subgoal clear wipe all subgoals
22
+ *
23
+ * Subgoals get appended to both the judge prompt (verdict must consider
24
+ * them) and the continuation prompt (agent sees them) on the next turn
25
+ * boundary — no special kick needed.
26
+ */
27
+ export declare function handleSubgoal(args: string[]): Promise<CommandHandlerResult>;
@@ -0,0 +1,128 @@
1
+ import { getGoalManager } from '../../goals/goal-manager.js';
2
+ /**
3
+ * /goal — standing goal with judge + auto-continue loop (the Ralph loop,
4
+ * ported from Hermes Agent).
5
+ *
6
+ * Forms:
7
+ * /goal <text> set a new goal (replaces any old one) and start turn 1
8
+ * /goal | status show current state
9
+ * /goal pause halt auto-continuation, keep the goal
10
+ * /goal resume restart the loop (resets the turn budget)
11
+ * /goal clear discard the goal (aliases: stop, done)
12
+ */
13
+ export async function handleGoal(args) {
14
+ const arg = args.join(' ').trim();
15
+ const lower = arg.toLowerCase();
16
+ const mgr = getGoalManager();
17
+ if (!arg || lower === 'status') {
18
+ return textResult(mgr.statusLine());
19
+ }
20
+ if (lower === 'pause') {
21
+ const state = mgr.pause('user-paused');
22
+ return textResult(state ? `⏸ Goal paused: ${state.goal}` : 'No goal set.');
23
+ }
24
+ if (lower === 'resume') {
25
+ const state = mgr.resume();
26
+ if (!state) {
27
+ return textResult('No goal to resume.');
28
+ }
29
+ return textResult(`▶ Goal resumed: ${state.goal}\n` +
30
+ 'Send any message to kick the loop off (e.g. "continue").');
31
+ }
32
+ if (['clear', 'stop', 'done'].includes(lower)) {
33
+ const had = mgr.hasGoal();
34
+ mgr.clear();
35
+ return textResult(had ? '✓ Goal cleared.' : 'No active goal.');
36
+ }
37
+ // Otherwise treat the arg as the goal text.
38
+ let state;
39
+ try {
40
+ state = mgr.set(arg);
41
+ }
42
+ catch (error) {
43
+ return textResult(`Invalid goal: ${error instanceof Error ? error.message : String(error)}`);
44
+ }
45
+ return {
46
+ handled: true,
47
+ entry: {
48
+ type: 'assistant',
49
+ content: `⊙ Goal set (${state.maxTurns}-turn budget): ${state.goal}\n` +
50
+ 'After each turn, a judge model checks if the goal is done. Code Buddy ' +
51
+ 'keeps working until it is, you pause/clear it, or the budget is ' +
52
+ 'exhausted. Use /goal status, /goal pause, /goal resume, /goal clear. ' +
53
+ 'Tip: for unattended runs, enable auto-approval (/yolo or auto-edit) so ' +
54
+ 'the loop is not blocked on confirmations.',
55
+ timestamp: new Date(),
56
+ },
57
+ // Kick the loop off immediately — the dispatcher feeds the goal text as
58
+ // the first turn, then the after-turn hook drives the continuation loop.
59
+ passToAI: true,
60
+ prompt: state.goal,
61
+ };
62
+ }
63
+ /**
64
+ * /subgoal — extra acceptance criteria added mid-loop.
65
+ *
66
+ * Forms:
67
+ * /subgoal show current subgoals
68
+ * /subgoal <text> append a criterion
69
+ * /subgoal remove <n> drop subgoal n (1-based)
70
+ * /subgoal clear wipe all subgoals
71
+ *
72
+ * Subgoals get appended to both the judge prompt (verdict must consider
73
+ * them) and the continuation prompt (agent sees them) on the next turn
74
+ * boundary — no special kick needed.
75
+ */
76
+ export async function handleSubgoal(args) {
77
+ const arg = args.join(' ').trim();
78
+ const mgr = getGoalManager();
79
+ if (!mgr.hasGoal()) {
80
+ return textResult('No active goal. Set one with /goal <text>.');
81
+ }
82
+ if (!arg) {
83
+ return textResult(`${mgr.statusLine()}\n${mgr.renderSubgoals()}`);
84
+ }
85
+ const [verb, ...restTokens] = arg.split(/\s+/);
86
+ const rest = restTokens.join(' ').trim();
87
+ if (verb?.toLowerCase() === 'remove') {
88
+ if (!rest) {
89
+ return textResult('Usage: /subgoal remove <n>');
90
+ }
91
+ const idx = Number(rest.split(/\s+/)[0]);
92
+ if (!Number.isInteger(idx)) {
93
+ return textResult('/subgoal remove: <n> must be an integer (1-based index).');
94
+ }
95
+ try {
96
+ const removed = mgr.removeSubgoal(idx);
97
+ return textResult(`✓ Removed subgoal ${idx}: ${removed}`);
98
+ }
99
+ catch (error) {
100
+ return textResult(`/subgoal remove: ${error instanceof Error ? error.message : String(error)}`);
101
+ }
102
+ }
103
+ if (verb?.toLowerCase() === 'clear') {
104
+ try {
105
+ const prev = mgr.clearSubgoals();
106
+ return textResult(prev ? `✓ Cleared ${prev} subgoal${prev !== 1 ? 's' : ''}.` : 'No subgoals to clear.');
107
+ }
108
+ catch (error) {
109
+ return textResult(`/subgoal clear: ${error instanceof Error ? error.message : String(error)}`);
110
+ }
111
+ }
112
+ // Otherwise — append the whole arg as a new subgoal.
113
+ try {
114
+ const text = mgr.addSubgoal(arg);
115
+ const idx = mgr.state?.subgoals.length ?? 0;
116
+ return textResult(`✓ Added subgoal ${idx}: ${text}`);
117
+ }
118
+ catch (error) {
119
+ return textResult(`/subgoal: ${error instanceof Error ? error.message : String(error)}`);
120
+ }
121
+ }
122
+ function textResult(content) {
123
+ return {
124
+ handled: true,
125
+ entry: { type: 'assistant', content, timestamp: new Date() },
126
+ };
127
+ }
128
+ //# sourceMappingURL=goal-handler.js.map
@@ -36,6 +36,7 @@ export { handleFastMode, isFastModeEnabled, getFastModeModel, getFastModeService
36
36
  export { handleBackup, } from './backup-handlers.js';
37
37
  export { handleBtw, setBtwClient, } from './btw-handler.js';
38
38
  export { handleHeartbeat } from './heartbeat-handler.js';
39
+ export { handleGoal, handleSubgoal } from './goal-handler.js';
39
40
  export { handleDailyReset } from './daily-reset-handler.js';
40
41
  export { handleShare } from './team-session-handler.js';
41
42
  export { handleAgents } from './agents-handler.js';
@@ -74,6 +74,8 @@ export { handleBackup, } from './backup-handlers.js';
74
74
  export { handleBtw, setBtwClient, } from './btw-handler.js';
75
75
  // Heartbeat handler (fleet AUTONOMOUS-FLEET-PROTOCOL v0.1)
76
76
  export { handleHeartbeat } from './heartbeat-handler.js';
77
+ // Goal handler (Hermes Agent parity — Ralph loop)
78
+ export { handleGoal, handleSubgoal } from './goal-handler.js';
77
79
  // Daily reset handler (audit OpenClaw heritage activation)
78
80
  export { handleDailyReset } from './daily-reset-handler.js';
79
81
  // Team session handler — slash /share (audit OpenClaw heritage activation, TeamSessionManager wake)
@@ -808,6 +808,26 @@ const personaCommands = [
808
808
  // Autonomy & Permissions Commands
809
809
  // ============================================================================
810
810
  const autonomyCommands = [
811
+ {
812
+ name: 'goal',
813
+ description: 'Standing goal with judge + auto-continue loop (Ralph loop): /goal <text> | status | pause | resume | clear',
814
+ prompt: '__GOAL__',
815
+ filePath: '',
816
+ isBuiltin: true,
817
+ arguments: [
818
+ { name: 'action', description: '<text> to set a goal, or status, pause, resume, clear', required: false }
819
+ ]
820
+ },
821
+ {
822
+ name: 'subgoal',
823
+ description: 'Add acceptance criteria to the active goal: /subgoal <text> | remove <n> | clear',
824
+ prompt: '__SUBGOAL__',
825
+ filePath: '',
826
+ isBuiltin: true,
827
+ arguments: [
828
+ { name: 'action', description: '<text> to add a criterion, remove <n>, clear, or empty to list', required: false }
829
+ ]
830
+ },
811
831
  {
812
832
  name: 'yolo',
813
833
  description: 'Toggle YOLO mode (full auto-execution with guardrails)',
@@ -69,6 +69,20 @@ export const ENV_SCHEMA = [
69
69
  category: 'core',
70
70
  min: 1000,
71
71
  },
72
+ {
73
+ name: 'CODEBUDDY_GOAL_MAX_TURNS',
74
+ type: 'number',
75
+ description: 'Turn budget for /goal auto-continue loops (default 20)',
76
+ category: 'core',
77
+ min: 1,
78
+ max: 1000,
79
+ },
80
+ {
81
+ name: 'CODEBUDDY_GOAL_JUDGE_MODEL',
82
+ type: 'string',
83
+ description: 'Model used by the /goal judge (default: current session model)',
84
+ category: 'core',
85
+ },
72
86
  {
73
87
  name: 'GROK_FORCE_TOOLS',
74
88
  type: 'boolean',
@@ -56,6 +56,13 @@ const DEFAULT_FEATURE_FLAGS = {
56
56
  category: 'ai',
57
57
  envOverride: 'USER_MODEL_DIALECTIC_ON_SESSION_END',
58
58
  },
59
+ SESSION_END_FLUSH: {
60
+ name: 'SESSION_END_FLUSH',
61
+ enabled: true,
62
+ description: 'At session end, write a short handoff (.codebuddy/HANDOFF.md) and propose review-gated lesson candidates from the transcript (WS3-T1)',
63
+ category: 'ai',
64
+ envOverride: 'CODEBUDDY_SESSION_END_FLUSH',
65
+ },
59
66
  VOICE_CONTROL: {
60
67
  name: 'VOICE_CONTROL',
61
68
  enabled: false,
@@ -60,6 +60,23 @@ export interface ContextStats {
60
60
  isNearLimit: boolean;
61
61
  isCritical: boolean;
62
62
  }
63
+ /**
64
+ * Periodic memory snapshot (WS3-T2) — a compact, persisted view of the
65
+ * session so very long runs (12–15 h) survive crashes and aggressive
66
+ * compaction without losing the thread.
67
+ */
68
+ export interface ContextSnapshot {
69
+ sessionId: string;
70
+ takenAt: string;
71
+ stats: {
72
+ messageCount: number;
73
+ tokenCount: number;
74
+ compressionCount: number;
75
+ totalTokensSaved: number;
76
+ };
77
+ /** Extractive, privacy-linted summary of the conversation so far. */
78
+ summary: string;
79
+ }
63
80
  /**
64
81
  * Memory metrics for monitoring context manager health
65
82
  */
@@ -125,6 +142,10 @@ export declare class ContextManagerV2 {
125
142
  private _cachedStatsFingerprint;
126
143
  /** Last compression timestamp */
127
144
  private lastCompressionTime;
145
+ /** WS3-T2 — periodic snapshot timer (unref'd, never keeps the process alive) */
146
+ private snapshotTimer;
147
+ /** WS3-T2 — snapshots taken this session */
148
+ private snapshotCount;
128
149
  static readonly DEFAULT_CONFIG: ContextManagerConfig;
129
150
  constructor(config?: Partial<ContextManagerConfig>);
130
151
  /**
@@ -344,6 +365,24 @@ export declare class ContextManagerV2 {
344
365
  * Format compression statistics as human-readable string
345
366
  */
346
367
  formatCompressionStats(): string;
368
+ /**
369
+ * Take a compact snapshot of the session and persist it to
370
+ * `.codebuddy/context-snapshot.json` (latest wins). Returns null when the
371
+ * conversation is too small to be worth snapshotting.
372
+ *
373
+ * The summary is privacy-linted before it touches disk (WS3 guard-rail).
374
+ */
375
+ takeSnapshot(messages: CodeBuddyMessage[], workDir?: string): ContextSnapshot | null;
376
+ /**
377
+ * Start the periodic snapshot loop for long sessions (12–15 h).
378
+ *
379
+ * Interval resolution: explicit param → `CODEBUDDY_SNAPSHOT_INTERVAL_MIN`
380
+ * env (minutes) → 45 min default. `0` (or negative) disables. The timer
381
+ * is unref'd so it never keeps a finished process alive.
382
+ */
383
+ startPeriodicSnapshot(getMessages: () => CodeBuddyMessage[], intervalMs?: number, workDir?: string): void;
384
+ /** Stop the periodic snapshot loop (idempotent). */
385
+ stopPeriodicSnapshot(): void;
347
386
  }
348
387
  export type { KeyInformation, ContextArchive, CompressionMetrics, EnhancedCompressionResult, };
349
388
  /**
@@ -12,6 +12,9 @@
12
12
  * - Content-type-aware Compression
13
13
  * - Key Information Preservation
14
14
  */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import { redactSecrets } from '../fleet/privacy-lint.js';
15
18
  import { createTokenCounter } from './token-counter.js';
16
19
  import { logger } from '../utils/logger.js';
17
20
  import { getModelToolConfig } from '../config/model-tools.js';
@@ -74,6 +77,10 @@ export class ContextManagerV2 {
74
77
  _cachedStatsFingerprint = '';
75
78
  /** Last compression timestamp */
76
79
  lastCompressionTime = null;
80
+ /** WS3-T2 — periodic snapshot timer (unref'd, never keeps the process alive) */
81
+ snapshotTimer = null;
82
+ /** WS3-T2 — snapshots taken this session */
83
+ snapshotCount = 0;
77
84
  // Default configuration based on research recommendations
78
85
  static DEFAULT_CONFIG = {
79
86
  maxContextTokens: 4096,
@@ -921,6 +928,89 @@ export class ContextManagerV2 {
921
928
  }
922
929
  return lines.join('\n');
923
930
  }
931
+ // ==========================================================================
932
+ // WS3-T2 — Periodic memory snapshot
933
+ // ==========================================================================
934
+ /**
935
+ * Take a compact snapshot of the session and persist it to
936
+ * `.codebuddy/context-snapshot.json` (latest wins). Returns null when the
937
+ * conversation is too small to be worth snapshotting.
938
+ *
939
+ * The summary is privacy-linted before it touches disk (WS3 guard-rail).
940
+ */
941
+ takeSnapshot(messages, workDir = process.cwd()) {
942
+ if (!messages || messages.length < 4)
943
+ return null;
944
+ const snapshot = {
945
+ sessionId: this.sessionId,
946
+ takenAt: new Date().toISOString(),
947
+ stats: {
948
+ messageCount: messages.length,
949
+ tokenCount: this.countTokens(messages),
950
+ compressionCount: this.compressionCount,
951
+ totalTokensSaved: this.totalTokensSaved,
952
+ },
953
+ summary: redactSecrets(this.createSummary(messages)),
954
+ };
955
+ try {
956
+ const dir = path.join(workDir, '.codebuddy');
957
+ fs.mkdirSync(dir, { recursive: true });
958
+ fs.writeFileSync(path.join(dir, 'context-snapshot.json'), JSON.stringify(snapshot, null, 2), 'utf8');
959
+ }
960
+ catch (err) {
961
+ logger.debug('Context snapshot write failed', { error: String(err) });
962
+ return null;
963
+ }
964
+ this.snapshotCount++;
965
+ try {
966
+ const runStore = RunStore.getInstance();
967
+ if (runStore.getCurrentRunId()) {
968
+ runStore.appendEvent('context_snapshot', {
969
+ sessionId: snapshot.sessionId,
970
+ messageCount: snapshot.stats.messageCount,
971
+ tokenCount: snapshot.stats.tokenCount,
972
+ snapshotCount: this.snapshotCount,
973
+ });
974
+ }
975
+ }
976
+ catch {
977
+ // Observability must never break the snapshot path.
978
+ }
979
+ return snapshot;
980
+ }
981
+ /**
982
+ * Start the periodic snapshot loop for long sessions (12–15 h).
983
+ *
984
+ * Interval resolution: explicit param → `CODEBUDDY_SNAPSHOT_INTERVAL_MIN`
985
+ * env (minutes) → 45 min default. `0` (or negative) disables. The timer
986
+ * is unref'd so it never keeps a finished process alive.
987
+ */
988
+ startPeriodicSnapshot(getMessages, intervalMs, workDir = process.cwd()) {
989
+ this.stopPeriodicSnapshot();
990
+ let resolved = intervalMs;
991
+ if (resolved === undefined) {
992
+ const envMin = parseInt(process.env.CODEBUDDY_SNAPSHOT_INTERVAL_MIN || '45', 10);
993
+ resolved = (Number.isFinite(envMin) ? envMin : 45) * 60_000;
994
+ }
995
+ if (!resolved || resolved <= 0)
996
+ return;
997
+ this.snapshotTimer = setInterval(() => {
998
+ try {
999
+ this.takeSnapshot(getMessages(), workDir);
1000
+ }
1001
+ catch (err) {
1002
+ logger.debug('Periodic context snapshot failed', { error: String(err) });
1003
+ }
1004
+ }, resolved);
1005
+ this.snapshotTimer.unref();
1006
+ }
1007
+ /** Stop the periodic snapshot loop (idempotent). */
1008
+ stopPeriodicSnapshot() {
1009
+ if (this.snapshotTimer) {
1010
+ clearInterval(this.snapshotTimer);
1011
+ this.snapshotTimer = null;
1012
+ }
1013
+ }
924
1014
  }
925
1015
  /**
926
1016
  * Create a context manager with auto-detection of model limits
@@ -34,6 +34,9 @@
34
34
  import * as fs from 'fs';
35
35
  import * as path from 'path';
36
36
  import { spawnSync } from 'child_process';
37
+ import { buildColabGoalContinuationPrompt } from './colab-goal.js';
38
+ /** Tail of agent stdout kept for the goal-mode judge (matches the judge's 4 KB cap). */
39
+ const OUTPUT_TAIL_CHARS = 4000;
37
40
  /** Resolve the buddy CLI entrypoint inside `repoRoot`. */
38
41
  function resolveEntrypoint(repoRoot) {
39
42
  const tsx = path.join(repoRoot, 'node_modules', '.bin', 'tsx');
@@ -82,7 +85,11 @@ export function createAgentTaskExecutor(opts = {}) {
82
85
  if (!entry) {
83
86
  return { ok: false, summary: 'no buddy entrypoint', error: `no src/index.ts or dist/index.js under ${repoRoot}` };
84
87
  }
85
- const prompt = `${task.title}\n\n${task.description ?? ''}`.trim();
88
+ // Goal-mode continuation: on later turns the worker gets the judge's
89
+ // nudge instead of the bare task text, so it targets the remaining gap.
90
+ const prompt = task.goalMode && (task.goalTurnsUsed ?? 0) > 0
91
+ ? buildColabGoalContinuationPrompt(task)
92
+ : `${task.title}\n\n${task.description ?? ''}`.trim();
86
93
  const env = buildAgentEnv(model);
87
94
  const started = Date.now();
88
95
  const res = doSpawn(entry.cmd, [...entry.baseArgs, '-p', prompt, '--permission-mode', permissionMode, '--output-format', 'text', ...extraArgs], { cwd: workspaceRoot, env, encoding: 'utf-8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
@@ -99,6 +106,8 @@ export function createAgentTaskExecutor(opts = {}) {
99
106
  error: reason,
100
107
  };
101
108
  }
109
+ // Tail of the agent's real output — what the goal-mode judge evaluates.
110
+ const output = (res.stdout ?? '').slice(-OUTPUT_TAIL_CHARS).trim();
102
111
  // Acceptance gate: if the task carries a verify command AND running task-
103
112
  // supplied shell is allowed (opt-in), the agent "finishing" isn't enough —
104
113
  // the gate must pass for the task to count as completed. When not allowed,
@@ -126,6 +135,7 @@ export function createAgentTaskExecutor(opts = {}) {
126
135
  ok: true,
127
136
  summary: `agent ran ${task.id} [${model.tier}/${model.model}] + gate \`${gate}\` passed (${elapsedSeconds}s)`,
128
137
  elapsedSeconds,
138
+ ...(output ? { output } : {}),
129
139
  };
130
140
  }
131
141
  const elapsedSeconds = Math.round((Date.now() - started) / 1000);
@@ -133,6 +143,7 @@ export function createAgentTaskExecutor(opts = {}) {
133
143
  ok: true,
134
144
  summary: `agent ran ${task.id} [${model.tier}/${model.model}] in ${workspaceRoot} (${elapsedSeconds}s)`,
135
145
  elapsedSeconds,
146
+ ...(output ? { output } : {}),
136
147
  };
137
148
  };
138
149
  }
@@ -18,6 +18,7 @@ import { FileWatcherTrigger } from '../agent/file-watcher-trigger.js';
18
18
  import { FleetAutonomousLoop } from './autonomous-loop.js';
19
19
  import { createLocalModelTaskExecutor } from './ollama-task-executor.js';
20
20
  import { createAgentTaskExecutor } from './agent-task-executor.js';
21
+ import { createColabGoalJudge } from './colab-goal.js';
21
22
  const DEFAULT_INTERVAL_MS = 30_000;
22
23
  export class FleetAutonomousDaemon {
23
24
  loop;
@@ -132,6 +133,7 @@ export function createDefaultAutonomousLoop(opts = {}) {
132
133
  store,
133
134
  tierConfig,
134
135
  executor,
136
+ goalJudge: createColabGoalJudge(),
135
137
  ...(opts.policy ? { policy: opts.policy } : {}),
136
138
  ...(opts.enabled ? { enabled: opts.enabled } : {}),
137
139
  });
@@ -22,12 +22,19 @@
22
22
  */
23
23
  import { type AutonomousModelChoice, type ModelTierConfig, type ModelTierPolicy } from '../agent/model-tier.js';
24
24
  import type { ColabTask, ColabWorklogFileChange, FleetColabStore } from '../fleet/colab-store.js';
25
+ import { type ColabGoalJudge } from './colab-goal.js';
25
26
  export interface TaskExecutionResult {
26
27
  ok: boolean;
27
28
  summary: string;
28
29
  filesModified?: ColabWorklogFileChange[];
29
30
  elapsedSeconds?: number;
30
31
  error?: string;
32
+ /**
33
+ * Tail of the worker's actual output (capped). Goal-mode judges evaluate
34
+ * this; absent for executors that don't capture output (judge falls back
35
+ * to `summary`).
36
+ */
37
+ output?: string;
31
38
  }
32
39
  export type TaskExecutor = (task: ColabTask, model: AutonomousModelChoice) => Promise<TaskExecutionResult>;
33
40
  export interface AutonomousLoopConfig {
@@ -37,9 +44,14 @@ export interface AutonomousLoopConfig {
37
44
  policy?: ModelTierPolicy;
38
45
  /** Kill-switch — when it returns false the tick is a no-op. Default: always on. */
39
46
  enabled?: () => boolean;
47
+ /**
48
+ * Judge for `goalMode` tasks (Hermes kanban goal-mode parity). When absent,
49
+ * goal-mode tasks complete like plain tasks (no judge gate).
50
+ */
51
+ goalJudge?: ColabGoalJudge;
40
52
  }
41
53
  export interface TickResult {
42
- outcome: 'disabled' | 'idle' | 'completed' | 'failed' | 'saturated';
54
+ outcome: 'disabled' | 'idle' | 'completed' | 'failed' | 'saturated' | 'goal_continue' | 'goal_blocked';
43
55
  taskId?: string;
44
56
  taskTitle?: string;
45
57
  model?: AutonomousModelChoice;
@@ -58,7 +70,15 @@ export declare class FleetAutonomousLoop {
58
70
  * success. Resets across process restarts — escalation is a within-run feature.
59
71
  */
60
72
  private readonly failures;
73
+ private readonly goalJudge;
61
74
  constructor(config: AutonomousLoopConfig);
62
75
  /** Run a single autonomous tick. Never throws — failures are logged + reported. */
63
76
  tick(): Promise<TickResult>;
77
+ /**
78
+ * Goal-mode decision ladder. Returns a TickResult when the loop should NOT
79
+ * complete the task (judge says continue / budget exhausted), or null when
80
+ * the task may complete (judge done/skipped, or judge unreachable —
81
+ * fail-open like the interactive Ralph loop).
82
+ */
83
+ private evaluateGoalModeTask;
64
84
  }