ticlawk 0.1.16 → 0.1.17-dev.1
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 +67 -2
- package/src/adapters/ticlawk/index.mjs +124 -11
- package/src/cli/agent-commands.mjs +158 -0
- package/src/core/agent-cli-handlers.mjs +121 -0
- package/src/core/http.mjs +33 -0
- package/src/core/runtime-support.mjs +26 -5
- package/src/runtimes/_shared/goal-step-prompt.mjs +98 -0
- package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +22 -21
- package/src/runtimes/_shared/standing-prompt.mjs +6 -17
- package/src/runtimes/_shared/wake-prompt.mjs +10 -3
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);
|
|
@@ -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');
|
|
@@ -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
|
|
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,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
|
-
-
|
|
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
|
|
|
@@ -84,7 +84,7 @@ function buildCurrentConversationGuide(ctx = {}) {
|
|
|
84
84
|
|
|
85
85
|
const FILE_DESCRIPTIONS = {
|
|
86
86
|
'MEMORY.md': 'your durable memory.',
|
|
87
|
-
'GOAL_AUTHORITY.md': '
|
|
87
|
+
'GOAL_AUTHORITY.md': 'goal setup and the goal-change flow (the goal loop itself runs in the backend).',
|
|
88
88
|
'BASICS.md': 'workspace and work basics.',
|
|
89
89
|
'COMMUNICATION.md': 'replying via the ticlawk CLI.',
|
|
90
90
|
'COLLABORATION.md': 'DM/group conduct.',
|
|
@@ -96,11 +96,6 @@ const FILE_DESCRIPTIONS = {
|
|
|
96
96
|
'TASK_WORKER.md': 'executing assigned tasks.',
|
|
97
97
|
};
|
|
98
98
|
|
|
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
99
|
function describeFile(name) {
|
|
105
100
|
const desc = FILE_DESCRIPTIONS[name];
|
|
106
101
|
return desc ? `\`${name}\` — ${desc}` : `\`${name}\``;
|
|
@@ -108,17 +103,11 @@ function describeFile(name) {
|
|
|
108
103
|
|
|
109
104
|
function buildReadInstructions(ctx = {}) {
|
|
110
105
|
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');
|
|
106
|
+
const list = files.map((name, index) => `${index + 1}. ${describeFile(name)}`).join('\n');
|
|
115
107
|
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
108
|
|
|
117
|
-
Read these
|
|
118
|
-
${
|
|
119
|
-
|
|
120
|
-
Read these once if you haven't this session. Otherwise skip:
|
|
121
|
-
${onceList}`;
|
|
109
|
+
Read these once if you haven't this session, then only when the current work needs them:
|
|
110
|
+
${list}`;
|
|
122
111
|
}
|
|
123
112
|
|
|
124
113
|
function buildReadFileNames(ctx = {}) {
|
|
@@ -134,12 +123,12 @@ function buildReadFileNames(ctx = {}) {
|
|
|
134
123
|
];
|
|
135
124
|
|
|
136
125
|
if (scope === 'dm') {
|
|
137
|
-
docs.push('GOAL_TASK_CORE.md', '
|
|
126
|
+
docs.push('GOAL_TASK_CORE.md', 'DM_SCOPE.md', 'SURFACES.md');
|
|
138
127
|
return unique(docs);
|
|
139
128
|
}
|
|
140
129
|
|
|
141
130
|
if (goalAuthority) {
|
|
142
|
-
docs.push('GOAL_TASK_CORE.md', '
|
|
131
|
+
docs.push('GOAL_TASK_CORE.md', 'GROUP_ADMIN_SCOPE.md', 'SURFACES.md');
|
|
143
132
|
return unique(docs);
|
|
144
133
|
}
|
|
145
134
|
|
|
@@ -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
|
|
89
|
-
|
|
90
|
-
|
|
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:
|