ticlawk 0.1.16 → 0.1.17-dev.2

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.
package/README.md CHANGED
@@ -230,6 +230,8 @@ Commands:
230
230
  profile list or switch saved local identities
231
231
  message send/read chat messages (agent CLI surface)
232
232
  task claim/update/list tasks (agent CLI surface)
233
+ goal report FSM transitions / signal goal changes (agent CLI surface)
234
+ approval canonical goal-loop approval request/resolve (agent CLI surface)
233
235
  charter get/set conversation goal and role spec (agent CLI surface)
234
236
  group create/list/delete groups, manage charters/members (agent CLI surface)
235
237
  dashboard set/get conversation dashboards (agent CLI surface)
package/bin/ticlawk.mjs CHANGED
@@ -57,6 +57,11 @@ import {
57
57
  runMessageCheckCommand,
58
58
  runMessageReactCommand,
59
59
  runMessageReadCommand,
60
+ runGoalChangedCommand,
61
+ runGoalReportCommand,
62
+ runApprovalRequestCommand,
63
+ runApprovalResolveCommand,
64
+ runApprovalListCommand,
60
65
  runMessageSearchCommand,
61
66
  runMessageSendCommand,
62
67
  runProfileShowCommand,
@@ -129,6 +134,8 @@ Commands:
129
134
  profile list or switch saved local identities
130
135
  message send/read chat messages (agent CLI surface)
131
136
  task claim/update/list tasks (agent CLI surface)
137
+ goal report FSM transitions / signal goal changes (agent CLI surface)
138
+ approval canonical goal-loop approval request/resolve (agent CLI surface)
132
139
  charter get/set conversation goal and role spec (agent CLI surface)
133
140
  group create/list/delete groups, manage charters/members (agent CLI surface)
134
141
  dashboard set/get conversation dashboards (agent CLI surface)
@@ -470,6 +477,46 @@ async function main() {
470
477
  process.exit(1);
471
478
  }
472
479
 
480
+ if (command === 'goal') {
481
+ const sub = args._[1];
482
+ if (args.help || args.h || !sub) {
483
+ console.log(AGENT_COMMAND_HELP.goal);
484
+ return;
485
+ }
486
+ if (sub === 'report') {
487
+ process.exitCode = await runGoalReportCommand(args);
488
+ return;
489
+ }
490
+ if (sub === 'changed') {
491
+ process.exitCode = await runGoalChangedCommand(args);
492
+ return;
493
+ }
494
+ console.error(`unknown goal subcommand: ${sub}`);
495
+ process.exit(1);
496
+ }
497
+
498
+ if (command === 'approval') {
499
+ const sub = args._[1];
500
+ if (args.help || args.h || !sub) {
501
+ console.log(AGENT_COMMAND_HELP.approval);
502
+ return;
503
+ }
504
+ if (sub === 'request') {
505
+ process.exitCode = await runApprovalRequestCommand(args);
506
+ return;
507
+ }
508
+ if (sub === 'resolve') {
509
+ process.exitCode = await runApprovalResolveCommand(args);
510
+ return;
511
+ }
512
+ if (sub === 'list') {
513
+ process.exitCode = await runApprovalListCommand(args);
514
+ return;
515
+ }
516
+ console.error(`unknown approval subcommand: ${sub}`);
517
+ process.exit(1);
518
+ }
519
+
473
520
  if (command === 'workstream') {
474
521
  const sub = args._[1];
475
522
  if (args.help || args.h || !sub) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticlawk",
3
- "version": "0.1.16",
3
+ "version": "0.1.17-dev.2",
4
4
  "description": "Local connector that links agent harnesses (Claude Code, Codex, OpenClaw, opencode, Pi) to the Ticlawk mobile app.",
5
5
  "type": "module",
6
6
  "main": "ticlawk.mjs",
@@ -152,13 +152,13 @@ export async function updateChannel(id, updates) {
152
152
 
153
153
  // ── Deliveries (replaces legacy message_jobs poll/ack) ──
154
154
 
155
- export async function claimPendingDeliveries(hostId, limit = 5, excludedAgentIds = []) {
155
+ export async function claimPendingDeliveries(hostId, limit = 5, excludedChannels = []) {
156
156
  const { data } = await apiFetch('/api/agent/deliveries/claim-pending', {
157
157
  method: 'POST',
158
158
  body: JSON.stringify(withTiclawkVersion({
159
159
  runtime_host_id: hostId,
160
160
  limit,
161
- excluded_agent_ids: excludedAgentIds,
161
+ excluded_channels: excludedChannels,
162
162
  })),
163
163
  });
164
164
  return data || [];
@@ -286,6 +286,71 @@ export async function updateAgentTask({ actingAgentId, taskId, status }) {
286
286
  });
287
287
  }
288
288
 
289
+ export async function reportGoalTransition({
290
+ actingAgentId, conversationId, transitionId, outcome, detail, currentTaskId,
291
+ }) {
292
+ return apiFetch('/api/agent/goal/report', {
293
+ method: 'POST',
294
+ body: JSON.stringify({
295
+ acting_as_agent_id: actingAgentId,
296
+ conversation_id: conversationId,
297
+ transition_id: transitionId,
298
+ outcome,
299
+ detail: detail ?? null,
300
+ current_task_id: currentTaskId ?? null,
301
+ }),
302
+ });
303
+ }
304
+
305
+ export async function noteGoalChanged({ actingAgentId, conversationId }) {
306
+ return apiFetch('/api/agent/goal/changed', {
307
+ method: 'POST',
308
+ body: JSON.stringify({
309
+ acting_as_agent_id: actingAgentId,
310
+ conversation_id: conversationId,
311
+ }),
312
+ });
313
+ }
314
+
315
+ export async function requestGoalApproval({
316
+ actingAgentId, conversationId, title, detail, ttlSeconds,
317
+ }) {
318
+ return apiFetch('/api/agent/approval/request', {
319
+ method: 'POST',
320
+ body: JSON.stringify({
321
+ acting_as_agent_id: actingAgentId,
322
+ conversation_id: conversationId,
323
+ title,
324
+ detail: detail ?? null,
325
+ ttl_seconds: ttlSeconds ?? null,
326
+ }),
327
+ });
328
+ }
329
+
330
+ export async function listGoalApprovals({ actingAgentId, conversationId }) {
331
+ const params = new URLSearchParams();
332
+ params.set('acting_as_agent_id', actingAgentId);
333
+ params.set('conversation_id', conversationId);
334
+ const { data } = await apiFetch(`/api/agent/approval/list?${params}`);
335
+ return data || [];
336
+ }
337
+
338
+ export async function resolveGoalApproval({
339
+ actingAgentId, requestId, decision, originalText, confidence, sourceMessageId,
340
+ }) {
341
+ return apiFetch('/api/agent/approval/resolve', {
342
+ method: 'POST',
343
+ body: JSON.stringify({
344
+ acting_as_agent_id: actingAgentId,
345
+ request_id: requestId,
346
+ decision,
347
+ original_text: originalText ?? null,
348
+ confidence: confidence ?? null,
349
+ source_message_id: sourceMessageId ?? null,
350
+ }),
351
+ });
352
+ }
353
+
289
354
  export async function listAgentTasks({ actingAgentId, conversationId }) {
290
355
  const params = new URLSearchParams();
291
356
  params.set('acting_as_agent_id', actingAgentId);
@@ -13,6 +13,7 @@ import * as api from './api.mjs';
13
13
  import { persistApiCredential, persistRuntimeCredentials } from './credentials.mjs';
14
14
  import { TiclawkWakeClient } from './wake-client.mjs';
15
15
  import { buildInboundWakePrompt } from '../../runtimes/_shared/wake-prompt.mjs';
16
+ import { buildGoalStepPrompt } from '../../runtimes/_shared/goal-step-prompt.mjs';
16
17
 
17
18
  const require = createRequire(import.meta.url);
18
19
  const qrcode = require('qrcode-terminal');
@@ -80,19 +81,23 @@ export function normalizeInboundMessage(msg) {
80
81
  const deliveryId = msg.delivery_id || null;
81
82
  const media = normalizeInboundMediaAssets(msg);
82
83
  const enriched = { ...msg, id: messageId };
83
- const wakePrompt = buildInboundWakePrompt(enriched);
84
+ const isTransition = msg.reason === 'transition';
85
+ // A transition delivery is a goal-lane FSM step, not a chat message: it has
86
+ // no backing message, so build a per-step goal prompt from its payload.
87
+ const wakePrompt = isTransition ? buildGoalStepPrompt(enriched) : buildInboundWakePrompt(enriched);
84
88
  return {
85
89
  bindingId: recipientAgentId,
86
90
  messageId,
87
91
  deliveryId,
88
92
  conversationId: msg.conversation_id || null,
93
+ lane: isTransition ? 'goal' : 'chat',
89
94
  seq: msg.seq != null ? Number(msg.seq) : null,
90
95
  senderType: msg.sender_type || 'human',
91
96
  envelopeHeader: wakePrompt.header,
92
97
  envelopeTarget: wakePrompt.target,
93
98
  text: wakePrompt.text,
94
99
  rawText: wakePrompt.rawText,
95
- action: msg.action || (media.length > 0 ? 'image' : 'task'),
100
+ action: isTransition ? 'transition' : (msg.action || (media.length > 0 ? 'image' : 'task')),
96
101
  media,
97
102
  raw: {
98
103
  ...msg,
@@ -130,6 +135,19 @@ function getAgentIdFromPayload(payload) {
130
135
  return String(payload?.recipient_agent_id || '').trim();
131
136
  }
132
137
 
138
+ // Two lanes run concurrently per agent (see the lane-aware claim RPC):
139
+ // the goal lane carries FSM transition deliveries (reason='transition'),
140
+ // everything else is the user-facing chat lane. The claim unit and the
141
+ // daemon's in-flight key are the (agent, lane) channel, so a running chat
142
+ // turn and a running goal transition never block each other.
143
+ function getDeliveryLaneFromPayload(payload) {
144
+ return payload?.reason === 'transition' ? 'goal' : 'chat';
145
+ }
146
+
147
+ function channelKeyFor(agentId, lane) {
148
+ return `${agentId}:${lane}`;
149
+ }
150
+
133
151
  // Whitelist of meta keys runtimes actually consume. Anything else in
134
152
  // the source row's meta blob is dropped on the floor so stale fields
135
153
  // (like the post-Y2 workdir/cwd/projectDir leftovers) can't sneak
@@ -140,7 +158,11 @@ const RUNTIME_META_KEYS = [
140
158
  'runtimePath',
141
159
  'rotatePending',
142
160
  'lastRotatedAt',
143
- 'conversationSessions',
161
+ // Per-lane scoped session maps (keyed by conversation): the chat lane and
162
+ // the goal-FSM lane keep separate runtime sessions so a transition turn
163
+ // never resumes a user-chat session or vice-versa.
164
+ 'chatSessions',
165
+ 'goalSessions',
144
166
  // claude_code
145
167
  'claudePath',
146
168
  'claudeVersion',
@@ -642,6 +664,91 @@ export function createTiclawkAdapter(ctx) {
642
664
  }
643
665
  }
644
666
 
667
+ // The goal lane's canonical completion is `ticlawk goal report`, which
668
+ // completes the transition delivery inside report_goal_transition (before
669
+ // emitting the next step) so the live-transition slot frees up. By the time
670
+ // the turn returns the row is usually already 'completed', so this
671
+ // best-effort complete then 409s — that is the expected healthy path, not an
672
+ // error. A turn that finished without reporting leaves the row 'claimed' for
673
+ // stale-claimed recovery.
674
+ async function completeGoalDelivery(deliveryId) {
675
+ try {
676
+ await api.completeDelivery(deliveryId, hostId);
677
+ } catch (err) {
678
+ if (err?.status === 409) return;
679
+ throw err;
680
+ }
681
+ }
682
+
683
+ async function processGoalTransitionsForAgent(agentId, messages) {
684
+ for (const msg of messages) {
685
+ const deliveryId = msg.delivery_id;
686
+ const step = msg?.payload?.step || null;
687
+ try {
688
+ const messageHostId = getRuntimeHostIdFromPayload(msg);
689
+ if (messageHostId && messageHostId !== hostId) {
690
+ await api.releaseDelivery(deliveryId, hostId, 'host-mismatch');
691
+ continue;
692
+ }
693
+
694
+ const binding = await ctx.persistBinding(buildBindingFromSource(msg));
695
+ if (!binding?.runtime) {
696
+ throw new Error('claimed transition missing runtime binding');
697
+ }
698
+ if (!belongsToRuntimeHost(binding, hostId)) {
699
+ await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
700
+ continue;
701
+ }
702
+
703
+ const completed = await ctx.bus.dispatchToAgent(binding.runtime, binding.id, normalizeInboundMessage(msg));
704
+ if (completed !== true) {
705
+ if (isTerminalRuntimeFailure(completed)) {
706
+ await completeGoalDelivery(deliveryId);
707
+ void requestDrain('transition.terminal-completed');
708
+ debugError('ticlawk', 'transition.terminal-failed', {
709
+ agentId,
710
+ deliveryId,
711
+ step,
712
+ runtime: binding.runtime,
713
+ reason: completed.reason || 'runtime terminal failure',
714
+ });
715
+ continue;
716
+ }
717
+ throw new Error('runtime did not complete transition turn');
718
+ }
719
+ await completeGoalDelivery(deliveryId);
720
+ void requestDrain('transition.completed');
721
+ debugLog('ticlawk', 'transition.completed', {
722
+ agentId,
723
+ deliveryId,
724
+ step,
725
+ runtime: binding.runtime,
726
+ });
727
+ } catch (err) {
728
+ if (api.isUpdateRequiredError(err)) {
729
+ recordUpdateRequired(err, 'transition.dispatch');
730
+ }
731
+ try {
732
+ await api.releaseDelivery(deliveryId, hostId, 'transition-dispatch-error');
733
+ } catch (releaseErr) {
734
+ debugError('ticlawk', 'transition.release-failed', {
735
+ agentId,
736
+ deliveryId,
737
+ hostId,
738
+ error: releaseErr?.message || 'unknown error',
739
+ });
740
+ }
741
+ debugError('ticlawk', 'transition.dispatch-failed', {
742
+ agentId,
743
+ deliveryId,
744
+ step,
745
+ hostId,
746
+ error: err?.message || 'unknown error',
747
+ });
748
+ }
749
+ }
750
+ }
751
+
645
752
  async function refreshBindings(reason = 'manual') {
646
753
  let channels = [];
647
754
  const startedAt = Date.now();
@@ -828,29 +935,35 @@ export function createTiclawkAdapter(ctx) {
828
935
  });
829
936
  continue;
830
937
  }
831
- const bucket = grouped.get(agentId) || [];
832
- bucket.push(msg);
833
- grouped.set(agentId, bucket);
938
+ const lane = getDeliveryLaneFromPayload(msg);
939
+ const channelKey = channelKeyFor(agentId, lane);
940
+ const bucket = grouped.get(channelKey) || { agentId, lane, messages: [] };
941
+ bucket.messages.push(msg);
942
+ grouped.set(channelKey, bucket);
834
943
  }
835
944
 
836
945
  let launched = 0;
837
- for (const [agentId, messages] of grouped.entries()) {
838
- if (processingChannels.has(agentId)) {
946
+ for (const [channelKey, { agentId, lane, messages }] of grouped.entries()) {
947
+ if (processingChannels.has(channelKey)) {
839
948
  debugError('ticlawk', 'claim.blocked-claimed-rows', {
840
949
  reason,
950
+ channelKey,
841
951
  agentId,
952
+ lane,
842
953
  blockedRows: messages.length,
843
954
  });
844
955
  await releaseBlockedRows(agentId, messages, reason);
845
956
  continue;
846
957
  }
847
- const run = processPendingMessagesForAgent(agentId, messages)
958
+ const run = (lane === 'goal'
959
+ ? processGoalTransitionsForAgent(agentId, messages)
960
+ : processPendingMessagesForAgent(agentId, messages))
848
961
  .catch(() => {})
849
962
  .finally(() => {
850
- processingChannels.delete(agentId);
963
+ processingChannels.delete(channelKey);
851
964
  void requestDrain('channel.completed');
852
965
  });
853
- processingChannels.set(agentId, run);
966
+ processingChannels.set(channelKey, run);
854
967
  launched += messages.length;
855
968
  }
856
969
 
@@ -344,6 +344,137 @@ export async function runTaskUpdateCommand(args) {
344
344
  return exitFromStatus(res.statusCode);
345
345
  }
346
346
 
347
+ const GOAL_OUTCOMES = [
348
+ 'gap', 'no_gap', 'wait',
349
+ 'task_completed', 'needs_approval', 'blocked',
350
+ 'accepted', 'rejected',
351
+ ];
352
+
353
+ export async function runGoalReportCommand(args) {
354
+ const env = requireAgentEnv();
355
+ const conversationId = getArg(args, 'conversation') || getArg(args, 'conversation-id') || env.currentConversationId;
356
+ const transitionId = getArg(args, 'transition') || getArg(args, 'transition-id');
357
+ const outcome = getArg(args, 'outcome');
358
+ if (!conversationId) {
359
+ console.error('--conversation is required');
360
+ return 2;
361
+ }
362
+ if (!transitionId) {
363
+ console.error('--transition is required');
364
+ return 2;
365
+ }
366
+ if (!outcome || !GOAL_OUTCOMES.includes(outcome)) {
367
+ console.error(`--outcome must be one of: ${GOAL_OUTCOMES.join(', ')}`);
368
+ return 2;
369
+ }
370
+ const res = await daemonRequest({
371
+ method: 'POST',
372
+ path: '/agent/goal/report',
373
+ headers: commonHeaders(env),
374
+ body: {
375
+ conversation_id: conversationId,
376
+ transition_id: transitionId,
377
+ outcome,
378
+ detail: getArg(args, 'detail'),
379
+ current_task_id: getArg(args, 'current-task') || getArg(args, 'task-id'),
380
+ },
381
+ });
382
+ printJson(res.body);
383
+ return exitFromStatus(res.statusCode);
384
+ }
385
+
386
+ export async function runGoalChangedCommand(args) {
387
+ const env = requireAgentEnv();
388
+ const conversationId = getArg(args, 'conversation') || getArg(args, 'conversation-id') || env.currentConversationId;
389
+ if (!conversationId) {
390
+ console.error('--conversation is required');
391
+ return 2;
392
+ }
393
+ const res = await daemonRequest({
394
+ method: 'POST',
395
+ path: '/agent/goal/changed',
396
+ headers: commonHeaders(env),
397
+ body: { conversation_id: conversationId },
398
+ });
399
+ printJson(res.body);
400
+ return exitFromStatus(res.statusCode);
401
+ }
402
+
403
+ export async function runApprovalRequestCommand(args) {
404
+ const env = requireAgentEnv();
405
+ const conversationId = getArg(args, 'conversation') || getArg(args, 'conversation-id') || env.currentConversationId;
406
+ const title = getArg(args, 'title');
407
+ if (!conversationId) {
408
+ console.error('--conversation is required');
409
+ return 2;
410
+ }
411
+ if (!title) {
412
+ console.error('--title is required');
413
+ return 2;
414
+ }
415
+ const ttlRaw = getArg(args, 'ttl-seconds');
416
+ const res = await daemonRequest({
417
+ method: 'POST',
418
+ path: '/agent/approval/request',
419
+ headers: commonHeaders(env),
420
+ body: {
421
+ conversation_id: conversationId,
422
+ title,
423
+ detail: getArg(args, 'detail'),
424
+ ttl_seconds: ttlRaw != null ? Number(ttlRaw) : undefined,
425
+ },
426
+ });
427
+ printJson(res.body);
428
+ return exitFromStatus(res.statusCode);
429
+ }
430
+
431
+ export async function runApprovalResolveCommand(args) {
432
+ const env = requireAgentEnv();
433
+ const requestId = getArg(args, 'request') || getArg(args, 'request-id');
434
+ let decision = getArg(args, 'decision');
435
+ if (args.grant) decision = 'granted';
436
+ if (args.reject) decision = 'rejected';
437
+ if (!requestId) {
438
+ console.error('--request is required');
439
+ return 2;
440
+ }
441
+ if (decision !== 'granted' && decision !== 'rejected') {
442
+ console.error('--decision must be granted or rejected (or use --grant / --reject)');
443
+ return 2;
444
+ }
445
+ const confidenceRaw = getArg(args, 'confidence');
446
+ const res = await daemonRequest({
447
+ method: 'POST',
448
+ path: '/agent/approval/resolve',
449
+ headers: commonHeaders(env),
450
+ body: {
451
+ request_id: requestId,
452
+ decision,
453
+ original_text: getArg(args, 'original-text'),
454
+ confidence: confidenceRaw != null ? Number(confidenceRaw) : undefined,
455
+ source_message_id: getArg(args, 'source-message-id'),
456
+ },
457
+ });
458
+ printJson(res.body);
459
+ return exitFromStatus(res.statusCode);
460
+ }
461
+
462
+ export async function runApprovalListCommand(args) {
463
+ const env = requireAgentEnv();
464
+ const params = new URLSearchParams();
465
+ const target = getArg(args, 'target');
466
+ const conversationId = getArg(args, 'conversation') || getArg(args, 'conversation-id') || env.currentConversationId;
467
+ if (target) params.set('target', target);
468
+ if (conversationId) params.set('conversation_id', conversationId);
469
+ const res = await daemonRequest({
470
+ method: 'GET',
471
+ path: `/agent/approval/list?${params}`,
472
+ headers: commonHeaders(env),
473
+ });
474
+ printJson(res.body);
475
+ return exitFromStatus(res.statusCode);
476
+ }
477
+
347
478
  export async function runMessageReactCommand(args) {
348
479
  const env = requireAgentEnv();
349
480
  const messageId = getArg(args, 'message-id');
@@ -1325,6 +1456,33 @@ export const AGENT_COMMAND_HELP = {
1325
1456
  workstream: `ticlawk workstream <create|delete|list|charter>
1326
1457
  Compatibility alias for group admin commands. Prefer \`ticlawk group ...\`
1327
1458
  for groups and \`ticlawk charter ...\` for conversation charters.
1459
+ `,
1460
+ goal: `ticlawk goal <report|changed>
1461
+ Goal-lane (FSM) control. Normally invoked from a goal-step turn, not by hand.
1462
+ ticlawk goal report --transition <id> --outcome <outcome> [--conversation <id>] [--detail <text>] [--current-task <task-id>]
1463
+ Report the outcome of the current FSM step and advance the state machine.
1464
+ --conversation defaults to the current goal-turn conversation.
1465
+ Outcomes by step:
1466
+ gap_analysis: gap | no_gap | wait
1467
+ execute: task_completed | needs_approval | blocked
1468
+ review: accepted | rejected
1469
+ ticlawk goal changed [--conversation <id>]
1470
+ Signal that the conversation's goal changed; wakes the FSM into gap analysis.
1471
+ `,
1472
+ approval: `ticlawk approval <request|resolve|list>
1473
+ Canonical goal-loop approval flow. The goal lane parks on an approval and is
1474
+ resumed by exactly one idempotent decision (button token OR chat resolver).
1475
+ ticlawk approval request --title "<text>" [--conversation <id>] [--detail <text>] [--ttl-seconds <n>]
1476
+ Park a pending owner approval on the goal_session (after EXECUTE reports
1477
+ needs_approval). Returns request_id plus a one-time button action token.
1478
+ --conversation defaults to the current goal-turn conversation.
1479
+ ticlawk approval list [--conversation <id> | --target "<target>"]
1480
+ List pending approval requests in the conversation. Use this from the chat
1481
+ lane to find which request an owner's natural-language approval refers to.
1482
+ --conversation defaults to the current conversation.
1483
+ ticlawk approval resolve --request <id> (--grant | --reject | --decision granted|rejected) [--original-text <text>] [--confidence <0-1>] [--source-message-id <id>]
1484
+ Resolve a pending approval from a natural-language chat decision (source=chat).
1485
+ Idempotent: a second resolve (or a button tap) on the same request is a no-op.
1328
1486
  `,
1329
1487
  charter: `ticlawk charter <get|set> [--target "<target>" | --conversation-id <id>]
1330
1488
  ticlawk charter get [--target "<target>" | --conversation-id <id>]
@@ -387,6 +387,127 @@ export async function handleTaskUpdate(req, body, ctx) {
387
387
  }
388
388
  }
389
389
 
390
+ export async function handleGoalReport(req, body, ctx) {
391
+ const actingAgentId = getActingAgentId(req, body);
392
+ const v = validateActingAgent(actingAgentId, ctx);
393
+ if (!v.ok) return { status: v.status, body: { error: v.error } };
394
+ const conversationId = body?.conversation_id || getCurrentConversationId(req, body);
395
+ if (!conversationId) return { status: 400, body: { error: 'conversation_id is required' } };
396
+ if (!body?.transition_id) return { status: 400, body: { error: 'transition_id is required' } };
397
+ if (!body?.outcome) return { status: 400, body: { error: 'outcome is required' } };
398
+ try {
399
+ const data = await api.reportGoalTransition({
400
+ actingAgentId,
401
+ conversationId,
402
+ transitionId: body.transition_id,
403
+ outcome: body.outcome,
404
+ detail: body.detail || null,
405
+ currentTaskId: body.current_task_id || null,
406
+ });
407
+ debugLog('agent-cli', 'goal.report', {
408
+ actingAgentId,
409
+ conversationId,
410
+ outcome: body.outcome,
411
+ state: data?.state,
412
+ });
413
+ return { status: data?.ok ? 200 : 400, body: data };
414
+ } catch (err) {
415
+ return { status: err?.status || 500, body: { error: err?.message || 'goal report failed' } };
416
+ }
417
+ }
418
+
419
+ export async function handleGoalChanged(req, body, ctx) {
420
+ const actingAgentId = getActingAgentId(req, body);
421
+ const v = validateActingAgent(actingAgentId, ctx);
422
+ if (!v.ok) return { status: v.status, body: { error: v.error } };
423
+ const conversationId = body?.conversation_id || getCurrentConversationId(req, body);
424
+ if (!conversationId) return { status: 400, body: { error: 'conversation_id is required' } };
425
+ try {
426
+ const data = await api.noteGoalChanged({ actingAgentId, conversationId });
427
+ debugLog('agent-cli', 'goal.changed', {
428
+ actingAgentId,
429
+ conversationId,
430
+ goalVersion: data?.goal_version,
431
+ });
432
+ return { status: data?.ok ? 200 : 400, body: data };
433
+ } catch (err) {
434
+ return { status: err?.status || 500, body: { error: err?.message || 'goal changed failed' } };
435
+ }
436
+ }
437
+
438
+ export async function handleApprovalRequest(req, body, ctx) {
439
+ const actingAgentId = getActingAgentId(req, body);
440
+ const v = validateActingAgent(actingAgentId, ctx);
441
+ if (!v.ok) return { status: v.status, body: { error: v.error } };
442
+ const conversationId = body?.conversation_id || getCurrentConversationId(req, body);
443
+ if (!conversationId) return { status: 400, body: { error: 'conversation_id is required' } };
444
+ if (!body?.title) return { status: 400, body: { error: 'title is required' } };
445
+ try {
446
+ const data = await api.requestGoalApproval({
447
+ actingAgentId,
448
+ conversationId,
449
+ title: body.title,
450
+ detail: body.detail || null,
451
+ ttlSeconds: body.ttl_seconds || null,
452
+ });
453
+ debugLog('agent-cli', 'approval.request', {
454
+ actingAgentId,
455
+ conversationId,
456
+ requestId: data?.request_id,
457
+ });
458
+ return { status: data?.ok ? 200 : 400, body: data };
459
+ } catch (err) {
460
+ return { status: err?.status || 500, body: { error: err?.message || 'approval request failed' } };
461
+ }
462
+ }
463
+
464
+ export async function handleApprovalList(req, query, ctx) {
465
+ const actingAgentId = getActingAgentId(req, query);
466
+ const v = validateActingAgent(actingAgentId, ctx);
467
+ if (!v.ok) return { status: v.status, body: { error: v.error } };
468
+ let conversationId = query?.conversation_id || null;
469
+ if (!conversationId && query?.target) {
470
+ const resolved = await resolveTarget(actingAgentId, String(query.target));
471
+ if (!resolved.error) conversationId = resolved.conversationId;
472
+ }
473
+ if (!conversationId) return { status: 400, body: { error: 'conversation_id is required' } };
474
+ try {
475
+ const data = await api.listGoalApprovals({ actingAgentId, conversationId });
476
+ return { status: 200, body: { data } };
477
+ } catch (err) {
478
+ return { status: err?.status || 500, body: { error: err?.message || 'approval list failed' } };
479
+ }
480
+ }
481
+
482
+ export async function handleApprovalResolve(req, body, ctx) {
483
+ const actingAgentId = getActingAgentId(req, body);
484
+ const v = validateActingAgent(actingAgentId, ctx);
485
+ if (!v.ok) return { status: v.status, body: { error: v.error } };
486
+ if (!body?.request_id) return { status: 400, body: { error: 'request_id is required' } };
487
+ if (body?.decision !== 'granted' && body?.decision !== 'rejected') {
488
+ return { status: 400, body: { error: 'decision must be granted or rejected' } };
489
+ }
490
+ try {
491
+ const data = await api.resolveGoalApproval({
492
+ actingAgentId,
493
+ requestId: body.request_id,
494
+ decision: body.decision,
495
+ originalText: body.original_text || null,
496
+ confidence: body.confidence ?? null,
497
+ sourceMessageId: body.source_message_id || null,
498
+ });
499
+ debugLog('agent-cli', 'approval.resolve', {
500
+ actingAgentId,
501
+ requestId: body.request_id,
502
+ decision: body.decision,
503
+ resumed: data?.resumed,
504
+ });
505
+ return { status: data?.ok ? 200 : 400, body: data };
506
+ } catch (err) {
507
+ return { status: err?.status || 500, body: { error: err?.message || 'approval resolve failed' } };
508
+ }
509
+ }
510
+
390
511
  export async function handleTaskList(req, query, ctx) {
391
512
  const actingAgentId = getActingAgentId(req, query);
392
513
  const v = validateActingAgent(actingAgentId, ctx);
package/src/core/http.mjs CHANGED
@@ -39,6 +39,11 @@ import {
39
39
  handleReminderSchedule,
40
40
  handleReminderSnooze,
41
41
  handleReminderUpdate,
42
+ handleGoalChanged,
43
+ handleGoalReport,
44
+ handleApprovalRequest,
45
+ handleApprovalResolve,
46
+ handleApprovalList,
42
47
  handleServerInfo,
43
48
  handleTaskClaim,
44
49
  handleTaskCreate,
@@ -144,6 +149,34 @@ export function startLocalHttpServer({ port, adapter, ctx }) {
144
149
  const r = await handleTaskList(req, parseQuery(req.url || ''), cliCtx);
145
150
  return writeJson(res, r.status, r.body);
146
151
  }
152
+ if (urlNoQuery === '/agent/goal/report' && method === 'POST') {
153
+ const body = await readJsonBody(req);
154
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
155
+ const r = await handleGoalReport(req, body, cliCtx);
156
+ return writeJson(res, r.status, r.body);
157
+ }
158
+ if (urlNoQuery === '/agent/goal/changed' && method === 'POST') {
159
+ const body = await readJsonBody(req);
160
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
161
+ const r = await handleGoalChanged(req, body, cliCtx);
162
+ return writeJson(res, r.status, r.body);
163
+ }
164
+ if (urlNoQuery === '/agent/approval/request' && method === 'POST') {
165
+ const body = await readJsonBody(req);
166
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
167
+ const r = await handleApprovalRequest(req, body, cliCtx);
168
+ return writeJson(res, r.status, r.body);
169
+ }
170
+ if (urlNoQuery === '/agent/approval/resolve' && method === 'POST') {
171
+ const body = await readJsonBody(req);
172
+ if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
173
+ const r = await handleApprovalResolve(req, body, cliCtx);
174
+ return writeJson(res, r.status, r.body);
175
+ }
176
+ if (urlNoQuery === '/agent/approval/list' && method === 'GET') {
177
+ const r = await handleApprovalList(req, parseQuery(req.url || ''), cliCtx);
178
+ return writeJson(res, r.status, r.body);
179
+ }
147
180
  if (urlNoQuery === '/agent/message/react' && method === 'POST') {
148
181
  const body = await readJsonBody(req);
149
182
  if (body === null) return writeJson(res, 400, { error: 'invalid json body' });
@@ -150,11 +150,22 @@ function pruneScopedRuntimeSessions(sessions) {
150
150
  .slice(0, MAX_SCOPED_RUNTIME_SESSIONS));
151
151
  }
152
152
 
153
+ // The chat lane and the goal-FSM lane keep separate scoped session maps so a
154
+ // transition turn never resumes a user-chat runtime session (their per-step
155
+ // prompts and context differ). Lane is carried on the inbound; default chat.
156
+ function laneSessionsField(lane) {
157
+ return lane === 'goal' ? 'goalSessions' : 'chatSessions';
158
+ }
159
+
153
160
  export function resolveRuntimeSessionScope(meta = {}, inbound = {}) {
161
+ const lane = inbound?.lane === 'goal' ? 'goal' : 'chat';
162
+ const field = laneSessionsField(lane);
154
163
  const key = readScopedSessionKey(inbound);
155
164
  if (!key) {
156
165
  return {
157
166
  key: '',
167
+ lane,
168
+ field,
158
169
  sessions: {},
159
170
  sessionId: meta.sessionId || null,
160
171
  path: meta.path || null,
@@ -163,10 +174,12 @@ export function resolveRuntimeSessionScope(meta = {}, inbound = {}) {
163
174
  };
164
175
  }
165
176
 
166
- const sessions = meta.rotatePending ? {} : normalizeScopedRuntimeSessions(meta.conversationSessions);
177
+ const sessions = meta.rotatePending ? {} : normalizeScopedRuntimeSessions(meta[field]);
167
178
  const scoped = sessions[key] || {};
168
179
  return {
169
180
  key,
181
+ lane,
182
+ field,
170
183
  sessions,
171
184
  sessionId: scoped.sessionId || null,
172
185
  path: scoped.path || null,
@@ -178,6 +191,8 @@ export function resolveRuntimeSessionScope(meta = {}, inbound = {}) {
178
191
  export function buildRuntimeSessionMetaPatch(meta = {}, scope = {}, result = {}) {
179
192
  const now = new Date().toISOString();
180
193
  const scoped = Boolean(scope.key);
194
+ const lane = scope.lane === 'goal' ? 'goal' : 'chat';
195
+ const field = scope.field || laneSessionsField(lane);
181
196
  const sessionId = result?.sessionId || scope.sessionId || (scoped ? null : meta.sessionId || null);
182
197
  const path = result?.path || scope.path || (scoped ? null : meta.path || null);
183
198
  const lastRotatedAt = scope.shouldRotate
@@ -205,11 +220,17 @@ export function buildRuntimeSessionMetaPatch(meta = {}, scope = {}, result = {})
205
220
  };
206
221
  }
207
222
 
208
- return {
209
- sessionId,
210
- path,
211
- conversationSessions: pruneScopedRuntimeSessions(sessions),
223
+ const patch = {
224
+ [field]: pruneScopedRuntimeSessions(sessions),
212
225
  rotatePending: false,
213
226
  lastRotatedAt,
214
227
  };
228
+ // The flat sessionId/path is the chat lane's "last used" mirror, read only
229
+ // by non-scoped resolution and display. The goal lane owns its own map and
230
+ // must not clobber that mirror.
231
+ if (lane === 'chat') {
232
+ patch.sessionId = sessionId;
233
+ patch.path = path;
234
+ }
235
+ return patch;
215
236
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Per-step goal-lane (FSM) prompt builder.
3
+ *
4
+ * A transition delivery is an FSM event, not a chat message: it has no
5
+ * backing message, only a payload carrying { transition_id, kind,
6
+ * goal_version, step }. The goal lane runs exactly the step named in the
7
+ * payload, then reports the outcome with `ticlawk goal report`, which
8
+ * advances the state machine and (for running states) schedules the next
9
+ * step as a fresh transition. This is the goal-lane analogue of
10
+ * buildInboundWakePrompt — same {header, target, text, rawText} contract.
11
+ */
12
+
13
+ import { buildEnvelopeTarget, buildCharterBlock } from './wake-prompt.mjs';
14
+
15
+ const STEP_GUIDES = {
16
+ gap_analysis: {
17
+ title: 'GAP ANALYSIS',
18
+ body: `Compare the current state of the work against the goal and success criteria. Read whatever you need (charter, dashboard, task board, repo, prior messages) to judge where things actually stand.
19
+ - If there is concrete executable work toward the goal, first make sure the next unit exists as a task (create/assign it with \`ticlawk task ...\` when a task fits), then report outcome=gap.
20
+ - If the goal/milestone is fully met with no meaningful gap, report outcome=no_gap.
21
+ - If closing the gap depends on a future or external state that nobody can act on right now, schedule a reminder for the resume condition, then report outcome=wait.`,
22
+ outcomes: ['gap', 'no_gap', 'wait'],
23
+ },
24
+ execute: {
25
+ title: 'EXECUTE',
26
+ body: `Do the next concrete unit of work toward the goal (or drive the current task to completion). Send interim updates with \`ticlawk message send --phase progress\` as you go.
27
+ - When the unit of work is finished and ready to be checked, report outcome=task_completed.
28
+ - If you cannot proceed without an owner approval, decision, or permission, park ONE canonical approval with \`ticlawk approval request --title "<what you need approved>" [--detail "<context>"]\`, tell the owner what you need and why with \`ticlawk message send\`, then report outcome=needs_approval. The owner's approval (button or a natural-language reply) resumes you automatically.
29
+ - If you are blocked by something else (missing input, external failure, a needed resource), explain it to the owner, then report outcome=blocked.`,
30
+ outcomes: ['task_completed', 'needs_approval', 'blocked'],
31
+ },
32
+ review: {
33
+ title: 'REVIEW',
34
+ body: `Review the work that was just completed against the task and the goal.
35
+ - If it meets the bar, mark the task done (\`ticlawk task update ... --status done\` where applicable) and report outcome=accepted.
36
+ - If it needs rework, return it with a clean, specific redo instruction, then report outcome=rejected.`,
37
+ outcomes: ['accepted', 'rejected'],
38
+ },
39
+ };
40
+
41
+ function readPayload(msg) {
42
+ const payload = msg?.payload && typeof msg.payload === 'object' ? msg.payload : {};
43
+ return {
44
+ transitionId: String(payload.transition_id || '').trim(),
45
+ goalVersion: payload.goal_version != null ? String(payload.goal_version) : '',
46
+ kind: String(payload.kind || '').trim(),
47
+ step: String(payload.step || '').trim(),
48
+ };
49
+ }
50
+
51
+ function buildGoalStepHeader(msg, { step, transitionId, goalVersion, kind }) {
52
+ const target = buildEnvelopeTarget(msg);
53
+ const time = msg.created_at || new Date().toISOString();
54
+ return `[goal_lane target=${target} step=${step || 'unknown'} transition=${transitionId} goal_version=${goalVersion} kind=${kind} time=${time}]`;
55
+ }
56
+
57
+ export function buildGoalStepPrompt(msg) {
58
+ const { transitionId, goalVersion, kind, step } = readPayload(msg);
59
+ const target = buildEnvelopeTarget(msg);
60
+ const conversationId = msg.conversation_id || '';
61
+ const header = buildGoalStepHeader(msg, { step, transitionId, goalVersion, kind });
62
+ const charterBlock = buildCharterBlock(msg);
63
+ const guide = STEP_GUIDES[step] || null;
64
+
65
+ const reportCmd = `ticlawk goal report --conversation ${conversationId} --transition ${transitionId} --outcome <${guide ? guide.outcomes.join('|') : 'outcome'}>`;
66
+
67
+ const sections = [];
68
+ if (charterBlock) sections.push(charterBlock);
69
+
70
+ if (guide) {
71
+ sections.push([
72
+ `[goal_step] You are running the goal loop for this conversation. This is a backend FSM step, not a user message — do NOT treat it as something to reply to.`,
73
+ ``,
74
+ `Current step: ${guide.title}`,
75
+ guide.body,
76
+ ``,
77
+ `When the step is done, advance the state machine by running EXACTLY ONE report:`,
78
+ ` ${reportCmd}`,
79
+ ``,
80
+ `Reporting the outcome is what continues the loop: a running next state arrives as a fresh step, and the loop parks itself when there is no gap or it must wait. Send owner-facing updates with \`ticlawk message send --target ${target} --phase progress\` (use --phase final only when the loop reaches no_gap/wait/blocked-on-owner). Report exactly once; do not loop inside this single turn.`,
81
+ `[/goal_step]`,
82
+ ].join('\n'));
83
+ } else {
84
+ sections.push([
85
+ `[goal_step] Goal-loop FSM step with an unrecognized step "${step}". Re-evaluate the goal as a gap analysis: decide whether there is a gap, and report with:`,
86
+ ` ${reportCmd}`,
87
+ `[/goal_step]`,
88
+ ].join('\n'));
89
+ }
90
+
91
+ const text = sections.filter(Boolean).join('\n\n');
92
+ return {
93
+ header,
94
+ target,
95
+ text,
96
+ rawText: '',
97
+ };
98
+ }
@@ -4,13 +4,29 @@ DO NOT EDIT.
4
4
 
5
5
  Use this in DMs, and in groups where your conversation role is admin or owner.
6
6
 
7
+ The goal execution loop (gap analysis, execution, review) runs in the backend
8
+ goal lane for this conversation — not here, and not inside your reply. You do
9
+ NOT run that loop yourself. Your job on this (chat) side is to handle the
10
+ incoming message and to keep the conversation's goal/charter correct so the goal
11
+ lane has the right target. When the goal is set or changes, you wake the goal
12
+ lane with `ticlawk goal changed`.
13
+
7
14
  ## Goal Authority Overlay
8
15
 
9
- - You are responsible for driving the conversation toward its goal, not only replying to isolated messages.
16
+ - Handle the incoming message: reply to the user and do what it asks, then send the result. That is the end of your turn — do not start running gap analysis, creating execution tasks, or driving the goal yourself. The goal lane does that.
10
17
  - Maintain or infer the current goal from the direct ask, charter, dashboard/briefing quote, task board, and conversation context.
11
- - Each turn, first handle the incoming message: reply to the user and do what it asks, sending the result. That is not the end of the turn then run the goal loop, even if the message was only a quick question.
12
- - If a goal exists, run the loop and drive any gap (see Goal Loop and Gap States); if none exists, decide whether this message warrants setting one (see Goal Setup When No Specific Goal Exists). The turn ends only when the loop reaches `no_gap`, `wait`, or a gap that is blocked on an owner resource/decision after you send the bundled request.
13
- - Until that terminal point, any chat send is `--phase progress`; send `--phase final` only after the incoming ask is handled and the goal loop has reached a stop condition.
18
+ - If the message sets, clarifies, or changes the goal: after handling it, update the charter (when you have scope authority) and run `ticlawk goal changed --conversation <id>` to wake the goal lane into a fresh gap analysis. If the goal is unchanged, just answer there is nothing to signal.
19
+ - If the conversation has no goal yet, decide whether this message warrants setting one (see Goal Setup When No Specific Goal Exists).
20
+
21
+ ## Owner Approvals
22
+
23
+ - The goal lane parks when it needs the owner to approve, decide, or grant permission, and surfaces one canonical approval request. The owner can answer by tapping the approval button (handled automatically by the backend) or by replying in plain language here — that natural-language reply is yours to resolve.
24
+ - When an incoming message reads as the owner approving or rejecting something (e.g. "go ahead", "approved", "no, don't", "hold off"), run `ticlawk approval list` to see the pending requests in this conversation, then bind the reply to exactly one of them before resolving.
25
+ - Bind and resolve:
26
+ - If the message quotes/replies to a specific approval prompt, or there is exactly one pending request, that is the target: `ticlawk approval resolve --request <id> (--grant | --reject) --original-text "<owner's words>" --source-message-id <id>`.
27
+ - If there are multiple pending requests and the owner's reply has no exact anchor and no unique reference, do NOT guess — ask the owner which one they mean, following `COMMUNICATION.md`.
28
+ - If the targeted request is already decided or expired, do not resolve again; tell the owner it was already handled.
29
+ - Resolving is idempotent and backend-owned: a button tap and your `approval resolve` on the same request collapse to one decision, and that decision is what resumes the goal lane. You do not resume the lane yourself.
14
30
 
15
31
  ## Goal Setup When No Specific Goal Exists
16
32
 
@@ -18,23 +34,8 @@ Use this in DMs, and in groups where your conversation role is admin or owner.
18
34
  - Treat explicit goal statements, goal discussions, or questions about what the conversation/group should pursue as goal setup candidates. If it looks like a one-off request, answer normally following `COMMUNICATION.md`. Otherwise ask naturally following `COMMUNICATION.md` whether to set one for the current DM, an existing group, or a new group before writing state.
19
35
  - Clarify only the details needed to proceed: goal definition, success/completion criteria, time range, constraints/boundaries, rough approach or roadmap, the agent's deliverables, owner responsibilities, required files/repos/accounts/credentials/budget/resources, dashboard decision view and metrics, and briefing triggers/cadence.
20
36
  - Before setting a charter, summarize the proposed short charter and ask for confirmation. Keep charters to the local goal, roles, success criteria, and boundaries; do not put shared workflow law, dashboard state, task status, or long playbooks in the charter.
21
- - After confirmation, write the charter if you have scope authority. Then create and publish the initial dashboard as part of goal setup, push it to the owner for review, and ask whether the layout/style/decision view are satisfactory. Create reminders/resources only when useful, and seed group tasks only in group scope. Then enter the normal goal loop.
22
- - Treat goal setup as revisable. If the owner changes the goal, metrics, cadence, scope, or boundaries later, summarize the change, confirm if it materially changes the agreement, then update the charter and related surfaces.
23
-
24
- ## Goal Loop
25
-
26
- - Run the goal loop as a simple state machine after goal setup, and again whenever returned task work is accepted or the task queue becomes empty.
27
- - At each boundary, perform a private goal loop check with: current facts, goal/success criterion, task queue state, gap status (`gap`, `no_gap`, or `wait`), next action, and stop/continue decision. Do not expose this check as recipient-facing content.
28
- - The private goal loop check is required execution trace, not a chat message. Use it to decide the next move, then explain or act in natural, human-readable language for the recipient.
29
- - Do not send this internal state as chat content; when recipient-facing content is needed, follow `COMMUNICATION.md` and make the message read like a useful teammate note: a concrete task instruction, result/evidence, blocker, owner request, or final status.
30
- - If the task queue has open work, work the queue before inventing new work: make sure the next task is assigned or claimed, review returned work against the task and goal, mark accepted work `done`, return incomplete work with a clean redo/blocker instruction, then assign the next queued task.
31
- - When the queue is empty, return to evaluating the goal for `gap`, `no_gap`, or `wait`.
32
-
33
- ## Gap States
34
-
35
- - State `gap`: current facts show a concrete gap from the goal. Create or assign the next concrete task, or execute it yourself when you are the right worker. Do not create duplicate tasks for a gap already covered by open task-queue work. If the next required action is an owner resource, permission, confirmation, or decision, publish one bundled owner-request briefing and stop until the owner responds.
36
- - State `wait`: whether a gap exists, or whether it can close, depends on an external/time-based future state and no agent or owner can act now. Schedule a reminder or explicit resume condition, then stop. Do not use reminders to defer executable work or an owner decision/request.
37
- - State `no_gap`: there is no meaningful gap and the goal or milestone is complete. Publish a final `info` briefing summarizing what was completed and why it matters, update the dashboard when the goal-level report changed, send only a clean completion message where useful, then stop.
37
+ - After confirmation, write the charter if you have scope authority. Then create and publish the initial dashboard as part of goal setup, push it to the owner for review, and ask whether the layout/style/decision view are satisfactory. Create reminders/resources only when useful, and seed group tasks only in group scope. Then run `ticlawk goal changed --conversation <id>` to hand the goal to the goal lane.
38
+ - Treat goal setup as revisable. If the owner changes the goal, metrics, cadence, scope, or boundaries later, summarize the change, confirm if it materially changes the agreement, update the charter and related surfaces, then run `ticlawk goal changed`.
38
39
 
39
40
  ## State Surfaces
40
41
 
@@ -20,6 +20,24 @@ ${buildReadInstructions(ctx)}
20
20
  Read other local files only when the current work clearly needs them.`;
21
21
  }
22
22
 
23
+ // A goal-lane turn is a single backend FSM step, not a chat message. Its
24
+ // per-step instructions and exact CLI commands (`goal report`, `task`,
25
+ // `approval request`, `message send`) live in the goal-step (user) prompt
26
+ // built by goal-step-prompt.mjs. The system prompt here therefore stays
27
+ // minimal: identity + the one reply invariant. It deliberately omits the
28
+ // chat-lane role framing and handbook read-list, which would compete with
29
+ // the narrow step instruction and pull the model toward proactive
30
+ // assistant behavior. See system.md "Lane-aware standing prompt".
31
+ function buildGoalLaneStandingPrompt(_ctx = {}) {
32
+ return `You are executing one backend goal-loop step for this conversation, dispatched by the system — not a message from a person. Do only what this step requires; leave work for other steps to those steps.
33
+
34
+ Your normal output is private and reaches no one. When the step calls for an owner-facing update, send it with \`ticlawk message send --target <target> --phase progress|final\`. Read \`MEMORY.md\` only if the step needs durable context.`;
35
+ }
36
+
37
+ function isGoalLane(ctx = {}) {
38
+ return ctx?.inbound?.lane === 'goal' || readDeliveryReason(ctx) === 'transition';
39
+ }
40
+
23
41
  function getInboundRaw(ctx = {}) {
24
42
  return ctx?.inbound?.raw && typeof ctx.inbound.raw === 'object'
25
43
  ? ctx.inbound.raw
@@ -84,7 +102,7 @@ function buildCurrentConversationGuide(ctx = {}) {
84
102
 
85
103
  const FILE_DESCRIPTIONS = {
86
104
  'MEMORY.md': 'your durable memory.',
87
- 'GOAL_AUTHORITY.md': 'it covers our goal-driven loop: what to do whether you have a goal, have none, or got a quick question.',
105
+ 'GOAL_AUTHORITY.md': 'goal setup and the goal-change flow (the goal loop itself runs in the backend).',
88
106
  'BASICS.md': 'workspace and work basics.',
89
107
  'COMMUNICATION.md': 'replying via the ticlawk CLI.',
90
108
  'COLLABORATION.md': 'DM/group conduct.',
@@ -96,11 +114,6 @@ const FILE_DESCRIPTIONS = {
96
114
  'TASK_WORKER.md': 'executing assigned tasks.',
97
115
  };
98
116
 
99
- // Files re-read at the start of every turn, not just once per session.
100
- // MEMORY.md for continuity; GOAL_AUTHORITY.md so the goal loop runs each turn
101
- // (it only appears in goal-authority scopes).
102
- const EVERY_TURN_FILES = new Set(['MEMORY.md', 'GOAL_AUTHORITY.md']);
103
-
104
117
  function describeFile(name) {
105
118
  const desc = FILE_DESCRIPTIONS[name];
106
119
  return desc ? `\`${name}\` — ${desc}` : `\`${name}\``;
@@ -108,17 +121,11 @@ function describeFile(name) {
108
121
 
109
122
  function buildReadInstructions(ctx = {}) {
110
123
  const files = buildReadFileNames(ctx);
111
- const everyTurn = files.filter((name) => EVERY_TURN_FILES.has(name));
112
- const once = files.filter((name) => !EVERY_TURN_FILES.has(name));
113
- const everyTurnList = everyTurn.map((name, index) => `${index + 1}. ${describeFile(name)}`).join('\n');
114
- const onceList = once.map((name, index) => `${index + 1}. ${describeFile(name)}`).join('\n');
124
+ const list = files.map((name, index) => `${index + 1}. ${describeFile(name)}`).join('\n');
115
125
  return `To reply to the user or group, use \`ticlawk message send --target <target> --phase progress|final\`. Normal assistant output is private and is not sent to the user. Details are in \`COMMUNICATION.md\`.
116
126
 
117
- Read these every turn before acting (they may have changed):
118
- ${everyTurnList}
119
-
120
- Read these once if you haven't this session. Otherwise skip:
121
- ${onceList}`;
127
+ Read these once if you haven't this session, then only when the current work needs them:
128
+ ${list}`;
122
129
  }
123
130
 
124
131
  function buildReadFileNames(ctx = {}) {
@@ -134,12 +141,12 @@ function buildReadFileNames(ctx = {}) {
134
141
  ];
135
142
 
136
143
  if (scope === 'dm') {
137
- docs.push('GOAL_TASK_CORE.md', 'GOAL_AUTHORITY.md', 'DM_SCOPE.md', 'SURFACES.md');
144
+ docs.push('GOAL_TASK_CORE.md', 'DM_SCOPE.md', 'SURFACES.md');
138
145
  return unique(docs);
139
146
  }
140
147
 
141
148
  if (goalAuthority) {
142
- docs.push('GOAL_TASK_CORE.md', 'GOAL_AUTHORITY.md', 'GROUP_ADMIN_SCOPE.md', 'SURFACES.md');
149
+ docs.push('GOAL_TASK_CORE.md', 'GROUP_ADMIN_SCOPE.md', 'SURFACES.md');
143
150
  return unique(docs);
144
151
  }
145
152
 
@@ -155,6 +162,7 @@ function unique(values) {
155
162
  }
156
163
 
157
164
  export function buildStandingPrompt(_ctx = {}) {
165
+ if (isGoalLane(_ctx)) return promptBlock(buildGoalLaneStandingPrompt(_ctx));
158
166
  return promptBlock(buildBaseStandingPrompt(_ctx));
159
167
  }
160
168
 
@@ -85,9 +85,16 @@ export function buildGroupContextBlock(msg) {
85
85
  export function buildCharterBlock(msg) {
86
86
  const charter = (msg.conversation_charter || '').trim();
87
87
  if (!charter) return '';
88
- const authorityLine = hasGoalAuthority(msg)
89
- ? 'Use it as the current goal and role spec. If the owner appears to change the goal, read GOAL_AUTHORITY.md and follow the goal-change flow before updating state.'
90
- : 'Use it as current group goal and role context. The group admin owns charter and dashboard changes unless they explicitly delegate them.';
88
+ const conversationId = msg.conversation_id || '';
89
+ // The goal lane (transition deliveries) executes against the charter; its
90
+ // per-step instructions come from the goal-step prompt, so here the charter
91
+ // is just the goal/success spec. The chat lane must NOT run the loop — it
92
+ // only signals goal changes to wake the goal lane.
93
+ const authorityLine = msg.reason === 'transition'
94
+ ? 'This is the goal and success spec for this conversation. Run the current step against it.'
95
+ : hasGoalAuthority(msg)
96
+ ? `This is the goal the backend goal lane is already driving. Handle the incoming message and reply; do not run a goal loop or start gap/execution work yourself. If this message sets, clarifies, or changes the goal, update the charter and then run \`ticlawk goal changed --conversation ${conversationId}\` to wake the goal lane. See GOAL_AUTHORITY.md.`
97
+ : 'Use it as current group goal and role context. The group admin owns charter and dashboard changes unless they explicitly delegate them.';
91
98
  return promptBlock(`
92
99
  [conversation_goal]
93
100
  Current charter for this conversation: