agent-standup 0.20.0
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/LICENSE +21 -0
- package/README.md +322 -0
- package/dist/bin/standup-hook.js +296 -0
- package/dist/bin/standup.js +3351 -0
- package/dist/chunk-4TIZQTUZ.js +19195 -0
- package/dist/chunk-N7G677FC.js +1042 -0
- package/dist/chunk-VBXNDGOD.js +203 -0
- package/dist/hook-scripts/http.js +1265 -0
- package/dist/live-R6WH7ER7.js +326 -0
- package/dist/plugin/.claude-plugin/plugin.json +9 -0
- package/dist/plugin/.mcp.json +8 -0
- package/dist/plugin/hooks/hooks.json +35 -0
- package/dist/plugin/skills/setup-agent-standup/SKILL.md +53 -0
- package/dist/run-init-VVQPJOTB.js +407 -0
- package/package.json +88 -0
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
import {
|
|
2
|
+
INTERVENTION_SCORE_MEANINGS,
|
|
3
|
+
MAX_COMMAND_CHARS,
|
|
4
|
+
MAX_SESSION_ID_CHARS,
|
|
5
|
+
MAX_TOOL_CHARS,
|
|
6
|
+
SCALE_POINTS,
|
|
7
|
+
capPaths,
|
|
8
|
+
capText,
|
|
9
|
+
isBlockingLevel
|
|
10
|
+
} from "./chunk-VBXNDGOD.js";
|
|
11
|
+
|
|
12
|
+
// src/lib/hook/override.ts
|
|
13
|
+
var MIN_OVERRIDE_REASON_LENGTH = 20;
|
|
14
|
+
var MAX_OVERRIDE_REASON_LENGTH = 1e3;
|
|
15
|
+
function overrideApplies(claim, entryId, level) {
|
|
16
|
+
if (level === "hard-block") {
|
|
17
|
+
return { applies: false, refusal: "level-not-overridable" };
|
|
18
|
+
}
|
|
19
|
+
if (!isBlockingLevel(level)) return { applies: false };
|
|
20
|
+
if (claim === void 0) return { applies: false, refusal: "no-override" };
|
|
21
|
+
if (claim.entryId !== entryId) return { applies: false, refusal: "wrong-entry" };
|
|
22
|
+
const reason = claim.reason.trim();
|
|
23
|
+
if (reason.length < MIN_OVERRIDE_REASON_LENGTH) {
|
|
24
|
+
return { applies: false, refusal: "reason-too-short" };
|
|
25
|
+
}
|
|
26
|
+
return { applies: true, reason: reason.slice(0, MAX_OVERRIDE_REASON_LENGTH) };
|
|
27
|
+
}
|
|
28
|
+
function readOverrideClaim(value) {
|
|
29
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
30
|
+
const record = value;
|
|
31
|
+
const entryId = record.entryId ?? record.entry_id;
|
|
32
|
+
const reason = record.reason;
|
|
33
|
+
if (typeof entryId !== "string" || entryId.trim() === "") return void 0;
|
|
34
|
+
if (typeof reason !== "string" || reason.trim() === "") return void 0;
|
|
35
|
+
return { entryId: entryId.trim(), reason: reason.trim() };
|
|
36
|
+
}
|
|
37
|
+
function overrideRemedy(entryId, level) {
|
|
38
|
+
if (level !== "block-overridable") return null;
|
|
39
|
+
return `This block can be overridden. To proceed, re-run the call with an override naming "${entryId}" and a written reason of at least ${MIN_OVERRIDE_REASON_LENGTH} characters saying why it is right to go ahead. The reason is recorded against this finding and can be read later \u2014 it is kept as a record, not checked for correctness.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/lib/hook/payload.ts
|
|
43
|
+
var HOOK_EVENT_TYPES = ["PreToolUse", "PostToolUse", "Stop"];
|
|
44
|
+
function isHookEventType(value) {
|
|
45
|
+
return typeof value === "string" && HOOK_EVENT_TYPES.includes(value);
|
|
46
|
+
}
|
|
47
|
+
var MAX_TOOL_RESULT_CHARS = 4e3;
|
|
48
|
+
var COMMAND_FIELDS = ["command", "file_path", "filePath", "path", "pattern", "url"];
|
|
49
|
+
function property(value, key) {
|
|
50
|
+
return typeof value === "object" && value !== null ? value[key] : void 0;
|
|
51
|
+
}
|
|
52
|
+
function firstString(source, keys) {
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
const value = property(source, key);
|
|
55
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
56
|
+
}
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
function parseHookPayload(text) {
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = JSON.parse(text);
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, reason: "the hook payload was not valid JSON" };
|
|
65
|
+
}
|
|
66
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
67
|
+
return { ok: false, reason: "the hook payload was not a JSON object" };
|
|
68
|
+
}
|
|
69
|
+
const eventType = firstString(raw, ["hook_event_name", "hookEventName", "eventType"]);
|
|
70
|
+
if (!isHookEventType(eventType)) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: `unrecognised hook event type ${eventType === void 0 ? "(absent)" : `"${eventType}"`}`
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const sessionId = firstString(raw, ["session_id", "sessionId"]);
|
|
77
|
+
if (sessionId === void 0) {
|
|
78
|
+
return { ok: false, reason: "the hook payload carried no session identifier" };
|
|
79
|
+
}
|
|
80
|
+
const tool = firstString(raw, ["tool_name", "toolName", "tool"]);
|
|
81
|
+
const toolInput = property(raw, "tool_input") ?? property(raw, "toolInput");
|
|
82
|
+
const command = firstString(toolInput, COMMAND_FIELDS);
|
|
83
|
+
const toolResult = readToolResult(
|
|
84
|
+
property(raw, "tool_response") ?? property(raw, "toolResponse")
|
|
85
|
+
);
|
|
86
|
+
const override = readOverrideClaim(
|
|
87
|
+
property(raw, "standup_override") ?? property(raw, "standupOverride")
|
|
88
|
+
);
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
event: {
|
|
92
|
+
eventType,
|
|
93
|
+
sessionId,
|
|
94
|
+
...tool === void 0 ? {} : { tool },
|
|
95
|
+
...command === void 0 ? {} : { command },
|
|
96
|
+
...toolResult === void 0 ? {} : { toolResult },
|
|
97
|
+
...override === void 0 ? {} : { override }
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function readToolResult(value) {
|
|
102
|
+
if (value === void 0 || value === null) return void 0;
|
|
103
|
+
let text;
|
|
104
|
+
if (typeof value === "string") {
|
|
105
|
+
text = value;
|
|
106
|
+
} else {
|
|
107
|
+
try {
|
|
108
|
+
text = JSON.stringify(value) ?? "";
|
|
109
|
+
} catch {
|
|
110
|
+
return void 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (text.length === 0) return void 0;
|
|
114
|
+
return text.length > MAX_TOOL_RESULT_CHARS ? text.slice(0, MAX_TOOL_RESULT_CHARS) : text;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/lib/hook/response.ts
|
|
118
|
+
var HOOK_EXIT = {
|
|
119
|
+
/** Allowed — say nothing. */
|
|
120
|
+
ALLOW: 0,
|
|
121
|
+
/**
|
|
122
|
+
* Denied. `2` rather than `1` because agent tools conventionally treat a
|
|
123
|
+
* hook's `2` as "block and feed stderr back to the model", where `1` is
|
|
124
|
+
* an ordinary script failure that is reported to the person and otherwise
|
|
125
|
+
* ignored — and a guard whose refusal the model never sees is a guard that
|
|
126
|
+
* does not change what the model does next.
|
|
127
|
+
*/
|
|
128
|
+
DENY: 2
|
|
129
|
+
};
|
|
130
|
+
function renderResponse(verdict, eventName) {
|
|
131
|
+
if (verdict.decision === "allow") {
|
|
132
|
+
return { stdout: "", stderr: "", exitCode: HOOK_EXIT.ALLOW };
|
|
133
|
+
}
|
|
134
|
+
const output = {
|
|
135
|
+
decision: "deny",
|
|
136
|
+
reason: verdict.reason,
|
|
137
|
+
source: verdict.source,
|
|
138
|
+
hookSpecificOutput: {
|
|
139
|
+
hookEventName: eventName,
|
|
140
|
+
permissionDecision: "deny",
|
|
141
|
+
permissionDecisionReason: verdict.reason
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
stdout: `${JSON.stringify(output)}
|
|
146
|
+
`,
|
|
147
|
+
stderr: `${verdict.reason}
|
|
148
|
+
`,
|
|
149
|
+
exitCode: HOOK_EXIT.DENY
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function renderWithStopCatch(response, stopCatch) {
|
|
153
|
+
if (stopCatch === null) return response;
|
|
154
|
+
const advisory = `[standup:${stopCatch.kind}] ${stopCatch.text}
|
|
155
|
+
`;
|
|
156
|
+
return {
|
|
157
|
+
stdout: response.stdout,
|
|
158
|
+
stderr: `${response.stderr}${advisory}`,
|
|
159
|
+
// Deliberately the response's own code — see the note above.
|
|
160
|
+
exitCode: response.exitCode
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function renderWithNudges(verdict, eventName, nudges) {
|
|
164
|
+
const base = renderResponse(verdict, eventName);
|
|
165
|
+
if (nudges.length === 0) return base;
|
|
166
|
+
const advisory = nudges.map((nudge) => `[standup:${nudge.kind}] ${nudge.text}`).join("\n");
|
|
167
|
+
return {
|
|
168
|
+
stdout: base.stdout,
|
|
169
|
+
stderr: base.stderr === "" ? `${advisory}
|
|
170
|
+
` : `${base.stderr}${advisory}
|
|
171
|
+
`,
|
|
172
|
+
// Deliberately `base.exitCode`, never a value derived from `nudges`.
|
|
173
|
+
exitCode: base.exitCode
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function renderWithStopSurvey(response, survey) {
|
|
177
|
+
if (survey === null) return response;
|
|
178
|
+
const advisory = `[standup:${survey.kind}] ${survey.text}
|
|
179
|
+
`;
|
|
180
|
+
return {
|
|
181
|
+
stdout: response.stdout,
|
|
182
|
+
stderr: `${response.stderr}${advisory}`,
|
|
183
|
+
// Deliberately the response's own code, never a value derived from the
|
|
184
|
+
// survey. A questionnaire that could raise an exit code would be a
|
|
185
|
+
// refused stop, which DECISIONS.md section 6 rules out outright.
|
|
186
|
+
exitCode: response.exitCode
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/lib/hook/enforcement.ts
|
|
191
|
+
var SESSION_STATUSES = ["active", "displaced", "unregistered", "incompatible"];
|
|
192
|
+
function isSessionStatus(value) {
|
|
193
|
+
return typeof value === "string" && SESSION_STATUSES.includes(value);
|
|
194
|
+
}
|
|
195
|
+
var REASONS = Object.freeze({
|
|
196
|
+
displaced: "This session holds no claim on the work it is running \u2014 the item was taken over by another session. Stop here and report what you had completed; do not continue making changes.",
|
|
197
|
+
unregistered: "This session has not registered with the server, so its actions cannot be attributed or guarded. Register the session, then retry.",
|
|
198
|
+
incompatible: "This session's hook is older than the server's minimum supported protocol version, so the rules it would enforce are not the rules the server expects. Update, then retry."
|
|
199
|
+
});
|
|
200
|
+
function enforcementRefusal(enforcement) {
|
|
201
|
+
if (enforcement === void 0) return null;
|
|
202
|
+
if (enforcement.status === "active") return null;
|
|
203
|
+
if (!isSessionStatus(enforcement.status)) {
|
|
204
|
+
return {
|
|
205
|
+
status: "incompatible",
|
|
206
|
+
reason: "The server reported a session status this hook does not recognise, so it cannot tell whether this session is allowed to act. Update, then retry."
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const base = REASONS[enforcement.status];
|
|
210
|
+
return {
|
|
211
|
+
status: enforcement.status,
|
|
212
|
+
reason: enforcement.detail === void 0 || enforcement.detail.length === 0 ? base : `${base} (${enforcement.detail})`
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function readSessionStatus(value) {
|
|
216
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
217
|
+
const record = value;
|
|
218
|
+
const status = record.status;
|
|
219
|
+
if (typeof status !== "string") return void 0;
|
|
220
|
+
const detail = typeof record.detail === "string" ? record.detail : void 0;
|
|
221
|
+
return { status, ...detail === void 0 ? {} : { detail } };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/lib/hook/nudge.ts
|
|
225
|
+
var NUDGE_KINDS = [
|
|
226
|
+
"delegate",
|
|
227
|
+
"staging",
|
|
228
|
+
"escalation",
|
|
229
|
+
"wind-down",
|
|
230
|
+
"background"
|
|
231
|
+
];
|
|
232
|
+
function isNudgeKind(value) {
|
|
233
|
+
return typeof value === "string" && NUDGE_KINDS.includes(value);
|
|
234
|
+
}
|
|
235
|
+
var BUDGET_BANDS = ["free", "selective", "wind-down", "stop"];
|
|
236
|
+
var WRITE_SHAPED_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "NotebookEdit", "Bash"]);
|
|
237
|
+
function isWriteShaped(tool) {
|
|
238
|
+
return tool !== void 0 && WRITE_SHAPED_TOOLS.has(tool);
|
|
239
|
+
}
|
|
240
|
+
var DELEGATE_TEXT = "You are orchestrating and this call changes something directly. Consider dispatching it to a crew member instead \u2014 delegation is the configured preference here. This is advice, not a refusal: the call has already run.";
|
|
241
|
+
var STAGING_TEXT = (count) => `There ${count === 1 ? "is" : "are"} ${count} file${count === 1 ? "" : "s"} of uncommitted work in this session. Commit at a logical checkpoint so the work is not lost if the session ends unexpectedly.`;
|
|
242
|
+
var BACKGROUND_NUDGE_THRESHOLD_SECONDS = 120;
|
|
243
|
+
var WIND_DOWN_TEXT = "The budget window has reached its wind-down band. Start nothing new: bring in-flight work to a good stopping point, take shortcuts to a clean pause, and write the handoff. Finishing is not required \u2014 a clean pause is.";
|
|
244
|
+
function approximateDuration(seconds) {
|
|
245
|
+
if (seconds < 60) return `~${Math.round(seconds)}s`;
|
|
246
|
+
return `~${Math.round(seconds / 60)} min`;
|
|
247
|
+
}
|
|
248
|
+
var BACKGROUND_TEXT = (seconds) => `This command has taken ${approximateDuration(seconds)} on previous runs. Consider running it in the background and picking the result up later, rather than blocking this session while it runs. Some calls genuinely cannot be backgrounded \u2014 this is advice, not a refusal.`;
|
|
249
|
+
function evaluateNudges(context) {
|
|
250
|
+
const already = new Set(context.alreadyNudged ?? []);
|
|
251
|
+
const nudges = [];
|
|
252
|
+
if (context.delegationMode === "allowed" && context.isOrchestrator === true && context.writeShaped === true) {
|
|
253
|
+
nudges.push({ kind: "delegate", text: DELEGATE_TEXT });
|
|
254
|
+
}
|
|
255
|
+
if (context.writeShaped === true && context.unstagedFiles !== void 0 && context.unstagedFiles > 0) {
|
|
256
|
+
nudges.push({ kind: "staging", text: STAGING_TEXT(context.unstagedFiles) });
|
|
257
|
+
}
|
|
258
|
+
if (context.escalation !== void 0 && context.escalation.length > 0) {
|
|
259
|
+
nudges.push({ kind: "escalation", text: context.escalation });
|
|
260
|
+
}
|
|
261
|
+
if (context.budgetBand === "wind-down") {
|
|
262
|
+
nudges.push({ kind: "wind-down", text: WIND_DOWN_TEXT });
|
|
263
|
+
}
|
|
264
|
+
if (context.beforeCall === true && context.alreadyBackgrounded !== true && context.typicalDurationSeconds !== void 0 && Number.isFinite(context.typicalDurationSeconds) && context.typicalDurationSeconds > BACKGROUND_NUDGE_THRESHOLD_SECONDS) {
|
|
265
|
+
nudges.push({
|
|
266
|
+
kind: "background",
|
|
267
|
+
text: BACKGROUND_TEXT(context.typicalDurationSeconds)
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return nudges.filter((nudge) => !already.has(nudge.kind));
|
|
271
|
+
}
|
|
272
|
+
function readNudgeContext(value) {
|
|
273
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
274
|
+
const record = value;
|
|
275
|
+
const delegationMode = record.delegationMode === "never" || record.delegationMode === "allowed" || record.delegationMode === "required" ? record.delegationMode : void 0;
|
|
276
|
+
const budgetBand = BUDGET_BANDS.includes(record.budgetBand) ? record.budgetBand : void 0;
|
|
277
|
+
const unstagedFiles = typeof record.unstagedFiles === "number" && Number.isInteger(record.unstagedFiles) ? record.unstagedFiles : void 0;
|
|
278
|
+
const escalation = typeof record.escalation === "string" ? record.escalation : void 0;
|
|
279
|
+
const alreadyNudged = Array.isArray(record.alreadyNudged) ? record.alreadyNudged.filter(isNudgeKind) : void 0;
|
|
280
|
+
const typicalDurationSeconds = typeof record.typicalDurationSeconds === "number" && Number.isFinite(record.typicalDurationSeconds) && record.typicalDurationSeconds >= 0 ? record.typicalDurationSeconds : void 0;
|
|
281
|
+
const context = {
|
|
282
|
+
...delegationMode === void 0 ? {} : { delegationMode },
|
|
283
|
+
...typeof record.isOrchestrator === "boolean" ? { isOrchestrator: record.isOrchestrator } : {},
|
|
284
|
+
...unstagedFiles === void 0 ? {} : { unstagedFiles },
|
|
285
|
+
...escalation === void 0 ? {} : { escalation },
|
|
286
|
+
...budgetBand === void 0 ? {} : { budgetBand },
|
|
287
|
+
...alreadyNudged === void 0 ? {} : { alreadyNudged },
|
|
288
|
+
...typicalDurationSeconds === void 0 ? {} : { typicalDurationSeconds },
|
|
289
|
+
...typeof record.alreadyBackgrounded === "boolean" ? { alreadyBackgrounded: record.alreadyBackgrounded } : {}
|
|
290
|
+
};
|
|
291
|
+
return Object.keys(context).length === 0 ? void 0 : context;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/lib/hook/decide.ts
|
|
295
|
+
var ALLOW = (source, reason) => ({
|
|
296
|
+
decision: "allow",
|
|
297
|
+
reason,
|
|
298
|
+
source
|
|
299
|
+
});
|
|
300
|
+
function canBlock(event) {
|
|
301
|
+
return event.eventType === "PreToolUse";
|
|
302
|
+
}
|
|
303
|
+
async function decide({
|
|
304
|
+
event,
|
|
305
|
+
askServer,
|
|
306
|
+
enforcement
|
|
307
|
+
}) {
|
|
308
|
+
const localRefusal = enforcementRefusal(enforcement);
|
|
309
|
+
if (localRefusal !== null && canBlock(event)) {
|
|
310
|
+
return { decision: "deny", reason: localRefusal.reason, source: "enforcement" };
|
|
311
|
+
}
|
|
312
|
+
let answer;
|
|
313
|
+
try {
|
|
314
|
+
answer = await askServer(event);
|
|
315
|
+
} catch {
|
|
316
|
+
answer = void 0;
|
|
317
|
+
}
|
|
318
|
+
if (answer === void 0) {
|
|
319
|
+
return ALLOW(
|
|
320
|
+
"server-unreachable",
|
|
321
|
+
"the server could not be reached for a verdict; the hook allows when it has no answer"
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (!canBlock(event)) {
|
|
325
|
+
return ALLOW(
|
|
326
|
+
"post-cannot-block",
|
|
327
|
+
"this event fires after the call has run, so it reports rather than refuses"
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const refusal = enforcementRefusal(answer.enforcement);
|
|
331
|
+
if (refusal !== null) {
|
|
332
|
+
return { decision: "deny", reason: refusal.reason, source: "enforcement" };
|
|
333
|
+
}
|
|
334
|
+
if (answer.decision === "block") {
|
|
335
|
+
const blocking = (answer.findings ?? []).filter((finding) => isBlockingLevel(finding.level));
|
|
336
|
+
const overridden = blocking.filter(
|
|
337
|
+
(finding) => overrideApplies(event.override, finding.id, finding.level).applies
|
|
338
|
+
);
|
|
339
|
+
if (blocking.length > 0 && overridden.length === blocking.length) {
|
|
340
|
+
const reason = overrideApplies(event.override, blocking[0].id, blocking[0].level).reason ?? "";
|
|
341
|
+
return {
|
|
342
|
+
...ALLOW("override", `overridden by the caller with a written reason: ${reason}`),
|
|
343
|
+
// The structured record. The prose above is for a person reading
|
|
344
|
+
// stderr; this is what reaches `intervention_events.override_reason`
|
|
345
|
+
// so the reason survives the process that printed it.
|
|
346
|
+
override: { entryIds: overridden.map((finding) => finding.id), reason }
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const remedies = blocking.map((finding) => overrideRemedy(finding.id, finding.level)).filter((remedy) => remedy !== null);
|
|
350
|
+
const base = answer.reason ?? "blocked by the server";
|
|
351
|
+
return {
|
|
352
|
+
decision: "deny",
|
|
353
|
+
reason: remedies.length === 0 ? base : `${base} ${remedies.join(" ")}`,
|
|
354
|
+
source: "server"
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
return ALLOW("server", answer.reason ?? "allowed by the server");
|
|
358
|
+
}
|
|
359
|
+
async function decideWithNudges(options) {
|
|
360
|
+
let volunteered;
|
|
361
|
+
let findings;
|
|
362
|
+
const askServer = async (asked) => {
|
|
363
|
+
const answer = await options.askServer(asked);
|
|
364
|
+
if (answer?.nudge !== void 0) volunteered = answer.nudge;
|
|
365
|
+
if (answer?.findings !== void 0) findings = answer.findings;
|
|
366
|
+
return answer;
|
|
367
|
+
};
|
|
368
|
+
const verdict = await decide({ ...options, askServer });
|
|
369
|
+
const merged = { ...options.nudge, ...volunteered };
|
|
370
|
+
const nudges = evaluateNudges({
|
|
371
|
+
...merged,
|
|
372
|
+
// The event knows which tool ran; the context does not, and nothing
|
|
373
|
+
// upstream should have to restate it. A `writeShaped` already on the
|
|
374
|
+
// context still wins.
|
|
375
|
+
writeShaped: merged.writeShaped ?? isWriteShaped(options.event.tool),
|
|
376
|
+
// Same reasoning for `beforeCall`, which the backgrounding nudge keys
|
|
377
|
+
// off (MILESTONES.md #65). This is read from the *event*, never from
|
|
378
|
+
// the server: whether the call has already run is a local fact, and a
|
|
379
|
+
// server that claimed otherwise would be overriding something it
|
|
380
|
+
// cannot observe. Hence `??` on the two fields above and a plain
|
|
381
|
+
// assignment here.
|
|
382
|
+
beforeCall: options.event.eventType === "PreToolUse"
|
|
383
|
+
});
|
|
384
|
+
return { verdict, nudges, findings: findings ?? [] };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/lib/interventions/survey.ts
|
|
388
|
+
var MAX_SURVEY_ITEMS = 5;
|
|
389
|
+
var WIND_DOWN_QUIET_MS = 2 * 60 * 1e3;
|
|
390
|
+
function shouldSurvey(context, quietMs = WIND_DOWN_QUIET_MS) {
|
|
391
|
+
if (context.alreadySurveyed === true) return false;
|
|
392
|
+
const unrated = context.unrated;
|
|
393
|
+
if (unrated === void 0 || unrated.length === 0) return false;
|
|
394
|
+
if (context.liveCrew !== void 0 && context.liveCrew > 0) return false;
|
|
395
|
+
if (context.wakeScheduled === true) return false;
|
|
396
|
+
const idleMs = context.idleMs;
|
|
397
|
+
if (idleMs === void 0) return false;
|
|
398
|
+
return idleMs >= quietMs;
|
|
399
|
+
}
|
|
400
|
+
function dedupeForSurvey(firings, limit = MAX_SURVEY_ITEMS) {
|
|
401
|
+
const latestByEntry = /* @__PURE__ */ new Map();
|
|
402
|
+
for (const firing of firings) {
|
|
403
|
+
const held = latestByEntry.get(firing.entryId);
|
|
404
|
+
if (held === void 0 || firing.at > held.at) latestByEntry.set(firing.entryId, firing);
|
|
405
|
+
}
|
|
406
|
+
return [...latestByEntry.values()].sort((a, b) => b.at - a.at).slice(0, Math.max(0, limit));
|
|
407
|
+
}
|
|
408
|
+
function buildSurvey(firings) {
|
|
409
|
+
const asked = dedupeForSurvey(firings);
|
|
410
|
+
if (asked.length === 0) return null;
|
|
411
|
+
const scale = SCALE_POINTS.map(
|
|
412
|
+
(score) => ` ${score} \u2014 ${INTERVENTION_SCORE_MEANINGS[score]}`
|
|
413
|
+
).join("\n");
|
|
414
|
+
const items = asked.map((firing, index) => {
|
|
415
|
+
const parts = [`${index + 1}. [${firing.entryId}] eventId ${firing.eventId}`];
|
|
416
|
+
if (firing.tool !== void 0) parts.push(` you were calling: ${firing.tool}`);
|
|
417
|
+
if (firing.outcome !== void 0) parts.push(` it: ${firing.outcome}`);
|
|
418
|
+
if (firing.message !== void 0) parts.push(` it told you: ${firing.message}`);
|
|
419
|
+
return parts.join("\n");
|
|
420
|
+
}).join("\n");
|
|
421
|
+
const prompt = [
|
|
422
|
+
`Before you finish: ${asked.length} intervention${asked.length === 1 ? "" : "s"} fired during this session. Rate how useful each one actually was, so the unhelpful ones can be removed.`,
|
|
423
|
+
"",
|
|
424
|
+
scale,
|
|
425
|
+
"",
|
|
426
|
+
items,
|
|
427
|
+
"",
|
|
428
|
+
"Reply with JSON only \u2014 no prose, no explanation outside the notes:",
|
|
429
|
+
'{"scores":[{"eventId":"<id>","score":<1-5>,"note":"<optional, one line>"}]}',
|
|
430
|
+
"",
|
|
431
|
+
"A note is worth adding when the score is low, because a 1 or a 2 can mean two very different things: the detection was wrong, or the detection was right and the message did not say what to do next. Only the second is fixed by rewording it."
|
|
432
|
+
].join("\n");
|
|
433
|
+
return { kind: "intervention-survey", firings: asked, prompt };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/lib/hook/stop-catch.ts
|
|
437
|
+
function isWakeScheduled(context) {
|
|
438
|
+
return context.wakeScheduled === true || context.waitBackgrounded === true;
|
|
439
|
+
}
|
|
440
|
+
var stopText = (liveCrew) => `${liveCrew} crew member${liveCrew === 1 ? "" : "s"} ${liveCrew === 1 ? "is" : "are"} still running and nothing is scheduled to wake you when they finish, so their work would complete into a session that has stopped listening. Start a backgrounded wait before ending the turn. This is advice, not a refusal \u2014 the turn is not being held open.`;
|
|
441
|
+
function evaluateStopCatch(event, context) {
|
|
442
|
+
if (event.eventType !== "Stop") return null;
|
|
443
|
+
if (context === void 0) return null;
|
|
444
|
+
if (context.alreadyCaught === true) return null;
|
|
445
|
+
if (isWakeScheduled(context)) return null;
|
|
446
|
+
const liveCrew = context.liveCrew;
|
|
447
|
+
if (liveCrew === void 0 || liveCrew <= 0) return null;
|
|
448
|
+
return { kind: "stop-catch", text: stopText(liveCrew), liveCrew };
|
|
449
|
+
}
|
|
450
|
+
function readStopContext(value) {
|
|
451
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
452
|
+
const record = value;
|
|
453
|
+
const liveCrew = typeof record.liveCrew === "number" && Number.isInteger(record.liveCrew) && record.liveCrew >= 0 ? record.liveCrew : void 0;
|
|
454
|
+
const context = {
|
|
455
|
+
...liveCrew === void 0 ? {} : { liveCrew },
|
|
456
|
+
...typeof record.wakeScheduled === "boolean" ? { wakeScheduled: record.wakeScheduled } : {},
|
|
457
|
+
...typeof record.waitBackgrounded === "boolean" ? { waitBackgrounded: record.waitBackgrounded } : {},
|
|
458
|
+
...typeof record.alreadyCaught === "boolean" ? { alreadyCaught: record.alreadyCaught } : {}
|
|
459
|
+
};
|
|
460
|
+
return Object.keys(context).length === 0 ? void 0 : context;
|
|
461
|
+
}
|
|
462
|
+
function evaluateStopSurvey(event, context) {
|
|
463
|
+
if (event.eventType !== "Stop") return null;
|
|
464
|
+
if (context === void 0) return null;
|
|
465
|
+
if (!shouldSurvey(context)) return null;
|
|
466
|
+
const survey = buildSurvey(context.unrated ?? []);
|
|
467
|
+
if (survey === null) return null;
|
|
468
|
+
return {
|
|
469
|
+
kind: "intervention-survey",
|
|
470
|
+
text: survey.prompt,
|
|
471
|
+
asked: survey.firings.length
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/lib/hook/run.ts
|
|
476
|
+
async function runHook(options) {
|
|
477
|
+
const parsed = parseHookPayload(options.stdin);
|
|
478
|
+
if (!parsed.ok) {
|
|
479
|
+
const verdict2 = {
|
|
480
|
+
decision: "allow",
|
|
481
|
+
reason: `the hook could not read this event (${parsed.reason})`,
|
|
482
|
+
source: "unreadable-payload"
|
|
483
|
+
};
|
|
484
|
+
return renderResponse(verdict2, "Unknown");
|
|
485
|
+
}
|
|
486
|
+
const event = parsed.event;
|
|
487
|
+
let volunteeredStop;
|
|
488
|
+
const askServer = async (asked) => {
|
|
489
|
+
const answer = await options.askServer(asked);
|
|
490
|
+
if (answer?.stop !== void 0) volunteeredStop = answer.stop;
|
|
491
|
+
return answer;
|
|
492
|
+
};
|
|
493
|
+
const { verdict, nudges, findings } = await decideWithNudges({
|
|
494
|
+
event,
|
|
495
|
+
askServer,
|
|
496
|
+
...options.enforcement === void 0 ? {} : { enforcement: options.enforcement },
|
|
497
|
+
...options.nudge === void 0 ? {} : { nudge: options.nudge }
|
|
498
|
+
});
|
|
499
|
+
if (findings.length > 0 && options.onFindings !== void 0) {
|
|
500
|
+
try {
|
|
501
|
+
await options.onFindings({
|
|
502
|
+
event,
|
|
503
|
+
findings,
|
|
504
|
+
blocked: verdict.decision === "deny",
|
|
505
|
+
// Only ever set on an allow whose source is `override`, so a
|
|
506
|
+
// callback can record the reason against the findings it excused.
|
|
507
|
+
...verdict.override === void 0 ? {} : { override: verdict.override }
|
|
508
|
+
});
|
|
509
|
+
} catch {
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const stopCatch = evaluateStopCatch(event, mergeStopContext(options.stop, volunteeredStop));
|
|
513
|
+
const survey = evaluateStopSurvey(event, options.survey);
|
|
514
|
+
return renderWithStopSurvey(
|
|
515
|
+
renderWithStopCatch(renderWithNudges(verdict, event.eventType, nudges), stopCatch),
|
|
516
|
+
survey
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
function mergeStopContext(local, volunteered) {
|
|
520
|
+
if (local === void 0) return volunteered;
|
|
521
|
+
if (volunteered === void 0) return local;
|
|
522
|
+
return { ...local, ...volunteered };
|
|
523
|
+
}
|
|
524
|
+
function captureContextFor(report) {
|
|
525
|
+
return {
|
|
526
|
+
sessionId: report.event.sessionId,
|
|
527
|
+
...report.event.tool === void 0 ? {} : { tool: report.event.tool },
|
|
528
|
+
...report.event.command === void 0 ? {} : { command: report.event.command },
|
|
529
|
+
blocked: report.blocked,
|
|
530
|
+
...report.override === void 0 ? {} : {
|
|
531
|
+
overriddenEntryIds: report.override.entryIds,
|
|
532
|
+
overrideReason: report.override.reason
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/lib/hook/spool.ts
|
|
538
|
+
var DEFAULT_MAX_RECORDS = 2e4;
|
|
539
|
+
var DEFAULT_BATCH_SIZE = 200;
|
|
540
|
+
function serialiseRecord(record) {
|
|
541
|
+
return `${JSON.stringify(record)}
|
|
542
|
+
`;
|
|
543
|
+
}
|
|
544
|
+
function parseRecord(line) {
|
|
545
|
+
let raw;
|
|
546
|
+
try {
|
|
547
|
+
raw = JSON.parse(line);
|
|
548
|
+
} catch {
|
|
549
|
+
return void 0;
|
|
550
|
+
}
|
|
551
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
|
|
552
|
+
const value = raw;
|
|
553
|
+
if (typeof value.sessionId !== "string" || value.sessionId.length === 0) return void 0;
|
|
554
|
+
if (typeof value.ts !== "string" || value.ts.length === 0) return void 0;
|
|
555
|
+
if (typeof value.tool !== "string" || value.tool.length === 0) return void 0;
|
|
556
|
+
return raw;
|
|
557
|
+
}
|
|
558
|
+
function readSpool(text) {
|
|
559
|
+
if (text === void 0 || text.length === 0) return { records: [], skipped: 0 };
|
|
560
|
+
const records = [];
|
|
561
|
+
let skipped = 0;
|
|
562
|
+
for (const line of text.split("\n")) {
|
|
563
|
+
if (line.trim().length === 0) continue;
|
|
564
|
+
const record = parseRecord(line);
|
|
565
|
+
if (record === void 0) skipped += 1;
|
|
566
|
+
else records.push(record);
|
|
567
|
+
}
|
|
568
|
+
return { records, skipped };
|
|
569
|
+
}
|
|
570
|
+
function serialiseSpool(records) {
|
|
571
|
+
return records.map(serialiseRecord).join("");
|
|
572
|
+
}
|
|
573
|
+
function trimSpool(records, maxRecords = DEFAULT_MAX_RECORDS) {
|
|
574
|
+
if (maxRecords <= 0) return { records: [], dropped: records.length };
|
|
575
|
+
if (records.length <= maxRecords) return { records, dropped: 0 };
|
|
576
|
+
const dropped = records.length - maxRecords;
|
|
577
|
+
return { records: records.slice(dropped), dropped };
|
|
578
|
+
}
|
|
579
|
+
var DEFAULT_TRIM_INTERVAL = 1e3;
|
|
580
|
+
function shouldTrimOnAppend(appendCount, interval = DEFAULT_TRIM_INTERVAL) {
|
|
581
|
+
if (interval <= 1) return true;
|
|
582
|
+
return appendCount % Math.floor(interval) === 0;
|
|
583
|
+
}
|
|
584
|
+
function batches(records, size = DEFAULT_BATCH_SIZE) {
|
|
585
|
+
const step = size > 0 ? Math.floor(size) : 1;
|
|
586
|
+
const out = [];
|
|
587
|
+
for (let index = 0; index < records.length; index += step) {
|
|
588
|
+
out.push(records.slice(index, index + step));
|
|
589
|
+
}
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// src/lib/hook/flush.ts
|
|
594
|
+
function toWireCall(record) {
|
|
595
|
+
return {
|
|
596
|
+
tool: record.tool,
|
|
597
|
+
ts: record.ts,
|
|
598
|
+
...record.command === void 0 ? {} : { command: record.command },
|
|
599
|
+
...record.paths === void 0 ? {} : { paths: record.paths },
|
|
600
|
+
inputTokens: record.inputTokens,
|
|
601
|
+
outputTokens: record.outputTokens,
|
|
602
|
+
cacheWriteTokens: record.cacheWriteTokens,
|
|
603
|
+
cacheReadTokens: record.cacheReadTokens,
|
|
604
|
+
...record.model === void 0 ? {} : { model: record.model },
|
|
605
|
+
...record.effort === void 0 ? {} : { effort: record.effort },
|
|
606
|
+
...record.usage5h === void 0 ? {} : { usage5h: record.usage5h },
|
|
607
|
+
...record.usageWeekly === void 0 ? {} : { usageWeekly: record.usageWeekly }
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
async function flushSpool(options) {
|
|
611
|
+
const { records, skipped } = readSpool(options.spoolText);
|
|
612
|
+
const trimmed = trimSpool(records, options.maxRecords ?? DEFAULT_MAX_RECORDS);
|
|
613
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
614
|
+
for (const record of trimmed.records) {
|
|
615
|
+
const existing = bySession.get(record.sessionId);
|
|
616
|
+
if (existing === void 0) bySession.set(record.sessionId, [record]);
|
|
617
|
+
else existing.push(record);
|
|
618
|
+
}
|
|
619
|
+
const size = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
620
|
+
let sent = 0;
|
|
621
|
+
let attempted = 0;
|
|
622
|
+
let stoppedEarly = false;
|
|
623
|
+
const acknowledged = /* @__PURE__ */ new Set();
|
|
624
|
+
for (const [sessionId, sessionRecords] of bySession) {
|
|
625
|
+
for (const batch of batches(sessionRecords, size)) {
|
|
626
|
+
attempted += 1;
|
|
627
|
+
let accepted = false;
|
|
628
|
+
try {
|
|
629
|
+
accepted = await options.send({ sessionId, calls: batch.map(toWireCall) });
|
|
630
|
+
} catch {
|
|
631
|
+
accepted = false;
|
|
632
|
+
}
|
|
633
|
+
if (!accepted) {
|
|
634
|
+
stoppedEarly = true;
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
for (const record of batch) acknowledged.add(record);
|
|
638
|
+
sent += batch.length;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const retainedRecords = trimmed.records.filter((record) => !acknowledged.has(record));
|
|
642
|
+
return {
|
|
643
|
+
sent,
|
|
644
|
+
retained: retainedRecords.length,
|
|
645
|
+
skipped,
|
|
646
|
+
dropped: trimmed.dropped,
|
|
647
|
+
attempted,
|
|
648
|
+
stoppedEarly,
|
|
649
|
+
remaining: serialiseSpool(retainedRecords)
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/lib/hook/spool-record.ts
|
|
654
|
+
function countOf(value) {
|
|
655
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
|
|
656
|
+
return Math.floor(value);
|
|
657
|
+
}
|
|
658
|
+
function readingOf(value) {
|
|
659
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return void 0;
|
|
660
|
+
return value;
|
|
661
|
+
}
|
|
662
|
+
function label(value, max) {
|
|
663
|
+
if (typeof value !== "string") return void 0;
|
|
664
|
+
const trimmed = value.trim();
|
|
665
|
+
if (trimmed.length === 0) return void 0;
|
|
666
|
+
return capText(trimmed, max);
|
|
667
|
+
}
|
|
668
|
+
function capPaths2(value) {
|
|
669
|
+
if (!Array.isArray(value)) return void 0;
|
|
670
|
+
const cleaned = [];
|
|
671
|
+
for (const entry of value) {
|
|
672
|
+
if (typeof entry !== "string") continue;
|
|
673
|
+
const trimmed = entry.trim();
|
|
674
|
+
if (trimmed.length > 0) cleaned.push(trimmed);
|
|
675
|
+
}
|
|
676
|
+
if (cleaned.length === 0) return void 0;
|
|
677
|
+
return capPaths(cleaned);
|
|
678
|
+
}
|
|
679
|
+
function buildRecord(options) {
|
|
680
|
+
const { event, now, usage } = options;
|
|
681
|
+
const tool = label(event.tool, MAX_TOOL_CHARS);
|
|
682
|
+
if (tool === void 0) return void 0;
|
|
683
|
+
const command = event.command === void 0 ? void 0 : label(event.command, MAX_COMMAND_CHARS);
|
|
684
|
+
const paths = capPaths2(options.paths);
|
|
685
|
+
const model = label(usage?.model, MAX_TOOL_CHARS);
|
|
686
|
+
const effort = label(usage?.effort, MAX_TOOL_CHARS);
|
|
687
|
+
const usage5h = readingOf(usage?.usage5h);
|
|
688
|
+
const usageWeekly = readingOf(usage?.usageWeekly);
|
|
689
|
+
return {
|
|
690
|
+
// Capped to the same bound the ingest applies, so the value the flush
|
|
691
|
+
// groups by is the value the server stores. Capping only server-side
|
|
692
|
+
// would let two sessions whose ids differ past the cap be spooled apart
|
|
693
|
+
// and stored together, which merges two sessions' telemetry silently.
|
|
694
|
+
sessionId: capText(event.sessionId, MAX_SESSION_ID_CHARS),
|
|
695
|
+
ts: new Date(now).toISOString(),
|
|
696
|
+
tool,
|
|
697
|
+
...command === void 0 ? {} : { command },
|
|
698
|
+
...paths === void 0 ? {} : { paths },
|
|
699
|
+
inputTokens: countOf(usage?.inputTokens),
|
|
700
|
+
outputTokens: countOf(usage?.outputTokens),
|
|
701
|
+
cacheWriteTokens: countOf(usage?.cacheWriteTokens),
|
|
702
|
+
cacheReadTokens: countOf(usage?.cacheReadTokens),
|
|
703
|
+
...model === void 0 ? {} : { model },
|
|
704
|
+
...effort === void 0 ? {} : { effort },
|
|
705
|
+
...usage5h === void 0 ? {} : { usage5h },
|
|
706
|
+
...usageWeekly === void 0 ? {} : { usageWeekly }
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/lib/hook/usage.ts
|
|
711
|
+
function property2(value, key) {
|
|
712
|
+
return typeof value === "object" && value !== null ? value[key] : void 0;
|
|
713
|
+
}
|
|
714
|
+
function first(source, keys) {
|
|
715
|
+
for (const key of keys) {
|
|
716
|
+
const value = property2(source, key);
|
|
717
|
+
if (value !== void 0 && value !== null) return value;
|
|
718
|
+
}
|
|
719
|
+
return void 0;
|
|
720
|
+
}
|
|
721
|
+
var USAGE_CONTAINERS = ["usage", "token_usage", "tokenUsage"];
|
|
722
|
+
var INPUT_KEYS = ["input_tokens", "inputTokens"];
|
|
723
|
+
var OUTPUT_KEYS = ["output_tokens", "outputTokens"];
|
|
724
|
+
var CACHE_WRITE_KEYS = [
|
|
725
|
+
"cache_creation_input_tokens",
|
|
726
|
+
"cache_write_tokens",
|
|
727
|
+
"cacheWriteTokens"
|
|
728
|
+
];
|
|
729
|
+
var CACHE_READ_KEYS = [
|
|
730
|
+
"cache_read_input_tokens",
|
|
731
|
+
"cache_read_tokens",
|
|
732
|
+
"cacheReadTokens"
|
|
733
|
+
];
|
|
734
|
+
var MODEL_KEYS = ["model", "model_id", "modelId"];
|
|
735
|
+
var EFFORT_KEYS = ["effort", "reasoning_effort", "reasoningEffort"];
|
|
736
|
+
var USAGE_5H_KEYS = ["usage_5h", "usage5h"];
|
|
737
|
+
var USAGE_WEEKLY_KEYS = ["usage_weekly", "usageWeekly"];
|
|
738
|
+
function readReportedUsage(raw) {
|
|
739
|
+
const containers = [];
|
|
740
|
+
for (const key of USAGE_CONTAINERS) {
|
|
741
|
+
const nested = property2(raw, key);
|
|
742
|
+
if (nested !== void 0 && nested !== null) containers.push(nested);
|
|
743
|
+
}
|
|
744
|
+
containers.push(raw);
|
|
745
|
+
const pick = (keys) => {
|
|
746
|
+
for (const container of containers) {
|
|
747
|
+
const value = first(container, keys);
|
|
748
|
+
if (value !== void 0) return value;
|
|
749
|
+
}
|
|
750
|
+
return void 0;
|
|
751
|
+
};
|
|
752
|
+
const numeric = (keys) => {
|
|
753
|
+
const value = pick(keys);
|
|
754
|
+
return typeof value === "number" ? value : void 0;
|
|
755
|
+
};
|
|
756
|
+
const text = (keys) => {
|
|
757
|
+
const value = pick(keys);
|
|
758
|
+
return typeof value === "string" ? value : void 0;
|
|
759
|
+
};
|
|
760
|
+
const inputTokens = numeric(INPUT_KEYS);
|
|
761
|
+
const outputTokens = numeric(OUTPUT_KEYS);
|
|
762
|
+
const cacheWriteTokens = numeric(CACHE_WRITE_KEYS);
|
|
763
|
+
const cacheReadTokens = numeric(CACHE_READ_KEYS);
|
|
764
|
+
const model = text(MODEL_KEYS);
|
|
765
|
+
const effort = text(EFFORT_KEYS);
|
|
766
|
+
const usage5h = numeric(USAGE_5H_KEYS);
|
|
767
|
+
const usageWeekly = numeric(USAGE_WEEKLY_KEYS);
|
|
768
|
+
return {
|
|
769
|
+
...inputTokens === void 0 ? {} : { inputTokens },
|
|
770
|
+
...outputTokens === void 0 ? {} : { outputTokens },
|
|
771
|
+
...cacheWriteTokens === void 0 ? {} : { cacheWriteTokens },
|
|
772
|
+
...cacheReadTokens === void 0 ? {} : { cacheReadTokens },
|
|
773
|
+
...model === void 0 ? {} : { model },
|
|
774
|
+
...effort === void 0 ? {} : { effort },
|
|
775
|
+
...usage5h === void 0 ? {} : { usage5h },
|
|
776
|
+
...usageWeekly === void 0 ? {} : { usageWeekly }
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
var PATH_LIST_KEYS = ["paths", "file_paths", "filePaths"];
|
|
780
|
+
var PATH_SINGLE_KEYS = ["file_path", "filePath", "path", "notebook_path"];
|
|
781
|
+
function readReportedPaths(raw) {
|
|
782
|
+
const toolInput = property2(raw, "tool_input") ?? property2(raw, "toolInput");
|
|
783
|
+
for (const source of [toolInput, raw]) {
|
|
784
|
+
const list = first(source, PATH_LIST_KEYS);
|
|
785
|
+
if (Array.isArray(list)) return list.filter((one) => typeof one === "string");
|
|
786
|
+
const single = first(source, PATH_SINGLE_KEYS);
|
|
787
|
+
if (typeof single === "string" && single.length > 0) return [single];
|
|
788
|
+
}
|
|
789
|
+
return void 0;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// src/lib/cli/envelope.ts
|
|
793
|
+
var EXIT = {
|
|
794
|
+
/** Accepted. */
|
|
795
|
+
OK: 0,
|
|
796
|
+
/** Unexpected failure — the caller did nothing wrong to cause it. */
|
|
797
|
+
FAILURE: 1,
|
|
798
|
+
/** Malformed command — not a thing this build can be asked to do. */
|
|
799
|
+
MALFORMED: 2,
|
|
800
|
+
/** Rejected by a rule. */
|
|
801
|
+
REJECTED: 3,
|
|
802
|
+
/** Not configured — neither binding could be resolved. */
|
|
803
|
+
UNCONFIGURED: 4
|
|
804
|
+
};
|
|
805
|
+
var EXIT_BY_CODE = {
|
|
806
|
+
invalid_input: EXIT.MALFORMED,
|
|
807
|
+
not_found: EXIT.REJECTED,
|
|
808
|
+
guard_rejected: EXIT.REJECTED,
|
|
809
|
+
conflict: EXIT.REJECTED,
|
|
810
|
+
forbidden: EXIT.REJECTED,
|
|
811
|
+
not_implemented: EXIT.FAILURE,
|
|
812
|
+
internal: EXIT.FAILURE
|
|
813
|
+
};
|
|
814
|
+
function exitCodeFor(envelope) {
|
|
815
|
+
if (envelope.ok) return EXIT.OK;
|
|
816
|
+
if (envelope.error.code === "malformed_command") return EXIT.MALFORMED;
|
|
817
|
+
return EXIT_BY_CODE[envelope.error.code];
|
|
818
|
+
}
|
|
819
|
+
function ok(data) {
|
|
820
|
+
return { ok: true, data };
|
|
821
|
+
}
|
|
822
|
+
function rejected(rejection, message) {
|
|
823
|
+
return {
|
|
824
|
+
ok: false,
|
|
825
|
+
error: {
|
|
826
|
+
code: rejection.code,
|
|
827
|
+
message,
|
|
828
|
+
fields: rejection.fields,
|
|
829
|
+
...rejection.guard === void 0 ? {} : { guard: rejection.guard }
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function malformed(message, fields = []) {
|
|
834
|
+
return { ok: false, error: { code: "malformed_command", message, fields } };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/lib/cli/hook-command.ts
|
|
838
|
+
var HOOK_VERBS = ["run", "flush", "status"];
|
|
839
|
+
function isHookVerb(value) {
|
|
840
|
+
return typeof value === "string" && HOOK_VERBS.includes(value);
|
|
841
|
+
}
|
|
842
|
+
function spoolEvent(raw, spool, now, options) {
|
|
843
|
+
const parsed = parseHookPayload(raw);
|
|
844
|
+
if (!parsed.ok) return void 0;
|
|
845
|
+
let payload;
|
|
846
|
+
try {
|
|
847
|
+
payload = JSON.parse(raw);
|
|
848
|
+
} catch {
|
|
849
|
+
return void 0;
|
|
850
|
+
}
|
|
851
|
+
const record = buildRecord({
|
|
852
|
+
event: parsed.event,
|
|
853
|
+
now,
|
|
854
|
+
usage: readReportedUsage(payload),
|
|
855
|
+
paths: readReportedPaths(payload)
|
|
856
|
+
});
|
|
857
|
+
if (record === void 0) return void 0;
|
|
858
|
+
try {
|
|
859
|
+
spool.append(serialiseRecord(record));
|
|
860
|
+
enforceCeiling(spool, options);
|
|
861
|
+
} catch {
|
|
862
|
+
return void 0;
|
|
863
|
+
}
|
|
864
|
+
return record;
|
|
865
|
+
}
|
|
866
|
+
function enforceCeiling(spool, options) {
|
|
867
|
+
const interval = options?.trimInterval ?? DEFAULT_TRIM_INTERVAL;
|
|
868
|
+
const counter = options?.appendCounter;
|
|
869
|
+
if (counter === void 0) return;
|
|
870
|
+
if (!shouldTrimOnAppend(counter(), interval)) return;
|
|
871
|
+
const text = spool.read();
|
|
872
|
+
const { records } = readSpool(text);
|
|
873
|
+
const trimmed = trimSpool(records, options?.maxRecords ?? DEFAULT_MAX_RECORDS);
|
|
874
|
+
if (trimmed.dropped === 0) return;
|
|
875
|
+
spool.replace(serialiseSpool(trimmed.records));
|
|
876
|
+
}
|
|
877
|
+
async function runHookCommand(options) {
|
|
878
|
+
if (options.verb === "run") {
|
|
879
|
+
const stdin = options.stdin ?? "";
|
|
880
|
+
const response = await runHook({
|
|
881
|
+
stdin,
|
|
882
|
+
now: options.now,
|
|
883
|
+
askServer: options.hook?.askServer ?? (async () => void 0),
|
|
884
|
+
...options.hook?.enforcement === void 0 ? {} : { enforcement: options.hook.enforcement }
|
|
885
|
+
});
|
|
886
|
+
spoolEvent(stdin, options.spool, options.now, {
|
|
887
|
+
...options.appendCounter === void 0 ? {} : { appendCounter: options.appendCounter },
|
|
888
|
+
...options.maxRecords === void 0 ? {} : { maxRecords: options.maxRecords },
|
|
889
|
+
...options.trimInterval === void 0 ? {} : { trimInterval: options.trimInterval }
|
|
890
|
+
});
|
|
891
|
+
return { kind: "hook-response", response };
|
|
892
|
+
}
|
|
893
|
+
if (options.verb === "status") {
|
|
894
|
+
let text2;
|
|
895
|
+
try {
|
|
896
|
+
text2 = options.spool.read();
|
|
897
|
+
} catch {
|
|
898
|
+
text2 = void 0;
|
|
899
|
+
}
|
|
900
|
+
const contents = readSpool(text2);
|
|
901
|
+
const envelope = ok({
|
|
902
|
+
pending: contents.records.length,
|
|
903
|
+
unreadableLines: contents.skipped
|
|
904
|
+
});
|
|
905
|
+
return { kind: "envelope", envelope, exitCode: EXIT.OK };
|
|
906
|
+
}
|
|
907
|
+
if (options.send === void 0) {
|
|
908
|
+
return {
|
|
909
|
+
kind: "envelope",
|
|
910
|
+
envelope: malformed("standup hook flush needs somewhere to send to", ["send"]),
|
|
911
|
+
exitCode: EXIT.MALFORMED
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
let text;
|
|
915
|
+
try {
|
|
916
|
+
text = options.spool.read();
|
|
917
|
+
} catch {
|
|
918
|
+
text = void 0;
|
|
919
|
+
}
|
|
920
|
+
const result = await flushSpool({
|
|
921
|
+
send: options.send,
|
|
922
|
+
...text === void 0 ? {} : { spoolText: text },
|
|
923
|
+
...options.batchSize === void 0 ? {} : { batchSize: options.batchSize },
|
|
924
|
+
...options.maxRecords === void 0 ? {} : { maxRecords: options.maxRecords }
|
|
925
|
+
});
|
|
926
|
+
const changed = result.sent > 0 || result.dropped > 0 || result.skipped > 0;
|
|
927
|
+
if (changed) {
|
|
928
|
+
try {
|
|
929
|
+
options.spool.replace(result.remaining);
|
|
930
|
+
} catch {
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
kind: "envelope",
|
|
935
|
+
envelope: ok({
|
|
936
|
+
sent: result.sent,
|
|
937
|
+
retained: result.retained,
|
|
938
|
+
skipped: result.skipped,
|
|
939
|
+
dropped: result.dropped,
|
|
940
|
+
batches: result.attempted,
|
|
941
|
+
stoppedEarly: result.stoppedEarly
|
|
942
|
+
}),
|
|
943
|
+
exitCode: EXIT.OK
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// src/lib/hook/flush-http.ts
|
|
948
|
+
var DEFAULT_FLUSH_TIMEOUT_MS = 15e3;
|
|
949
|
+
function isPermanent(status) {
|
|
950
|
+
if (status === 408 || status === 429) return false;
|
|
951
|
+
return status >= 400 && status < 500;
|
|
952
|
+
}
|
|
953
|
+
function createHttpFlush(options) {
|
|
954
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
|
|
955
|
+
const makeSignal = options.timeoutSignal ?? ((ms) => typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(ms) : void 0);
|
|
956
|
+
return async (batch) => {
|
|
957
|
+
const signal = makeSignal(timeoutMs);
|
|
958
|
+
try {
|
|
959
|
+
const response = await options.fetch(
|
|
960
|
+
`${options.baseUrl.replace(/\/+$/, "")}/api/tool-calls`,
|
|
961
|
+
{
|
|
962
|
+
method: "POST",
|
|
963
|
+
headers: {
|
|
964
|
+
"content-type": "application/json",
|
|
965
|
+
...options.token === void 0 || options.token === "" ? {} : { authorization: `Bearer ${options.token}` }
|
|
966
|
+
},
|
|
967
|
+
body: JSON.stringify(batch),
|
|
968
|
+
...signal === void 0 ? {} : { signal }
|
|
969
|
+
}
|
|
970
|
+
);
|
|
971
|
+
if (response.ok) return true;
|
|
972
|
+
options.onFailure?.({ status: response.status, permanent: isPermanent(response.status) });
|
|
973
|
+
return false;
|
|
974
|
+
} catch {
|
|
975
|
+
options.onFailure?.({ permanent: false });
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// src/lib/cli/spool-file.ts
|
|
982
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
983
|
+
import path from "node:path";
|
|
984
|
+
function spoolPath(env) {
|
|
985
|
+
const configured = env.STANDUP_SPOOL;
|
|
986
|
+
if (configured !== void 0 && configured.trim() !== "") return configured.trim();
|
|
987
|
+
const home = env.HOME ?? env.USERPROFILE ?? ".";
|
|
988
|
+
return path.join(home, ".standup", "telemetry.jsonl");
|
|
989
|
+
}
|
|
990
|
+
function fileSpool(file) {
|
|
991
|
+
return {
|
|
992
|
+
append: (line) => {
|
|
993
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
994
|
+
appendFileSync(file, line, "utf-8");
|
|
995
|
+
},
|
|
996
|
+
read: () => {
|
|
997
|
+
try {
|
|
998
|
+
return readFileSync(file, "utf-8");
|
|
999
|
+
} catch {
|
|
1000
|
+
return void 0;
|
|
1001
|
+
}
|
|
1002
|
+
},
|
|
1003
|
+
replace: (text) => {
|
|
1004
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1005
|
+
writeFileSync(file, text, "utf-8");
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
function fileAppendCounter(file) {
|
|
1010
|
+
return () => {
|
|
1011
|
+
try {
|
|
1012
|
+
return Math.floor(statSync(file).size / APPROXIMATE_RECORD_BYTES);
|
|
1013
|
+
} catch {
|
|
1014
|
+
return 0;
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
var APPROXIMATE_RECORD_BYTES = 600;
|
|
1019
|
+
|
|
1020
|
+
export {
|
|
1021
|
+
EXIT,
|
|
1022
|
+
exitCodeFor,
|
|
1023
|
+
ok,
|
|
1024
|
+
rejected,
|
|
1025
|
+
malformed,
|
|
1026
|
+
parseHookPayload,
|
|
1027
|
+
readSessionStatus,
|
|
1028
|
+
readNudgeContext,
|
|
1029
|
+
HOOK_EXIT,
|
|
1030
|
+
readStopContext,
|
|
1031
|
+
runHook,
|
|
1032
|
+
captureContextFor,
|
|
1033
|
+
flushSpool,
|
|
1034
|
+
HOOK_VERBS,
|
|
1035
|
+
isHookVerb,
|
|
1036
|
+
spoolEvent,
|
|
1037
|
+
runHookCommand,
|
|
1038
|
+
createHttpFlush,
|
|
1039
|
+
spoolPath,
|
|
1040
|
+
fileSpool,
|
|
1041
|
+
fileAppendCounter
|
|
1042
|
+
};
|