@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
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { chooseAutonomousModel, } from '../agent/model-tier.js';
24
24
  import { beginFleetWork, isFleetSaturated } from '../fleet/fleet-load.js';
25
+ import { DEFAULT_COLAB_GOAL_MAX_TURNS } from './colab-goal.js';
25
26
  export class FleetAutonomousLoop {
26
27
  store;
27
28
  tierConfig;
@@ -35,12 +36,14 @@ export class FleetAutonomousLoop {
35
36
  * success. Resets across process restarts — escalation is a within-run feature.
36
37
  */
37
38
  failures = new Map();
39
+ goalJudge;
38
40
  constructor(config) {
39
41
  this.store = config.store;
40
42
  this.tierConfig = config.tierConfig;
41
43
  this.executor = config.executor;
42
44
  this.policy = config.policy ?? {};
43
45
  this.enabled = config.enabled ?? (() => true);
46
+ this.goalJudge = config.goalJudge;
44
47
  }
45
48
  /** Run a single autonomous tick. Never throws — failures are logged + reported. */
46
49
  async tick() {
@@ -88,6 +91,14 @@ export class FleetAutonomousLoop {
88
91
  doneLoad();
89
92
  }
90
93
  if (result.ok) {
94
+ // Goal-mode gate (Hermes kanban goal-mode): a successful attempt is not
95
+ // enough — the judge must confirm the task's criteria are satisfied.
96
+ if (task.goalMode && this.goalJudge) {
97
+ const goalOutcome = await this.evaluateGoalModeTask(task, result, model);
98
+ if (goalOutcome)
99
+ return goalOutcome;
100
+ // null → judge said done (or skipped): fall through to completion.
101
+ }
91
102
  this.failures.delete(task.id);
92
103
  this.store.completeTask(task.id, {
93
104
  summary: result.summary,
@@ -118,5 +129,54 @@ export class FleetAutonomousLoop {
118
129
  ...(result.error ? { detail: result.error } : {}),
119
130
  };
120
131
  }
132
+ /**
133
+ * Goal-mode decision ladder. Returns a TickResult when the loop should NOT
134
+ * complete the task (judge says continue / budget exhausted), or null when
135
+ * the task may complete (judge done/skipped, or judge unreachable —
136
+ * fail-open like the interactive Ralph loop).
137
+ */
138
+ async evaluateGoalModeTask(task, result, model) {
139
+ let verdict;
140
+ try {
141
+ verdict = await this.goalJudge(task, result, model);
142
+ }
143
+ catch {
144
+ return null; // fail-open: an unusable judge never blocks completion
145
+ }
146
+ if (verdict.verdict !== 'continue')
147
+ return null;
148
+ const maxTurns = task.goalMaxTurns ?? DEFAULT_COLAB_GOAL_MAX_TURNS;
149
+ const turnsUsed = (task.goalTurnsUsed ?? 0) + 1;
150
+ if (turnsUsed >= maxTurns) {
151
+ // Hermes rule: block for human review instead of spinning forever.
152
+ const reason = `goal budget exhausted (${turnsUsed}/${maxTurns}) — judge: ${verdict.reason}`;
153
+ this.store.blockTask(task.id, reason);
154
+ this.store.appendWorklog({
155
+ agent: this.store.agentId,
156
+ taskId: task.id,
157
+ summary: `Goal-mode blocked after ${turnsUsed}/${maxTurns} turns: ${verdict.reason}`,
158
+ filesModified: result.filesModified ?? [],
159
+ issues: [reason],
160
+ nextSteps: ['human review: unblock, split, or complete the task manually'],
161
+ });
162
+ this.store.updatePresence({ status: 'idle', currentTask: null });
163
+ return { outcome: 'goal_blocked', taskId: task.id, taskTitle: task.title, model, detail: verdict.reason };
164
+ }
165
+ // Under budget: persist the consumed turn + reason, release the task so a
166
+ // later tick continues it with the continuation nudge. A judge "continue"
167
+ // is NOT an executor failure — the model ladder must not escalate.
168
+ this.store.recordGoalTurn(task.id, verdict.reason);
169
+ this.store.appendWorklog({
170
+ agent: this.store.agentId,
171
+ taskId: task.id,
172
+ summary: `Goal-mode turn ${turnsUsed}/${maxTurns} — judge: continue: ${verdict.reason}`,
173
+ filesModified: result.filesModified ?? [],
174
+ issues: [],
175
+ nextSteps: ['continue on a later tick with the goal continuation nudge'],
176
+ });
177
+ this.store.releaseTask(task.id);
178
+ this.store.updatePresence({ status: 'idle', currentTask: null });
179
+ return { outcome: 'goal_continue', taskId: task.id, taskTitle: task.title, model, detail: verdict.reason };
180
+ }
121
181
  }
122
182
  //# sourceMappingURL=autonomous-loop.js.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Goal-mode for fleet colab tasks — port of Hermes Agent's kanban goal-mode.
3
+ *
4
+ * A task with `goalMode: true` is not completed on the worker's first
5
+ * successful attempt: an LLM judge checks the task's title/description (with
6
+ * `acceptanceCriteria` as strict numbered criteria) against the worker's
7
+ * output. "Continue" re-opens the task with a continuation nudge so the next
8
+ * tick keeps going; once `goalMaxTurns` is spent the task is BLOCKED for
9
+ * human review instead of spinning (Hermes' "block instead of loop" rule).
10
+ *
11
+ * The judge is fail-open (judgeGoal semantics): a broken judge yields
12
+ * "continue", and the turn budget is the backstop.
13
+ */
14
+ import type { ColabTask } from '../fleet/colab-store.js';
15
+ import { GoalJudgeResult } from '../goals/goal-judge.js';
16
+ import type { AutonomousModelChoice } from '../agent/model-tier.js';
17
+ import type { TaskExecutionResult } from './autonomous-loop.js';
18
+ /**
19
+ * Conservative default for unattended subprocess loops (Hermes' kanban
20
+ * example uses 7; interactive /goal uses 20).
21
+ */
22
+ export declare const DEFAULT_COLAB_GOAL_MAX_TURNS = 5;
23
+ export type ColabGoalJudge = (task: ColabTask, result: TaskExecutionResult, model: AutonomousModelChoice) => Promise<GoalJudgeResult>;
24
+ /** The goal text the judge evaluates: title + description. */
25
+ export declare function goalTextForTask(task: Pick<ColabTask, 'title' | 'description'>): string;
26
+ /**
27
+ * Continuation nudge fed to the worker on later goal-mode turns (port of
28
+ * Hermes' KANBAN_GOAL_CONTINUATION_TEMPLATE, adapted to the colab lifecycle:
29
+ * completion is decided by the loop's judge, so the worker is nudged to
30
+ * finish and state the outcome explicitly).
31
+ */
32
+ export declare function buildColabGoalContinuationPrompt(task: ColabTask): string;
33
+ /**
34
+ * Default judge: a one-shot call on the same tier model the worker ran on
35
+ * (local tiers stay free), overridable via `goals.judgeModel`. Task
36
+ * `acceptanceCriteria` become strict numbered criteria for the judge.
37
+ */
38
+ export declare function createColabGoalJudge(): ColabGoalJudge;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Goal-mode for fleet colab tasks — port of Hermes Agent's kanban goal-mode.
3
+ *
4
+ * A task with `goalMode: true` is not completed on the worker's first
5
+ * successful attempt: an LLM judge checks the task's title/description (with
6
+ * `acceptanceCriteria` as strict numbered criteria) against the worker's
7
+ * output. "Continue" re-opens the task with a continuation nudge so the next
8
+ * tick keeps going; once `goalMaxTurns` is spent the task is BLOCKED for
9
+ * human review instead of spinning (Hermes' "block instead of loop" rule).
10
+ *
11
+ * The judge is fail-open (judgeGoal semantics): a broken judge yields
12
+ * "continue", and the turn budget is the backstop.
13
+ */
14
+ import { CodeBuddyClient } from '../codebuddy/client.js';
15
+ import { judgeGoal } from '../goals/goal-judge.js';
16
+ import { resolveGoalsConfig } from '../goals/goal-manager.js';
17
+ import { logger } from '../utils/logger.js';
18
+ /**
19
+ * Conservative default for unattended subprocess loops (Hermes' kanban
20
+ * example uses 7; interactive /goal uses 20).
21
+ */
22
+ export const DEFAULT_COLAB_GOAL_MAX_TURNS = 5;
23
+ /** The goal text the judge evaluates: title + description. */
24
+ export function goalTextForTask(task) {
25
+ return `${task.title}\n\n${task.description ?? ''}`.trim();
26
+ }
27
+ /**
28
+ * Continuation nudge fed to the worker on later goal-mode turns (port of
29
+ * Hermes' KANBAN_GOAL_CONTINUATION_TEMPLATE, adapted to the colab lifecycle:
30
+ * completion is decided by the loop's judge, so the worker is nudged to
31
+ * finish and state the outcome explicitly).
32
+ */
33
+ export function buildColabGoalContinuationPrompt(task) {
34
+ const criteria = task.acceptanceCriteria?.length
35
+ ? `\nAcceptance criteria (ALL must be satisfied):\n${task.acceptanceCriteria
36
+ .map((text, i) => `- ${i + 1}. ${text}`)
37
+ .join('\n')}\n`
38
+ : '';
39
+ const lastReason = task.goalLastReason ? `\nJudge's last verdict: ${task.goalLastReason}\n` : '';
40
+ return ('[Continuing toward this fleet task — the judge says it is not done yet]\n' +
41
+ `Task: ${goalTextForTask(task)}\n` +
42
+ criteria +
43
+ lastReason +
44
+ '\nFinish the remaining work. When everything is done, state completion ' +
45
+ 'explicitly with evidence (file contents, command output). If you are ' +
46
+ 'blocked, state the blocker clearly.');
47
+ }
48
+ /**
49
+ * Default judge: a one-shot call on the same tier model the worker ran on
50
+ * (local tiers stay free), overridable via `goals.judgeModel`. Task
51
+ * `acceptanceCriteria` become strict numbered criteria for the judge.
52
+ */
53
+ export function createColabGoalJudge() {
54
+ return async (task, result, model) => {
55
+ try {
56
+ const config = resolveGoalsConfig();
57
+ const client = new CodeBuddyClient(process.env.GROK_API_KEY || process.env.OPENAI_API_KEY || 'local', config.judgeModel || model.model, model.baseUrl);
58
+ return await judgeGoal(client, {
59
+ goal: goalTextForTask(task),
60
+ lastResponse: result.output || result.summary,
61
+ ...(task.acceptanceCriteria?.length ? { subgoals: task.acceptanceCriteria } : {}),
62
+ ...(config.judgeModel ? { model: config.judgeModel } : {}),
63
+ timeoutMs: config.judgeTimeoutMs,
64
+ });
65
+ }
66
+ catch (error) {
67
+ // Fail-open, like judgeGoal itself: never wedge the loop on judge setup.
68
+ logger.debug('colab goal judge: setup failed — continue', { error: String(error) });
69
+ return { verdict: 'continue', reason: `judge setup error: ${String(error)}`, parseFailed: false };
70
+ }
71
+ };
72
+ }
73
+ //# sourceMappingURL=colab-goal.js.map
@@ -48,6 +48,20 @@ export interface ColabTask {
48
48
  verifyCommand?: string;
49
49
  /** Ids of tasks that must be `completed` before this one is claimable (DAG). */
50
50
  dependsOn?: string[];
51
+ /**
52
+ * Goal-mode (Hermes kanban goal-mode parity): the worker loops on this task
53
+ * — after each attempt an LLM judge checks the title/description (+
54
+ * `acceptanceCriteria` as strict criteria); "continue" re-opens the task
55
+ * with a continuation nudge until `goalMaxTurns` is spent, then it is
56
+ * BLOCKED for human review instead of spinning.
57
+ */
58
+ goalMode?: boolean;
59
+ /** Per-task turn budget for goal-mode (default 5). */
60
+ goalMaxTurns?: number;
61
+ /** Goal-mode turns consumed so far (persisted — survives daemon restarts). */
62
+ goalTurnsUsed?: number;
63
+ /** Last judge reason (shown in the next continuation nudge). */
64
+ goalLastReason?: string;
51
65
  createdBy?: string;
52
66
  createdAt?: string;
53
67
  }
@@ -104,6 +118,8 @@ export interface AddTaskInput {
104
118
  acceptanceCriteria?: string[];
105
119
  verifyCommand?: string;
106
120
  dependsOn?: string[];
121
+ goalMode?: boolean;
122
+ goalMaxTurns?: number;
107
123
  createdBy?: string;
108
124
  id?: string;
109
125
  }
@@ -164,6 +180,11 @@ export declare class FleetColabStore {
164
180
  };
165
181
  /** Block a task with a reason (needs human/unblocking). */
166
182
  blockTask(taskId: string, reason: string): ColabTask;
183
+ /**
184
+ * Record a consumed goal-mode turn (persisted, so the budget survives daemon
185
+ * restarts) and remember the judge's reason for the next continuation nudge.
186
+ */
187
+ recordGoalTurn(taskId: string, reason: string): ColabTask;
167
188
  /** Release a claimed task back to the open pool. */
168
189
  releaseTask(taskId: string): ColabTask;
169
190
  addTask(input: AddTaskInput): ColabTask;
@@ -207,6 +207,20 @@ export class FleetColabStore {
207
207
  this.writeTasks(file);
208
208
  return { ...task };
209
209
  }
210
+ /**
211
+ * Record a consumed goal-mode turn (persisted, so the budget survives daemon
212
+ * restarts) and remember the judge's reason for the next continuation nudge.
213
+ */
214
+ recordGoalTurn(taskId, reason) {
215
+ const file = this.readTasks();
216
+ const task = file.tasks.find((t) => t.id === taskId);
217
+ if (!task)
218
+ throw new Error(`Unknown fleet task '${taskId}'`);
219
+ task.goalTurnsUsed = (task.goalTurnsUsed ?? 0) + 1;
220
+ task.goalLastReason = reason;
221
+ this.writeTasks(file);
222
+ return { ...task };
223
+ }
210
224
  /** Release a claimed task back to the open pool. */
211
225
  releaseTask(taskId) {
212
226
  const file = this.readTasks();
@@ -234,6 +248,8 @@ export class FleetColabStore {
234
248
  ...(input.acceptanceCriteria ? { acceptanceCriteria: input.acceptanceCriteria } : {}),
235
249
  ...(input.verifyCommand ? { verifyCommand: input.verifyCommand } : {}),
236
250
  ...(input.dependsOn && input.dependsOn.length > 0 ? { dependsOn: [...new Set(input.dependsOn)] } : {}),
251
+ ...(input.goalMode ? { goalMode: true } : {}),
252
+ ...(input.goalMaxTurns && input.goalMaxTurns > 0 ? { goalMaxTurns: Math.trunc(input.goalMaxTurns) } : {}),
237
253
  createdBy: input.createdBy ?? this.agentId,
238
254
  createdAt: this.isoNow(),
239
255
  };
@@ -42,7 +42,7 @@ export type PeerChatClientGetter = () => CodeBuddyClient | null;
42
42
  * getter; un-wire first if you need to swap).
43
43
  */
44
44
  export declare function wirePeerSessionBridge(getClient: PeerChatClientGetter): Promise<void>;
45
- /** Detach all three methods. Idempotent. Does NOT clear in-memory sessions. */
45
+ /** Detach all registered methods. Idempotent. Does NOT clear in-memory sessions. */
46
46
  export declare function unwirePeerSessionBridge(): void;
47
47
  /** Whether the bridge is currently registered on the peer-rpc registry. */
48
48
  export declare function isPeerSessionBridgeWired(): boolean;
@@ -31,7 +31,10 @@
31
31
  */
32
32
  import { beginFleetWork } from './fleet-load.js';
33
33
  import { registerPeerMethod, unregisterPeerMethod } from '../server/websocket/peer-rpc.js';
34
- import { broadcastChatSessionEnd, broadcastChatSessionStart, broadcastChatSessionTurn, } from '../server/websocket/fleet-bridge.js';
34
+ import { broadcastChatSessionEnd, broadcastChatSessionGoal, broadcastChatSessionStart, broadcastChatSessionTurn, } from '../server/websocket/fleet-bridge.js';
35
+ import { judgeGoal } from '../goals/goal-judge.js';
36
+ import { resolveGoalsConfig } from '../goals/goal-manager.js';
37
+ import { applyJudgeOutcome, createGoalState, formatGoalStatusLine, normalizeGoalState, renderSubgoalsBlock, } from '../goals/goal-state.js';
35
38
  import { logger } from '../utils/logger.js';
36
39
  import { getPeerSessionStore, } from './peer-session-store.js';
37
40
  import { DEFAULT_DISPATCH_POLICY_PREVIEW_TOOLS, FLEET_DISPATCH_PROFILES, buildDispatchSystemPrompt, buildHermesToolsetDescriptor, getDispatchToolPolicy, isFleetDispatchProfile, mergeDispatchSystemPrompt, normalizeDispatchProfile, } from './dispatch-profile.js';
@@ -144,10 +147,60 @@ function snapshot(session) {
144
147
  toolDecisions: session.toolDecisions,
145
148
  toolset: session.toolset,
146
149
  messages: [...session.messages],
150
+ ...(session.goal ? { goal: { ...session.goal, subgoals: [...session.goal.subgoals] } } : {}),
147
151
  createdAt: session.createdAt,
148
152
  lastUsedAt: session.lastUsedAt,
149
153
  };
150
154
  }
155
+ /**
156
+ * Post-turn goal hook shared by `continue` and `continue-stream`. Judges the
157
+ * assistant's response against the session goal, applies the Hermes decision
158
+ * ladder, and returns the report embedded in the RPC response. The judge is
159
+ * fail-open; this never throws. The caller persists the session afterwards.
160
+ */
161
+ async function evaluateSessionGoalAfterTurn(session, assistantText) {
162
+ const goal = session.goal;
163
+ if (!goal || goal.status !== 'active')
164
+ return null;
165
+ if (!assistantText.trim())
166
+ return null; // empty turn: don't burn budget
167
+ let report;
168
+ try {
169
+ const config = resolveGoalsConfig();
170
+ const outcome = await judgeGoal(cachedGetter?.() ?? null, {
171
+ goal: goal.goal,
172
+ lastResponse: assistantText,
173
+ ...(goal.subgoals.length ? { subgoals: goal.subgoals } : {}),
174
+ ...(config.judgeModel ? { model: config.judgeModel } : {}),
175
+ timeoutMs: config.judgeTimeoutMs,
176
+ });
177
+ const decision = applyJudgeOutcome(goal, outcome);
178
+ report = {
179
+ status: decision.status,
180
+ verdict: decision.verdict,
181
+ reason: decision.reason,
182
+ turnsUsed: goal.turnsUsed,
183
+ maxTurns: goal.maxTurns,
184
+ message: decision.message,
185
+ ...(decision.continuationPrompt ? { continuationPrompt: decision.continuationPrompt } : {}),
186
+ };
187
+ }
188
+ catch (err) {
189
+ logger.warn('[peer-session-bridge] goal evaluation failed — skipping this turn', {
190
+ sessionId: session.sessionId,
191
+ error: err instanceof Error ? err.message : String(err),
192
+ });
193
+ return null;
194
+ }
195
+ broadcastChatSessionGoal({
196
+ sessionId: session.sessionId,
197
+ status: report.status,
198
+ verdict: report.verdict,
199
+ turnsUsed: report.turnsUsed,
200
+ maxTurns: report.maxTurns,
201
+ });
202
+ return report;
203
+ }
151
204
  /**
152
205
  * Register the `peer.chat-session.*` methods. The `getClient` closure
153
206
  * is captured and called fresh on each invocation so the caller can
@@ -174,6 +227,7 @@ export async function wirePeerSessionBridge(getClient) {
174
227
  for (const p of persisted) {
175
228
  if (now - p.lastUsedAt > idleMs)
176
229
  continue; // double-check vs the boundary
230
+ const goal = p.goal ? normalizeGoalState(p.goal) : null;
177
231
  sessions.set(p.sessionId, {
178
232
  sessionId: p.sessionId,
179
233
  systemPrompt: p.systemPrompt,
@@ -183,6 +237,7 @@ export async function wirePeerSessionBridge(getClient) {
183
237
  toolDecisions: p.toolDecisions,
184
238
  toolset: p.toolset,
185
239
  messages: [...p.messages],
240
+ ...(goal && goal.status !== 'cleared' ? { goal } : {}),
186
241
  createdAt: p.createdAt,
187
242
  lastUsedAt: p.lastUsedAt,
188
243
  pending: Promise.resolve(),
@@ -312,6 +367,11 @@ export async function wirePeerSessionBridge(getClient) {
312
367
  const text = response?.choices?.[0]?.message?.content ?? '';
313
368
  session.messages.push({ role: 'assistant', content: text });
314
369
  session.lastUsedAt = Date.now();
370
+ // Goal Ralph-loop (Hermes gateway parity): judge the turn server-side,
371
+ // mutate the session goal state, and report the verdict to the caller
372
+ // (who drives the continuation). Runs BEFORE the disk flush so the
373
+ // snapshot below persists the updated goal counters.
374
+ const goalReport = await evaluateSessionGoalAfterTurn(session, text);
315
375
  // V1.2-saga — flush the new turn to disk before returning so a
316
376
  // crash mid-conversation can be replayed on next boot. Failure
317
377
  // logs but doesn't fail the turn: the caller already got an
@@ -340,6 +400,7 @@ export async function wirePeerSessionBridge(getClient) {
340
400
  finishReason: response?.choices?.[0]?.finish_reason,
341
401
  usage: response?.usage,
342
402
  traceId: ctx.traceId,
403
+ ...(goalReport ? { goal: goalReport } : {}),
343
404
  ...sessionPolicyMetadata(session),
344
405
  };
345
406
  };
@@ -439,6 +500,8 @@ export async function wirePeerSessionBridge(getClient) {
439
500
  }
440
501
  session.messages.push({ role: 'assistant', content: aggregate });
441
502
  session.lastUsedAt = Date.now();
503
+ // Goal Ralph-loop — same server-side judge as the non-streaming path.
504
+ const goalReport = await evaluateSessionGoalAfterTurn(session, aggregate);
442
505
  try {
443
506
  await getPeerSessionStore().save(snapshot(session));
444
507
  }
@@ -460,6 +523,7 @@ export async function wirePeerSessionBridge(getClient) {
460
523
  finishReason,
461
524
  usage,
462
525
  traceId: ctx.traceId,
526
+ ...(goalReport ? { goal: goalReport } : {}),
463
527
  ...sessionPolicyMetadata(session),
464
528
  };
465
529
  };
@@ -497,6 +561,143 @@ export async function wirePeerSessionBridge(getClient) {
497
561
  traceId: ctx.traceId,
498
562
  };
499
563
  });
564
+ // Goal Ralph-loop controls (Hermes gateway parity). One method, action-
565
+ // dispatched: set | status | pause | resume | clear | subgoal-add |
566
+ // subgoal-list | subgoal-remove | subgoal-clear. Mirrors the /goal +
567
+ // /subgoal slash surface. Setting a NEW goal while one is active is
568
+ // rejected (Hermes mid-run rule) — pause/clear first. status/pause/
569
+ // resume/clear are safe mid-turn: they only touch goal metadata.
570
+ registerPeerMethod('peer.chat-session.goal', async (params, ctx) => {
571
+ const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
572
+ const action = typeof params.action === 'string' ? params.action : 'status';
573
+ if (!sessionId) {
574
+ throw new Error('peer.chat-session.goal: sessionId is required (string)');
575
+ }
576
+ const session = sessions.get(sessionId);
577
+ if (!session) {
578
+ throw new Error(`SESSION_NOT_FOUND: no session with id "${sessionId}"`);
579
+ }
580
+ const persist = async () => {
581
+ try {
582
+ await getPeerSessionStore().save(snapshot(session));
583
+ }
584
+ catch (err) {
585
+ logger.warn('[peer-session-bridge] save on goal mutation failed', {
586
+ sessionId,
587
+ error: err instanceof Error ? err.message : String(err),
588
+ });
589
+ }
590
+ };
591
+ const emitGoal = (status) => {
592
+ broadcastChatSessionGoal({
593
+ sessionId,
594
+ status,
595
+ ...(session.goal ? { turnsUsed: session.goal.turnsUsed, maxTurns: session.goal.maxTurns } : {}),
596
+ });
597
+ };
598
+ const goalView = () => session.goal
599
+ ? {
600
+ goal: session.goal.goal,
601
+ status: session.goal.status,
602
+ turnsUsed: session.goal.turnsUsed,
603
+ maxTurns: session.goal.maxTurns,
604
+ subgoals: [...session.goal.subgoals],
605
+ ...(session.goal.lastVerdict ? { lastVerdict: session.goal.lastVerdict } : {}),
606
+ ...(session.goal.lastReason ? { lastReason: session.goal.lastReason } : {}),
607
+ ...(session.goal.pausedReason ? { pausedReason: session.goal.pausedReason } : {}),
608
+ statusLine: formatGoalStatusLine(session.goal),
609
+ }
610
+ : { status: 'none', statusLine: formatGoalStatusLine(null) };
611
+ switch (action) {
612
+ case 'set': {
613
+ const text = typeof params.goal === 'string' ? params.goal.trim() : '';
614
+ if (!text) {
615
+ throw new Error('peer.chat-session.goal: action "set" requires goal text (string)');
616
+ }
617
+ if (session.goal?.status === 'active') {
618
+ throw new Error('GOAL_ACTIVE: a goal is already active on this session. ' +
619
+ 'Use action "status"/"pause"/"clear" mid-run; clear it before setting a new goal.');
620
+ }
621
+ const maxTurns = typeof params.maxTurns === 'number' && params.maxTurns > 0
622
+ ? Math.trunc(params.maxTurns)
623
+ : resolveGoalsConfig().maxTurns;
624
+ session.goal = createGoalState(text, maxTurns);
625
+ await persist();
626
+ emitGoal('active');
627
+ return { ...goalView(), traceId: ctx.traceId };
628
+ }
629
+ case 'status':
630
+ return { ...goalView(), traceId: ctx.traceId };
631
+ case 'pause': {
632
+ if (!session.goal)
633
+ return { ...goalView(), traceId: ctx.traceId };
634
+ session.goal.status = 'paused';
635
+ session.goal.pausedReason = 'user-paused';
636
+ await persist();
637
+ emitGoal('paused');
638
+ return { ...goalView(), traceId: ctx.traceId };
639
+ }
640
+ case 'resume': {
641
+ if (!session.goal)
642
+ return { ...goalView(), traceId: ctx.traceId };
643
+ session.goal.status = 'active';
644
+ delete session.goal.pausedReason;
645
+ session.goal.turnsUsed = 0; // Hermes resume semantics: budget reset
646
+ await persist();
647
+ emitGoal('active');
648
+ return { ...goalView(), traceId: ctx.traceId };
649
+ }
650
+ case 'clear': {
651
+ const had = Boolean(session.goal);
652
+ delete session.goal;
653
+ await persist();
654
+ if (had)
655
+ emitGoal('cleared');
656
+ return { cleared: had, ...goalView(), traceId: ctx.traceId };
657
+ }
658
+ case 'subgoal-add': {
659
+ const text = typeof params.text === 'string' ? params.text.trim() : '';
660
+ if (!text) {
661
+ throw new Error('peer.chat-session.goal: action "subgoal-add" requires text (string)');
662
+ }
663
+ if (!session.goal || !['active', 'paused'].includes(session.goal.status)) {
664
+ throw new Error('NO_ACTIVE_GOAL: set a goal before adding subgoals');
665
+ }
666
+ session.goal.subgoals.push(text);
667
+ await persist();
668
+ return { ...goalView(), traceId: ctx.traceId };
669
+ }
670
+ case 'subgoal-list':
671
+ return {
672
+ ...goalView(),
673
+ rendered: session.goal ? renderSubgoalsBlock(session.goal.subgoals) : '',
674
+ traceId: ctx.traceId,
675
+ };
676
+ case 'subgoal-remove': {
677
+ const index = typeof params.index === 'number' ? Math.trunc(params.index) : NaN;
678
+ if (!session.goal || !['active', 'paused'].includes(session.goal.status)) {
679
+ throw new Error('NO_ACTIVE_GOAL: set a goal before removing subgoals');
680
+ }
681
+ if (!Number.isInteger(index) || index < 1 || index > session.goal.subgoals.length) {
682
+ throw new Error(`peer.chat-session.goal: subgoal index out of range (1..${session.goal.subgoals.length})`);
683
+ }
684
+ const [removed] = session.goal.subgoals.splice(index - 1, 1);
685
+ await persist();
686
+ return { removed, ...goalView(), traceId: ctx.traceId };
687
+ }
688
+ case 'subgoal-clear': {
689
+ if (!session.goal || !['active', 'paused'].includes(session.goal.status)) {
690
+ throw new Error('NO_ACTIVE_GOAL: set a goal before clearing subgoals');
691
+ }
692
+ const previous = session.goal.subgoals.length;
693
+ session.goal.subgoals = [];
694
+ await persist();
695
+ return { cleared: previous, ...goalView(), traceId: ctx.traceId };
696
+ }
697
+ default:
698
+ throw new Error(`peer.chat-session.goal: unknown action "${action}" (expected set | status | pause | resume | clear | subgoal-add | subgoal-list | subgoal-remove | subgoal-clear)`);
699
+ }
700
+ });
500
701
  registerPeerMethod('peer.chat-session.end', async (params, ctx) => {
501
702
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
502
703
  if (!sessionId) {
@@ -520,13 +721,14 @@ export async function wirePeerSessionBridge(getClient) {
520
721
  wired = true;
521
722
  logger.debug('[peer-session-bridge] wired');
522
723
  }
523
- /** Detach all three methods. Idempotent. Does NOT clear in-memory sessions. */
724
+ /** Detach all registered methods. Idempotent. Does NOT clear in-memory sessions. */
524
725
  export function unwirePeerSessionBridge() {
525
726
  if (!wired)
526
727
  return;
527
728
  unregisterPeerMethod('peer.chat-session.start');
528
729
  unregisterPeerMethod('peer.chat-session.continue');
529
730
  unregisterPeerMethod('peer.chat-session.continue-stream');
731
+ unregisterPeerMethod('peer.chat-session.goal');
530
732
  unregisterPeerMethod('peer.chat-session.end');
531
733
  cachedGetter = null;
532
734
  wired = false;
@@ -546,6 +748,7 @@ export function _unwireForTests() {
546
748
  unregisterPeerMethod('peer.chat-session.start');
547
749
  unregisterPeerMethod('peer.chat-session.continue');
548
750
  unregisterPeerMethod('peer.chat-session.continue-stream');
751
+ unregisterPeerMethod('peer.chat-session.goal');
549
752
  unregisterPeerMethod('peer.chat-session.list');
550
753
  unregisterPeerMethod('peer.chat-session.end');
551
754
  }
@@ -27,6 +27,7 @@
27
27
  *
28
28
  * @module fleet/peer-session-store
29
29
  */
30
+ import type { GoalState } from '../goals/goal-state.js';
30
31
  import type { FleetDispatchProfile, FleetHermesToolsetDescriptor, FleetDispatchToolDecision, FleetDispatchToolPolicy } from './dispatch-profile.js';
31
32
  export interface PersistedChatMessage {
32
33
  role: 'system' | 'user' | 'assistant';
@@ -42,6 +43,8 @@ export interface PersistedChatSession {
42
43
  toolset?: FleetHermesToolsetDescriptor;
43
44
  /** User/assistant turns (system prompt is held separately). */
44
45
  messages: PersistedChatMessage[];
46
+ /** Standing goal attached via `peer.chat-session.goal` (Hermes gateway parity). */
47
+ goal?: GoalState;
45
48
  createdAt: number;
46
49
  lastUsedAt: number;
47
50
  }
@@ -35,3 +35,11 @@ export interface PrivacyLintResult {
35
35
  * Scan a prompt for secrets. Returns matches with previews.
36
36
  */
37
37
  export declare function scanForSecrets(prompt: string): PrivacyLintResult;
38
+ /**
39
+ * Replace every secret/PII match with a `[REDACTED:<kind>]` marker.
40
+ *
41
+ * Run this on FULL text before any truncation — cutting a PEM block (or
42
+ * any multi-line secret) in half can hide it from the patterns above.
43
+ * Used by every memory-persistence path (WS3 guard-rail).
44
+ */
45
+ export declare function redactSecrets(text: string): string;
@@ -164,4 +164,26 @@ function redactPreview(text, start, end) {
164
164
  : matched.slice(0, 4) + '…[redacted]…' + matched.slice(-4);
165
165
  return `${before}${redacted}${after}`.replace(/\s+/g, ' ').trim();
166
166
  }
167
+ /**
168
+ * Replace every secret/PII match with a `[REDACTED:<kind>]` marker.
169
+ *
170
+ * Run this on FULL text before any truncation — cutting a PEM block (or
171
+ * any multi-line secret) in half can hide it from the patterns above.
172
+ * Used by every memory-persistence path (WS3 guard-rail).
173
+ */
174
+ export function redactSecrets(text) {
175
+ const lint = scanForSecrets(text);
176
+ if (!lint.hasSecrets)
177
+ return text;
178
+ let out = '';
179
+ let cursor = 0;
180
+ for (const match of [...lint.matches].sort((a, b) => a.start - b.start)) {
181
+ if (match.start < cursor)
182
+ continue; // overlapping match already covered
183
+ out += text.slice(cursor, match.start) + `[REDACTED:${match.kind}]`;
184
+ cursor = match.end;
185
+ }
186
+ out += text.slice(cursor);
187
+ return out;
188
+ }
167
189
  //# sourceMappingURL=privacy-lint.js.map