ticlawk 0.1.16 → 0.1.17-dev.10
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 +2 -0
- package/bin/ticlawk.mjs +47 -0
- package/package.json +1 -1
- package/src/adapters/ticlawk/api.mjs +69 -3
- package/src/adapters/ticlawk/index.mjs +124 -11
- package/src/cli/agent-commands.mjs +183 -1
- package/src/core/agent-cli-handlers.mjs +122 -0
- package/src/core/http.mjs +33 -0
- package/src/core/runtime-support.mjs +26 -5
- package/src/runtimes/_shared/goal-step-prompt.mjs +135 -0
- package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +22 -21
- package/src/runtimes/_shared/handbook/SURFACES.md +2 -0
- package/src/runtimes/_shared/standing-prompt.mjs +25 -17
- package/src/runtimes/_shared/wake-prompt.mjs +26 -4
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
|
@@ -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,
|
|
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
|
-
|
|
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);
|
|
@@ -310,7 +375,7 @@ export async function getAgentServerInfo({ actingAgentId }) {
|
|
|
310
375
|
// ── Reminders ──
|
|
311
376
|
|
|
312
377
|
export async function scheduleAgentReminder({
|
|
313
|
-
actingAgentId, title, fireAt, anchorConversationId, anchorMessageId,
|
|
378
|
+
actingAgentId, title, fireAt, anchorConversationId, anchorMessageId, recurrence,
|
|
314
379
|
}) {
|
|
315
380
|
return apiFetch('/api/agent/reminders', {
|
|
316
381
|
method: 'POST',
|
|
@@ -320,6 +385,7 @@ export async function scheduleAgentReminder({
|
|
|
320
385
|
fire_at: fireAt,
|
|
321
386
|
anchor_conversation_id: anchorConversationId,
|
|
322
387
|
anchor_message_id: anchorMessageId ?? null,
|
|
388
|
+
recurrence: recurrence ?? null,
|
|
323
389
|
}),
|
|
324
390
|
});
|
|
325
391
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
832
|
-
|
|
833
|
-
grouped.
|
|
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(
|
|
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 =
|
|
958
|
+
const run = (lane === 'goal'
|
|
959
|
+
? processGoalTransitionsForAgent(agentId, messages)
|
|
960
|
+
: processPendingMessagesForAgent(agentId, messages))
|
|
848
961
|
.catch(() => {})
|
|
849
962
|
.finally(() => {
|
|
850
|
-
processingChannels.delete(
|
|
963
|
+
processingChannels.delete(channelKey);
|
|
851
964
|
void requestDrain('channel.completed');
|
|
852
965
|
});
|
|
853
|
-
processingChannels.set(
|
|
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');
|
|
@@ -426,6 +557,18 @@ export async function runReminderScheduleCommand(args) {
|
|
|
426
557
|
console.error('--target or --anchor-conversation-id is required');
|
|
427
558
|
return 2;
|
|
428
559
|
}
|
|
560
|
+
// Optional recurrence: --recur-at HH:MM (owner-local) [--recur-weekday 1,2,3].
|
|
561
|
+
// The timezone is system-owned (the owner's), filled in by the backend — the
|
|
562
|
+
// agent never passes it. The backend also computes the first fire_at in it.
|
|
563
|
+
let recurrence = null;
|
|
564
|
+
const recurAt = getArg(args, 'recur-at');
|
|
565
|
+
if (recurAt) {
|
|
566
|
+
const wdRaw = getArg(args, 'recur-weekday');
|
|
567
|
+
const byWeekday = wdRaw
|
|
568
|
+
? String(wdRaw).split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => n >= 1 && n <= 7)
|
|
569
|
+
: [];
|
|
570
|
+
recurrence = { at: recurAt, ...(byWeekday.length ? { by_weekday: byWeekday } : {}) };
|
|
571
|
+
}
|
|
429
572
|
const res = await daemonRequest({
|
|
430
573
|
method: 'POST',
|
|
431
574
|
path: '/agent/reminder/schedule',
|
|
@@ -436,6 +579,7 @@ export async function runReminderScheduleCommand(args) {
|
|
|
436
579
|
target,
|
|
437
580
|
anchor_conversation_id: anchorConversationId,
|
|
438
581
|
anchor_message_id: anchorMessageId,
|
|
582
|
+
recurrence,
|
|
439
583
|
},
|
|
440
584
|
});
|
|
441
585
|
printJson(res.body);
|
|
@@ -1294,7 +1438,7 @@ export const AGENT_COMMAND_HELP = {
|
|
|
1294
1438
|
to a user, use \`ticlawk message send --attach <file>\` instead.
|
|
1295
1439
|
`,
|
|
1296
1440
|
reminder: `ticlawk reminder <schedule|list|snooze|update|cancel|log>
|
|
1297
|
-
ticlawk reminder schedule --title <t> (--fire-at <iso> | --in-seconds N | --in-minutes N) (--target "<target>" | --anchor-conversation-id <id>) [--anchor-message-id <id>]
|
|
1441
|
+
ticlawk reminder schedule --title <t> (--fire-at <iso> | --in-seconds N | --in-minutes N) (--target "<target>" | --anchor-conversation-id <id>) [--anchor-message-id <id>] [--recur-at HH:MM [--recur-tz <IANA>] [--recur-weekday 1,2,3]]
|
|
1298
1442
|
ticlawk reminder list [--status active|fired|canceled]
|
|
1299
1443
|
ticlawk reminder snooze <reminder-id> (--fire-at <iso> | --in-seconds N | --in-minutes N)
|
|
1300
1444
|
ticlawk reminder update <reminder-id> [--title <t>] [--fire-at <iso>]
|
|
@@ -1304,6 +1448,17 @@ export const AGENT_COMMAND_HELP = {
|
|
|
1304
1448
|
Use reminders for follow-up that depends on future state you cannot resolve
|
|
1305
1449
|
now. A reminder fires by posting a system message into the anchor
|
|
1306
1450
|
conversation and waking the owner agent via an explicit delivery.
|
|
1451
|
+
|
|
1452
|
+
RECURRING: for a fixed cadence (e.g. a daily/weekly meal-time check-in), use
|
|
1453
|
+
ONE recurring reminder instead of enumerating many one-shots. Pass --recur-at
|
|
1454
|
+
(the owner's local HH:MM) and optionally --recur-weekday (ISO 1=Mon..7=Sun,
|
|
1455
|
+
comma-separated; omit for every day). You do NOT set a timezone — the backend
|
|
1456
|
+
fills the owner's timezone and computes the fire time in it. On each fire the
|
|
1457
|
+
reminder auto-advances to the next occurrence and stays active. Example —
|
|
1458
|
+
weekday 18:30 dinner check-in (--fire-at is ignored for recurring, the backend
|
|
1459
|
+
computes it):
|
|
1460
|
+
ticlawk reminder schedule --title "晚餐" --anchor-conversation-id <id> \\
|
|
1461
|
+
--in-minutes 1 --recur-at 18:30 --recur-weekday 1,2,3,4,5
|
|
1307
1462
|
`,
|
|
1308
1463
|
task: `ticlawk task <create|claim|unclaim|update|list>
|
|
1309
1464
|
ticlawk task create --target "<target>" [--title <t>] [--assign-agent <agent-id>]
|
|
@@ -1325,6 +1480,33 @@ export const AGENT_COMMAND_HELP = {
|
|
|
1325
1480
|
workstream: `ticlawk workstream <create|delete|list|charter>
|
|
1326
1481
|
Compatibility alias for group admin commands. Prefer \`ticlawk group ...\`
|
|
1327
1482
|
for groups and \`ticlawk charter ...\` for conversation charters.
|
|
1483
|
+
`,
|
|
1484
|
+
goal: `ticlawk goal <report|changed>
|
|
1485
|
+
Goal-lane (FSM) control. Normally invoked from a goal-step turn, not by hand.
|
|
1486
|
+
ticlawk goal report --transition <id> --outcome <outcome> [--conversation <id>] [--detail <text>] [--current-task <task-id>]
|
|
1487
|
+
Report the outcome of the current FSM step and advance the state machine.
|
|
1488
|
+
--conversation defaults to the current goal-turn conversation.
|
|
1489
|
+
Outcomes by step:
|
|
1490
|
+
gap_analysis: gap | no_gap | wait
|
|
1491
|
+
execute: task_completed | needs_approval | blocked
|
|
1492
|
+
review: accepted | rejected
|
|
1493
|
+
ticlawk goal changed [--conversation <id>]
|
|
1494
|
+
Signal that the conversation's goal changed; wakes the FSM into gap analysis.
|
|
1495
|
+
`,
|
|
1496
|
+
approval: `ticlawk approval <request|resolve|list>
|
|
1497
|
+
Canonical goal-loop approval flow. The goal lane parks on an approval and is
|
|
1498
|
+
resumed by exactly one idempotent decision (button token OR chat resolver).
|
|
1499
|
+
ticlawk approval request --title "<text>" [--conversation <id>] [--detail <text>] [--ttl-seconds <n>]
|
|
1500
|
+
Park a pending owner approval on the goal_session (after EXECUTE reports
|
|
1501
|
+
needs_approval). Returns request_id plus a one-time button action token.
|
|
1502
|
+
--conversation defaults to the current goal-turn conversation.
|
|
1503
|
+
ticlawk approval list [--conversation <id> | --target "<target>"]
|
|
1504
|
+
List pending approval requests in the conversation. Use this from the chat
|
|
1505
|
+
lane to find which request an owner's natural-language approval refers to.
|
|
1506
|
+
--conversation defaults to the current conversation.
|
|
1507
|
+
ticlawk approval resolve --request <id> (--grant | --reject | --decision granted|rejected) [--original-text <text>] [--confidence <0-1>] [--source-message-id <id>]
|
|
1508
|
+
Resolve a pending approval from a natural-language chat decision (source=chat).
|
|
1509
|
+
Idempotent: a second resolve (or a button tap) on the same request is a no-op.
|
|
1328
1510
|
`,
|
|
1329
1511
|
charter: `ticlawk charter <get|set> [--target "<target>" | --conversation-id <id>]
|
|
1330
1512
|
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);
|
|
@@ -469,6 +590,7 @@ export async function handleReminderSchedule(req, body, ctx) {
|
|
|
469
590
|
fireAt: body.fire_at,
|
|
470
591
|
anchorConversationId,
|
|
471
592
|
anchorMessageId: body?.anchor_message_id || null,
|
|
593
|
+
recurrence: body?.recurrence ?? null,
|
|
472
594
|
});
|
|
473
595
|
debugLog('agent-cli', 'reminder.schedule', {
|
|
474
596
|
actingAgentId,
|
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
|
|
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
|
-
|
|
209
|
-
|
|
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,135 @@
|
|
|
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 against the goal and success criteria. The [goal_context] block above gives you the open tasks, active reminders, and dashboard state — judge from it; read the charter/repo/prior messages only for what it doesn't cover. The dashboard is the owner's at-a-glance visualization of how far this goal has progressed — this step owns keeping it true to reality: create it if a durable goal has none, refresh it when progress moved materially (\`ticlawk dashboard set\`; see SURFACES.md).
|
|
19
|
+
- Judge "due now" against the current owner-local time above. Produce only what is due now; do NOT pre-produce future occurrences (a later meal, tomorrow's item) — each one is produced when its own reminder fires and wakes you at that time.
|
|
20
|
+
- If there is concrete work to do NOW, make sure the next unit exists as a task (\`ticlawk task ...\`), then report outcome=gap.
|
|
21
|
+
- If nothing needs doing this instant but the goal is ONGOING/STANDING — its job is to keep something maintained and work recurs (e.g. an active recurring reminder above already covers the next occurrence) — report outcome=wait. Do NOT report no_gap for a standing goal: it has no "done", and parking on no_gap would stop it from waking at the next occurrence. If nothing is scheduled to resume it yet, schedule a reminder first, then report wait.
|
|
22
|
+
- Report outcome=no_gap ONLY if the goal is genuinely, permanently met and will never need action again (an achievement goal that is finished). The completed result is something the owner is waiting on — surface it per the briefing rule below.`,
|
|
23
|
+
outcomes: ['gap', 'no_gap', 'wait'],
|
|
24
|
+
},
|
|
25
|
+
execute: {
|
|
26
|
+
title: 'EXECUTE',
|
|
27
|
+
body: `Do the next concrete unit of work toward the goal (or drive the current task to completion). Send routine interim progress with \`ticlawk message send --phase progress\`; if an update clears the briefing bar below (e.g. it is a result the owner explicitly asked to be told about), surface it as a briefing instead.
|
|
28
|
+
- When the unit of work is finished and ready to be checked, report outcome=task_completed.
|
|
29
|
+
- 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, then report outcome=needs_approval. The owner's approval (button or a natural-language reply) resumes you automatically.
|
|
30
|
+
- If you are blocked by something else (missing input, external failure, a needed resource), explain it to the owner, then report outcome=blocked.`,
|
|
31
|
+
outcomes: ['task_completed', 'needs_approval', 'blocked'],
|
|
32
|
+
},
|
|
33
|
+
review: {
|
|
34
|
+
title: 'REVIEW',
|
|
35
|
+
body: `Review the work that was just completed against the task and the goal.
|
|
36
|
+
- If it meets the bar, mark the task done (\`ticlawk task update ... --status done\` where applicable) and report outcome=accepted.
|
|
37
|
+
- If it needs rework, return it with a clean, specific redo instruction, then report outcome=rejected.`,
|
|
38
|
+
outcomes: ['accepted', 'rejected'],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function readPayload(msg) {
|
|
43
|
+
const payload = msg?.payload && typeof msg.payload === 'object' ? msg.payload : {};
|
|
44
|
+
return {
|
|
45
|
+
transitionId: String(payload.transition_id || '').trim(),
|
|
46
|
+
goalVersion: payload.goal_version != null ? String(payload.goal_version) : '',
|
|
47
|
+
kind: String(payload.kind || '').trim(),
|
|
48
|
+
step: String(payload.step || '').trim(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Per-step context: the claim attaches msg.goal_context (open tasks, active
|
|
53
|
+
// reminders, current task, dashboard) for transition deliveries. Render the
|
|
54
|
+
// slice THIS step needs so each step decides on facts, not guesses.
|
|
55
|
+
function buildGoalContextBlock(msg, step) {
|
|
56
|
+
const gc = msg && msg.goal_context && typeof msg.goal_context === 'object' ? msg.goal_context : null;
|
|
57
|
+
if (!gc) return '';
|
|
58
|
+
const lines = ['[goal_context] Current state for this goal (given to you — use it, do not re-derive):'];
|
|
59
|
+
if (gc.now_local) {
|
|
60
|
+
lines.push(`- current time (owner local): ${gc.now_local}${gc.timezone ? ` [${gc.timezone}]` : ''}`);
|
|
61
|
+
}
|
|
62
|
+
if (step === 'gap_analysis' || !STEP_GUIDES[step]) {
|
|
63
|
+
const tasks = Array.isArray(gc.open_tasks) ? gc.open_tasks : [];
|
|
64
|
+
const rems = Array.isArray(gc.active_reminders) ? gc.active_reminders : [];
|
|
65
|
+
lines.push(`- open tasks (${tasks.length}): ${tasks.length
|
|
66
|
+
? tasks.map((t) => `#${t.number} ${t.title} [${t.status}]`).join('; ')
|
|
67
|
+
: 'none'}`);
|
|
68
|
+
lines.push(`- active reminders (${rems.length}): ${rems.length
|
|
69
|
+
? rems.map((r) => `"${r.title}" @ ${r.fire_at}${r.recurrence ? ' (recurring)' : ''}`).join('; ')
|
|
70
|
+
: 'none'}`);
|
|
71
|
+
lines.push(`- dashboard: ${gc.dashboard ? 'exists' : 'none yet'}`);
|
|
72
|
+
} else {
|
|
73
|
+
const ct = gc.current_task;
|
|
74
|
+
lines.push(ct ? `- current task: #${ct.number} ${ct.title} [${ct.status}]` : '- current task: (none set)');
|
|
75
|
+
}
|
|
76
|
+
lines.push('[/goal_context]');
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildGoalStepHeader(msg, { step, transitionId, goalVersion, kind }) {
|
|
81
|
+
const target = buildEnvelopeTarget(msg);
|
|
82
|
+
const time = msg.created_at || new Date().toISOString();
|
|
83
|
+
return `[goal_lane target=${target} step=${step || 'unknown'} transition=${transitionId} goal_version=${goalVersion} kind=${kind} time=${time}]`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildGoalStepPrompt(msg) {
|
|
87
|
+
const { transitionId, goalVersion, kind, step } = readPayload(msg);
|
|
88
|
+
const target = buildEnvelopeTarget(msg);
|
|
89
|
+
const conversationId = msg.conversation_id || '';
|
|
90
|
+
const header = buildGoalStepHeader(msg, { step, transitionId, goalVersion, kind });
|
|
91
|
+
const charterBlock = buildCharterBlock(msg);
|
|
92
|
+
const guide = STEP_GUIDES[step] || null;
|
|
93
|
+
|
|
94
|
+
const reportCmd = `ticlawk goal report --conversation ${conversationId} --transition ${transitionId} --outcome <${guide ? guide.outcomes.join('|') : 'outcome'}>`;
|
|
95
|
+
|
|
96
|
+
const sections = [];
|
|
97
|
+
if (charterBlock) sections.push(charterBlock);
|
|
98
|
+
const goalContextBlock = buildGoalContextBlock(msg, step);
|
|
99
|
+
if (goalContextBlock) sections.push(goalContextBlock);
|
|
100
|
+
|
|
101
|
+
if (guide) {
|
|
102
|
+
sections.push([
|
|
103
|
+
`[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.`,
|
|
104
|
+
``,
|
|
105
|
+
`Current step: ${guide.title}`,
|
|
106
|
+
guide.body,
|
|
107
|
+
``,
|
|
108
|
+
`Briefing rule (independent of which step you are on): a briefing (\`ticlawk briefing publish\`) interrupts the owner — it is only for things worth their attention. Default to NOT sending one; routine progress belongs on the dashboard (the owner pulls it) or a chat \`ticlawk message send\`. Send a briefing only when one of these holds:`,
|
|
109
|
+
` (a) the owner must act or decide — e.g. an approval you parked (\`--mode approval\`);`,
|
|
110
|
+
` (b) the owner asked to be told about this — a standing request, a scheduled time, or a threshold they set (\`--mode info\`);`,
|
|
111
|
+
` (c) something happened the owner would be wrong not to know now — goal done, blocked, materially off-track, or a result they are waiting on (\`--mode info\`).`,
|
|
112
|
+
`If you are unsure, it is NOT a briefing — put it on the dashboard. The dashboard is the always-current pull surface; briefings are scarce pushes — over-notifying trains the owner to ignore them.`,
|
|
113
|
+
``,
|
|
114
|
+
`When the step is done, advance the state machine by running EXACTLY ONE report:`,
|
|
115
|
+
` ${reportCmd}`,
|
|
116
|
+
``,
|
|
117
|
+
`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. Reach the owner only through Ticlawk surfaces — \`ticlawk message send --target ${target}\` (chat), \`ticlawk briefing publish\` (push, per the rule above), \`ticlawk dashboard set\` (goal report); see \`SURFACES.md\`. Any owner-facing text is for the owner in their language: say what changed, why it matters, and what (if anything) they must do; never expose internal task titles, file paths, step numbers, or harness tokens. Report exactly once; do not loop inside this single turn.`,
|
|
118
|
+
`[/goal_step]`,
|
|
119
|
+
].join('\n'));
|
|
120
|
+
} else {
|
|
121
|
+
sections.push([
|
|
122
|
+
`[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:`,
|
|
123
|
+
` ${reportCmd}`,
|
|
124
|
+
`[/goal_step]`,
|
|
125
|
+
].join('\n'));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const text = sections.filter(Boolean).join('\n\n');
|
|
129
|
+
return {
|
|
130
|
+
header,
|
|
131
|
+
target,
|
|
132
|
+
text,
|
|
133
|
+
rawText: '',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -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
|
-
-
|
|
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
|
-
-
|
|
12
|
-
- If
|
|
13
|
-
|
|
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
|
|
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,
|
|
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
|
|
|
@@ -39,3 +39,5 @@ Use `ticlawk credential request --name <ENV_VAR>` to create the credential slot.
|
|
|
39
39
|
## Reminders
|
|
40
40
|
|
|
41
41
|
Use reminders only for external/time-based future follow-up or visible, persistent resume conditions. Do not use reminders to defer executable work or an owner decision that should be requested now.
|
|
42
|
+
|
|
43
|
+
For a fixed cadence (a daily or weekly check-in, e.g. meal-time reminders), use ONE recurring reminder, not many enumerated one-shots: `ticlawk reminder schedule ... --recur-at HH:MM [--recur-weekday 1,2,3]`. Give `--recur-at` as the owner's local wall-clock time; the timezone is filled in by the system, so you never pass it. It auto-advances to the next occurrence on each fire and stays active, so the cadence never runs out.
|
|
@@ -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. The owner is reached only through Ticlawk surfaces: \`ticlawk message send\` (chat update), \`ticlawk briefing publish\` (active notification/decision), \`ticlawk dashboard set\` (goal-level report). The step tells you which one to use; \`SURFACES.md\` holds the rules for each. Read \`SURFACES.md\` or \`MEMORY.md\` only if the step needs them.`;
|
|
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': '
|
|
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
|
|
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
|
|
118
|
-
${
|
|
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', '
|
|
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', '
|
|
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
|
|
|
@@ -84,10 +84,32 @@ export function buildGroupContextBlock(msg) {
|
|
|
84
84
|
|
|
85
85
|
export function buildCharterBlock(msg) {
|
|
86
86
|
const charter = (msg.conversation_charter || '').trim();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
const conversationId = msg.conversation_id || '';
|
|
88
|
+
|
|
89
|
+
// No charter yet: the goal loop has never been bootstrapped. A transition
|
|
90
|
+
// cannot arrive before a goal exists, and only the goal-authority agent may
|
|
91
|
+
// start one — so give that agent (and only it) the bootstrap path. Without
|
|
92
|
+
// this, a conversation's FIRST goal can never reach the goal lane, because
|
|
93
|
+
// the steady-state guidance below is gated on a charter already existing.
|
|
94
|
+
if (!charter) {
|
|
95
|
+
if (msg.reason === 'transition' || !hasGoalAuthority(msg)) return '';
|
|
96
|
+
return promptBlock(`
|
|
97
|
+
[conversation_goal]
|
|
98
|
+
This conversation has no goal charter yet, so the backend goal loop is not running.
|
|
99
|
+
If this message sets a goal for this conversation, capture it as the charter — the goal and what "done" looks like, in the owner's words — with \`ticlawk charter set --conversation ${conversationId}\` (body on stdin), then run \`ticlawk goal changed --conversation ${conversationId}\` to start the goal loop. Otherwise handle the message normally. See GOAL_AUTHORITY.md.
|
|
100
|
+
[/conversation_goal]
|
|
101
|
+
`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The goal lane (transition deliveries) executes against the charter; its
|
|
105
|
+
// per-step instructions come from the goal-step prompt, so here the charter
|
|
106
|
+
// is just the goal/success spec. The chat lane must NOT run the loop — it
|
|
107
|
+
// only signals goal changes to wake the goal lane.
|
|
108
|
+
const authorityLine = msg.reason === 'transition'
|
|
109
|
+
? 'This is the goal and success spec for this conversation. Run the current step against it.'
|
|
110
|
+
: hasGoalAuthority(msg)
|
|
111
|
+
? `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.`
|
|
112
|
+
: 'Use it as current group goal and role context. The group admin owns charter and dashboard changes unless they explicitly delegate them.';
|
|
91
113
|
return promptBlock(`
|
|
92
114
|
[conversation_goal]
|
|
93
115
|
Current charter for this conversation:
|