@jeffober/thatch 0.2.1 → 0.2.3
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 +4 -2
- package/artifacts/skills/thatch-plan-refinement.md +3 -2
- package/artifacts/skills/thatch-refine-infra.md +66 -0
- package/artifacts/skills/thatch-refine-oss-contribution.md +68 -0
- package/artifacts/skills/thatch-refine-personal.md +61 -0
- package/artifacts/skills/thatch-refine-shared-lib.md +69 -0
- package/artifacts/skills/thatch-refine-spike.md +53 -0
- package/artifacts/skills/thatch-refine-team-app.md +69 -0
- package/artifacts/skills/thatch-refine.md +84 -0
- package/bin/thatch +9 -5
- package/docs/dev/README.md +21 -25
- package/docs/dev/features/cli.md +1 -0
- package/docs/dev/features/extraction.md +120 -81
- package/docs/dev/features/nudge-pipeline.md +44 -39
- package/docs/dev/features/session-lifecycle.md +34 -20
- package/docs/dev/features/watchers.md +9 -0
- package/docs/dev/gotchas.md +33 -26
- package/docs/dev/mcp-parity.md +17 -18
- package/docs/dev/setup-and-hooks.md +1 -1
- package/docs/dev/skills.md +8 -1
- package/docs/user/README.md +7 -0
- package/docs/user/commands.md +8 -0
- package/docs/user/skills.md +12 -0
- package/docs/user/watchers.md +6 -0
- package/package.json +1 -1
- package/src/chat.ts +1 -1
- package/src/commands.ts +23 -3
- package/src/extract-queue.ts +16 -1
- package/src/extraction.ts +4 -3
- package/src/opencode/v2.ts +4 -3
- package/src/prompts.ts +26 -33
- package/src/runtime.ts +119 -106
- package/src/skills.ts +7 -0
- package/src/tool-defs.ts +39 -31
- package/src/watchers.ts +31 -0
package/src/runtime.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
compactionContext,
|
|
10
10
|
sessionStartReminder,
|
|
11
11
|
recallNudge,
|
|
12
|
-
extractionNudge,
|
|
13
12
|
extractionDirectPrompt,
|
|
14
13
|
predictionNudge,
|
|
15
14
|
behaviorNudge,
|
|
@@ -497,12 +496,6 @@ export async function createRuntime(input: {
|
|
|
497
496
|
// degradation: nudges stay off for that session, but no crash).
|
|
498
497
|
const compacting = new Set<string>();
|
|
499
498
|
|
|
500
|
-
// Per-session count of consecutive extraction nudges delivered without any
|
|
501
|
-
// memory_remember call in between. Drives nudge escalation: the agent gets
|
|
502
|
-
// a couple of polite chances, then the tone shifts to directive, then to
|
|
503
|
-
// all-caps shouting. Reset to 0 whenever the agent writes a memory.
|
|
504
|
-
const missedNudges = new Map<string, number>();
|
|
505
|
-
|
|
506
499
|
// Parent-child session mapping for cross-session buffer drain. opencode
|
|
507
500
|
// creates real child sessions with their own IDs for sub-agents; without
|
|
508
501
|
// this map, a memory_remember call in a child (e.g. a background
|
|
@@ -531,24 +524,21 @@ export async function createRuntime(input: {
|
|
|
531
524
|
|
|
532
525
|
// Parent sessions with an active direct-extraction child. When the parent
|
|
533
526
|
// goes idle with pending tool interactions, the plugin creates a child
|
|
534
|
-
// session and prompts it directly through the host's prompt capability
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
// or is deleted. If direct extraction fails, the set is cleared so the nudge
|
|
540
|
-
// path takes over as a fallback on the next chat.message.
|
|
527
|
+
// session and prompts it directly through the host's prompt capability.
|
|
528
|
+
// This set prevents re-triggering if the parent goes idle again before
|
|
529
|
+
// the child finishes. Cleared when the child goes idle, errors, or is
|
|
530
|
+
// deleted. If direct extraction fails, the set is cleared so the next
|
|
531
|
+
// idle retries - there is no model-facing nudge fallback.
|
|
541
532
|
const extracting = new Set<string>();
|
|
542
533
|
|
|
543
534
|
// Child session IDs created by triggerExtraction (direct extraction only).
|
|
544
535
|
// The child idle handler uses this to distinguish plugin-created extraction
|
|
545
|
-
// children from task-dispatched sub-agents (code review specialists,
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
// them.
|
|
536
|
+
// children from task-dispatched sub-agents (code review specialists, any
|
|
537
|
+
// model-dispatched task). Only extraction children get the full cleanup:
|
|
538
|
+
// buffer drain, session deletion, toast. Non-extraction children get
|
|
539
|
+
// claim-scoped completion (a no-op for a child that never fetched a
|
|
540
|
+
// payload) so their sessions are not deleted out from under the task tool
|
|
541
|
+
// that dispatched them.
|
|
552
542
|
const extractionChildren = new Set<string>();
|
|
553
543
|
|
|
554
544
|
// Per-child-session extraction metrics for the toast notification. When the
|
|
@@ -876,7 +866,6 @@ export async function createRuntime(input: {
|
|
|
876
866
|
// that crashed before its payload call) has no claim and must not
|
|
877
867
|
// drop the accepted set - another extractor may hold it in flight.
|
|
878
868
|
extraction.completeClaimed(sessionID);
|
|
879
|
-
missedNudges.delete(parentID);
|
|
880
869
|
extracting.delete(parentID);
|
|
881
870
|
childKinds.delete(sessionID);
|
|
882
871
|
childToParent.delete(sessionID);
|
|
@@ -899,7 +888,6 @@ export async function createRuntime(input: {
|
|
|
899
888
|
// fetched leaves its entries held; requeueStaleAccepted bounds the
|
|
900
889
|
// linger.
|
|
901
890
|
extraction.completeClaimed(sessionID);
|
|
902
|
-
missedNudges.delete(parentID);
|
|
903
891
|
}
|
|
904
892
|
};
|
|
905
893
|
|
|
@@ -912,12 +900,17 @@ export async function createRuntime(input: {
|
|
|
912
900
|
// own idle event, and the finalize is idempotent if both paths race.
|
|
913
901
|
const reconcileRestoredChildren = async () => {
|
|
914
902
|
if (restoredChildren.size === 0) return;
|
|
903
|
+
// The reload window: how long to let the event pump repopulate the
|
|
904
|
+
// status map before judging a restored child "no longer running".
|
|
905
|
+
// Read at call time (not module load) so the test suite can shorten it
|
|
906
|
+
// per server() instance instead of waiting out the production 10s.
|
|
907
|
+
const windowMs = Number(process.env.THATCH_CHILD_RECONCILE_MS) || 10_000;
|
|
915
908
|
await new Promise<void>((resolve) => {
|
|
916
|
-
const t = setTimeout(resolve,
|
|
909
|
+
const t = setTimeout(resolve, windowMs);
|
|
917
910
|
t.unref?.();
|
|
918
911
|
});
|
|
919
912
|
// Dispose (a v2 plugin reload or process exit) may have closed the db
|
|
920
|
-
// during the
|
|
913
|
+
// during the window; finalizing into it would throw on the journal
|
|
921
914
|
// write. The reloaded instance's own reconciler owns the rows now.
|
|
922
915
|
if (disposed) return;
|
|
923
916
|
let live: Record<string, { type: string }> = {};
|
|
@@ -966,13 +959,16 @@ export async function createRuntime(input: {
|
|
|
966
959
|
|
|
967
960
|
// Direct extraction: create a child session linked to the parent and prompt
|
|
968
961
|
// it with the extraction payload. The child runs the fact-extractor skill,
|
|
969
|
-
// writes memories, and goes idle. The
|
|
970
|
-
//
|
|
971
|
-
//
|
|
962
|
+
// writes memories, and goes idle. The childToParent / claim machinery
|
|
963
|
+
// handles buffer cleanup. If this throws, the entries stay pending and
|
|
964
|
+
// the next idle retries.
|
|
972
965
|
//
|
|
973
|
-
// Does NOT call extraction.accept - entries stay in pending
|
|
974
|
-
//
|
|
975
|
-
//
|
|
966
|
+
// Does NOT call extraction.accept - entries stay in pending. The child's
|
|
967
|
+
// payload fetch claims them (the fetch is the delivery record); entries
|
|
968
|
+
// that arrive after the fetch stay pending for the next idle. The
|
|
969
|
+
// extracting set (not accept) suppresses nothing any more - there is no
|
|
970
|
+
// model-facing extraction nudge - but it still gates re-triggering while
|
|
971
|
+
// a child runs.
|
|
976
972
|
//
|
|
977
973
|
// The child ID is added to extractionChildren so the idle handler can
|
|
978
974
|
// distinguish plugin-created extraction children from task-dispatched
|
|
@@ -1060,7 +1056,6 @@ export async function createRuntime(input: {
|
|
|
1060
1056
|
// same logic, so the bodies live here instead of inline in the hook.
|
|
1061
1057
|
const toolMemoryRemember = async (sessionID: string, overwrite: unknown) => {
|
|
1062
1058
|
extraction.consume(sessionID);
|
|
1063
|
-
missedNudges.delete(sessionID);
|
|
1064
1059
|
// A session that fetched its own payload (direct parent-side fetch or a
|
|
1065
1060
|
// child's payload call) completes its claim when it writes a memory -
|
|
1066
1061
|
// the write proves the delivered entries were processed.
|
|
@@ -1079,52 +1074,56 @@ export async function createRuntime(input: {
|
|
|
1079
1074
|
// Complete the parent's accepted entries (the extractor confirmed
|
|
1080
1075
|
// it is alive and saving). Completion is scoped to THIS child's
|
|
1081
1076
|
// claimed delivery - the fetch IS the delivery record, so whatever
|
|
1082
|
-
// the child received is consumed here and nothing else.
|
|
1083
|
-
// entries the child never received
|
|
1084
|
-
//
|
|
1085
|
-
// (The old dispatch-time snapshot drain was either a no-op under
|
|
1086
|
-
// claim semantics or a silent-loss vector for children that never
|
|
1087
|
-
// fetched - removed September 2026. The journal's snapshot field
|
|
1088
|
-
// remains for restart recovery.)
|
|
1077
|
+
// the child received is consumed here and nothing else. Accepted
|
|
1078
|
+
// entries the child never received stay held; requeueStaleAccepted
|
|
1079
|
+
// bounds their linger: no silent loss, no whole-set wipe.
|
|
1089
1080
|
extraction.completeClaimed(sessionID);
|
|
1090
1081
|
journalChild(sessionID);
|
|
1091
|
-
missedNudges.delete(parentID);
|
|
1092
|
-
journalChild(sessionID);
|
|
1093
|
-
missedNudges.delete(parentID);
|
|
1094
1082
|
}
|
|
1095
1083
|
};
|
|
1096
1084
|
const toolExtractionDone = (sessionID: string, targetID?: string) => {
|
|
1097
1085
|
const parentID = childToParent.get(sessionID);
|
|
1098
1086
|
if (parentID) {
|
|
1099
|
-
// The child's completion consumes its own claimed delivery.
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
}
|
|
1107
|
-
missedNudges.delete(parentID);
|
|
1087
|
+
// The child's completion consumes its own claimed delivery. A child
|
|
1088
|
+
// that acks without ever fetching (its payload call errored, or it
|
|
1089
|
+
// acked before fetching) has no claim - it processed NOTHING, so its
|
|
1090
|
+
// ack must be a no-op here, never a whole-set drop: the accepted set
|
|
1091
|
+
// may be another extractor's in-flight delivery. Held entries without
|
|
1092
|
+
// a completer requeue via requeueStaleAccepted (15 min bound).
|
|
1093
|
+
extraction.completeClaimed(sessionID);
|
|
1108
1094
|
extraction.consume(sessionID);
|
|
1109
1095
|
} else if (targetID && targetID !== sessionID) {
|
|
1110
1096
|
// A completion ack for ANOTHER session - the fact-extractor sub-agent
|
|
1111
1097
|
// naming its parent. v2 links no parentID on dispatched sessions
|
|
1112
1098
|
// (SessionCreateInput has none), so childToParent cannot know this
|
|
1113
1099
|
// relationship and the explicit session_id is the only signal that
|
|
1114
|
-
// the target's accepted entries are accounted for.
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1100
|
+
// the target's accepted entries are accounted for. Completion is
|
|
1101
|
+
// scoped to the child's claimed delivery; a no-claim ack is a no-op
|
|
1102
|
+
// for the same reason as above - the ack proves nothing was
|
|
1103
|
+
// processed, so it must not drop entries another extractor holds.
|
|
1104
|
+
// (The old whole-set completeAccepted fallback here was the
|
|
1105
|
+
// accept-before-fetch interaction loss: a child acking - or mis-
|
|
1106
|
+
// targeting - before its fetch wiped the accepted set the new
|
|
1107
|
+
// extractor was about to claim, silently, every round. September
|
|
1108
|
+
// 2026 dispatch-loop report, defect 1.)
|
|
1109
|
+
extraction.completeClaimed(sessionID);
|
|
1110
|
+
} else {
|
|
1111
|
+
// A top-level session acking its own buffer. Two legitimate shapes:
|
|
1112
|
+
// - The wrap-up checklist (/thatch/compact, /thatch/exit): the
|
|
1113
|
+
// parent fetched its OWN payload (recording a claim on itself),
|
|
1114
|
+
// processed it inline, and acks - a no-save run writes no memory,
|
|
1115
|
+
// so this ack is the only completion signal for that claim.
|
|
1116
|
+
// - A legacy dispatch-time ack (MCP-style habit, or an in-flight
|
|
1117
|
+
// nudge from an older plugin version) with no claim: accept is
|
|
1118
|
+
// non-destructive - entries move to the holding area and a
|
|
1119
|
+
// crashed extractor's entries requeue via requeueStaleAccepted.
|
|
1120
|
+
// Note the opencode plugin no longer ASKS the parent to ack after a
|
|
1121
|
+
// dispatch - direct extraction (triggerExtraction at idle) owns the
|
|
1122
|
+
// whole lifecycle; the accept branch is tolerance, not protocol.
|
|
1120
1123
|
if (!extraction.completeClaimed(sessionID)) {
|
|
1121
|
-
extraction.
|
|
1124
|
+
extraction.accept(sessionID);
|
|
1122
1125
|
}
|
|
1123
|
-
missedNudges.delete(targetID);
|
|
1124
|
-
} else {
|
|
1125
|
-
extraction.accept(sessionID);
|
|
1126
1126
|
}
|
|
1127
|
-
missedNudges.delete(sessionID);
|
|
1128
1127
|
};
|
|
1129
1128
|
const toolMemoryForget = (sessionID: string) => {
|
|
1130
1129
|
const metrics = childMetrics.get(sessionID) ?? { new: 0, updated: 0, deleted: 0 };
|
|
@@ -1140,9 +1139,21 @@ export async function createRuntime(input: {
|
|
|
1140
1139
|
// signal consumes only them (claim semantics). Without a fetcher
|
|
1141
1140
|
// (no host session context) the fetch is a pure peek - legacy
|
|
1142
1141
|
// behavior.
|
|
1142
|
+
//
|
|
1143
|
+
// An omitted (or self-named) target resolves to the fetcher's own
|
|
1144
|
+
// session - which for a child is the WRONG key: the buffer belongs
|
|
1145
|
+
// to the parent. When the plugin knows the parent link
|
|
1146
|
+
// (childToParent - populated for plugin-created extraction
|
|
1147
|
+
// children, and for v1 model-dispatched ones), retarget to the
|
|
1148
|
+
// parent so the model never has to copy a session ID at all. v2
|
|
1149
|
+
// model-dispatched sub-agents carry no parentID, so no link exists
|
|
1150
|
+
// for them and the explicit-ID path remains the only signal.
|
|
1151
|
+
const resolved = (fetcherID && targetID === fetcherID && childToParent.has(fetcherID))
|
|
1152
|
+
? childToParent.get(fetcherID)!
|
|
1153
|
+
: targetID;
|
|
1143
1154
|
const all = fetcherID
|
|
1144
|
-
? extraction.claim(
|
|
1145
|
-
: [...extraction.peekAccepted(
|
|
1155
|
+
? extraction.claim(resolved, fetcherID)
|
|
1156
|
+
: [...extraction.peekAccepted(resolved), ...extraction.peek(resolved)];
|
|
1146
1157
|
if (all.length === 0) return null;
|
|
1147
1158
|
return extraction.buildPayload(all, repo);
|
|
1148
1159
|
},
|
|
@@ -1172,7 +1183,7 @@ export async function createRuntime(input: {
|
|
|
1172
1183
|
},
|
|
1173
1184
|
|
|
1174
1185
|
// 3. Tool buffering - feeds the extraction pipeline (direct extraction
|
|
1175
|
-
// via SDK
|
|
1186
|
+
// via SDK at session idle). Excluded tools:
|
|
1176
1187
|
// - thatch_*: extracting facts from memory ops would echo the store
|
|
1177
1188
|
// - skill/task: meta-tools that orchestrate agent behavior (loading
|
|
1178
1189
|
// skills, dispatching sub-agents). Buffering them creates a feedback
|
|
@@ -1471,38 +1482,34 @@ export async function createRuntime(input: {
|
|
|
1471
1482
|
// Best-effort. Version check failure must not block nudges.
|
|
1472
1483
|
}
|
|
1473
1484
|
|
|
1474
|
-
// Extraction
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1485
|
+
// Extraction is plugin-driven on opencode (triggerExtraction at
|
|
1486
|
+
// session idle) - there is deliberately NO model-facing extraction
|
|
1487
|
+
// nudge here. The old nudge-driven handshake (model dispatches a
|
|
1488
|
+
// sub-agent, parent acks with extraction_done, child fetches by
|
|
1489
|
+
// copied session id) raced its own state machine: acks landed before
|
|
1490
|
+
// fetches, no-claim completions wiped in-flight accepted sets, and
|
|
1491
|
+
// mis-targeted ids made every transition a silent no-op - the
|
|
1492
|
+
// buffered count then re-fired identically forever (the September
|
|
1493
|
+
// 2026 dispatch-loop report, defects 1 and 2). Accepted entries whose
|
|
1494
|
+
// completion signal never came (crashed extractor, a v2 dispatch
|
|
1495
|
+
// whose ack never named the parent) requeue at the idle site, the
|
|
1496
|
+
// only consumer of pending state now.
|
|
1497
|
+
// Nudges skip injected prompts. A background-task completion, chat
|
|
1498
|
+
// notification, or watcher notification arrives as a prompt whose
|
|
1499
|
+
// parts are all synthetic - it is not user input, and nudging on it
|
|
1500
|
+
// feeds the pipeline its own exhaust: an extractor sub-agent's
|
|
1501
|
+
// completion would otherwise re-trigger the nudge machinery for the
|
|
1502
|
+
// entries the handling turn itself queued (the circular-nudge
|
|
1503
|
+
// pattern). Real user messages carry at least one non-synthetic
|
|
1504
|
+
// part, and an empty-parts message keeps nudging (unchanged
|
|
1505
|
+
// behavior).
|
|
1506
|
+
if (output.parts.length > 0 && (output.parts as any[]).every((p) => p.synthetic)) return;
|
|
1483
1507
|
// Task-dispatched sub-agent sessions may have restricted tool lists
|
|
1484
|
-
// that exclude the thatch tools,
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1487
|
-
//
|
|
1488
|
-
// extractionDirectPrompt.
|
|
1508
|
+
// that exclude the thatch tools, so recall/prediction/behavior
|
|
1509
|
+
// nudges there only burn "No tool named ..." error rounds or add
|
|
1510
|
+
// noise. Extraction children are excluded: they run with full tools
|
|
1511
|
+
// and are driven by extractionDirectPrompt.
|
|
1489
1512
|
if (childToParent.has(input.sessionID) && !extractionChildren.has(input.sessionID)) return;
|
|
1490
|
-
if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
|
|
1491
|
-
const batch = extraction.peek(input.sessionID);
|
|
1492
|
-
const missed = missedNudges.get(input.sessionID) ?? 0;
|
|
1493
|
-
const text = extractionNudge(batch.length, missed, "thatch_memory_remember", input.sessionID);
|
|
1494
|
-
missedNudges.set(input.sessionID, missed + 1);
|
|
1495
|
-
|
|
1496
|
-
output.parts.push({
|
|
1497
|
-
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
1498
|
-
sessionID: input.sessionID,
|
|
1499
|
-
messageID: input.messageID ?? output.message.id,
|
|
1500
|
-
type: "text",
|
|
1501
|
-
text,
|
|
1502
|
-
synthetic: true,
|
|
1503
|
-
});
|
|
1504
|
-
return;
|
|
1505
|
-
}
|
|
1506
1513
|
|
|
1507
1514
|
// No extraction pending - try the prompt-aware recall nudge. Extract
|
|
1508
1515
|
// the user's prompt text from the message parts, embed it with the
|
|
@@ -1620,8 +1627,8 @@ export async function createRuntime(input: {
|
|
|
1620
1627
|
// entries go back to pending. Only extraction children (created by
|
|
1621
1628
|
// triggerExtraction, tracked in extractionChildren) get the full cleanup
|
|
1622
1629
|
// (buffer drain, session deletion, toast). Task-dispatched sub-agents
|
|
1623
|
-
// get
|
|
1624
|
-
//
|
|
1630
|
+
// get claim-scoped completion only, so their sessions are not deleted
|
|
1631
|
+
// out from under the task tool.
|
|
1625
1632
|
onEvent: async (event) => {
|
|
1626
1633
|
// Pause alerts: the LLM blocked on a human decision. Both lines
|
|
1627
1634
|
// publish these on the bus; v2's adapter translates them into the
|
|
@@ -1704,13 +1711,14 @@ export async function createRuntime(input: {
|
|
|
1704
1711
|
if (parentID && sessionID) {
|
|
1705
1712
|
// A child session went idle. Two cases:
|
|
1706
1713
|
// - Extraction child (created by triggerExtraction, tracked in
|
|
1707
|
-
// extractionChildren):
|
|
1708
|
-
//
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1713
|
-
//
|
|
1714
|
+
// extractionChildren): fire a toast with metrics, delete the
|
|
1715
|
+
// child session, clean up all maps. Completion of the parent's
|
|
1716
|
+
// accepted entries is claim-scoped inside.
|
|
1717
|
+
// - Task-dispatched sub-agent (code review specialist, any
|
|
1718
|
+
// model-dispatched task): complete what THIS child claimed via
|
|
1719
|
+
// its payload fetch, nothing more. Do NOT drain the buffer or
|
|
1720
|
+
// delete the session - the task tool that dispatched the
|
|
1721
|
+
// sub-agent needs to read its output.
|
|
1714
1722
|
await finishExtractionChild(sessionID, parentID);
|
|
1715
1723
|
return;
|
|
1716
1724
|
}
|
|
@@ -1834,8 +1842,13 @@ export async function createRuntime(input: {
|
|
|
1834
1842
|
})();
|
|
1835
1843
|
}
|
|
1836
1844
|
// Parent went idle - trigger direct extraction if there are pending
|
|
1837
|
-
// tool interactions and no extraction is already running.
|
|
1838
|
-
//
|
|
1845
|
+
// tool interactions and no extraction is already running. This is
|
|
1846
|
+
// the ONLY extraction trigger on opencode: no model-facing nudge
|
|
1847
|
+
// exists any more. Stale accepted entries (a crashed extractor,
|
|
1848
|
+
// a v2 dispatch whose ack never named the parent) requeue first so
|
|
1849
|
+
// this same idle re-extracts them instead of leaving them held
|
|
1850
|
+
// until the next idle.
|
|
1851
|
+
extraction.requeueStaleAccepted();
|
|
1839
1852
|
if (
|
|
1840
1853
|
sessionID &&
|
|
1841
1854
|
!compacting.has(sessionID) &&
|
package/src/skills.ts
CHANGED
|
@@ -71,6 +71,13 @@ function loadSharedSkills(): SkillDef[] {
|
|
|
71
71
|
"thatch-session-reflection",
|
|
72
72
|
"thatch-coding-workflow",
|
|
73
73
|
"thatch-plan-refinement",
|
|
74
|
+
"thatch-refine",
|
|
75
|
+
"thatch-refine-team-app",
|
|
76
|
+
"thatch-refine-personal",
|
|
77
|
+
"thatch-refine-shared-lib",
|
|
78
|
+
"thatch-refine-spike",
|
|
79
|
+
"thatch-refine-infra",
|
|
80
|
+
"thatch-refine-oss-contribution",
|
|
74
81
|
"thatch-pr-description",
|
|
75
82
|
"thatch-ticket-description",
|
|
76
83
|
"thatch-split-overlarge-pr",
|
package/src/tool-defs.ts
CHANGED
|
@@ -24,8 +24,8 @@ import {
|
|
|
24
24
|
import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } from "./notify";
|
|
25
25
|
import { predictionVerb, formatWhenLine, chatInboxFrame } from "./prompts";
|
|
26
26
|
import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
|
|
27
|
-
import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type Watcher, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
|
|
28
|
-
import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, sortChatRoster, splitChatRoster, type ChatHostKind } from "./chat";
|
|
27
|
+
import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, describeBaselineCheckRuns, type WatcherRegistry, type Watcher, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
|
|
28
|
+
import { CHAT_STALE_MS, MAX_BODY_LEN, chatLiveness, humanAge, renderChatParticipant, sortChatRoster, splitChatRoster, type ChatHostKind } from "./chat";
|
|
29
29
|
import { detectWorktreeKind } from "./git";
|
|
30
30
|
|
|
31
31
|
// Near-duplicate thresholds for matcher/prediction/behavior dedup at
|
|
@@ -602,7 +602,9 @@ const getExtractionPayloadDef: ToolDef = {
|
|
|
602
602
|
"the session_id from the extraction nudge to get the JSON payload " +
|
|
603
603
|
"(interactions, projectStore, globalStore). Then run the " +
|
|
604
604
|
"thatch-fact-extractor skill on the returned payload. Omit session_id " +
|
|
605
|
-
"when acting on the session you are running in."
|
|
605
|
+
"when acting on the session you are running in. If you are an " +
|
|
606
|
+
"extraction child whose parent link the plugin knows, an omitted or " +
|
|
607
|
+
"self-named session_id resolves to the parent's queue automatically.",
|
|
606
608
|
args: {
|
|
607
609
|
session_id: z.string().optional().describe(
|
|
608
610
|
"The session whose tool interactions are queued for extraction. " +
|
|
@@ -632,45 +634,47 @@ const getExtractionPayloadDef: ToolDef = {
|
|
|
632
634
|
};
|
|
633
635
|
|
|
634
636
|
/**
|
|
635
|
-
* Extraction-buffer acknowledgment
|
|
637
|
+
* Extraction-buffer acknowledgment.
|
|
636
638
|
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
* includes no-save runs that write no memory. If the child errors or is
|
|
639
|
+
* In the fact-extractor sub-agent, call at the end of the run to mark the
|
|
640
|
+
* entries THIS child claimed via its payload fetch complete - never the
|
|
641
|
+
* whole accepted set, so a sibling sub-agent's completion cannot drop
|
|
642
|
+
* another extractor's in-flight payload. Completion includes no-save runs
|
|
643
|
+
* that write no memory. Pass session_id (the parent's) to drain the
|
|
644
|
+
* parent's file-backed queue on the MCP path. If the child errors or is
|
|
644
645
|
* deleted before either signal, the host requeues the entries so the facts
|
|
645
646
|
* are not lost.
|
|
646
647
|
*
|
|
648
|
+
* On the MCP path the parent session may also call this after dispatching
|
|
649
|
+
* the fact-extractor, to reset the nudge escalation counter. The ack does
|
|
650
|
+
* NOT drain the parent's queue - a drain at ack time deleted the queue
|
|
651
|
+
* before the sub-agent's payload fetch (the accept-before-fetch loss); the
|
|
652
|
+
* queue is consumed by the extractor's completion instead.
|
|
653
|
+
*
|
|
647
654
|
* The actual state changes happen in the host's post-tool hook
|
|
648
655
|
* (tool.execute.after for opencode, PostToolBatch/appendBatch for MCP) -
|
|
649
656
|
* this tool's execute function is a no-op confirmation. The tool exists so
|
|
650
|
-
* the model has a recognizable tool name to key on.
|
|
651
|
-
* file-backed queue is consumed on this call (or on any memory_remember),
|
|
652
|
-
* which is durable across interruption because the queue persists on disk
|
|
653
|
-
* until then.
|
|
657
|
+
* the model has a recognizable tool name to key on.
|
|
654
658
|
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
+
* On the opencode plugin path there is no parent ack any more: the plugin
|
|
660
|
+
* drives extraction end-to-end (triggerExtraction at session idle), so the
|
|
661
|
+
* model never runs the dispatch handshake. A parent-side extraction_done
|
|
662
|
+
* there is tolerated as a non-destructive accept (entries held, not
|
|
663
|
+
* dropped) for backward compatibility with an in-flight older nudge.
|
|
659
664
|
*/
|
|
660
665
|
const extractionDoneDef: ToolDef = {
|
|
661
666
|
name: "extraction_done",
|
|
662
667
|
description:
|
|
663
|
-
"Acknowledge extraction-buffer work. In
|
|
664
|
-
"
|
|
665
|
-
"
|
|
666
|
-
"
|
|
667
|
-
"entries complete, even when nothing was worth saving. Pass session_id " +
|
|
668
|
-
"when running as a sub-agent to drain the parent session's queue.",
|
|
668
|
+
"Acknowledge extraction-buffer work. In the fact-extractor sub-agent, " +
|
|
669
|
+
"call at the end of the run to mark the entries you claimed complete, " +
|
|
670
|
+
"even when nothing was worth saving. Pass session_id (the parent " +
|
|
671
|
+
"session's ID from the extraction nudge) to drain the parent's queue.",
|
|
669
672
|
args: {
|
|
670
673
|
session_id: z.string().optional().describe(
|
|
671
|
-
"The parent session's ID, when called from a sub-agent
|
|
672
|
-
"
|
|
673
|
-
"
|
|
674
|
+
"The parent session's ID, when called from a sub-agent. Drains the " +
|
|
675
|
+
"parent's file-backed queue (MCP path) and completes the entries " +
|
|
676
|
+
"this sub-agent claimed. Omit when called from the parent session " +
|
|
677
|
+
"itself.",
|
|
674
678
|
),
|
|
675
679
|
},
|
|
676
680
|
async execute(args, ctx) {
|
|
@@ -1523,12 +1527,14 @@ const watchCreateDef: ToolDef = {
|
|
|
1523
1527
|
const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events, { once: args.once === true });
|
|
1524
1528
|
if (!result.ok) return `Watcher not created: ${result.error}`;
|
|
1525
1529
|
const w = result.watcher;
|
|
1530
|
+
const ciNote = w.events.includes("pr_ci") ? describeBaselineCheckRuns(w.state.checkRuns, w.state.headSha) : null;
|
|
1526
1531
|
return (
|
|
1527
1532
|
`[watching] ${w.repo}#${w.pr}\n` +
|
|
1528
1533
|
`id: ${w.id}\n` +
|
|
1529
1534
|
`events: ${w.events.join(", ")}\n` +
|
|
1530
1535
|
(w.once ? `mode: one-shot - auto-cancels after the first event\n` : "") +
|
|
1531
1536
|
`head: ${w.state.headSha.slice(0, 7)} (${w.state.state}${w.state.merged ? ", merged" : ""})\n\n` +
|
|
1537
|
+
(ciNote ? `${ciNote}\n\n` : "") +
|
|
1532
1538
|
`The baseline is captured now - only changes from this point notify, ` +
|
|
1533
1539
|
`and the first poll lands within ~${ctx.watchers.pollSeconds}s. ` +
|
|
1534
1540
|
`You will receive a system notification in this session when a watched event happens. ` +
|
|
@@ -1636,6 +1642,7 @@ const watchBranchCreateDef: ToolDef = {
|
|
|
1636
1642
|
);
|
|
1637
1643
|
if (!result.ok) return `Watcher not created: ${result.error}`;
|
|
1638
1644
|
const w = result.watcher;
|
|
1645
|
+
const ciNote = w.events.includes("branch_ci") ? describeBaselineCheckRuns(w.state.checkRuns, w.state.headSha) : null;
|
|
1639
1646
|
return (
|
|
1640
1647
|
`[watching] ${w.repo}@${w.branch}\n` +
|
|
1641
1648
|
`id: ${w.id}\n` +
|
|
@@ -1643,6 +1650,7 @@ const watchBranchCreateDef: ToolDef = {
|
|
|
1643
1650
|
(w.workflows.length > 0 ? `workflow filter: ${w.workflows.join(", ")}\n` : "") +
|
|
1644
1651
|
(w.once ? `mode: one-shot - auto-cancels after the first event\n` : "") +
|
|
1645
1652
|
`head: ${w.state.headSha.slice(0, 7)}\n\n` +
|
|
1653
|
+
(ciNote ? `${ciNote}\n\n` : "") +
|
|
1646
1654
|
`The baseline is captured now - only changes from this point notify, ` +
|
|
1647
1655
|
`and the first poll lands within ~${ctx.watchers.pollSeconds}s. ` +
|
|
1648
1656
|
`You will receive a system notification in this session when a watched event happens. ` +
|
|
@@ -2039,7 +2047,7 @@ const chatSendDef: ToolDef = {
|
|
|
2039
2047
|
"sessions on other machines cannot be reached.",
|
|
2040
2048
|
args: {
|
|
2041
2049
|
to: z.string().describe("Recipient display name (see chat_list)."),
|
|
2042
|
-
body: z.string().describe(
|
|
2050
|
+
body: z.string().describe(`Message body, at most ${MAX_BODY_LEN} characters (longer is refused, not truncated). Keep it short and self-contained - the recipient may lack your context.`),
|
|
2043
2051
|
as: mcpIdentityArg(),
|
|
2044
2052
|
},
|
|
2045
2053
|
async execute(args, ctx, host) {
|
|
@@ -2144,8 +2152,8 @@ const chatBroadcastDef: ToolDef = {
|
|
|
2144
2152
|
"turn on it.",
|
|
2145
2153
|
args: {
|
|
2146
2154
|
body: z.string().describe(
|
|
2147
|
-
|
|
2148
|
-
"short and self-contained - the recipients may lack your context.",
|
|
2155
|
+
`Message body, delivered to every other registered session, at most ${MAX_BODY_LEN} characters ` +
|
|
2156
|
+
"(longer is refused, not truncated). Keep it short and self-contained - the recipients may lack your context.",
|
|
2149
2157
|
),
|
|
2150
2158
|
as: mcpIdentityArg(),
|
|
2151
2159
|
},
|
package/src/watchers.ts
CHANGED
|
@@ -703,6 +703,37 @@ export async function fetchBranchState(gh: GhRunner, repo: string, branch: strin
|
|
|
703
703
|
/** At most this many events per watcher per cycle; comment floods collapse. */
|
|
704
704
|
const MAX_EVENTS_PER_DIFF = 10;
|
|
705
705
|
|
|
706
|
+
/**
|
|
707
|
+
* Describes the check runs already present on a head at watcher creation,
|
|
708
|
+
* for the registration response. The diff only fires on a not-completed ->
|
|
709
|
+
* completed transition, so runs that are ALREADY completed in the baseline
|
|
710
|
+
* can never produce a CI event for this head - a watcher registered after a
|
|
711
|
+
* fast CI (one that finishes in under a minute) is silently dead for the
|
|
712
|
+
* head it was meant to cover. Saying so at creation lets the caller just
|
|
713
|
+
* read the result instead of waiting on a notification that cannot come.
|
|
714
|
+
* Returns null when there is nothing to say: no runs yet, or some still
|
|
715
|
+
* running (those WILL transition and notify).
|
|
716
|
+
*/
|
|
717
|
+
export function describeBaselineCheckRuns(checkRuns: Record<string, CheckRunRef>, headSha: string): string | null {
|
|
718
|
+
const runs = Object.values(checkRuns);
|
|
719
|
+
if (runs.length === 0) return null;
|
|
720
|
+
const completed = runs.filter((r) => r.status === "completed");
|
|
721
|
+
if (completed.length < runs.length) return null;
|
|
722
|
+
const counts = new Map<string, number>();
|
|
723
|
+
for (const r of completed) {
|
|
724
|
+
const key = r.conclusion ?? "no conclusion";
|
|
725
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
726
|
+
}
|
|
727
|
+
const breakdown = [...counts.entries()].map(([k, n]) => `${n} ${k}`).join(", ");
|
|
728
|
+
return (
|
|
729
|
+
`NOTE: all ${completed.length} check run${completed.length === 1 ? "" : "s"} on ${headSha.slice(0, 7)} ` +
|
|
730
|
+
`${completed.length === 1 ? "has" : "have"} already completed (${breakdown}). ` +
|
|
731
|
+
`CI events fire only on a running -> completed transition, so this watcher will NOT notify for this head's CI - ` +
|
|
732
|
+
`read the result now (gh api repos/<owner>/<repo>/commits/${headSha.slice(0, 7)}/check-runs) ` +
|
|
733
|
+
`rather than waiting. To catch a push's CI, register the watcher BEFORE pushing.`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
706
737
|
/**
|
|
707
738
|
* Shared check-run diff: emits one event per run that reaches a completed
|
|
708
739
|
* status. A run that was already completed before produces nothing; a run
|