@popoverinstall/cli 0.9.1 → 0.10.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/CHANGELOG.md +79 -0
- package/README.md +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -1
- package/dist/threads.d.ts +163 -0
- package/dist/threads.d.ts.map +1 -0
- package/dist/threads.js +576 -0
- package/dist/threads.js.map +1 -0
- package/package.json +3 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/ask.md +18 -9
- package/plugin/commands/message.md +152 -0
- package/plugin/commands/team.md +9 -7
- package/plugin/commands/tell.md +12 -3
- package/plugin/commands/vault.md +34 -13
- package/plugin/hooks/hooks.json +25 -1
- package/plugin/mcp/index.mjs +323 -7
- package/plugin/scripts/_ipc.mjs +49 -1
- package/plugin/scripts/announce-roster.mjs +5 -4
- package/plugin/scripts/deliver-messages.mjs +7 -5
- package/plugin/scripts/emit-event.mjs +11 -2
- package/plugin/scripts/inject-message.mjs +297 -0
- package/plugin/scripts/nudge-vault.mjs +517 -0
- package/plugin/skills/popover/SKILL.md +159 -70
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Says out loud that a task just landed, at the moment it lands.
|
|
3
|
+
//
|
|
4
|
+
// The skill file carries the disposition — when you finish, vault before you report
|
|
5
|
+
// completion (docs/vaults.md §3, and the standing rule in skills/popover/SKILL.md). Doctrine
|
|
6
|
+
// alone loses to attention: an agent that has just spent forty turns building something is
|
|
7
|
+
// thinking about the report it owes the user, not about an archive, and the moment §3 is
|
|
8
|
+
// about is precisely the one you are too deep in the work to notice. This hook is the
|
|
9
|
+
// reinforcement that arrives *at* the moment rather than in a paragraph read an hour earlier.
|
|
10
|
+
//
|
|
11
|
+
// It is registered synchronously on Stop and answers with
|
|
12
|
+
// `hookSpecificOutput.additionalContext`, which Claude Code delivers as non-error feedback
|
|
13
|
+
// that continues the conversation. That timing is the whole reason the script has this shape:
|
|
14
|
+
//
|
|
15
|
+
// - `async: true` delivers `additionalContext` a model call late (measured). A nudge that
|
|
16
|
+
// lands one turn after completion arrives while the user is already asking about
|
|
17
|
+
// something else, which is where a signal goes to become noise. So this one is
|
|
18
|
+
// synchronous, and the existing async emit-event.mjs entry on Stop is left alone.
|
|
19
|
+
// - `Stop` does not fire when the turn was interrupted by the user — official behaviour,
|
|
20
|
+
// verified on 2.1.252. So the interrupted-turn exclusion is free, and there is
|
|
21
|
+
// deliberately no attempt here to detect interruption from the payload: there is no field
|
|
22
|
+
// for it, and inventing one would only produce a guess.
|
|
23
|
+
//
|
|
24
|
+
// What qualifies as "a task landed" is deliberately narrow: a machine-verifiable fact about
|
|
25
|
+
// work that git has already recorded. Two tiers, and nothing else — a push, and behind it a
|
|
26
|
+
// commit. Neither is inferred from the shape of the conversation, because the shape of a
|
|
27
|
+
// conversation cannot carry that weight.
|
|
28
|
+
//
|
|
29
|
+
// A third tier was written, tested and cut, and it is worth knowing why before anyone adds it
|
|
30
|
+
// back: files changed earlier in the session, and none changed this turn. That reads like a
|
|
31
|
+
// task which has just finished, and it is exactly as much like the user stopping the agent to
|
|
32
|
+
// ask a question — the turn after a burst of editing looks identical either way, and the
|
|
33
|
+
// difference lives in the user's head where no hook can reach it. `Stop` firing tells you the
|
|
34
|
+
// *turn* completed. It does not tell you the *task* did, and that gap is the whole distance
|
|
35
|
+
// between the two tiers here and the one that is not. Cut outright rather than left behind a
|
|
36
|
+
// flag: a branch nobody exercises rots, and git remembers the code.
|
|
37
|
+
//
|
|
38
|
+
// What it must never do, in the order the damage would be worst:
|
|
39
|
+
//
|
|
40
|
+
// - Nudge on nothing. A line that fires when no task finished teaches the user, in one
|
|
41
|
+
// sitting, that the line means nothing — and after that the turn where it was right looks
|
|
42
|
+
// exactly like the turns where it was wrong. announce-roster.mjs records the same failure
|
|
43
|
+
// for the roster line and the same rule follows: silence beats a false positive, every
|
|
44
|
+
// time. Everything below that looks over-cautious is this.
|
|
45
|
+
// - Nudge twice for the same work. Deduped per session by what landed, not by time.
|
|
46
|
+
// - Break a session. Every path fails open: no git, no daemon, a wedged spawn, a malformed
|
|
47
|
+
// payload — exit 0, print nothing.
|
|
48
|
+
// - Spin the agent. `stop_hook_active` is true when Claude Code is already continuing
|
|
49
|
+
// because of a stop hook. Feeding it more context then is how you get a loop, so that
|
|
50
|
+
// case exits before doing any work at all.
|
|
51
|
+
// - Talk over a colleague. A message waiting for this session wins the turn
|
|
52
|
+
// (docs/messages.md §7) and this hook yields to it — without spending its dedupe key, so
|
|
53
|
+
// the nudge it swallowed comes back on the next turn.
|
|
54
|
+
//
|
|
55
|
+
// Every timeout inside is sized so that the worst case — slow stdin, a slow `git status`, a
|
|
56
|
+
// tip lookup, a commit count and a degraded daemon, all in one turn — still adds up to less
|
|
57
|
+
// than the 5-second timeout hooks.json gives it. Being killed by that timeout would only cost
|
|
58
|
+
// a nudge, which is survivable, but it could also truncate the state file mid-write and cost
|
|
59
|
+
// the session its baseline. The ordinary turn is one `git status` and nothing else.
|
|
60
|
+
//
|
|
61
|
+
// It does not vault anything and it holds no opinion about whether this conversation deserves
|
|
62
|
+
// to be vaulted. That judgement is the agent's — §3 gives it the authority deliberately, and
|
|
63
|
+
// a hook that decided for it would be an automatic disclosure wearing a nudge's clothes. The
|
|
64
|
+
// job here is to put an accurate factual statement in front of the model and stop.
|
|
65
|
+
|
|
66
|
+
import { spawnSync } from "node:child_process";
|
|
67
|
+
import {
|
|
68
|
+
mkdirSync,
|
|
69
|
+
readFileSync,
|
|
70
|
+
readdirSync,
|
|
71
|
+
rmSync,
|
|
72
|
+
statSync,
|
|
73
|
+
writeFileSync,
|
|
74
|
+
} from "node:fs";
|
|
75
|
+
import path from "node:path";
|
|
76
|
+
import {
|
|
77
|
+
callerSessionId,
|
|
78
|
+
inboxWaitingSince,
|
|
79
|
+
popoverHome,
|
|
80
|
+
readStdin,
|
|
81
|
+
request,
|
|
82
|
+
} from "./_ipc.mjs";
|
|
83
|
+
|
|
84
|
+
// A fork answering a teammate's question must not be nudged to publish anything. It is a
|
|
85
|
+
// throwaway session whose output goes back to whoever asked, its "completion" is answering
|
|
86
|
+
// one question, and the repo it happens to run in is not its own — see the header of
|
|
87
|
+
// emit-event.mjs for what forks running the plugin's hooks unguarded did to the roster.
|
|
88
|
+
if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
|
|
89
|
+
|
|
90
|
+
/** At most this many nudges in one session, whatever lands. The wallpaper ceiling. */
|
|
91
|
+
const MAX_NUDGES = 3;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// The payload is not optional here the way it is in deliver-messages.mjs:
|
|
95
|
+
// `stop_hook_active` exists only in it, and without that flag there is no loop guard. An
|
|
96
|
+
// unreadable payload therefore means silence.
|
|
97
|
+
const payload = JSON.parse(await readStdin(400));
|
|
98
|
+
const sessionId = payload?.session_id || callerSessionId();
|
|
99
|
+
const cwd = payload?.cwd || process.cwd();
|
|
100
|
+
|
|
101
|
+
if (sessionId) {
|
|
102
|
+
if (payload?.hook_event_name === "SessionStart") {
|
|
103
|
+
recordBaseline(sessionId, cwd);
|
|
104
|
+
} else if (!payload?.stop_hook_active) {
|
|
105
|
+
await onStop(sessionId, cwd);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Fail open, always. A wedged hook at the end of every turn would be worse than no hook.
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
process.exit(0);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Everything a Stop needs to decide, and then the state for the next one.
|
|
116
|
+
*
|
|
117
|
+
* Ordering matters: the tier is judged against the *previous* stop's state, and the new state
|
|
118
|
+
* is written afterwards whether or not anything was said. Two things have to be recorded even
|
|
119
|
+
* by a silent stop: that this session was once ahead of its upstream, which is one of the two
|
|
120
|
+
* routes to recognising a push, and which keys have already been nudged. Writing also keeps
|
|
121
|
+
* the file's mtime fresh, which is what the week-old sweep below prunes on.
|
|
122
|
+
*/
|
|
123
|
+
async function onStop(sessionId, cwd) {
|
|
124
|
+
const snap = snapshot(cwd);
|
|
125
|
+
if (!snap) return; // Not a git repo, no commits yet, or git took too long.
|
|
126
|
+
|
|
127
|
+
const state = readState(sessionId);
|
|
128
|
+
if (!state) {
|
|
129
|
+
// No baseline: the plugin was installed mid-session, the SessionStart hook failed, or
|
|
130
|
+
// POPOVER_HOME moved. Start the clock now and say nothing — a session whose beginning is
|
|
131
|
+
// unknown cannot honestly claim that anything advanced during it.
|
|
132
|
+
writeState(sessionId, freshState(snap, cwd));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const next = {
|
|
137
|
+
...state,
|
|
138
|
+
// Sticky: once this session has held an unpushed commit, the later disappearance of that
|
|
139
|
+
// gap is a push even if HEAD never moved — see classify().
|
|
140
|
+
wasAhead: state.wasAhead || snap.ahead > 0,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// A waiting message outranks anything this hook has to say — docs/messages.md §7. The two
|
|
145
|
+
// do not share a budget (that hook injects on PostToolBatch, this one continues a Stop)
|
|
146
|
+
// but they compete for one turn, and the tie-break is not close: a vault nudge can wait,
|
|
147
|
+
// because its dedupe key is keyed on the commit and will fire again next turn, while a
|
|
148
|
+
// colleague blocked on a reply cannot.
|
|
149
|
+
//
|
|
150
|
+
// Note where this sits — inside the try, so the `finally` below still records `wasAhead`,
|
|
151
|
+
// and before classify(), so `nudged` is never appended to. Yielding must not cost the
|
|
152
|
+
// nudge its key; a yield that silently consumed it would turn "wait a turn" into "never".
|
|
153
|
+
if (inboxWaitingSince(sessionId) !== null) return;
|
|
154
|
+
|
|
155
|
+
const tier = classify(state, snap, cwd);
|
|
156
|
+
const already = state.nudged ?? [];
|
|
157
|
+
if (tier && !already.includes(tier.key) && already.length < MAX_NUDGES) {
|
|
158
|
+
if (await popoverUsable(sessionId, cwd)) {
|
|
159
|
+
emit(tier.text);
|
|
160
|
+
next.nudged = [...already, tier.key];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
writeState(sessionId, next);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Which of the two signals fired, stronger first, or null for silence.
|
|
170
|
+
*
|
|
171
|
+
* The order is not a preference, it is what the signals actually mean:
|
|
172
|
+
*
|
|
173
|
+
* 1. **A push landed.** The work has left the machine and become the team's, irreversibly,
|
|
174
|
+
* and it is the moment teammates start meeting this code without this conversation
|
|
175
|
+
* attached to it. Nothing else available to a hook is that unambiguous.
|
|
176
|
+
* 2. **A commit landed.** Real, but private and revertible, and as often a checkpoint as a
|
|
177
|
+
* finish. Worth stating; not worth stating as completion.
|
|
178
|
+
*
|
|
179
|
+
* Both are facts git can be asked for and will answer the same way twice. There is no weaker
|
|
180
|
+
* rung under them, and silence is the default — the header records what used to be there and
|
|
181
|
+
* why it is not.
|
|
182
|
+
*
|
|
183
|
+
* The identity and timestamp tests exist to keep a `git pull` from reading as a push: a
|
|
184
|
+
* fast-forward advances HEAD and closes the gap to upstream exactly as a push does, and the
|
|
185
|
+
* only cheap thing that separates them is whose commit is at the tip and when it was made.
|
|
186
|
+
*/
|
|
187
|
+
function classify(state, snap, cwd) {
|
|
188
|
+
const advanced = Boolean(snap.head) && snap.head !== state.baseHead;
|
|
189
|
+
|
|
190
|
+
// Only spawn for the tip commit when a tier is actually in play. On an ordinary turn this
|
|
191
|
+
// whole function costs nothing beyond the one `git status` already taken.
|
|
192
|
+
const tip = advanced || state.wasAhead ? tipCommit(cwd) : null;
|
|
193
|
+
|
|
194
|
+
// Unknown identity on either side degrades to the timestamp test rather than blocking: a
|
|
195
|
+
// repo can be configured without user.email, and a hook that silently stops working
|
|
196
|
+
// because of a config gap is the failure announce-roster.mjs took a release to notice.
|
|
197
|
+
const identity =
|
|
198
|
+
tip?.email && state.email ? tip.email.toLowerCase() === state.email.toLowerCase() : null;
|
|
199
|
+
const ours = Boolean(tip) && identity !== false;
|
|
200
|
+
// 120s of slack for skew between the committer's clock and this process's.
|
|
201
|
+
const madeThisSession = ours && tip.at >= (state.startedAt ?? 0) - 120;
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
snap.upstream &&
|
|
205
|
+
snap.ahead === 0 &&
|
|
206
|
+
snap.behind === 0 &&
|
|
207
|
+
((advanced && madeThisSession) || (state.wasAhead && ours))
|
|
208
|
+
) {
|
|
209
|
+
return { key: `push:${snap.head}`, text: pushText(state, snap, cwd) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A commit that is still local. `ahead > 0` is what keeps this from firing a second time on
|
|
213
|
+
// the same work after the push tier already has — and a repo with no upstream at all can
|
|
214
|
+
// never reach the push tier, so it qualifies here on its own.
|
|
215
|
+
if (advanced && madeThisSession && (!snap.upstream || snap.ahead > 0)) {
|
|
216
|
+
return { key: `commit:${snap.head}`, text: commitText(state, snap, cwd) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/*
|
|
223
|
+
* The two statements.
|
|
224
|
+
*
|
|
225
|
+
* Written as statements on purpose. The hook docs warn that text reading as an injected
|
|
226
|
+
* system instruction can trip Claude's prompt-injection defences and be surfaced to the user
|
|
227
|
+
* as a suspicious string instead of acted on — which would turn a nudge into a support
|
|
228
|
+
* question. So each of these says what just happened and what exists, names the judgement as
|
|
229
|
+
* the agent's, and never issues an order. `additionalContext` is capped at 10,000 characters;
|
|
230
|
+
* both of these sit well under a thousand, and the shortness is also deliberate — the model
|
|
231
|
+
* has to read this at the end of a long turn.
|
|
232
|
+
*/
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Shared closing. Both tiers need the same three facts: what the tool is, that the call is a
|
|
236
|
+
* judgement rather than a reflex, and that a conversation already vaulted needs nothing. The
|
|
237
|
+
* last sentence is the one announce-roster.mjs learned the hard way: without it the model
|
|
238
|
+
* reports the notice to the user as news.
|
|
239
|
+
*
|
|
240
|
+
* Deliberately short, and deliberately does not restate what is worth vaulting. That rule
|
|
241
|
+
* lives in the popover skill's `description`, which is in context on every turn of every
|
|
242
|
+
* session — so a second copy here would not be reaching an agent that lacked it, it would be
|
|
243
|
+
* a second place the rule has to be edited, and the two would drift the first time one of
|
|
244
|
+
* them changed. The disclosure that a vault is readable by the service is likewise already
|
|
245
|
+
* on `vault_create` itself, which an agent has necessarily read by the time it can call it.
|
|
246
|
+
* What this text is for is the part the skill cannot know: that it is being said now, and
|
|
247
|
+
* why now.
|
|
248
|
+
*
|
|
249
|
+
* A function and not a `const`, which is not a style choice. The top-level `await` that runs
|
|
250
|
+
* this script sits above every declaration down here, and a `const` read from inside it is in
|
|
251
|
+
* its temporal dead zone — so the first nudge threw a ReferenceError straight into the
|
|
252
|
+
* fail-open catch and the hook printed nothing, forever, in silence. Function declarations
|
|
253
|
+
* hoist; that is the whole difference, and it is the exact failure this file's fail-open
|
|
254
|
+
* posture is worst at showing you.
|
|
255
|
+
*/
|
|
256
|
+
function tail() {
|
|
257
|
+
return (
|
|
258
|
+
"popover's vault_create tool publishes this conversation to the team's archive, where " +
|
|
259
|
+
"anyone on the team can ask it once this session no longer exists. Whether this " +
|
|
260
|
+
"particular conversation is one of those is a judgement that is yours to make, on the " +
|
|
261
|
+
"standing rule you already carry, and a conversation already vaulted needs nothing " +
|
|
262
|
+
"further. This notice is only worth mentioning to the user if it turns out to be " +
|
|
263
|
+
"relevant."
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function pushText(state, snap, cwd) {
|
|
268
|
+
const made = commitsSince(cwd, state.baseHead);
|
|
269
|
+
const what =
|
|
270
|
+
made > 0
|
|
271
|
+
? `${made} commit${made === 1 ? "" : "s"} made in this session ` +
|
|
272
|
+
`${made === 1 ? "is" : "are"} now on ${snap.upstream}`
|
|
273
|
+
: `the commits this branch was holding are now on ${snap.upstream}`;
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
`[popover] A push landed during this session: ${what}, with HEAD at ` +
|
|
277
|
+
`${short(snap.head)}. The work has left this machine and teammates encounter this code ` +
|
|
278
|
+
`from here on, while the reasoning behind it exists only in this conversation, which is ` +
|
|
279
|
+
`deleted when the session ends. docs/vaults.md §3 names this — the completion of a task ` +
|
|
280
|
+
`— as the moment a vault belongs to, and the standing rule in the popover skill is to ` +
|
|
281
|
+
`vault before reporting completion rather than after it. ${tail()}`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Two numbers, not one, and the second is why: the branch's ahead count alone was
|
|
287
|
+
* ambiguous. A branch can be carrying unpushed commits this session never made — a test run
|
|
288
|
+
* caught it reading "6 commits ahead" at a session that had made exactly one — and a nudge
|
|
289
|
+
* whose first factual clause overstates what just happened is the kind of thing an agent
|
|
290
|
+
* repeats to the user. So the session's own count leads and the branch's gap follows.
|
|
291
|
+
*/
|
|
292
|
+
function commitText(state, snap, cwd) {
|
|
293
|
+
const made = commitsSince(cwd, state.baseHead);
|
|
294
|
+
const mine =
|
|
295
|
+
made > 0
|
|
296
|
+
? `${made} commit${made === 1 ? "" : "s"} made in this session`
|
|
297
|
+
: "a commit made in this session";
|
|
298
|
+
const where = snap.upstream
|
|
299
|
+
? `${mine}, with the branch now ${snap.ahead} ahead of ${snap.upstream}`
|
|
300
|
+
: `${mine}, on a branch with no upstream to push to`;
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
`[popover] A commit landed during this session: ${short(snap.head)} — ${where}. This ` +
|
|
304
|
+
`is a weaker signal than a push — a commit is private, revertible, and as often a ` +
|
|
305
|
+
`checkpoint as a finish — so it is stated as a fact and not as a claim that anything ` +
|
|
306
|
+
`is done. If the work is in fact done, this is the moment docs/vaults.md §3 is about: ` +
|
|
307
|
+
`your grasp of it is as good as it is going to get, and the user is about to move on. ` +
|
|
308
|
+
`${tail()}`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The one line of stdout this script is allowed to write. Anything else here is a bug. */
|
|
313
|
+
function emit(additionalContext) {
|
|
314
|
+
process.stdout.write(
|
|
315
|
+
`${JSON.stringify({
|
|
316
|
+
hookSpecificOutput: { hookEventName: "Stop", additionalContext },
|
|
317
|
+
})}\n`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Is popover actually in a position to accept a vault right now?
|
|
323
|
+
*
|
|
324
|
+
* Checked because the alternative is telling an agent a tool is available when calling it
|
|
325
|
+
* will fail — and a nudge that leads straight into an error is worse than no nudge, since the
|
|
326
|
+
* agent then reports the failure to a user who never asked for any of this. `roster.ok`
|
|
327
|
+
* coming back means the daemon is up and the machine is signed in; anything else means it is
|
|
328
|
+
* not, or the backend is unreachable, and both are reasons to stay quiet. `local` was the
|
|
329
|
+
* cheaper candidate and is the wrong one: it answers without credentials by design.
|
|
330
|
+
*
|
|
331
|
+
* Paid only on a turn where a tier has already matched, which is at most three turns a
|
|
332
|
+
* session.
|
|
333
|
+
*/
|
|
334
|
+
async function popoverUsable(sessionId, cwd) {
|
|
335
|
+
const reply = await request(
|
|
336
|
+
{ t: "roster", id: "hook", refresh: false, fromSessionId: sessionId, cwd },
|
|
337
|
+
{ timeoutMs: 1200 },
|
|
338
|
+
);
|
|
339
|
+
return reply?.t === "roster.ok";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* One `git status --porcelain=v2 --branch --untracked-files=no`, and the three facts both
|
|
344
|
+
* tiers need are all in its header: the HEAD sha, the upstream name, and the ahead/behind
|
|
345
|
+
* counts. `rev-parse HEAD`, `rev-parse @{u}` and a `rev-list` would cost three process spawns
|
|
346
|
+
* for the same thing, and on Windows a spawn is tens of milliseconds out of a budget that is
|
|
347
|
+
* spent at the end of every single turn.
|
|
348
|
+
*
|
|
349
|
+
* `--untracked-files=no` is what keeps that one spawn cheap. Hunting for untracked files is
|
|
350
|
+
* the expensive half of `git status` — a full walk of the working tree — and it was only ever
|
|
351
|
+
* here to fingerprint the dirty set for the tier that was cut. Nothing reads the file entries
|
|
352
|
+
* now, so they are not asked for. The call is still bounded and still gives up rather than
|
|
353
|
+
* holding up the end of a turn.
|
|
354
|
+
*/
|
|
355
|
+
function snapshot(cwd) {
|
|
356
|
+
const args = ["status", "--porcelain=v2", "--branch", "--untracked-files=no"];
|
|
357
|
+
const out = git(args, cwd, 1200);
|
|
358
|
+
if (out === null) return null;
|
|
359
|
+
|
|
360
|
+
const snap = { head: "", upstream: "", ahead: 0, behind: 0 };
|
|
361
|
+
|
|
362
|
+
for (const line of out.split("\n")) {
|
|
363
|
+
if (line.startsWith("# branch.oid ")) snap.head = line.slice(13).trim();
|
|
364
|
+
else if (line.startsWith("# branch.upstream ")) snap.upstream = line.slice(18).trim();
|
|
365
|
+
else if (line.startsWith("# branch.ab ")) {
|
|
366
|
+
const [a, b] = line.slice(12).trim().split(/\s+/);
|
|
367
|
+
snap.ahead = Math.abs(Number.parseInt(a, 10) || 0);
|
|
368
|
+
snap.behind = Math.abs(Number.parseInt(b, 10) || 0);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// `(initial)` before the first commit. Nothing here can advance from nothing.
|
|
373
|
+
if (!snap.head || snap.head === "(initial)") return null;
|
|
374
|
+
|
|
375
|
+
return snap;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Committer email and commit time of the tip, for the "is this ours, and is it new" test. */
|
|
379
|
+
function tipCommit(cwd) {
|
|
380
|
+
const out = git(["log", "-1", "--format=%ce%n%ct"], cwd, 800);
|
|
381
|
+
if (out === null) return null;
|
|
382
|
+
const [email, at] = out.split("\n");
|
|
383
|
+
const seconds = Number.parseInt((at ?? "").trim(), 10);
|
|
384
|
+
if (!Number.isFinite(seconds)) return null;
|
|
385
|
+
return { email: (email ?? "").trim(), at: seconds };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** How many commits this session added, for the push line. Zero is a legitimate answer. */
|
|
389
|
+
function commitsSince(cwd, baseHead) {
|
|
390
|
+
if (!baseHead) return 0;
|
|
391
|
+
const out = git(["rev-list", "--count", `${baseHead}..HEAD`], cwd, 800);
|
|
392
|
+
const n = Number.parseInt((out ?? "").trim(), 10);
|
|
393
|
+
return Number.isFinite(n) ? n : 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function localEmail(cwd) {
|
|
397
|
+
return (git(["config", "--get", "user.email"], cwd, 800) ?? "").trim();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* git, with every way it can hurt a session closed off.
|
|
402
|
+
*
|
|
403
|
+
* `timeout` kills a spawn that hangs — a network-mounted repo or a stale index lock must not
|
|
404
|
+
* hold up the end of a turn. stderr is discarded rather than inherited, because a hook that
|
|
405
|
+
* prints git's complaints puts them in front of the user as if they were popover's.
|
|
406
|
+
*/
|
|
407
|
+
function git(args, cwd, timeoutMs) {
|
|
408
|
+
try {
|
|
409
|
+
const r = spawnSync("git", args, {
|
|
410
|
+
cwd,
|
|
411
|
+
timeout: timeoutMs,
|
|
412
|
+
encoding: "utf8",
|
|
413
|
+
windowsHide: true,
|
|
414
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
415
|
+
});
|
|
416
|
+
if (r.error || r.status !== 0 || typeof r.stdout !== "string") return null;
|
|
417
|
+
return r.stdout;
|
|
418
|
+
} catch {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function short(sha) {
|
|
424
|
+
return (sha ?? "").slice(0, 7);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/*
|
|
428
|
+
* Per-session state.
|
|
429
|
+
*
|
|
430
|
+
* Same shape as announce-roster.mjs's signature file and in the same directory, so there is
|
|
431
|
+
* one place to look and one thing to sweep. Per session rather than per machine because the
|
|
432
|
+
* whole question is "did this land during *this* session", and because a new session should
|
|
433
|
+
* get its own single nudge.
|
|
434
|
+
*/
|
|
435
|
+
function stateFile(id) {
|
|
436
|
+
return path.join(popoverHome(), "announced", `${id}.vault.json`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function readState(id) {
|
|
440
|
+
try {
|
|
441
|
+
const state = JSON.parse(readFileSync(stateFile(id), "utf8"));
|
|
442
|
+
return state?.v === 1 ? state : null; // A future shape is ignored, not guessed at.
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function writeState(id, state) {
|
|
449
|
+
try {
|
|
450
|
+
mkdirSync(path.join(popoverHome(), "announced"), { recursive: true });
|
|
451
|
+
writeFileSync(stateFile(id), JSON.stringify(state), "utf8");
|
|
452
|
+
} catch {
|
|
453
|
+
// An unwritable state file means this session never nudges: every stop reads no baseline
|
|
454
|
+
// and starts the clock again. Degraded into silence, which is the right direction.
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Six fields, and every one of them is read by classify() or by the dedupe. Kept that way
|
|
460
|
+
* deliberately: this file briefly carried a stop counter, two tree fingerprints and a
|
|
461
|
+
* `lastHead` that nothing consulted any more, and a state field which is written but never
|
|
462
|
+
* read is a claim about how the detector works that has quietly stopped being true.
|
|
463
|
+
*/
|
|
464
|
+
function freshState(snap, cwd) {
|
|
465
|
+
return {
|
|
466
|
+
v: 1,
|
|
467
|
+
startedAt: Math.floor(Date.now() / 1000),
|
|
468
|
+
email: localEmail(cwd),
|
|
469
|
+
baseHead: snap.head,
|
|
470
|
+
wasAhead: snap.ahead > 0,
|
|
471
|
+
nudged: [],
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The session's starting line, taken at SessionStart so that a task finished in the very
|
|
477
|
+
* first turn is still detectable. Doing this lazily on the first Stop instead would make
|
|
478
|
+
* "commit and push this fix" — a whole task in one turn — the one shape that could never
|
|
479
|
+
* fire.
|
|
480
|
+
*
|
|
481
|
+
* An existing file is never overwritten. SessionStart also fires on resume and on compaction,
|
|
482
|
+
* and re-baselining there would move the starting line forward past the very commits it
|
|
483
|
+
* exists to notice.
|
|
484
|
+
*/
|
|
485
|
+
function recordBaseline(sessionId, cwd) {
|
|
486
|
+
if (readState(sessionId)) return;
|
|
487
|
+
const snap = snapshot(cwd);
|
|
488
|
+
if (!snap) return;
|
|
489
|
+
writeState(sessionId, freshState(snap, cwd));
|
|
490
|
+
sweepOldState();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Drop state files older than a week.
|
|
495
|
+
*
|
|
496
|
+
* announce-roster.mjs left this as a loose end — a session id is a uuid, nothing prunes the
|
|
497
|
+
* directory, and a heavy user accretes an entry per session forever. This sweeps both kinds,
|
|
498
|
+
* since they share the directory, once per session, on the one event already paying for a
|
|
499
|
+
* process spawn. It is still the second-best fix: the daemon knows when a session ends and
|
|
500
|
+
* could drop the file then, where this can only guess with a clock.
|
|
501
|
+
*/
|
|
502
|
+
function sweepOldState() {
|
|
503
|
+
const week = 7 * 24 * 60 * 60 * 1000;
|
|
504
|
+
try {
|
|
505
|
+
const dir = path.join(popoverHome(), "announced");
|
|
506
|
+
for (const name of readdirSync(dir)) {
|
|
507
|
+
const file = path.join(dir, name);
|
|
508
|
+
try {
|
|
509
|
+
if (Date.now() - statSync(file).mtimeMs > week) rmSync(file, { force: true });
|
|
510
|
+
} catch {
|
|
511
|
+
// Raced with another session, or not ours to delete. Leave it.
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
// No directory yet, which is the common case on a first run.
|
|
516
|
+
}
|
|
517
|
+
}
|