@yagni-app/code-staging 0.3.4-staging.1148.1 → 0.3.4-staging.1155.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/dist/extension/branding.d.ts +39 -0
- package/dist/extension/branding.js +76 -0
- package/dist/extension/index.js +6 -0
- package/dist/extension/silentTurnReminder.d.ts +109 -0
- package/dist/extension/silentTurnReminder.js +217 -0
- package/dist/extension/turnLog.d.ts +14 -0
- package/dist/extension/turnLog.js +5 -1
- package/package.json +2 -2
|
@@ -46,6 +46,45 @@ export declare const ULTRA_DELEGATION_PARAGRAPH: string;
|
|
|
46
46
|
* module stays a pure string, with no env dependency of its own.
|
|
47
47
|
*/
|
|
48
48
|
export declare const YAGNI_IDENTITY_DRIVER = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation: fan codebase mapping, wide searches, and mechanical multi-file work out to subagents (they run on cheaper tiers). Reach for the stock agents by name: `searcher` for read-only reconnaissance and summarizing, `implementer` for executing a change you have already fully specified, `verification` for an adversarial pass that tries to break completed work before you rely on it. Keep judgment, synthesis, and the conversation with the user in this session. Do not spawn a subagent for work you can finish in a couple of tool calls.";
|
|
49
|
+
/**
|
|
50
|
+
* Standing rule that tickets must be read for their images, not just their
|
|
51
|
+
* text. Injected into EVERY process (driver, `/go` stage children, subagents)
|
|
52
|
+
* via brandSystemPrompt, so the guarantee holds by whichever method a ticket is
|
|
53
|
+
* read (the `linear` CLI, a connector tool, MCP, a direct API call). Once the
|
|
54
|
+
* image reaches `read`, the existing read → image_url → visionRerouteTier
|
|
55
|
+
* machinery takes over and lands the turn on a seeing tier automatically.
|
|
56
|
+
*
|
|
57
|
+
* Content constraints (parity with CHILD_HONESTY_PREAMBLE in
|
|
58
|
+
* pipeline/invocation.ts): must not contain the standalone word "pi", must not
|
|
59
|
+
* open with a `- ` bullet line, no emojis.
|
|
60
|
+
*/
|
|
61
|
+
export declare const TICKET_IMAGE_RULE: string;
|
|
62
|
+
/**
|
|
63
|
+
* The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
|
|
64
|
+
* carries this exact sentence in every system prompt so its whole reminder
|
|
65
|
+
* family (including its own silent-turn reminder) is legible as system-
|
|
66
|
+
* injected rather than misread as part of the tool output they ride on. We
|
|
67
|
+
* adopt it for the same reason: our silent-turn nudge arrives stapled to a
|
|
68
|
+
* `psql`/`bash` result, and without this it reads as query output.
|
|
69
|
+
*/
|
|
70
|
+
export declare const SYSTEM_REMINDER_FRAMING: string;
|
|
71
|
+
/**
|
|
72
|
+
* The communication contract (YAG-574, Change B). Adapted from Claude Code's
|
|
73
|
+
* `SendUserMessage`/`Brief` prompt: ack in one line before going to look, then
|
|
74
|
+
* work, then result; a checkpoint between them only when something useful
|
|
75
|
+
* happened — a decision, a surprise, a phase boundary — never filler like
|
|
76
|
+
* "running tests…"; keep messages tight and second-person. Complements the
|
|
77
|
+
* silent-turn nudge by spelling out what "keep the user updated" means.
|
|
78
|
+
*/
|
|
79
|
+
export declare const COMMUNICATION_CONTRACT: string;
|
|
80
|
+
/**
|
|
81
|
+
* Write-findings-down (YAG-574, Change B). Claude Code's version ties the
|
|
82
|
+
* habit to result-clearing, which we do not do; reworded to the failure we
|
|
83
|
+
* actually saw, the model re-deriving the same answer turn after turn because
|
|
84
|
+
* nothing pushed it to commit a finding to its response (which is also what
|
|
85
|
+
* makes the user see it).
|
|
86
|
+
*/
|
|
87
|
+
export declare const WRITE_FINDINGS_DOWN: string;
|
|
49
88
|
/** The driver identity while /ultra is on: base identity + the diamond directive. */
|
|
50
89
|
export declare const YAGNI_IDENTITY_ULTRA = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation (ultra mode): the user has switched this session to ultra mode \u2014 aggressive multi-agent orchestration. Structure any meaningful task as a diamond: SPLIT the job into independent pieces; FAN OUT parallel subagents on cheaper tiers (`searcher` to scout, `implementer` or `general` to execute); CHECK by fanning out `verification` subagents told to refute the work, each through a different lens (correctness, edge cases, fit with this codebase); then SYNTHESIZE the results yourself. Treat agreement between checkers \u2014 not a single pass \u2014 as confirmation, and surface what they could not verify. Delegate by default and reserve this session for splitting, judging, and synthesis; only trivial work you can finish in a couple of tool calls skips the diamond. Subagents cannot touch your todo_write checklist, so keep it current yourself: update it when you split the job and again as each fanned-out piece lands, not only at the end.";
|
|
51
90
|
export declare const PI_IDENTITY_RE: RegExp;
|
|
@@ -75,6 +75,62 @@ export const ULTRA_DELEGATION_PARAGRAPH = "Delegation (ultra mode): the user has
|
|
|
75
75
|
* module stays a pure string, with no env dependency of its own.
|
|
76
76
|
*/
|
|
77
77
|
export const YAGNI_IDENTITY_DRIVER = `${YAGNI_IDENTITY}\n\n${DRIVER_DELEGATION_PARAGRAPH}`;
|
|
78
|
+
/**
|
|
79
|
+
* Standing rule that tickets must be read for their images, not just their
|
|
80
|
+
* text. Injected into EVERY process (driver, `/go` stage children, subagents)
|
|
81
|
+
* via brandSystemPrompt, so the guarantee holds by whichever method a ticket is
|
|
82
|
+
* read (the `linear` CLI, a connector tool, MCP, a direct API call). Once the
|
|
83
|
+
* image reaches `read`, the existing read → image_url → visionRerouteTier
|
|
84
|
+
* machinery takes over and lands the turn on a seeing tier automatically.
|
|
85
|
+
*
|
|
86
|
+
* Content constraints (parity with CHILD_HONESTY_PREAMBLE in
|
|
87
|
+
* pipeline/invocation.ts): must not contain the standalone word "pi", must not
|
|
88
|
+
* open with a `- ` bullet line, no emojis.
|
|
89
|
+
*/
|
|
90
|
+
export const TICKET_IMAGE_RULE = "When you read or fetch a ticket from any tracker (Linear, Jira, or another), " +
|
|
91
|
+
"by any method — the `linear` CLI, a connector tool, MCP, or a direct API call — " +
|
|
92
|
+
"treat every image embedded in or attached to that ticket as required input, not " +
|
|
93
|
+
"decoration. Before you rely on the ticket's contents, get each image onto disk " +
|
|
94
|
+
"as a local file and read it with the `read` tool. Never claim to understand a " +
|
|
95
|
+
"ticket whose images you have not actually looked at.";
|
|
96
|
+
/** Stable header that starts the ticket-image rule section (idempotency anchor). */
|
|
97
|
+
const TICKET_IMAGE_RULE_HEADER = "## Rule: always read a ticket's images";
|
|
98
|
+
/**
|
|
99
|
+
* The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
|
|
100
|
+
* carries this exact sentence in every system prompt so its whole reminder
|
|
101
|
+
* family (including its own silent-turn reminder) is legible as system-
|
|
102
|
+
* injected rather than misread as part of the tool output they ride on. We
|
|
103
|
+
* adopt it for the same reason: our silent-turn nudge arrives stapled to a
|
|
104
|
+
* `psql`/`bash` result, and without this it reads as query output.
|
|
105
|
+
*/
|
|
106
|
+
export const SYSTEM_REMINDER_FRAMING = "Tool results and user messages may include <system-reminder> tags. " +
|
|
107
|
+
"<system-reminder> tags contain useful information and reminders. They are " +
|
|
108
|
+
"automatically added by the system, and bear no direct relation to the " +
|
|
109
|
+
"specific tool results or user messages in which they appear.";
|
|
110
|
+
/**
|
|
111
|
+
* The communication contract (YAG-574, Change B). Adapted from Claude Code's
|
|
112
|
+
* `SendUserMessage`/`Brief` prompt: ack in one line before going to look, then
|
|
113
|
+
* work, then result; a checkpoint between them only when something useful
|
|
114
|
+
* happened — a decision, a surprise, a phase boundary — never filler like
|
|
115
|
+
* "running tests…"; keep messages tight and second-person. Complements the
|
|
116
|
+
* silent-turn nudge by spelling out what "keep the user updated" means.
|
|
117
|
+
*/
|
|
118
|
+
export const COMMUNICATION_CONTRACT = "Keep the user in the loop as you work. Acknowledge in one line before going " +
|
|
119
|
+
"to look, then report the result when you have it. Between the two, send a " +
|
|
120
|
+
"short checkpoint when something useful happened — a decision made, a " +
|
|
121
|
+
"surprise, a phase change — not for filler like running a test or reading a " +
|
|
122
|
+
"file. Keep every message tight: the decision, the file:line, the PR number. " +
|
|
123
|
+
"Address the user directly (second person).";
|
|
124
|
+
/**
|
|
125
|
+
* Write-findings-down (YAG-574, Change B). Claude Code's version ties the
|
|
126
|
+
* habit to result-clearing, which we do not do; reworded to the failure we
|
|
127
|
+
* actually saw, the model re-deriving the same answer turn after turn because
|
|
128
|
+
* nothing pushed it to commit a finding to its response (which is also what
|
|
129
|
+
* makes the user see it).
|
|
130
|
+
*/
|
|
131
|
+
export const WRITE_FINDINGS_DOWN = "When working with tool results, write down any important information you " +
|
|
132
|
+
"might need later in your response, so you do not re-derive it turn after " +
|
|
133
|
+
"turn.";
|
|
78
134
|
/** The driver identity while /ultra is on: base identity + the diamond directive. */
|
|
79
135
|
export const YAGNI_IDENTITY_ULTRA = `${YAGNI_IDENTITY}\n\n${ULTRA_DELEGATION_PARAGRAPH}`;
|
|
80
136
|
/**
|
|
@@ -186,6 +242,26 @@ export function brandSystemPrompt(original, opts = {}) {
|
|
|
186
242
|
if (rules && rulesHeader && !s.includes(rulesHeader)) {
|
|
187
243
|
s = `${s}\n\n${rules}`;
|
|
188
244
|
}
|
|
245
|
+
// 5b. Ticket-image rule — a standing directive placed AFTER the user's rules
|
|
246
|
+
// (so a long, noisy rules block cannot bury it) but BEFORE the closing
|
|
247
|
+
// reminder (which stays the most-recent instruction). The idempotency guard
|
|
248
|
+
// keys on the stable section header, mirroring the rules guard above.
|
|
249
|
+
if (!s.includes(TICKET_IMAGE_RULE_HEADER)) {
|
|
250
|
+
s = `${s}\n\n${TICKET_IMAGE_RULE_HEADER}\n${TICKET_IMAGE_RULE}`;
|
|
251
|
+
}
|
|
252
|
+
// 5c. YAG-574: the injected-reminder framing (which makes the silent-turn
|
|
253
|
+
// nudge legible) and the two communication lines (Change B). Appended after
|
|
254
|
+
// everything user-provided but before the closing reminder, each guarded by
|
|
255
|
+
// its own stable anchor so re-branding never duplicates them.
|
|
256
|
+
if (!s.includes(SYSTEM_REMINDER_FRAMING)) {
|
|
257
|
+
s = `${s}\n\n${SYSTEM_REMINDER_FRAMING}`;
|
|
258
|
+
}
|
|
259
|
+
if (!s.includes(COMMUNICATION_CONTRACT)) {
|
|
260
|
+
s = `${s}\n\n${COMMUNICATION_CONTRACT}`;
|
|
261
|
+
}
|
|
262
|
+
if (!s.includes(WRITE_FINDINGS_DOWN)) {
|
|
263
|
+
s = `${s}\n\n${WRITE_FINDINGS_DOWN}`;
|
|
264
|
+
}
|
|
189
265
|
// 6. Closing reinforcement. Weak open-weight models weight the most recent
|
|
190
266
|
// instruction heavily, and the user's own project files may name other
|
|
191
267
|
// harnesses; a trailing reminder keeps the agent from claiming one as its own.
|
package/dist/extension/index.js
CHANGED
|
@@ -45,6 +45,7 @@ import { buildYagniProvider } from "./provider.js";
|
|
|
45
45
|
import { registerChipEditor } from "./chipEditor.js";
|
|
46
46
|
import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
|
|
47
47
|
import { makeFlywheelState } from "./flywheel.js";
|
|
48
|
+
import { registerSilentTurnReminder } from "./silentTurnReminder.js";
|
|
48
49
|
function isEvalMode(env = process.env) {
|
|
49
50
|
return env.YAGNI_CODE_EVAL_MODE === "1";
|
|
50
51
|
}
|
|
@@ -191,6 +192,11 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
191
192
|
// above-editor widget, and /todos. Branch-replayed, so forks and resumes
|
|
192
193
|
// show the list as it stood at that point.
|
|
193
194
|
registerTodos(pi);
|
|
195
|
+
// YAG-574: the silent-turn reminder, driver-only. A child/subagent/advisor
|
|
196
|
+
// process has no direct user to answer, so it is never nudged (same gating
|
|
197
|
+
// as the delegation identity in branding.ts); eval mode is untouched so its
|
|
198
|
+
// measured behavior is not perturbed.
|
|
199
|
+
registerSilentTurnReminder(pi, { isDriver: isDriverCaller(env), evalMode, env: deps.env });
|
|
194
200
|
// Image paste with [Image #N] chips: replaces the editor on
|
|
195
201
|
// session_start (TUI mode) so pasting a screenshot drops a chip instead of a
|
|
196
202
|
// temp path, and registers the input transform that turns chips into image
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent-turn reminder — the harness fix for YAG-574.
|
|
3
|
+
*
|
|
4
|
+
* The failure behind that ticket was a termination failure, not a discovery
|
|
5
|
+
* one: the model had assembled the full answer in thinking, re-derived it 15
|
|
6
|
+
* times, and never converted "I have an answer" into "the turn ends." Nothing
|
|
7
|
+
* in the harness nudges it across that boundary when it goes quiet.
|
|
8
|
+
*
|
|
9
|
+
* This module is that nudge. It counts assistant turns and wall-clock time
|
|
10
|
+
* since the model last *spoke* (a non-empty visible text block, or a call to a
|
|
11
|
+
* user-facing tool), and when that silence passes a configurable threshold it
|
|
12
|
+
* appends a one-line reminder to the next tool result — the same
|
|
13
|
+
* result-modification seam the todos staleness reminder and ambient recall
|
|
14
|
+
* already ride, so it reaches the model mid-run without spending a turn.
|
|
15
|
+
*
|
|
16
|
+
* Structure mirrors Claude Code's `silent_turn_reminder` (the problem is real
|
|
17
|
+
* and already solved upstream): same trigger semantics, same single-fixed-
|
|
18
|
+
* sentence escalation, same "suppress on the turn after a user message" rule.
|
|
19
|
+
* Two deliberate deviations, both justified on the ticket:
|
|
20
|
+
* - trigger is wall-clock (90s default), not turn-count, because turn-count
|
|
21
|
+
* thresholds are tuned to a ~5s turn and ours drifted to ~34s;
|
|
22
|
+
* - no per-stretch nudge cap, because the cap is the exact thing that silences
|
|
23
|
+
* the mechanism during the silence it exists for.
|
|
24
|
+
*/
|
|
25
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
/**
|
|
27
|
+
* Default silence trigger, in milliseconds. Calibrated (see the ticket's
|
|
28
|
+
* trigger table) to nudge at roughly Claude Code's on-observed density while
|
|
29
|
+
* still covering the broken session's 25-minute dead zone.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEFAULT_SILENT_INTERVAL_MS = 90000;
|
|
32
|
+
/**
|
|
33
|
+
* Minimum assistant turns of silence before a reminder is eligible. Guards a
|
|
34
|
+
* single long tool call from counting as "the user is being ignored" when the
|
|
35
|
+
* model simply spent a while on one tool.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MIN_SILENT_TURNS = 2;
|
|
38
|
+
/**
|
|
39
|
+
* Tools that count as "speaking to the user." Claude Code's whitelist maps to
|
|
40
|
+
* `AskUserQuestion`, `SendUserMessage`, `Brief`, `ExitPlanMode`,
|
|
41
|
+
* `SendUserFile`; ours are the three tools that surface something directly to
|
|
42
|
+
* the human this session serves. A call to one of these is a substantive
|
|
43
|
+
* utterance even when the assistant emitted no visible text block.
|
|
44
|
+
*/
|
|
45
|
+
export declare const USER_FACING_TOOLS: Set<string>;
|
|
46
|
+
export declare const SILENT_REMINDER_ENV = "YAGNI_SILENT_TURN_REMINDER";
|
|
47
|
+
export declare const SILENT_REMINDER_SECONDS_ENV = "YAGNI_SILENT_TURN_REMINDER_SECONDS";
|
|
48
|
+
/** Disable the whole mechanism ("0"/"off"), or resolve the trigger override. */
|
|
49
|
+
export declare function silentReminderDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
50
|
+
/** Resolve the trigger interval: env seconds override wins, else the default. */
|
|
51
|
+
export declare function resolveSilentIntervalMs(env?: NodeJS.ProcessEnv): number;
|
|
52
|
+
/** The minimal slice of an assistant message the reminder logic cares about. */
|
|
53
|
+
interface AssistantLike {
|
|
54
|
+
content?: Array<{
|
|
55
|
+
type?: string;
|
|
56
|
+
text?: string;
|
|
57
|
+
name?: string;
|
|
58
|
+
}>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* PURE: whether the assistant emitted any non-empty, visible text block this
|
|
62
|
+
* turn. `thinking` blocks deliberately do NOT count — 525k chars of thinking
|
|
63
|
+
* is still silence to the user.
|
|
64
|
+
*/
|
|
65
|
+
export declare function hasSubstantiveText(message: AssistantLike): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* PURE: whether the assistant "spoke" this turn — visible text, OR a call to a
|
|
68
|
+
* user-facing tool (which is itself an utterance to the user, even without a
|
|
69
|
+
* text block).
|
|
70
|
+
*/
|
|
71
|
+
export declare function didSpeak(message: AssistantLike): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* PURE: is a reminder due? The turn count must pass {@link MIN_SILENT_TURNS},
|
|
74
|
+
* the wall-clock silence must pass the configured interval, and the spacing
|
|
75
|
+
* between nudges must pass the same interval. `lastNudgeAt` is `null` before
|
|
76
|
+
* any nudge has fired (still eligible once the other gates pass).
|
|
77
|
+
*/
|
|
78
|
+
export declare function shouldRemind(input: {
|
|
79
|
+
msSinceSpoke: number;
|
|
80
|
+
turnsSinceSpoke: number;
|
|
81
|
+
msSinceLastNudge: number | null;
|
|
82
|
+
intervalMs: number;
|
|
83
|
+
}): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The injected payload. Claude Code's wording plus one clause for the failure
|
|
86
|
+
* we actually saw (the model holding an assembled answer and never giving it).
|
|
87
|
+
* Wrapped in `<system-reminder>` tags — the framing branding.ts teaches the
|
|
88
|
+
* model to parse as system-injected, not part of the tool output.
|
|
89
|
+
*/
|
|
90
|
+
export declare function formatSilentReminder(): string;
|
|
91
|
+
export interface RegisterSilentTurnReminderDeps {
|
|
92
|
+
/** Only the interactive driver is nudged; children/subagents/advisor/no-op. */
|
|
93
|
+
isDriver?: boolean;
|
|
94
|
+
/** Eval mode (headless evals, scoping sessions) never nudges. */
|
|
95
|
+
evalMode?: boolean;
|
|
96
|
+
/** Env seam (defaults to process.env). */
|
|
97
|
+
env?: NodeJS.ProcessEnv;
|
|
98
|
+
/** Clock seam (defaults to Date.now). */
|
|
99
|
+
now?: () => number;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Wire the silent-turn reminder. No handlers are registered for non-driver
|
|
103
|
+
* processes, eval mode, or when disabled — so children, subagents, advisor
|
|
104
|
+
* consults, and headless evals are untouched, and the kill switch is a true
|
|
105
|
+
* no-op.
|
|
106
|
+
*/
|
|
107
|
+
export declare function registerSilentTurnReminder(pi: ExtensionAPI, deps?: RegisterSilentTurnReminderDeps): void;
|
|
108
|
+
export {};
|
|
109
|
+
//# sourceMappingURL=silentTurnReminder.d.ts.map
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent-turn reminder — the harness fix for YAG-574.
|
|
3
|
+
*
|
|
4
|
+
* The failure behind that ticket was a termination failure, not a discovery
|
|
5
|
+
* one: the model had assembled the full answer in thinking, re-derived it 15
|
|
6
|
+
* times, and never converted "I have an answer" into "the turn ends." Nothing
|
|
7
|
+
* in the harness nudges it across that boundary when it goes quiet.
|
|
8
|
+
*
|
|
9
|
+
* This module is that nudge. It counts assistant turns and wall-clock time
|
|
10
|
+
* since the model last *spoke* (a non-empty visible text block, or a call to a
|
|
11
|
+
* user-facing tool), and when that silence passes a configurable threshold it
|
|
12
|
+
* appends a one-line reminder to the next tool result — the same
|
|
13
|
+
* result-modification seam the todos staleness reminder and ambient recall
|
|
14
|
+
* already ride, so it reaches the model mid-run without spending a turn.
|
|
15
|
+
*
|
|
16
|
+
* Structure mirrors Claude Code's `silent_turn_reminder` (the problem is real
|
|
17
|
+
* and already solved upstream): same trigger semantics, same single-fixed-
|
|
18
|
+
* sentence escalation, same "suppress on the turn after a user message" rule.
|
|
19
|
+
* Two deliberate deviations, both justified on the ticket:
|
|
20
|
+
* - trigger is wall-clock (90s default), not turn-count, because turn-count
|
|
21
|
+
* thresholds are tuned to a ~5s turn and ours drifted to ~34s;
|
|
22
|
+
* - no per-stretch nudge cap, because the cap is the exact thing that silences
|
|
23
|
+
* the mechanism during the silence it exists for.
|
|
24
|
+
*/
|
|
25
|
+
import { logTurnLifecycle } from "./turnLog.js";
|
|
26
|
+
/**
|
|
27
|
+
* Default silence trigger, in milliseconds. Calibrated (see the ticket's
|
|
28
|
+
* trigger table) to nudge at roughly Claude Code's on-observed density while
|
|
29
|
+
* still covering the broken session's 25-minute dead zone.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_SILENT_INTERVAL_MS = 90_000;
|
|
32
|
+
/**
|
|
33
|
+
* Minimum assistant turns of silence before a reminder is eligible. Guards a
|
|
34
|
+
* single long tool call from counting as "the user is being ignored" when the
|
|
35
|
+
* model simply spent a while on one tool.
|
|
36
|
+
*/
|
|
37
|
+
export const MIN_SILENT_TURNS = 2;
|
|
38
|
+
/**
|
|
39
|
+
* Tools that count as "speaking to the user." Claude Code's whitelist maps to
|
|
40
|
+
* `AskUserQuestion`, `SendUserMessage`, `Brief`, `ExitPlanMode`,
|
|
41
|
+
* `SendUserFile`; ours are the three tools that surface something directly to
|
|
42
|
+
* the human this session serves. A call to one of these is a substantive
|
|
43
|
+
* utterance even when the assistant emitted no visible text block.
|
|
44
|
+
*/
|
|
45
|
+
export const USER_FACING_TOOLS = new Set([
|
|
46
|
+
"todo_write",
|
|
47
|
+
"file_ticket",
|
|
48
|
+
"update_ticket_status",
|
|
49
|
+
]);
|
|
50
|
+
export const SILENT_REMINDER_ENV = "YAGNI_SILENT_TURN_REMINDER";
|
|
51
|
+
export const SILENT_REMINDER_SECONDS_ENV = "YAGNI_SILENT_TURN_REMINDER_SECONDS";
|
|
52
|
+
/** Disable the whole mechanism ("0"/"off"), or resolve the trigger override. */
|
|
53
|
+
export function silentReminderDisabled(env = process.env) {
|
|
54
|
+
const v = env[SILENT_REMINDER_ENV];
|
|
55
|
+
return v === "0" || v?.toLowerCase() === "off";
|
|
56
|
+
}
|
|
57
|
+
/** Resolve the trigger interval: env seconds override wins, else the default. */
|
|
58
|
+
export function resolveSilentIntervalMs(env = process.env) {
|
|
59
|
+
const raw = env[SILENT_REMINDER_SECONDS_ENV];
|
|
60
|
+
if (raw === undefined || raw.trim() === "")
|
|
61
|
+
return DEFAULT_SILENT_INTERVAL_MS;
|
|
62
|
+
const seconds = Number(raw);
|
|
63
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
64
|
+
return DEFAULT_SILENT_INTERVAL_MS;
|
|
65
|
+
return Math.round(seconds * 1000);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* PURE: whether the assistant emitted any non-empty, visible text block this
|
|
69
|
+
* turn. `thinking` blocks deliberately do NOT count — 525k chars of thinking
|
|
70
|
+
* is still silence to the user.
|
|
71
|
+
*/
|
|
72
|
+
export function hasSubstantiveText(message) {
|
|
73
|
+
const content = message.content ?? [];
|
|
74
|
+
return content.some((c) => c.type === "text" && typeof c.text === "string" && c.text.trim().length > 0);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* PURE: whether the assistant "spoke" this turn — visible text, OR a call to a
|
|
78
|
+
* user-facing tool (which is itself an utterance to the user, even without a
|
|
79
|
+
* text block).
|
|
80
|
+
*/
|
|
81
|
+
export function didSpeak(message) {
|
|
82
|
+
const content = message.content ?? [];
|
|
83
|
+
for (const c of content) {
|
|
84
|
+
if (c.type === "text" && typeof c.text === "string" && c.text.trim().length > 0) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (c.type === "toolCall" && typeof c.name === "string" && USER_FACING_TOOLS.has(c.name)) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* PURE: is a reminder due? The turn count must pass {@link MIN_SILENT_TURNS},
|
|
95
|
+
* the wall-clock silence must pass the configured interval, and the spacing
|
|
96
|
+
* between nudges must pass the same interval. `lastNudgeAt` is `null` before
|
|
97
|
+
* any nudge has fired (still eligible once the other gates pass).
|
|
98
|
+
*/
|
|
99
|
+
export function shouldRemind(input) {
|
|
100
|
+
const { msSinceSpoke, turnsSinceSpoke, msSinceLastNudge, intervalMs } = input;
|
|
101
|
+
if (turnsSinceSpoke < MIN_SILENT_TURNS)
|
|
102
|
+
return false;
|
|
103
|
+
if (msSinceSpoke < intervalMs)
|
|
104
|
+
return false;
|
|
105
|
+
return msSinceLastNudge === null || msSinceLastNudge >= intervalMs;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The injected payload. Claude Code's wording plus one clause for the failure
|
|
109
|
+
* we actually saw (the model holding an assembled answer and never giving it).
|
|
110
|
+
* Wrapped in `<system-reminder>` tags — the framing branding.ts teaches the
|
|
111
|
+
* model to parse as system-injected, not part of the tool output.
|
|
112
|
+
*/
|
|
113
|
+
export function formatSilentReminder() {
|
|
114
|
+
return ("<system-reminder>\n" +
|
|
115
|
+
"The user hasn't heard from you in a while. As you continue, keep them updated " +
|
|
116
|
+
"when there's something to tell — a finding, a change of plan. If you already " +
|
|
117
|
+
"have an answer, give it now with your confidence and what's still open.\n" +
|
|
118
|
+
"</system-reminder>");
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Wire the silent-turn reminder. No handlers are registered for non-driver
|
|
122
|
+
* processes, eval mode, or when disabled — so children, subagents, advisor
|
|
123
|
+
* consults, and headless evals are untouched, and the kill switch is a true
|
|
124
|
+
* no-op.
|
|
125
|
+
*/
|
|
126
|
+
export function registerSilentTurnReminder(pi, deps = {}) {
|
|
127
|
+
const env = deps.env ?? process.env;
|
|
128
|
+
if (deps.evalMode)
|
|
129
|
+
return;
|
|
130
|
+
if (deps.isDriver === false)
|
|
131
|
+
return;
|
|
132
|
+
if (silentReminderDisabled(env))
|
|
133
|
+
return;
|
|
134
|
+
const now = deps.now ?? (() => Date.now());
|
|
135
|
+
const intervalMs = resolveSilentIntervalMs(env);
|
|
136
|
+
let lastSpokeAt = now();
|
|
137
|
+
let turnsSinceSpoke = 0;
|
|
138
|
+
let nudgeCount = 0; // per-stretch ordinal, reset when the silence clock resets
|
|
139
|
+
let lastNudgeAt = null;
|
|
140
|
+
const reset = (at) => {
|
|
141
|
+
lastSpokeAt = at;
|
|
142
|
+
turnsSinceSpoke = 0;
|
|
143
|
+
nudgeCount = 0;
|
|
144
|
+
};
|
|
145
|
+
// One tick per finalized assistant message: did it speak (reset the clock),
|
|
146
|
+
// or not (extend the silence)? The observability counters are logged here so
|
|
147
|
+
// the recorded values are identical to the ones that drive the decision.
|
|
148
|
+
pi.on("message_end", async (event) => {
|
|
149
|
+
try {
|
|
150
|
+
const msg = event.message;
|
|
151
|
+
if (msg?.role !== "assistant")
|
|
152
|
+
return;
|
|
153
|
+
const spoke = didSpeak(msg);
|
|
154
|
+
if (spoke) {
|
|
155
|
+
reset(now());
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
turnsSinceSpoke += 1;
|
|
159
|
+
}
|
|
160
|
+
logTurnLifecycle({
|
|
161
|
+
kind: "silent_turn",
|
|
162
|
+
secondsSinceSpoke: Math.round((now() - lastSpokeAt) / 1000),
|
|
163
|
+
turnsSinceSpoke,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// A reminder must never break a turn.
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
// Any user message resets the silence clock — the "suppressed on the turn
|
|
171
|
+
// right after a user message" rule, implemented as a clock reset.
|
|
172
|
+
pi.on("input", async () => {
|
|
173
|
+
try {
|
|
174
|
+
reset(now());
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Never break input handling.
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
// The nudge itself rides an existing tool result. Never appended to a
|
|
181
|
+
// user-facing tool's own result (a file_ticket/todo_write already IS
|
|
182
|
+
// speaking), and fail-soft: a reminder must never break a tool call.
|
|
183
|
+
pi.on("tool_result", async (event) => {
|
|
184
|
+
try {
|
|
185
|
+
if (event.isError)
|
|
186
|
+
return;
|
|
187
|
+
if (event.toolName === "todo_write" || event.toolName === "file_ticket" || event.toolName === "update_ticket_status") {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!shouldRemind({
|
|
191
|
+
msSinceSpoke: now() - lastSpokeAt,
|
|
192
|
+
turnsSinceSpoke,
|
|
193
|
+
msSinceLastNudge: lastNudgeAt === null ? null : now() - lastNudgeAt,
|
|
194
|
+
intervalMs,
|
|
195
|
+
})) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
nudgeCount += 1;
|
|
199
|
+
lastNudgeAt = now();
|
|
200
|
+
logTurnLifecycle({
|
|
201
|
+
kind: "silent_turn_nudge",
|
|
202
|
+
nudgesInStretch: nudgeCount,
|
|
203
|
+
secondsSinceSpoke: Math.round((now() - lastSpokeAt) / 1000),
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
content: [
|
|
207
|
+
...event.content,
|
|
208
|
+
{ type: "text", text: `\n\n${formatSilentReminder()}` },
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=silentTurnReminder.js.map
|
|
@@ -26,6 +26,20 @@ export type TurnLifecycleEvent = {
|
|
|
26
26
|
stopReason?: string;
|
|
27
27
|
/** Time since the matching turn_start, ms. */
|
|
28
28
|
elapsedMs?: number;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "silent_turn";
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
/** Whole seconds since the model last spoke (visible text or a user-facing tool). */
|
|
33
|
+
secondsSinceSpoke?: number;
|
|
34
|
+
/** Assistant turns since the model last spoke. */
|
|
35
|
+
turnsSinceSpoke?: number;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "silent_turn_nudge";
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
/** 1-indexed fire count within the current silence stretch. */
|
|
40
|
+
nudgesInStretch?: number;
|
|
41
|
+
/** Whole seconds of silence at the moment the nudge fired. */
|
|
42
|
+
secondsSinceSpoke?: number;
|
|
29
43
|
};
|
|
30
44
|
/**
|
|
31
45
|
* Append one sanitized lifecycle record. Fail-soft — a logging failure must
|
|
@@ -64,9 +64,13 @@ export function logTurnLifecycle(ev) {
|
|
|
64
64
|
ts: new Date().toISOString(),
|
|
65
65
|
event: ev.kind,
|
|
66
66
|
...(ev.sessionId ? { sessionId: ev.sessionId } : {}),
|
|
67
|
-
...(ev.turnIndex !== undefined ? { turnIndex: ev.turnIndex } : {}),
|
|
67
|
+
...((ev.kind === "turn_start" || ev.kind === "turn_end") && ev.turnIndex !== undefined ? { turnIndex: ev.turnIndex } : {}),
|
|
68
68
|
...(ev.kind === "turn_end" && ev.stopReason !== undefined ? { stopReason: ev.stopReason } : {}),
|
|
69
69
|
...(ev.kind === "turn_end" && ev.elapsedMs !== undefined ? { elapsedMs: ev.elapsedMs } : {}),
|
|
70
|
+
...(ev.kind === "silent_turn" && ev.secondsSinceSpoke !== undefined ? { secondsSinceSpoke: ev.secondsSinceSpoke } : {}),
|
|
71
|
+
...(ev.kind === "silent_turn" && ev.turnsSinceSpoke !== undefined ? { turnsSinceSpoke: ev.turnsSinceSpoke } : {}),
|
|
72
|
+
...(ev.kind === "silent_turn_nudge" && ev.nudgesInStretch !== undefined ? { nudgesInStretch: ev.nudgesInStretch } : {}),
|
|
73
|
+
...(ev.kind === "silent_turn_nudge" && ev.secondsSinceSpoke !== undefined ? { secondsSinceSpoke: ev.secondsSinceSpoke } : {}),
|
|
70
74
|
};
|
|
71
75
|
const path = turnLogPath();
|
|
72
76
|
mkdirSync(dirname(path), { recursive: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.3.4-staging.
|
|
3
|
+
"version": "0.3.4-staging.1155.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -39,5 +39,5 @@
|
|
|
39
39
|
"smol-toml": "^1.8.0",
|
|
40
40
|
"typebox": "^1.3.15"
|
|
41
41
|
},
|
|
42
|
-
"yagniSourceSha": "
|
|
42
|
+
"yagniSourceSha": "17bd689a1ff1bc6e8d9fc0d420b2d4e580a781dd"
|
|
43
43
|
}
|