yt-briefing 0.14.2 → 0.15.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.
@@ -34,7 +34,7 @@ while true:
34
34
  **On `rating_needed` — literally:**
35
35
 
36
36
  - **A.** Take `out.summary` (markdown) and `out.pending` (metadata).
37
- - **B.** In the same turn, as **your chat text** (NOT command output — the UI does not show it), paste `summary` **verbatim**: no paraphrase, no shortening, no comment, no "see above". The user must see it before the popup. B is **unconditional** — it does not depend on there being anything else to write, so an iteration that returns no `skipped` line still opens with the pasted summary, never straight with the tool call. _(If the user says "I don't see the summary" — you skipped B. In Claude Code a PreToolUse gate blocks the popup when the pending video's id is missing from your chat text; if it fires, paste the summary and ask again.)_
37
+ - **B.** In the same turn, as **your chat text** (NOT command output — the UI does not show it), paste `summary` **verbatim**: no paraphrase, no shortening, no comment, no "see above". The user must see it before the popup. B is **unconditional** — it does not depend on there being anything else to write, so an iteration that returns no `skipped` line still opens with the pasted summary, never straight with the tool call. _(If the user says "I don't see the summary" — you skipped B. In Claude Code a PreToolUse gate refuses to record the rating in step D when the pending video's id is missing from your chat text; if it fires, paste the summary, ask for the rating again, then record it.)_
38
38
  - **C.** In the same message call `AskUserQuestion` — **1 call, 1 question** (everything in one step), phrased in `output_lang`:
39
39
  - The question (e.g. "Rating?") with three options whose descriptions explain: **OK** = neutral (no effect on the filter), **Weak** = worthless (teach the filter to skip such titles), **Research** = break the loop and dig into this video's content together. The digits never appear in the popup — internally map to `--rating`: **OK → 1, Weak → 0**; Research maps to no digit, it exits the loop (see Research mode). There is **no positive rating** — keeping the channel is the implicit positive; you only down-rate noise (`0`) or steer with a comment.
40
40
  - **Other** is the comment / stop / research channel (no second question): the user types free text. If it equals `stop` (case-insensitive, trimmed) — or the popup is dismissed (✕) — **end the loop**. If it **starts with `?`**, the text after the `?` is a **research question** → Research mode. Otherwise it is a **comment**: **distill** the user's raw text into a clean, generalizable rule, and infer the rating — clearly negative → `0`, otherwise → `1`.
package/README.md CHANGED
@@ -139,21 +139,29 @@ session. To install the skills again for another tool or project, run
139
139
  The loop only works if you see the summary *before* you rate it, and that is the one step an
140
140
  agent can silently drop — the popup still appears, you still answer, and the rating is recorded
141
141
  against a summary nobody read. On Claude Code the installer wires a `PreToolUse` hook into your
142
- project's `.claude/settings.json` that refuses the rating popup unless the video's summary is in
143
- the chat. It merges with your existing hooks and updates itself on reinstall. If that file isn't
144
- valid JSON the installer leaves it alone and says so — add the entry yourself:
142
+ project's `.claude/settings.json` that refuses to *record* a rating unless the video's summary is
143
+ in the chat. It merges with your existing hooks and updates itself on reinstall. If that file
144
+ isn't valid JSON the installer leaves it alone and says so — add the entry yourself:
145
145
 
146
146
  ```json
147
147
  {
148
148
  "hooks": {
149
149
  "PreToolUse": [
150
- { "matcher": "AskUserQuestion",
150
+ { "matcher": "Bash",
151
151
  "hooks": [ { "type": "command", "command": "node \"${CLAUDE_PROJECT_DIR}/node_modules/yt-briefing/dist/yt-summary-gate.js\"" } ] }
152
152
  ]
153
153
  }
154
154
  }
155
155
  ```
156
156
 
157
+ It watches the rating write rather than the popup, because the popup cannot be gated: the agent
158
+ pastes the summary and opens the popup in one message, and the harness only writes a message to
159
+ the transcript once that message is complete — so the evidence does not exist yet at popup time.
160
+ The rating write is a separate call in the next message, where it does. The matcher is `Bash`, so
161
+ the hook is invoked on ordinary shell commands too; it reads the command line and exits before
162
+ touching anything. The tradeoff: a genuinely skipped paste is caught after you have answered, so
163
+ that one answer is wasted — but nothing reaches the channel profile unseen.
164
+
157
165
  Other agents have no equivalent hook, so there the instruction in `SKILL.md` is what holds.
158
166
 
159
167
  ## Providers
@@ -111,7 +111,7 @@ const GATE_ID = 'yt-summary-gate';
111
111
  * How settings.json must invoke the gate. Like the skill's engine commands it bakes nothing
112
112
  * machine-absolute, but a hook may NOT assume its cwd — Claude Code's hooks reference states the
113
113
  * working directory can vary, so a bare relative path would fail open, silently and invisibly
114
- * (the popup appears ungated, which is exactly the bug the gate exists to catch). Hence the
114
+ * (the rating is written ungated, which is exactly the bug the gate exists to catch). Hence the
115
115
  * `$CLAUDE_PROJECT_DIR` prefix: still just a placeholder string in the committed JSON, resolved
116
116
  * to the project root at hook time.
117
117
  */
@@ -119,14 +119,16 @@ export const gateCommand = (dist = false) => dist
119
119
  ? `${isBun ? 'bun' : 'node'} "\${CLAUDE_PROJECT_DIR}/${toProjectRel(join(DIST_DIR, GATE_ID + '.js'))}"`
120
120
  : `bun run src/${GATE_ID}.ts`;
121
121
  /**
122
- * Put the gate into a settings object: a PreToolUse hook on AskUserQuestion. Merges — every other
123
- * setting and hook is left as found, and our own entry is updated in place, so reinstalling (or
124
- * moving the package, which changes the baked path) never duplicates or clobbers anything.
122
+ * Put the gate into a settings object: a PreToolUse hook on Bash, which is where the rating is
123
+ * written (gating the popup instead cannot work — see src/yt-summary-gate.ts). Merges — every
124
+ * other setting and hook is left as found, and our own entry is updated in place, so
125
+ * reinstalling (or upgrading from the pre-0.15.0 AskUserQuestion matcher) never duplicates or
126
+ * clobbers anything.
125
127
  */
126
128
  export function withGateHook(settings, command) {
127
129
  const preToolUse = ((settings.hooks ??= {}).PreToolUse ??= []);
128
130
  const mine = preToolUse.find((e) => e.hooks?.some((h) => h.command?.includes(GATE_ID)));
129
- const entry = { matcher: 'AskUserQuestion', hooks: [{ type: 'command', command }] };
131
+ const entry = { matcher: 'Bash', hooks: [{ type: 'command', command }] };
130
132
  if (mine)
131
133
  Object.assign(mine, entry);
132
134
  else
@@ -2,18 +2,21 @@
2
2
  * Decision logic for the /yt summary gate — kept separate from the hook script so it is
3
3
  * runtime-agnostic and unit-testable: pure functions over a payload and a transcript.
4
4
  *
5
- * See `src/yt-summary-gate.ts` for why the gate exists and how the hook wires it up.
5
+ * See `src/yt-summary-gate.ts` for why the gate exists and where it had to move to work at all.
6
6
  */
7
+ /** Substring identifying the rating writer; the skill always invokes it by this script name. */
8
+ const RATING_SCRIPT = 'yt-rating';
7
9
  /**
8
- * Is this the /yt rating popup? Identified by its fixed option labels, which SKILL.md pins in
9
- * English across every `output_lang` (only the question text and descriptions are localised).
10
- * Anything else — including /yt-search's keep/skip — is none of the gate's business.
10
+ * Is this the call that writes a rating? The skill records every rating by running
11
+ * `yt-rating(.ts|.js) --rating <1|0>` through Bash, so the command line is the signal. Anything
12
+ * else — a sweep, a transcript pull, unrelated shell work — is none of the gate's business, and
13
+ * is waved through before this module does any I/O at all.
11
14
  */
12
- export function isRatingPopup(payload) {
13
- const labels = (payload.tool_input?.questions ?? [])
14
- .flatMap((q) => q.options ?? [])
15
- .map((o) => (o.label ?? '').trim().toLowerCase());
16
- return labels.includes('research') && labels.includes('weak');
15
+ export function isRatingWrite(payload) {
16
+ if (payload.tool_name !== 'Bash')
17
+ return false;
18
+ const command = payload.tool_input?.command ?? '';
19
+ return command.includes(RATING_SCRIPT) && command.includes('--rating');
17
20
  }
18
21
  /**
19
22
  * Did the agent actually paste the summary? The summary carries the video's watch URL, so its id
@@ -38,31 +41,3 @@ export function summaryWasPasted(transcript, videoId) {
38
41
  }
39
42
  return false;
40
43
  }
41
- /**
42
- * Same question as `summaryWasPasted`, but tolerant of the harness writing the transcript late.
43
- *
44
- * The agent pastes the summary and calls the popup in one turn, so the text block and the tool
45
- * call are separate JSONL entries written by the harness asynchronously — the hook can run
46
- * while only the tool call has reached disk. A single read then reports "not pasted" for a turn
47
- * that did paste, and the block fires on every single video (observed 2026-08-30: the same
48
- * transcript and pending file that blocked, replayed seconds later, allowed).
49
- *
50
- * So poll instead of guessing: re-read until the id shows up or the window closes. A turn that
51
- * genuinely skipped the paste never produces the line, so the gate still blocks — just later.
52
- * `read` returning null (a torn read mid-write) counts as "not yet", not as proof of absence.
53
- */
54
- export async function waitForSummary(read, videoId, opts = {}) {
55
- const timeoutMs = opts.timeoutMs ?? 5000;
56
- const intervalMs = opts.intervalMs ?? 200;
57
- const now = opts.now ?? (() => Date.now());
58
- const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
59
- const deadline = now() + timeoutMs;
60
- for (;;) {
61
- const transcript = read();
62
- if (transcript !== null && summaryWasPasted(transcript, videoId))
63
- return true;
64
- if (now() >= deadline)
65
- return false;
66
- await sleep(intervalMs);
67
- }
68
- }
@@ -1,29 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * yt-summary-gate — PreToolUse gate for Claude Code: refuses the /yt rating popup until the
3
+ * yt-summary-gate — PreToolUse gate for Claude Code: refuses to RECORD a rating until the
4
4
  * video's summary has actually been pasted into the chat.
5
5
  *
6
6
  * Step B of the rating loop (paste `out.summary` verbatim, THEN ask) is the one instruction the
7
7
  * agent can drop with no visible symptom: the popup still appears, the user still answers, and
8
- * the rating is recorded against a summary nobody saw. Observed failure mode — the paste happens
9
- * reliably while the sweep also returns a `skipped` list (there is other prose to write) and gets
10
- * dropped in iterations that return none, where the turn can open straight with a tool call. A
11
- * blind rating is worse than no rating: it teaches the title filter from noise.
8
+ * the rating is recorded against a summary nobody saw. A blind rating is worse than no rating:
9
+ * it teaches the title filter from noise.
12
10
  *
13
- * The engine cannot enforce this — `yt-sweep` only emits the summary, `yt-rating` only reads
14
- * `pending.json`, and neither can see the conversation. The transcript is the one place the
15
- * evidence exists and only the agent harness exposes it, so the check has to run as a hook.
11
+ * WHY IT GATES THE WRITE AND NOT THE POPUP. Until 0.15.0 this hook ran on `AskUserQuestion` and
12
+ * blocked the popup. That could never work: the harness writes an assistant message's entries to
13
+ * the transcript only once the message is COMPLETE — i.e. after its tool calls have returned —
14
+ * and the skill pastes the summary and opens the popup in a single message. So the gate asked
15
+ * the transcript for text that could not be there yet, and the write it was waiting on could not
16
+ * happen until the gate returned. It blocked every video and cleared on a blind retry (measured
17
+ * 2026-08-30: a marker in a long text block was absent from the transcript during its own
18
+ * message's tool call, and present in the next message immediately). 0.14.2's 5s poll treated
19
+ * that as a timing lag and so only made the block slower.
16
20
  *
17
- * The transcript is polled for a few seconds rather than read once: the paste and the popup are
18
- * a single turn and the harness flushes the JSONL asynchronously, so a single read can miss a
19
- * text block that is merely still in flight.
21
+ * The rating write is a separate Bash call in the FOLLOWING message, by which point the message
22
+ * carrying the summary is closed and on disk — the evidence exists exactly when this hook needs
23
+ * it. The tradeoff is honest: a genuinely skipped paste is now caught after the user has already
24
+ * answered the popup, so that one answer is wasted and the agent has to paste and re-ask. That
25
+ * costs nothing on the happy path, and it guards the thing that actually matters — what gets
26
+ * written into the channel profile.
20
27
  *
21
28
  * Reads the PreToolUse payload on stdin; exit 0 allows, exit 2 blocks and feeds stderr back to
22
29
  * the agent. Anything unexpected — foreign tool, unreadable transcript, no pending video —
23
- * allows: a gate that misfires on unrelated work would be worse than the bug it guards.
30
+ * allows: a gate that misfires on unrelated work would be worse than the bug it guards. This
31
+ * hook is matched on Bash, so it runs on ordinary shell work too: it must decide from the
32
+ * command string and exit before touching the disk.
24
33
  */
25
34
  import { readFileSync, existsSync } from 'node:fs';
26
- import { isRatingPopup, waitForSummary } from "./lib/summary-gate.js";
35
+ import { isRatingWrite, summaryWasPasted } from "./lib/summary-gate.js";
27
36
  const ALLOW = 0;
28
37
  const BLOCK = 2;
29
38
  const allow = () => process.exit(ALLOW);
@@ -34,7 +43,7 @@ try {
34
43
  catch {
35
44
  allow();
36
45
  }
37
- if (payload.tool_name !== 'AskUserQuestion' || !isRatingPopup(payload))
46
+ if (!isRatingWrite(payload))
38
47
  allow();
39
48
  // paths.ts derives the data dir from the cwd, which for a hook is the agent's, not ours — so the
40
49
  // import has to wait until we've moved there.
@@ -53,18 +62,16 @@ if (!pending.videoId)
53
62
  const transcript = payload.transcript_path;
54
63
  if (!transcript || !existsSync(transcript))
55
64
  allow();
56
- // Poll rather than read once: the paste and this popup are one turn, and the harness writes the
57
- // transcript asynchronously, so the text block can still be in flight while the hook runs.
58
- const seen = await waitForSummary(() => {
59
- try {
60
- return readFileSync(transcript, 'utf8');
61
- }
62
- catch {
63
- return null; // transient (file being written) — treat as "not yet", keep waiting
64
- }
65
- }, pending.videoId);
65
+ let seen = false;
66
+ try {
67
+ seen = summaryWasPasted(readFileSync(transcript, 'utf8'), pending.videoId);
68
+ }
69
+ catch {
70
+ allow();
71
+ }
66
72
  if (seen)
67
73
  allow();
68
- process.stderr.write(`/yt step B not done: the summary for ${pending.videoId} ("${pending.title ?? '?'}") is not in your chat text.\n` +
69
- `Paste out.summary verbatim as your reply first, then ask for the rating — the user rates what they can see.\n`);
74
+ process.stderr.write(`/yt step B not done: the summary for ${pending.videoId} ("${pending.title ?? '?'}") never appeared in your chat text, ` +
75
+ `so this rating would be recorded against a summary the user never saw.\n` +
76
+ `Paste out.summary verbatim as your reply, ask for the rating again, then record it.\n`);
70
77
  process.exit(BLOCK);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-briefing",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "description": "A self-learning YouTube briefing engine: it sweeps the channels you follow, filters noise in two stages (title, then transcript), summarizes the rest in your language, and adapts to your ratings — one video at a time.",
5
5
  "type": "module",
6
6
  "bin": {