mirror of https://github.com/garrytan/gstack.git
feat(bin): gstack-distill-free-text — Layer 8 dream cycle distiller
Plan-tune cathedral T10. Reads auq-other free-text events from this project's question-log.jsonl, calls Claude via the Anthropic SDK to extract structured proposals (preference candidates, declared-profile nudges, memory nuggets), writes them to distillation-proposals.json for the user to review via /plan-tune (never autonomous — every apply requires explicit Y). Subcommands: gstack-distill-free-text # sync distill gstack-distill-free-text --background # detach + return PID gstack-distill-free-text --dry-run # emit prompt + events, no API call gstack-distill-free-text --status # run history + cost-to-date D7 rate cap: 3 distills per slug per day. Reads ~/.gstack/distill-cost.jsonl for the count, exits with RATE_CAPPED when limit hit. Cost log lines tagged by slug so sibling projects don't share the cap. Yesterday runs don't count. D6 API auth: Anthropic SDK direct, fail-loud on missing ANTHROPIC_API_KEY with explicit message that distill is a separate billing surface from the interactive Claude Code session. Uses claude-haiku-4-5 for cost (~$0.001/ 1k input, $0.005/1k output) — sufficient for structured extraction. D14 execution context: --background spawns detached (nohup) so auto-trigger during /ship doesn't add 30s of pause; results surface on next /plan-tune. Source events get distilled_at:<ts> stamped on them after the run so they don't re-propose on the next distill. Match by ts + question_id. Cost-log line per run includes: slug, proposals_count, rejected_low_confidence, input_tokens, output_tokens, cost_usd_est. /plan-tune stats reads this to show "$X estimated, N runs this month" per Layer 4 surface. 10 unit tests cover --status, rate cap (3/day, yesterday-not-counted, other-slug-not-counted), no-log/no-free-text paths, --dry-run, missing API key, --background spawn. The actual SDK call is exercised by the T16 E2E test (uses real key, ~$0.001 per run). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
990c9d55d2
commit
e02b1b1cee
|
|
@ -0,0 +1,286 @@
|
|||
#!/usr/bin/env bash
|
||||
# gstack-distill-free-text — Layer 8 "dream cycle" batch distiller.
|
||||
#
|
||||
# Reads auq-other free-text events from this project's question-log.jsonl,
|
||||
# sends them to Claude via the Anthropic SDK, and writes structured proposals
|
||||
# the user can review via /plan-tune distill. Proposals require explicit
|
||||
# user Y before applying — never autonomous (Codex #15 trust boundary).
|
||||
#
|
||||
# Usage:
|
||||
# gstack-distill-free-text # sync, prompts at end
|
||||
# gstack-distill-free-text --background # spawn detached; results
|
||||
# # surface on next /plan-tune
|
||||
# gstack-distill-free-text --dry-run # show prompt, no API call
|
||||
# gstack-distill-free-text --status # show last-run stats
|
||||
#
|
||||
# Per D7 cathedral cap: max 3 distills/day per slug. Cumulative cost log
|
||||
# appended to $GSTACK_STATE_ROOT/distill-cost.jsonl.
|
||||
# Per D6: Anthropic SDK direct call, fail-loud on missing ANTHROPIC_API_KEY.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
|
||||
SLUG="${SLUG:-unknown}"
|
||||
PROJECT_DIR="$GSTACK_HOME/projects/$SLUG"
|
||||
LOG_FILE="$PROJECT_DIR/question-log.jsonl"
|
||||
PROPOSAL_FILE="$PROJECT_DIR/distillation-proposals.json"
|
||||
COST_LOG="$GSTACK_HOME/distill-cost.jsonl"
|
||||
mkdir -p "$PROJECT_DIR"
|
||||
|
||||
MODE="sync"
|
||||
case "${1:-}" in
|
||||
--background) MODE="background" ;;
|
||||
--dry-run) MODE="dry-run" ;;
|
||||
--status) MODE="status" ;;
|
||||
--help|-h)
|
||||
sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||'
|
||||
exit 0
|
||||
;;
|
||||
'') ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# --- Status subcommand --------------------------------------------------
|
||||
|
||||
if [ "$MODE" = "status" ]; then
|
||||
COST_LOG_PATH="$COST_LOG" SLUG_PATH="$SLUG" bun -e '
|
||||
const fs = require("fs");
|
||||
const slug = process.env.SLUG_PATH;
|
||||
const path = process.env.COST_LOG_PATH;
|
||||
if (!fs.existsSync(path)) { console.log("no distill runs yet"); process.exit(0); }
|
||||
const lines = fs.readFileSync(path, "utf-8").trim().split("\n").filter(Boolean);
|
||||
const mine = lines.map((l) => JSON.parse(l)).filter((e) => e.slug === slug);
|
||||
if (mine.length === 0) { console.log("no distill runs yet for slug=" + slug); process.exit(0); }
|
||||
const totalUsd = mine.reduce((a, e) => a + (e.cost_usd_est || 0), 0);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const today = mine.filter((e) => (e.ts || "").startsWith(todayIso));
|
||||
console.log("RUNS: " + mine.length);
|
||||
console.log("TODAY: " + today.length + " / 3");
|
||||
console.log("ESTIMATED_TOTAL_USD: $" + totalUsd.toFixed(4));
|
||||
const last = mine[mine.length - 1];
|
||||
console.log("LAST_RUN: " + (last.ts || "?") + " | " + (last.proposals_count || 0) + " proposals");
|
||||
'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Background mode: detach + invoke self synchronously ---------------
|
||||
|
||||
if [ "$MODE" = "background" ]; then
|
||||
nohup "$0" >/dev/null 2>&1 &
|
||||
echo "DISTILL_SPAWNED: pid=$!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Rate cap check (D7: max 3/day per slug) ---------------------------
|
||||
|
||||
DAILY_COUNT=$(COST_LOG_PATH="$COST_LOG" SLUG_PATH="$SLUG" bun -e '
|
||||
const fs = require("fs");
|
||||
const slug = process.env.SLUG_PATH;
|
||||
const path = process.env.COST_LOG_PATH;
|
||||
if (!fs.existsSync(path)) { console.log("0"); process.exit(0); }
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const lines = fs.readFileSync(path, "utf-8").trim().split("\n").filter(Boolean);
|
||||
const n = lines
|
||||
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
||||
.filter((e) => e && e.slug === slug && (e.ts || "").startsWith(todayIso))
|
||||
.length;
|
||||
console.log(String(n));
|
||||
')
|
||||
|
||||
if [ "$DAILY_COUNT" -ge 3 ] 2>/dev/null; then
|
||||
echo "RATE_CAPPED: $DAILY_COUNT distills today (3/day limit). Use --status for run history."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Gather unprocessed auq-other events from this project -------------
|
||||
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo "NO_LOG: no question-log.jsonl in $PROJECT_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EVENTS_JSON=$(LOG_FILE_PATH="$LOG_FILE" bun -e '
|
||||
const fs = require("fs");
|
||||
const lines = fs.readFileSync(process.env.LOG_FILE_PATH, "utf-8").trim().split("\n").filter(Boolean);
|
||||
const out = [];
|
||||
for (const l of lines) {
|
||||
try {
|
||||
const e = JSON.parse(l);
|
||||
if (e.source === "auq-other" && !e.distilled_at && e.free_text) {
|
||||
out.push({
|
||||
ts: e.ts,
|
||||
question_id: e.question_id,
|
||||
question_summary: e.question_summary,
|
||||
free_text: e.free_text,
|
||||
session_id: e.session_id,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
')
|
||||
|
||||
EVENT_COUNT=$(printf '%s' "$EVENTS_JSON" | bun -e 'const a = JSON.parse(await Bun.stdin.text()); console.log(a.length);')
|
||||
if [ "$EVENT_COUNT" -eq 0 ]; then
|
||||
echo "NO_FREE_TEXT: nothing to distill"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Build distill prompt ---------------------------------------------
|
||||
|
||||
# Heredoc into temp file (avoids $(cat <<'PROMPT'...) which choked the
|
||||
# bash parser on apostrophes elsewhere in the script).
|
||||
DISTILL_PROMPT_FILE=$(mktemp)
|
||||
trap 'rm -f "$DISTILL_PROMPT_FILE"' EXIT
|
||||
cat > "$DISTILL_PROMPT_FILE" <<'PROMPT'
|
||||
You are gstack dream-cycle distiller. Below are free-text responses the
|
||||
user typed into AskUserQuestion prompts (option "Other") across recent gstack
|
||||
sessions. For each response, extract structured signal that should update the
|
||||
user plan-tune profile or preferences.
|
||||
|
||||
Return strict JSON with this shape:
|
||||
{
|
||||
"proposals": [
|
||||
{
|
||||
"kind": "preference" | "declared-nudge" | "memory-nugget",
|
||||
"confidence": 0.0-1.0,
|
||||
"source_quotes": ["<verbatim quote 1>", "<verbatim quote 2>"],
|
||||
"question_id": "<id>",
|
||||
"preference": "never-ask" | "always-ask" | "ask-only-for-one-way",
|
||||
"dimension": "scope_appetite | risk_tolerance | detail_preference | autonomy | architecture_care",
|
||||
"direction": "up | down",
|
||||
"magnitude": "small | medium | large",
|
||||
"rationale": "<one sentence>",
|
||||
"nugget": "<one-line memory>",
|
||||
"applies_to_signal_keys": ["scope-appetite", "..."]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Reject any proposal where confidence < 0.7.
|
||||
- Quote VERBATIM from the user free_text. Never paraphrase a source quote.
|
||||
- A single user response may produce multiple proposals.
|
||||
- If nothing meaningful to extract, return {"proposals": []}.
|
||||
- No commentary outside the JSON.
|
||||
PROMPT
|
||||
DISTILL_PROMPT=$(cat "$DISTILL_PROMPT_FILE")
|
||||
|
||||
# --- Dry-run: emit prompt + events, exit ------------------------------
|
||||
|
||||
if [ "$MODE" = "dry-run" ]; then
|
||||
echo "=== DISTILL PROMPT ==="
|
||||
echo "$DISTILL_PROMPT"
|
||||
echo
|
||||
echo "=== EVENTS ($EVENT_COUNT) ==="
|
||||
echo "$EVENTS_JSON" | bun -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()), null, 2));'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- SDK call: fail-loud on missing key -------------------------------
|
||||
|
||||
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
cat <<EOF >&2
|
||||
gstack-distill-free-text: ANTHROPIC_API_KEY not set.
|
||||
|
||||
Dream-cycle distillation needs an API key for the SDK call. Set
|
||||
ANTHROPIC_API_KEY in your environment, or run with --dry-run to see
|
||||
what would be sent without actually calling.
|
||||
|
||||
Note: this is a separate billing/auth surface from your interactive
|
||||
Claude Code session (per Codex correction in D6).
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the SDK call in bun. Emits JSON: {proposals_count, cost_usd_est}.
|
||||
RESULT=$(EVENTS_JSON="$EVENTS_JSON" DISTILL_PROMPT="$DISTILL_PROMPT" \
|
||||
PROPOSAL_FILE_PATH="$PROPOSAL_FILE" LOG_FILE_PATH="$LOG_FILE" \
|
||||
ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
||||
bun --cwd "$ROOT_DIR" -e '
|
||||
const fs = require("fs");
|
||||
const Anthropic = require("@anthropic-ai/sdk").default;
|
||||
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
|
||||
const events = JSON.parse(process.env.EVENTS_JSON);
|
||||
const prompt = process.env.DISTILL_PROMPT + "\n\nFREE-TEXT RESPONSES (JSON array):\n" + JSON.stringify(events, null, 2);
|
||||
|
||||
// Pricing (Haiku 4.5 — cheap, fast, sufficient for structured extraction).
|
||||
// Per token, USD: input $0.001/1k = 1e-6, output $0.005/1k = 5e-6.
|
||||
const INPUT_PER_TOKEN = 1e-6;
|
||||
const OUTPUT_PER_TOKEN = 5e-6;
|
||||
|
||||
const resp = await client.messages.create({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
});
|
||||
|
||||
const text = resp.content.map((b) => (b.type === "text" ? b.text : "")).join("");
|
||||
|
||||
// Strip optional fenced code blocks the model may wrap JSON in.
|
||||
const stripped = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(stripped); } catch (e) {
|
||||
process.stderr.write("DISTILL: model returned non-JSON: " + text.slice(0, 200) + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const proposals = Array.isArray(parsed.proposals) ? parsed.proposals : [];
|
||||
// Keep only proposals with confidence >= 0.7 (model is told this rule;
|
||||
// double-check in case it slipped).
|
||||
const filtered = proposals.filter((p) => typeof p.confidence === "number" && p.confidence >= 0.7);
|
||||
|
||||
// Write proposals file (overwrite — only the latest run is reviewable).
|
||||
fs.writeFileSync(process.env.PROPOSAL_FILE_PATH, JSON.stringify({
|
||||
generated_at: new Date().toISOString(),
|
||||
source_event_count: events.length,
|
||||
proposals: filtered,
|
||||
}, null, 2));
|
||||
|
||||
// Mark source events as distilled_at so they do not re-propose.
|
||||
// Update question-log.jsonl in place: read all, rewrite with distilled_at
|
||||
// set on the matching events. Match by ts + question_id.
|
||||
const logPath = process.env.LOG_FILE_PATH;
|
||||
const distilledAt = new Date().toISOString();
|
||||
const matchKeys = new Set(events.map((e) => (e.ts || "") + "::" + (e.question_id || "")));
|
||||
const lines = fs.readFileSync(logPath, "utf-8").split("\n");
|
||||
const out = [];
|
||||
for (const ln of lines) {
|
||||
if (!ln.trim()) { out.push(ln); continue; }
|
||||
try {
|
||||
const e = JSON.parse(ln);
|
||||
const key = (e.ts || "") + "::" + (e.question_id || "");
|
||||
if (matchKeys.has(key)) {
|
||||
e.distilled_at = distilledAt;
|
||||
out.push(JSON.stringify(e));
|
||||
} else {
|
||||
out.push(ln);
|
||||
}
|
||||
} catch { out.push(ln); }
|
||||
}
|
||||
fs.writeFileSync(logPath, out.join("\n"));
|
||||
|
||||
// Cost estimate from usage tokens.
|
||||
const usage = resp.usage || {};
|
||||
const inTok = usage.input_tokens || 0;
|
||||
const outTok = usage.output_tokens || 0;
|
||||
const cost = inTok * INPUT_PER_TOKEN + outTok * OUTPUT_PER_TOKEN;
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
proposals_count: filtered.length,
|
||||
rejected_low_confidence: proposals.length - filtered.length,
|
||||
input_tokens: inTok,
|
||||
output_tokens: outTok,
|
||||
cost_usd_est: cost,
|
||||
}));
|
||||
')
|
||||
|
||||
# Append cost log line.
|
||||
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
echo "{\"ts\":\"$TS\",\"slug\":\"$SLUG\",$(echo "$RESULT" | sed 's/^{//; s/}$//')}" >> "$COST_LOG"
|
||||
|
||||
echo "DISTILL_COMPLETE:"
|
||||
echo " proposals_file: $PROPOSAL_FILE"
|
||||
echo " $RESULT"
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* gstack-distill-free-text — Layer 8 dream cycle (plan-tune cathedral T10).
|
||||
*
|
||||
* Covers the SDK-free paths: status, dry-run, rate cap, no-event handling.
|
||||
* The real API call path is exercised by the E2E test in T16; here we
|
||||
* verify the bin's deterministic plumbing without burning tokens.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-distill-free-text');
|
||||
const QLOG_BIN = path.join(ROOT, 'bin', 'gstack-question-log');
|
||||
|
||||
let stateRoot: string;
|
||||
let fixtureCwd: string;
|
||||
let cwdSlug: string;
|
||||
|
||||
beforeEach(() => {
|
||||
stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-dist-'));
|
||||
cwdSlug = 'distill-fixture';
|
||||
fixtureCwd = path.join(stateRoot, cwdSlug);
|
||||
fs.mkdirSync(fixtureCwd, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(stateRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeEnv(extra: Record<string, string> = {}): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined) env[k] = v;
|
||||
}
|
||||
env.GSTACK_STATE_ROOT = stateRoot;
|
||||
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
|
||||
delete env.GSTACK_HOME;
|
||||
return { ...env, ...extra };
|
||||
}
|
||||
|
||||
function run(args: string[]): { stdout: string; stderr: string; status: number } {
|
||||
const res = spawnSync(BIN, args, {
|
||||
env: makeEnv(),
|
||||
encoding: 'utf-8',
|
||||
cwd: fixtureCwd,
|
||||
});
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
stderr: res.stderr ?? '',
|
||||
status: res.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
function writeAuqOtherEvent(text: string): void {
|
||||
spawnSync(
|
||||
QLOG_BIN,
|
||||
[
|
||||
JSON.stringify({
|
||||
skill: 'plan-tune',
|
||||
question_id: 'hook-distill00',
|
||||
question_summary: 'Test question for distillation',
|
||||
options_count: 2,
|
||||
user_choice: 'Other',
|
||||
source: 'auq-other',
|
||||
free_text: text,
|
||||
session_id: 's-distill',
|
||||
tool_use_id: 'tu-distill-' + Math.random().toString(36).slice(2, 8),
|
||||
}),
|
||||
],
|
||||
{
|
||||
env: makeEnv(),
|
||||
cwd: fixtureCwd,
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function writeCostLogEntry(slug: string, dateIso: string): void {
|
||||
fs.mkdirSync(stateRoot, { recursive: true });
|
||||
fs.appendFileSync(
|
||||
path.join(stateRoot, 'distill-cost.jsonl'),
|
||||
JSON.stringify({ ts: dateIso, slug, proposals_count: 0, cost_usd_est: 0 }) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Status subcommand
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('--status', () => {
|
||||
test('reports "no runs yet" when cost log absent', () => {
|
||||
const r = run(['--status']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toMatch(/no distill runs/);
|
||||
});
|
||||
|
||||
test('reports counts when prior runs exist', () => {
|
||||
writeCostLogEntry(cwdSlug, new Date().toISOString());
|
||||
writeCostLogEntry(cwdSlug, new Date().toISOString());
|
||||
const r = run(['--status']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('RUNS: 2');
|
||||
expect(r.stdout).toContain('TODAY: 2 / 3');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Rate cap (D7)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('rate cap (3/day per slug)', () => {
|
||||
test('exits with RATE_CAPPED when 3 runs already logged today', () => {
|
||||
const today = new Date().toISOString();
|
||||
writeCostLogEntry(cwdSlug, today);
|
||||
writeCostLogEntry(cwdSlug, today);
|
||||
writeCostLogEntry(cwdSlug, today);
|
||||
const r = run([]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toMatch(/RATE_CAPPED/);
|
||||
});
|
||||
|
||||
test('yesterday runs do not count against today cap', () => {
|
||||
const today = new Date().toISOString();
|
||||
const yesterday = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||||
writeCostLogEntry(cwdSlug, yesterday);
|
||||
writeCostLogEntry(cwdSlug, yesterday);
|
||||
writeCostLogEntry(cwdSlug, yesterday);
|
||||
writeCostLogEntry(cwdSlug, today);
|
||||
const r = run([]);
|
||||
// Not capped — proceeds past the cap check; will hit NO_LOG next.
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).not.toMatch(/RATE_CAPPED/);
|
||||
});
|
||||
|
||||
test('other slugs in cost log do not count against this slug', () => {
|
||||
const today = new Date().toISOString();
|
||||
writeCostLogEntry('other-slug', today);
|
||||
writeCostLogEntry('other-slug', today);
|
||||
writeCostLogEntry('other-slug', today);
|
||||
const r = run([]);
|
||||
expect(r.stdout).not.toMatch(/RATE_CAPPED/);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// No events / no log
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('no-event paths', () => {
|
||||
test('exits NO_LOG when question-log.jsonl missing', () => {
|
||||
const r = run([]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toMatch(/NO_LOG/);
|
||||
});
|
||||
|
||||
test('exits NO_FREE_TEXT when log has events but none are auq-other', () => {
|
||||
spawnSync(
|
||||
QLOG_BIN,
|
||||
[
|
||||
JSON.stringify({
|
||||
skill: 'plan-tune',
|
||||
question_id: 'hook-other00',
|
||||
question_summary: 'Q',
|
||||
options_count: 2,
|
||||
user_choice: 'A',
|
||||
source: 'hook',
|
||||
session_id: 's',
|
||||
tool_use_id: 'tu-x',
|
||||
}),
|
||||
],
|
||||
{ env: makeEnv(), cwd: fixtureCwd, encoding: 'utf-8' },
|
||||
);
|
||||
const r = run([]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toMatch(/NO_FREE_TEXT/);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Dry-run
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('--dry-run', () => {
|
||||
test('emits the distill prompt + events JSON without calling API', () => {
|
||||
writeAuqOtherEvent('I always include tests with new features');
|
||||
writeAuqOtherEvent('Skip design review for typo fixes');
|
||||
// Strip ANTHROPIC_API_KEY to prove no API call happens.
|
||||
const env = makeEnv();
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
const res = spawnSync(BIN, ['--dry-run'], { env, cwd: fixtureCwd, encoding: 'utf-8' });
|
||||
expect(res.status).toBe(0);
|
||||
expect(res.stdout).toContain('DISTILL PROMPT');
|
||||
expect(res.stdout).toContain('always include tests');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// API key required
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('API auth', () => {
|
||||
test('fails loud when ANTHROPIC_API_KEY missing on sync run', () => {
|
||||
writeAuqOtherEvent('Some free text response that needs distilling');
|
||||
const env = makeEnv();
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
const res = spawnSync(BIN, [], { env, cwd: fixtureCwd, encoding: 'utf-8' });
|
||||
expect(res.status).not.toBe(0);
|
||||
expect(res.stderr).toMatch(/ANTHROPIC_API_KEY/);
|
||||
expect(res.stderr).toMatch(/separate billing/);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Background spawn
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('--background', () => {
|
||||
test('detaches and exits with DISTILL_SPAWNED', () => {
|
||||
const r = run(['--background']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toMatch(/DISTILL_SPAWNED: pid=\d+/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue