#!/usr/bin/env node
// say-the-word-gate — Stop hook. Three blocks, all about the owner's authority
// over their own decisions:
//   1. a closing message that parks already-approved work behind a fresh
//      permission ask ("say the word", "want me to proceed?");
//   2. a closing message that names the next action instead of performing it
//      ("Immediate next: ...", "## Next steps");
//   3. a closing message that claims work is blocked or deferred without
//      quoting the owner's own words that deferred it.
// Code spans, fenced blocks and quoted text are stripped before matching (1)
// and (2), so quoting the rule does not trip it. Fires at most once per stop
// chain; fail-open on error.
import fs from 'node:fs';

const PARKING = [
  /\b(?:just )?say the word\b/i,
  /\bgive me the (?:go|go-ahead)\b/i,
  /\b(?:on|await(?:ing)?) your (?:go|go-ahead|signal)\b/i,
  /\bready when you are\b/i,
  /\b(?:want|would you like) me to (?:proceed|start|begin|go|build|do|run|land|ship|continue)\b/i,
  /\bshall I (?:proceed|start|begin|go|build|continue)\b/i,
  /\bshould I (?:proceed|start|begin|go ahead|continue)\b/i,
  /\blet me know (?:if|when) you(?:'d| would)? (?:like|want)\b/i,
  /\bif you(?:'d| would)? like me to\b/i,
  /\bI can (?:start|begin|do it|build it) (?:when|once|as soon as) you\b/i,
  /\bjust say when\b/i,
  /\bwhenever you(?:'|’)?re ready\b/i,
  /\bdo you want me to\b/i,
  /\bhappy to (?:do|run|build|land|ship|start|begin|continue|proceed|add|fix|wire|apply|extend) (?:it |that |this )?if you (?:want|like|prefer|say)\b/i,
  /\bI can (?:do|run|build|land|ship|start|begin|continue|proceed|add|fix|wire|apply|extend) (?:it |that |this )?(?:if|when) you (?:want|prefer|say|like)\b/i,
  /\b(?:just )?say (?:the )?go(?:[- ]ahead)?\b/i,
  /\btell me to go(?:[- ]ahead)?\b/i,
  /\bgive (?:the|your) go(?:[- ]ahead)?\b/i,
];

// A closing message that labels the next action instead of performing it.
const NEXT_ACTION = [
  /^\s*#{1,4}\s*(?:immediate|suggested|recommended|possible|optional|up)?\s*next(?:\s+(?:steps?|actions?|up)\b|\s*:?\s*$)/im,
  /^\s*\**\s*(?:immediate|suggested|recommended|possible|optional|up)?\s*next\s*(?:steps?|actions?|up)?\s*\**\s*[:—-]/im,
  /\bnext (?:step|action)s? (?:is|are|would be)\b/i,
  /\bthe next thing to do is\b/i,
  /\brecommended next (?:step|action)\b/i,
  /\bI(?:'|’)?d recommend\b[^.\n]{0,80}\bnext\b/i,
  /\bmy recommendation is to\b[^.\n]{0,80}\bnext\b/i,
];

// A closing message asserting work was set aside. Only the owner can defer.
const DEFERRAL_CLAIM = [
  /^\s*#{0,4}\s*\**\s*deferred\b/im,
  /\b(?:user|owner|you) (?:asked|told|said|wanted|requested) (?:me )?(?:to )?(?:skip|defer|postpone|hold off|leave|drop|not)\b/i,
  /\b(?:deferred|descoped|postponed|parked|skipped) (?:per|at|by|on) (?:the )?(?:user|owner|your)\b/i,
  /\b(?:per|as per) (?:the )?(?:user|owner)(?:'s)? (?:instruction|request|direction|decision)\b/i,
];

// Evidence that the claim is sourced from the owner's own words rather than a
// memory file, a spec, or the model's own summary.
const QUOTE_EVIDENCE = [
  /"[^"\n]{12,}"/,
  /“[^”\n]{12,}”/,
  /^\s*>\s*\S/m,
  /\bverbatim\b/i,
];

const MEMORY_SOURCED = /\b(?:memory|MEMORY\.md|CLAUDE\.md|AGENTS\.md|the spec|the plan doc|per the doc|recorded (?:in|as)|noted (?:in|earlier))\b/i;

function lastAssistantText(rows, from) {
  const parts = [];
  for (let i = rows.length - 1; i > from; i--) {
    const o = rows[i];
    if (o?.type !== 'assistant') continue;
    const content = o.message?.content;
    if (!Array.isArray(content)) continue;
    if (content.some((b) => b?.type === 'tool_use')) break;
    for (const b of content) {
      if (b?.type === 'text' && typeof b.text === 'string') parts.unshift(b.text);
    }
    break;
  }
  return parts.join('\n');
}

// Anything the model is citing rather than saying: fences, inline code, and
// quoted spans. Matching inside them is what makes a rule-quote self-trip.
function stripCitations(text) {
  return text
    .replace(/```[\s\S]*?```/g, ' ')
    .replace(/`[^`\n]*`/g, ' ')
    .replace(/^\s*>.*$/gm, ' ')
    .replace(/"[^"\n]*"/g, ' ')
    .replace(/“[^”\n]*”/g, ' ')
    .replace(/'[^'\n]{0,80}'/g, ' ');
}

function main() {
  let inp = {};
  try {
    inp = JSON.parse(fs.readFileSync(0, 'utf8'));
  } catch {
    return;
  }
  if (inp.stop_hook_active) return;
  const tp = inp.transcript_path;
  if (!tp || !fs.existsSync(tp)) return;

  const rows = [];
  let lastPrompt = -1;
  try {
    for (const line of fs.readFileSync(tp, 'utf8').split('\n')) {
      if (!line) continue;
      let o;
      try {
        o = JSON.parse(line);
      } catch {
        continue;
      }
      rows.push(o);
      if (o?.type === 'user' && !o.isMeta && !o.toolUseResult) lastPrompt = rows.length - 1;
    }
  } catch {
    return;
  }
  if (lastPrompt < 0) return;

  const raw = lastAssistantText(rows, lastPrompt);
  if (!raw.trim()) return;
  const text = stripCitations(raw);

  const hit = PARKING.find((re) => re.test(text));
  if (hit) {
    const phrase = text.match(hit)[0];
    console.log(
      JSON.stringify({
        decision: 'block',
        reason:
          `say-the-word-gate: your closing message parks approved work behind a new permission ask ("${phrase}"). ` +
          `Approval given once authorizes the whole delivery — the user has already asked for this. ` +
          `Do that work NOW in this turn, then close with what shipped. ` +
          `If it is genuinely blocked by something only the user can supply, state what and why in one line instead of offering.`,
      })
    );
    return;
  }

  const named = NEXT_ACTION.find((re) => re.test(text));
  if (named) {
    const phrase = text.match(named)[0].trim();
    console.log(
      JSON.stringify({
        decision: 'block',
        reason:
          `say-the-word-gate: your closing message names the next action instead of performing it ("${phrase}"). ` +
          `If you can name it, you can do it — work actually done reads as done, never as "next". ` +
          `Do that work NOW in this turn, then close with what shipped. ` +
          `The only legitimate alternative is one line stating a genuine external blocker — a credential only the user holds, an upstream outage. ` +
          `A preference or a scope opinion is not a blocker.`,
      })
    );
    return;
  }

  // The deferral check reads the UNSTRIPPED message: a quote IS the evidence.
  const claim = DEFERRAL_CLAIM.find((re) => re.test(raw));
  if (!claim) return;
  if (QUOTE_EVIDENCE.some((re) => re.test(raw)) && !MEMORY_SOURCED.test(raw)) return;

  console.log(
    JSON.stringify({
      decision: 'block',
      reason:
        `say-the-word-gate: your closing message claims work was deferred or blocked by the user ("${raw.match(claim)[0].trim()}") ` +
        `without quoting the user's own words that deferred it. ` +
        `Only an EXPLICIT user deferral is a deferral. A memory file, MEMORY.md, CLAUDE.md, a spec, a plan doc, your own earlier summary, ` +
        `or an inference that "the user probably meant" is NOT evidence and never was. ` +
        `Find the user's actual message — a typed prompt, an AskUserQuestion answer, or a note they attached — and quote it with where and when it was said. ` +
        `Search the permanent prompt journal: \`/home/user/.local/bin/prompt-journal search "<terms>" --session <id>\`. ` +
        `If you cannot find those words, the user never deferred it: it is YOUR unfinished work. Go do it now.`,
    })
  );
}

main();
process.exit(0);
