@trygocode/notify 0.2.0 → 0.3.2
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 +75 -1
- package/dist/src/cli.js +77 -1
- package/dist/src/doctor.js +262 -0
- package/dist/src/on_stop.js +417 -11
- package/dist/src/send.js +8 -1
- package/dist/src/status.js +18 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/snippets/ralph-homer.sh +329 -9
package/dist/src/on_stop.js
CHANGED
|
@@ -25,11 +25,58 @@
|
|
|
25
25
|
// injectable so the dispatcher is unit-testable with zero network / git / fs.
|
|
26
26
|
//
|
|
27
27
|
// Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { promises as fs } from "node:fs";
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
28
31
|
import { resolveNotifySettings } from "./config.js";
|
|
29
32
|
import { deriveRepoIdentity } from "./repo_key.js";
|
|
30
33
|
import { pushOnStop, } from "./push.js";
|
|
31
34
|
import { appendLog, send } from "./send.js";
|
|
32
35
|
import { checkDedupLock } from "./dedup_lock.js";
|
|
36
|
+
/**
|
|
37
|
+
* Parse the Cursor `stop` hook stdin JSON and extract the `status` field.
|
|
38
|
+
* Best-effort: returns `undefined` on absent/empty/unparseable input or when the
|
|
39
|
+
* `status` field is missing, so the caller falls back to `finished` gracefully.
|
|
40
|
+
* Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export function parseCursorStopStatus(stdin) {
|
|
43
|
+
if (!stdin || stdin.trim() === "")
|
|
44
|
+
return undefined;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(stdin);
|
|
47
|
+
if (typeof parsed === "object" &&
|
|
48
|
+
parsed !== null &&
|
|
49
|
+
"status" in parsed &&
|
|
50
|
+
typeof parsed.status === "string") {
|
|
51
|
+
const s = parsed.status;
|
|
52
|
+
if (s === "completed" || s === "aborted" || s === "error")
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Unparseable stdin → fall back to `finished` (back-compat)
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Map a Cursor `stop` status to the appropriate {@link NotifyKind} (T-CUR1 / PRD §8.5):
|
|
63
|
+
* - `completed` → `finished` (agent turned cleanly)
|
|
64
|
+
* - `aborted` → `awaiting_input` (agent yielded back to the human)
|
|
65
|
+
* - `error` → `error` (agent hit an error)
|
|
66
|
+
* - `undefined` → `finished` (back-compat: absent/unrecognised)
|
|
67
|
+
*/
|
|
68
|
+
export function cursorStopStatusToKind(status) {
|
|
69
|
+
switch (status) {
|
|
70
|
+
case "completed":
|
|
71
|
+
return "finished";
|
|
72
|
+
case "aborted":
|
|
73
|
+
return "awaiting_input";
|
|
74
|
+
case "error":
|
|
75
|
+
return "error";
|
|
76
|
+
default:
|
|
77
|
+
return "finished"; // back-compat: absent/unrecognised → finished
|
|
78
|
+
}
|
|
79
|
+
}
|
|
33
80
|
/** Slice the merged settings down to what the push flow consumes. */
|
|
34
81
|
function toPushSettings(settings) {
|
|
35
82
|
return {
|
|
@@ -37,6 +84,326 @@ function toPushSettings(settings) {
|
|
|
37
84
|
commit_message: settings.commit_message,
|
|
38
85
|
};
|
|
39
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the `project` label for a per-turn ping (T-N4 / PRD §3.4). Prefers the
|
|
89
|
+
* derived repo identity's `repo_label`; when that is blank — the repo-derive
|
|
90
|
+
* threw (so `repo` is undefined) or a non-git cwd produced an empty label — falls
|
|
91
|
+
* back to the cwd basename so the phone ALWAYS shows SOMETHING ("better-than-
|
|
92
|
+
* nothing"). Returns `undefined` only when even the basename is empty (e.g. cwd
|
|
93
|
+
* is the filesystem root), so the caller still omits the field gracefully.
|
|
94
|
+
*/
|
|
95
|
+
export function projectLabel(repo, cwd) {
|
|
96
|
+
const label = repo?.repo_label?.trim();
|
|
97
|
+
// The user wants the plain project/folder name (e.g. "alerc8"), not the
|
|
98
|
+
// `owner/repo` form. `repo_label` from a git origin is `owner/repo`, and the
|
|
99
|
+
// `local:` fallback already strips the prefix in deriveRepoIdentity — so take
|
|
100
|
+
// the LAST path segment as the display name. Guard against a trailing slash.
|
|
101
|
+
if (label) {
|
|
102
|
+
const lastSeg = label.replace(/\/+$/, "").split("/").pop()?.trim();
|
|
103
|
+
if (lastSeg)
|
|
104
|
+
return lastSeg;
|
|
105
|
+
return label;
|
|
106
|
+
}
|
|
107
|
+
// Fall back to the cwd basename — but SKIP tool/config dot-dirs. The stop hook
|
|
108
|
+
// can run with a cwd inside `.cursor` (or `.git`, `.vscode`, …), whose basename
|
|
109
|
+
// would otherwise become the project name and render as "Cursor · .cursor"
|
|
110
|
+
// (the IDE name shown twice). Walk up past any leading-dot segment to the first
|
|
111
|
+
// real project folder so the label is the actual repo dir, not its tooling dir.
|
|
112
|
+
let dir = path.resolve(cwd);
|
|
113
|
+
for (let i = 0; i < 6; i++) {
|
|
114
|
+
const base = path.basename(dir).trim();
|
|
115
|
+
if (!base)
|
|
116
|
+
break; // reached filesystem root
|
|
117
|
+
if (!base.startsWith("."))
|
|
118
|
+
return base; // first non-dot folder wins
|
|
119
|
+
const parent = path.dirname(dir);
|
|
120
|
+
if (parent === dir)
|
|
121
|
+
break; // no more parents
|
|
122
|
+
dir = parent;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The project root the stop hook should reason about. Cursor (and Claude) run
|
|
128
|
+
* stop hooks with `process.cwd()` set to the IDE's hook/config dir (e.g.
|
|
129
|
+
* `~/.cursor`), NOT the user's open project — so deriving the repo from the bare
|
|
130
|
+
* cwd yields ".cursor" instead of the real repo (the bug in the 2026-06-12
|
|
131
|
+
* screenshot). The hook stdin, however, carries the real workspace root. Prefer
|
|
132
|
+
* it (mirrors what `@trygocode/sync` already does), falling back to the cwd only
|
|
133
|
+
* when the stdin omits it.
|
|
134
|
+
*
|
|
135
|
+
* Accepts the same field spellings sync consumes:
|
|
136
|
+
* `workspace_path` / `workspaceRoots[0]` / `workspace_roots[0]` / `cwd` /
|
|
137
|
+
* `workspaceFolders[0].path|uri`. Returns undefined when nothing usable is
|
|
138
|
+
* present, so the caller keeps the process cwd.
|
|
139
|
+
*/
|
|
140
|
+
export function workspaceRootFromHookStdin(hookStdin) {
|
|
141
|
+
if (!hookStdin || hookStdin.trim() === "")
|
|
142
|
+
return undefined;
|
|
143
|
+
let p;
|
|
144
|
+
try {
|
|
145
|
+
p = JSON.parse(hookStdin);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
const str = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
151
|
+
const firstOf = (v) => {
|
|
152
|
+
if (Array.isArray(v) && v.length > 0) {
|
|
153
|
+
const f = v[0];
|
|
154
|
+
if (typeof f === "string")
|
|
155
|
+
return str(f);
|
|
156
|
+
if (f && typeof f === "object") {
|
|
157
|
+
const o = f;
|
|
158
|
+
// VS Code/Cursor sometimes use {path} or {uri: "file:///…"}.
|
|
159
|
+
return str(o.path) ?? str(o.fsPath) ?? fromFileUri(str(o.uri));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
};
|
|
164
|
+
return (str(p.workspace_path) ??
|
|
165
|
+
str(p.workspacePath) ??
|
|
166
|
+
firstOf(p.workspaceRoots) ??
|
|
167
|
+
firstOf(p.workspace_roots) ??
|
|
168
|
+
firstOf(p.workspaceFolders) ??
|
|
169
|
+
str(p.cwd) ??
|
|
170
|
+
undefined);
|
|
171
|
+
}
|
|
172
|
+
/** Convert a `file:///abs/path` URI to a plain path; passthrough otherwise. */
|
|
173
|
+
function fromFileUri(uri) {
|
|
174
|
+
if (!uri)
|
|
175
|
+
return undefined;
|
|
176
|
+
if (uri.startsWith("file://")) {
|
|
177
|
+
try {
|
|
178
|
+
return decodeURIComponent(new URL(uri).pathname) || undefined;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return uri;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Derive the SAME stable `external_chat_id` that `@trygocode/sync` assigns to a
|
|
188
|
+
* synced transcript, so a per-turn notification can deep-link straight to that
|
|
189
|
+
* chat on tap. MUST stay byte-identical to gocode-sync's `deriveExternalChatId`:
|
|
190
|
+
* sha256 of `source\0(lower workspace)\0(session)`, first 32 hex chars.
|
|
191
|
+
*
|
|
192
|
+
* We re-derive (rather than import gocode-sync) because notify is a separate
|
|
193
|
+
* zero-dep package; the hash is tiny and pinned by a parity test.
|
|
194
|
+
*/
|
|
195
|
+
export function deriveIdeChatId(input) {
|
|
196
|
+
const basis = [
|
|
197
|
+
input.source.toLowerCase().trim(),
|
|
198
|
+
input.workspacePath.toLowerCase().trim(),
|
|
199
|
+
input.ideSessionId.trim(),
|
|
200
|
+
].join("\u0000");
|
|
201
|
+
return createHash("sha256").update(basis).digest("hex").slice(0, 32);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Best-effort: from the Cursor/Claude stop-hook stdin JSON + cwd, compute the
|
|
205
|
+
* synced chat's `external_chat_id` so the notification deep-links to it. Returns
|
|
206
|
+
* undefined when the payload lacks a stable session id (then the push falls back
|
|
207
|
+
* to Home, exactly as before). Never throws.
|
|
208
|
+
*
|
|
209
|
+
* Session id resolution mirrors the capture adapters:
|
|
210
|
+
* - Cursor: `conversation_id` / `conversationId`, else the `transcript_path`
|
|
211
|
+
* filename (minus `.jsonl`).
|
|
212
|
+
* - Claude: `session_id` / `sessionId`, else the `transcript_path` filename.
|
|
213
|
+
* Workspace mirrors capture: payload workspace/cwd, else the hook cwd.
|
|
214
|
+
*/
|
|
215
|
+
export function ideChatIdFromHookStdin(source, cwd, hookStdin) {
|
|
216
|
+
if (!hookStdin || hookStdin.trim() === "")
|
|
217
|
+
return undefined;
|
|
218
|
+
let p;
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse(hookStdin);
|
|
221
|
+
if (!parsed || typeof parsed !== "object")
|
|
222
|
+
return undefined;
|
|
223
|
+
p = parsed;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
const str = (...vals) => {
|
|
229
|
+
for (const v of vals)
|
|
230
|
+
if (typeof v === "string" && v.trim() !== "")
|
|
231
|
+
return v;
|
|
232
|
+
return undefined;
|
|
233
|
+
};
|
|
234
|
+
const transcriptPath = str(p.transcript_path, p.transcriptPath);
|
|
235
|
+
const sessionFromPath = transcriptPath
|
|
236
|
+
? path.basename(transcriptPath).replace(/\.jsonl$/i, "") || undefined
|
|
237
|
+
: undefined;
|
|
238
|
+
const ideSessionId = str(p.conversation_id, p.conversationId, p.session_id, p.sessionId, sessionFromPath);
|
|
239
|
+
if (!ideSessionId)
|
|
240
|
+
return undefined;
|
|
241
|
+
const workspacePath = str(p.workspace_path, Array.isArray(p.workspaceRoots) ? p.workspaceRoots[0] : undefined, p.cwd, cwd);
|
|
242
|
+
if (!workspacePath)
|
|
243
|
+
return undefined;
|
|
244
|
+
// The capture side stores `source: "cursor" | "claude_code"`; the hook passes
|
|
245
|
+
// `--source cursor|claude_code`, so they already match. Normalise just in case.
|
|
246
|
+
const normSource = source === "claude" ? "claude_code" : source;
|
|
247
|
+
return deriveIdeChatId({
|
|
248
|
+
source: normSource,
|
|
249
|
+
workspacePath: path.resolve(workspacePath),
|
|
250
|
+
ideSessionId,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Best-effort: derive a short chat title from the transcript named in the hook
|
|
255
|
+
* stdin, mirroring gocode-sync's `deriveTitleFromMessages` (first user message's
|
|
256
|
+
* first meaningful line). Used to name WHICH chat finished in the push body.
|
|
257
|
+
* Reads at most the first ~64KB of the JSONL (titles come from the first user
|
|
258
|
+
* turn). Returns undefined on any problem — never throws.
|
|
259
|
+
*/
|
|
260
|
+
export async function chatTitleFromHookStdin(hookStdin) {
|
|
261
|
+
if (!hookStdin || hookStdin.trim() === "")
|
|
262
|
+
return undefined;
|
|
263
|
+
let transcriptPath;
|
|
264
|
+
try {
|
|
265
|
+
const p = JSON.parse(hookStdin);
|
|
266
|
+
const v = p.transcript_path ?? p.transcriptPath;
|
|
267
|
+
if (typeof v === "string" && v.trim() !== "")
|
|
268
|
+
transcriptPath = v;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
if (!transcriptPath)
|
|
274
|
+
return undefined;
|
|
275
|
+
let raw;
|
|
276
|
+
try {
|
|
277
|
+
raw = await fs.readFile(transcriptPath, "utf8");
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
// Prefer the IDE's OWN assigned chat name when the transcript carries one, so
|
|
283
|
+
// the push names the chat exactly as the user sees it in the sidebar (rather
|
|
284
|
+
// than a synthesized first-line title). Verified empirically (2026-06-12):
|
|
285
|
+
// - Claude Code writes `{"type":"ai-title","aiTitle":"<sidebar name>"}`
|
|
286
|
+
// records, refined over the chat's life — so take the LAST one.
|
|
287
|
+
// - Cursor agent transcripts carry NO title record (only role/message), so
|
|
288
|
+
// this scan no-ops for Cursor and we fall through to synthesis below.
|
|
289
|
+
// Defensive: also accept a legacy Claude `{"type":"summary","summary":"…"}`
|
|
290
|
+
// record so an older/newer format degrades gracefully instead of throwing.
|
|
291
|
+
{
|
|
292
|
+
let assigned;
|
|
293
|
+
for (const line of raw.split("\n")) {
|
|
294
|
+
const t = line.trim();
|
|
295
|
+
if (!t || (!t.includes("ai-title") && !t.includes("summary")))
|
|
296
|
+
continue;
|
|
297
|
+
let rec;
|
|
298
|
+
try {
|
|
299
|
+
rec = JSON.parse(t);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const type = String(rec.type ?? "");
|
|
305
|
+
let cand;
|
|
306
|
+
if (type === "ai-title")
|
|
307
|
+
cand = rec.aiTitle ?? rec.ai_title;
|
|
308
|
+
else if (type === "summary")
|
|
309
|
+
cand = rec.summary;
|
|
310
|
+
if (typeof cand === "string" && cand.trim() !== "") {
|
|
311
|
+
assigned = cand.trim(); // keep scanning → last assigned title wins
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (assigned) {
|
|
315
|
+
if (assigned.length > 80)
|
|
316
|
+
assigned = assigned.slice(0, 79).trimEnd() + "…";
|
|
317
|
+
return assigned;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Synthesis fallback (no IDE-assigned title): name the chat after the LAST
|
|
321
|
+
// user prompt — the one that JUST finished — not the first prompt of the
|
|
322
|
+
// conversation. This reminds the user where they left off (per 2026-06-12
|
|
323
|
+
// request). We keep the last good title and return it after the full scan.
|
|
324
|
+
let lastTitle;
|
|
325
|
+
for (const line of raw.split("\n")) {
|
|
326
|
+
const t = line.trim();
|
|
327
|
+
if (!t)
|
|
328
|
+
continue;
|
|
329
|
+
let rec;
|
|
330
|
+
try {
|
|
331
|
+
rec = JSON.parse(t);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
// role: top-level `role` (Cursor) or nested `message.role` / `type` (Claude).
|
|
337
|
+
const nested = rec.message && typeof rec.message === "object"
|
|
338
|
+
? rec.message
|
|
339
|
+
: undefined;
|
|
340
|
+
const role = String(rec.role ?? nested?.role ?? rec.type ?? "");
|
|
341
|
+
if (role !== "user" && role !== "human")
|
|
342
|
+
continue;
|
|
343
|
+
// content: string or block array, inline or nested.
|
|
344
|
+
let content = "";
|
|
345
|
+
const src = rec.content ?? rec.text ?? nested?.content;
|
|
346
|
+
if (typeof src === "string")
|
|
347
|
+
content = src;
|
|
348
|
+
else if (Array.isArray(src)) {
|
|
349
|
+
for (const b of src) {
|
|
350
|
+
if (typeof b === "string")
|
|
351
|
+
content += b + "\n";
|
|
352
|
+
else if (b && typeof b === "object" && typeof b.text === "string")
|
|
353
|
+
content += String(b.text) + "\n";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Strip the harness `<user_query>` / `<timestamp>` plumbing so the title is
|
|
357
|
+
// the human's words (mirrors gocode-sync's sanitizer, minimal form).
|
|
358
|
+
content = content
|
|
359
|
+
.replace(/<timestamp(?:\s[^>]*)?>[\s\S]*?<\/timestamp>/gi, "")
|
|
360
|
+
.replace(/<image_files(?:\s[^>]*)?>[\s\S]*?<\/image_files>/gi, "")
|
|
361
|
+
.replace(/<open_and_recently_viewed_files(?:\s[^>]*)?>[\s\S]*?<\/open_and_recently_viewed_files>/gi, "")
|
|
362
|
+
.replace(/\[Image\](?!\()\s*/g, "")
|
|
363
|
+
.replace(/<\/?user_query(?:\s[^>]*)?>/gi, "");
|
|
364
|
+
const firstLine = content
|
|
365
|
+
.split("\n")
|
|
366
|
+
.map((l) => l.trim())
|
|
367
|
+
.find((l) => l !== "");
|
|
368
|
+
if (!firstLine)
|
|
369
|
+
continue;
|
|
370
|
+
let title = firstLine.replace(/^[#>\-*+\s]+/, "").replace(/[*_`]+/g, "").trim();
|
|
371
|
+
if (title === "")
|
|
372
|
+
continue;
|
|
373
|
+
if (title.length > 80)
|
|
374
|
+
title = title.slice(0, 79).trimEnd() + "…";
|
|
375
|
+
lastTitle = title; // keep scanning → LAST user prompt wins
|
|
376
|
+
}
|
|
377
|
+
return lastTitle;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Env var an Autopilot (Ralph/Homer) loop exports to mark that IT owns the
|
|
381
|
+
* current turn's notification (T-N7 / PRD §3.2). When truthy, {@link onStop}
|
|
382
|
+
* suppresses its per-turn ping entirely: the loop sends its OWN
|
|
383
|
+
* `loop_completed`/`loop_halted` Autopilot ping (`_loop_inner.sh
|
|
384
|
+
* push_notify_local`), so any stop-hook-driven `finished`/`push` ping for the
|
|
385
|
+
* same turn would be a duplicate of it.
|
|
386
|
+
*
|
|
387
|
+
* Defensive by design: today a Ralph `claude-tmux` iteration runs `claude` in its
|
|
388
|
+
* own tmux pane (Claude Code has no `Stop` hook here) and is NOT a Cursor turn, so
|
|
389
|
+
* the only per-turn hook (`cursor stop`) never fires for a loop — no double-fire
|
|
390
|
+
* exists to suppress. This gate is the READING half of the contract (the loop
|
|
391
|
+
* script exports the marker, T-N5) so that if a future loop arrangement DOES trip
|
|
392
|
+
* a stop hook, the duplicate is gated off at the source.
|
|
393
|
+
*/
|
|
394
|
+
export const AUTOPILOT_OWNS_TURN_ENV = "GOCODE_AUTOPILOT_OWNS_TURN";
|
|
395
|
+
/**
|
|
396
|
+
* True when {@link AUTOPILOT_OWNS_TURN_ENV} is set to a truthy value. Treats the
|
|
397
|
+
* usual falsy strings (`""`, `0`, `false`, `no`, `off`, any case) as not-owned so
|
|
398
|
+
* an accidental empty/`0` export never silently eats every per-turn ping.
|
|
399
|
+
*/
|
|
400
|
+
export function autopilotOwnsTurn(env = process.env) {
|
|
401
|
+
const raw = env[AUTOPILOT_OWNS_TURN_ENV];
|
|
402
|
+
if (raw == null)
|
|
403
|
+
return false;
|
|
404
|
+
const v = raw.trim().toLowerCase();
|
|
405
|
+
return v !== "" && v !== "0" && v !== "false" && v !== "no" && v !== "off";
|
|
406
|
+
}
|
|
40
407
|
/**
|
|
41
408
|
* The end-of-turn dispatcher (PRD §2.2). Resolves settings, then either delegates
|
|
42
409
|
* to the auto-push flow (which sends its own notification) OR fires the plain
|
|
@@ -48,7 +415,12 @@ function toPushSettings(settings) {
|
|
|
48
415
|
*/
|
|
49
416
|
export async function onStop(opts = {}) {
|
|
50
417
|
const source = opts.source ?? "unknown";
|
|
51
|
-
|
|
418
|
+
// Effective project root: prefer the workspace the hook stdin names (Cursor &
|
|
419
|
+
// Claude run hooks from the IDE's config dir, so process.cwd() is e.g.
|
|
420
|
+
// `~/.cursor` — using it makes the project show as ".cursor"). An explicit
|
|
421
|
+
// --cwd flag still wins (tests / manual invocations). Falls back to cwd.
|
|
422
|
+
const rawCwd = opts.cwd ?? process.cwd();
|
|
423
|
+
const cwd = opts.cwd ?? workspaceRootFromHookStdin(opts.hookStdin) ?? rawCwd;
|
|
52
424
|
const deriveRepo = opts.deriveRepo ?? deriveRepoIdentity;
|
|
53
425
|
const resolveSettings = opts.resolveSettings ?? resolveNotifySettings;
|
|
54
426
|
const logLine = async (line) => {
|
|
@@ -71,6 +443,21 @@ export async function onStop(opts = {}) {
|
|
|
71
443
|
timestamp: opts.timestamp,
|
|
72
444
|
}));
|
|
73
445
|
try {
|
|
446
|
+
// ── Step 0: Autopilot-owns-turn gate (T-N7 / PRD §3.2). ──
|
|
447
|
+
// When an Autopilot loop has marked that it owns this turn, it sends its OWN
|
|
448
|
+
// loop_completed/loop_halted Autopilot ping, so ANY per-turn stop-hook ping
|
|
449
|
+
// here (plain `finished` OR the auto-push notification) would be a duplicate.
|
|
450
|
+
// Suppress entirely — no send, no git push — before doing any other work.
|
|
451
|
+
// Best-effort + safe: gated behind an explicit truthy env marker only, so a
|
|
452
|
+
// normal hand-driven turn (no marker) is never affected.
|
|
453
|
+
if (autopilotOwnsTurn(opts.env)) {
|
|
454
|
+
await logLine(`autopilot owns turn (${AUTOPILOT_OWNS_TURN_ENV}) → suppressed per-turn ${source} ping (loop ping is authoritative)`);
|
|
455
|
+
return {
|
|
456
|
+
mode: "autopilot-suppressed",
|
|
457
|
+
settingsSource: "default",
|
|
458
|
+
detail: "autopilot loop owns the turn — per-turn ping suppressed",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
74
461
|
// ── Step 1: derive repo identity (never throws — local fallback on failure). ──
|
|
75
462
|
let repo;
|
|
76
463
|
try {
|
|
@@ -98,7 +485,7 @@ export async function onStop(opts = {}) {
|
|
|
98
485
|
settings: toPushSettings(settings),
|
|
99
486
|
source,
|
|
100
487
|
cwd,
|
|
101
|
-
project: repo
|
|
488
|
+
project: projectLabel(repo, cwd),
|
|
102
489
|
dedupeKey: opts.dedupeKey,
|
|
103
490
|
dryRun: opts.dryRun,
|
|
104
491
|
server: opts.server,
|
|
@@ -110,13 +497,21 @@ export async function onStop(opts = {}) {
|
|
|
110
497
|
await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
|
|
111
498
|
return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
|
|
112
499
|
}
|
|
113
|
-
// ── Step 3b: auto-push off → the plain
|
|
500
|
+
// ── Step 3b: auto-push off → the plain notification (legacy flow). ──
|
|
501
|
+
// Derive the notification kind from the Cursor stop hook's stdin JSON (T-CUR1
|
|
502
|
+
// / PRD §8.5). The status maps:
|
|
503
|
+
// completed → finished (agent turned cleanly)
|
|
504
|
+
// aborted → awaiting_input (agent yielded back to the human)
|
|
505
|
+
// error → error (agent hit an error)
|
|
506
|
+
// absent → finished (back-compat: no stdin or unrecognised status)
|
|
507
|
+
const hookStatus = parseCursorStopStatus(opts.hookStdin);
|
|
508
|
+
const sendKind = cursorStopStatusToKind(hookStatus);
|
|
114
509
|
if (opts.dryRun) {
|
|
115
|
-
await logLine(`dry-run: would send
|
|
510
|
+
await logLine(`dry-run: would send ${sendKind} (auto-push off, source: ${source}, settings: ${resolved.source})`);
|
|
116
511
|
return { mode: "dry-run-send", settingsSource: resolved.source, repo };
|
|
117
512
|
}
|
|
118
513
|
// Client fast-path cross-source dedup (T-N2 / PRD §2.2): before the plain
|
|
119
|
-
//
|
|
514
|
+
// send, consult a short-TTL lock keyed by repo+kind+minute-bucket.
|
|
120
515
|
// If another source (e.g. the Cursor `stop` hook vs this Claude `Stop` hook)
|
|
121
516
|
// already claimed the bucket within the window, skip OUR local send — the
|
|
122
517
|
// first arrival's notification stands. Best-effort + fail-open: the check
|
|
@@ -128,7 +523,7 @@ export async function onStop(opts = {}) {
|
|
|
128
523
|
try {
|
|
129
524
|
decision = await dedupCheck({
|
|
130
525
|
repoKey: repo?.repo_key,
|
|
131
|
-
kind:
|
|
526
|
+
kind: sendKind,
|
|
132
527
|
source,
|
|
133
528
|
windowMs: opts.dedupWindowMs,
|
|
134
529
|
home: opts.home,
|
|
@@ -138,16 +533,27 @@ export async function onStop(opts = {}) {
|
|
|
138
533
|
decision = "send"; // defence in depth — never let dedup block the send
|
|
139
534
|
}
|
|
140
535
|
if (decision === "suppress") {
|
|
141
|
-
await logLine(`dedup fast-path → suppressed duplicate
|
|
536
|
+
await logLine(`dedup fast-path → suppressed duplicate ${sendKind} (source: ${source}, settings: ${resolved.source})`);
|
|
142
537
|
return { mode: "deduped", settingsSource: resolved.source, repo };
|
|
143
538
|
}
|
|
144
|
-
const payload = { kind:
|
|
145
|
-
|
|
146
|
-
|
|
539
|
+
const payload = { kind: sendKind, source };
|
|
540
|
+
const project = projectLabel(repo, cwd);
|
|
541
|
+
if (project)
|
|
542
|
+
payload.project = project;
|
|
147
543
|
if (opts.dedupeKey)
|
|
148
544
|
payload.dedupe_key = opts.dedupeKey;
|
|
545
|
+
// Deep-link target: the synced chat's id (so tapping the push opens the
|
|
546
|
+
// chat, not just Home). Best-effort — omitted when the hook payload has no
|
|
547
|
+
// stable session id (push then falls back to Home, as before).
|
|
548
|
+
const ideChatId = ideChatIdFromHookStdin(source, cwd, opts.hookStdin);
|
|
549
|
+
if (ideChatId)
|
|
550
|
+
payload.ide_chat_id = ideChatId;
|
|
551
|
+
// Name WHICH chat finished in the body (best-effort; omitted if unreadable).
|
|
552
|
+
const chatTitle = await chatTitleFromHookStdin(opts.hookStdin);
|
|
553
|
+
if (chatTitle)
|
|
554
|
+
payload.chat = chatTitle;
|
|
149
555
|
const sent = await sendImpl(payload);
|
|
150
|
-
await logLine(`send path →
|
|
556
|
+
await logLine(`send path → ${sendKind} ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
|
|
151
557
|
return { mode: "send", settingsSource: resolved.source, send: sent, repo };
|
|
152
558
|
}
|
|
153
559
|
catch (err) {
|
package/dist/src/send.js
CHANGED
|
@@ -20,6 +20,13 @@ export const NOTIFY_KINDS = [
|
|
|
20
20
|
"awaiting_input",
|
|
21
21
|
"loop_completed",
|
|
22
22
|
"loop_halted",
|
|
23
|
+
// Ralph/Homer lifecycle kinds (PRD §4 — Notify Human-Gating PRD 2026-06-08).
|
|
24
|
+
// ralph_waiting: offline/quota stall edge — pushed once on the stall edge;
|
|
25
|
+
// server drops repeats until a resumed/completed/halted re-arms the edge.
|
|
26
|
+
// ralph_resumed: stall recovered, loop running again — NEVER pushed (silent
|
|
27
|
+
// control event that resets the server-side stall state machine to ARMED).
|
|
28
|
+
"ralph_waiting",
|
|
29
|
+
"ralph_resumed",
|
|
23
30
|
];
|
|
24
31
|
/** True when `value` is one of the canonical {@link NOTIFY_KINDS}. */
|
|
25
32
|
export function isNotifyKind(value) {
|
|
@@ -45,7 +52,7 @@ function normalizeServer(url) {
|
|
|
45
52
|
/** Build the JSON body, dropping any undefined/empty optional fields. */
|
|
46
53
|
function buildBody(payload) {
|
|
47
54
|
const body = { kind: payload.kind };
|
|
48
|
-
for (const field of ["title", "body", "source", "project", "dedupe_key"]) {
|
|
55
|
+
for (const field of ["title", "body", "source", "project", "dedupe_key", "ide_chat_id", "chat"]) {
|
|
49
56
|
const v = payload[field];
|
|
50
57
|
if (typeof v === "string" && v !== "")
|
|
51
58
|
body[field] = v;
|
package/dist/src/status.js
CHANGED
|
@@ -84,6 +84,18 @@ export async function gatherStatus(opts = {}) {
|
|
|
84
84
|
function mark(ok) {
|
|
85
85
|
return ok ? "✓" : "✗";
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* One-liner hint appended at the bottom of {@link formatStatus}.
|
|
89
|
+
* Kept as an exported constant so tests can match against the exact text.
|
|
90
|
+
*/
|
|
91
|
+
export const STATUS_ONELINER_HINT = "To notify from your own script, add: gocode-notify send --kind finished --source <name> || true";
|
|
92
|
+
/**
|
|
93
|
+
* Warning surfaced when credentials are absent (T-COV1: make the silent no-op LOUD).
|
|
94
|
+
* Defined here so both `status` and `doctor` can use the same text without a
|
|
95
|
+
* circular import (doctor.ts already imports from status.ts).
|
|
96
|
+
* Exported so tests can match against the exact text.
|
|
97
|
+
*/
|
|
98
|
+
export const UNPAIRED_WARNING = "⚠️ loop completion pushes will NOT reach your phone — run `gocode-notify login`";
|
|
87
99
|
/** Render a {@link StatusReport} as human-readable lines (one per element). */
|
|
88
100
|
export function formatStatus(report) {
|
|
89
101
|
const lines = ["gocode-notify status", ""];
|
|
@@ -98,6 +110,11 @@ export function formatStatus(report) {
|
|
|
98
110
|
lines.push(`${mark(false)} Credentials: not paired — run \`gocode-notify login\``);
|
|
99
111
|
}
|
|
100
112
|
lines.push(` path: ${c.path}`);
|
|
113
|
+
// T-COV1: Surface the loud unpaired warning so loop scripts that check
|
|
114
|
+
// `gocode-notify status` never silently miss that pushes are disabled.
|
|
115
|
+
if (!c.present) {
|
|
116
|
+
lines.push("", UNPAIRED_WARNING);
|
|
117
|
+
}
|
|
101
118
|
lines.push(`${mark(report.server.reachable)} Server: ${report.server.url} (${report.server.detail})`);
|
|
102
119
|
lines.push("", "Runtimes:");
|
|
103
120
|
for (const r of report.runtimes) {
|
|
@@ -109,6 +126,7 @@ export function formatStatus(report) {
|
|
|
109
126
|
lines.push(` ${mark(true)} ${r.name}: detected (${cfg})`);
|
|
110
127
|
lines.push(` config: ${r.configPath}`);
|
|
111
128
|
}
|
|
129
|
+
lines.push("", STATUS_ONELINER_HINT);
|
|
112
130
|
return lines;
|
|
113
131
|
}
|
|
114
132
|
/**
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Single source of truth for the CLI version. Keep in sync with package.json.
|
|
2
|
-
export const VERSION = "0.2
|
|
2
|
+
export const VERSION = "0.3.2";
|
package/package.json
CHANGED