mirror of https://github.com/garrytan/gstack.git
feat(bin): gstack-distill-apply — apply distillation proposals with gbrain tag
Plan-tune cathedral T11. Bin that applies a single user-approved proposal
from distillation-proposals.json to the right surface:
- memory-nugget → appended to ~/.gstack/free-text-memory.json (durable
local source-of-truth; gbrain is mirror when configured).
- preference → routed through gstack-question-preference --write
with source=plan-tune (clears the user-origin gate).
- declared-nudge → atomic update to developer-profile.json declared dim,
small=0.05, medium=0.10, large=0.15, clamped to [0, 1].
Why a separate bin (not inline in the skill template): /plan-tune's apply
step needs to be invokable from any host (Claude, Codex, etc) and must
write to multiple state files atomically. A bin centralizes the schema
+ clamp logic; the skill template just calls it after user Y.
gbrain coordination: --gbrain-published true marks the nugget so /plan-tune
stats can show "12 nuggets, 8 mirrored to gbrain". The skill template
invokes mcp__gbrain__put_page / extract_facts / add_tag in the same turn
(those are MCP tools, not CLI-callable) before calling this bin. Local file
remains canonical so the PreToolUse hook injection path (T12) doesn't
depend on gbrain availability.
Subcommands:
gstack-distill-apply --list # show pending proposals
gstack-distill-apply --proposal <N> # apply, file fallback
gstack-distill-apply --proposal <N> --gbrain-published true
Applied proposals get applied_at + gbrain_published stamped on them so
re-running --list shows only unconsumed ones.
11 unit tests cover --list (all three kinds + quotes), memory-nugget
append + non-clobber, preference routing through the gate-respecting bin,
declared-nudge math (medium=0.10, small=0.05, large=0.15, clamp at [0,1]),
proposal mark-applied with gbrain flag, and error paths (bad index, missing
--proposal).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e02b1b1cee
commit
241be5c352
|
|
@ -0,0 +1,181 @@
|
|||
#!/usr/bin/env bash
|
||||
# gstack-distill-apply — apply a single distillation proposal after user Y.
|
||||
#
|
||||
# Plan-tune cathedral T11. Reads distillation-proposals.json, applies the
|
||||
# Nth proposal to the right surface:
|
||||
#
|
||||
# preference → gstack-question-preference --write
|
||||
# declared-nudge → atomic update to ~/.gstack/developer-profile.json declared
|
||||
# memory-nugget → append to ~/.gstack/free-text-memory.json (local fallback)
|
||||
#
|
||||
# Always confirm before calling this from the skill — the bin assumes the user
|
||||
# already approved (Codex #15 trust boundary). The skill template (/plan-tune
|
||||
# distill review section) handles the confirm UX.
|
||||
#
|
||||
# gbrain integration: when gbrain is configured, the skill template ALSO
|
||||
# invokes mcp__gbrain__put_page / extract_facts / add_tag in the same turn
|
||||
# (those are MCP tools, not CLI-callable). Pass --gbrain-published true to
|
||||
# mark the proposal as mirrored to gbrain. The local file always gets the
|
||||
# write so it's the durable source-of-truth even on machines without gbrain.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-distill-apply --proposal <N> # apply Nth proposal
|
||||
# gstack-distill-apply --proposal <N> --gbrain-published true
|
||||
# gstack-distill-apply --list # show pending proposals
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && 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"
|
||||
PROPOSAL_FILE="$PROJECT_DIR/distillation-proposals.json"
|
||||
MEMORY_FILE="$GSTACK_HOME/free-text-memory.json"
|
||||
PROFILE_FILE="$GSTACK_HOME/developer-profile.json"
|
||||
|
||||
ACTION="apply"
|
||||
PROPOSAL_IDX=""
|
||||
GBRAIN_PUBLISHED="false"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--proposal) PROPOSAL_IDX="$2"; shift 2 ;;
|
||||
--gbrain-published) GBRAIN_PUBLISHED="$2"; shift 2 ;;
|
||||
--list) ACTION="list"; shift ;;
|
||||
--help|-h)
|
||||
sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||'
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown arg: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -f "$PROPOSAL_FILE" ]; then
|
||||
echo "NO_PROPOSALS: $PROPOSAL_FILE missing — run gstack-distill-free-text first"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$ACTION" = "list" ]; then
|
||||
PROPOSAL_FILE_PATH="$PROPOSAL_FILE" bun -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.env.PROPOSAL_FILE_PATH, "utf-8"));
|
||||
const proposals = p.proposals || [];
|
||||
if (proposals.length === 0) { console.log("(no proposals)"); process.exit(0); }
|
||||
console.log("GENERATED: " + p.generated_at);
|
||||
console.log("SOURCE_EVENTS: " + (p.source_event_count || 0));
|
||||
proposals.forEach((pr, i) => {
|
||||
console.log("");
|
||||
console.log("[" + i + "] " + (pr.kind || "?") + " (confidence: " + (pr.confidence || "?") + ")");
|
||||
if (pr.rationale) console.log(" rationale: " + pr.rationale);
|
||||
if (pr.kind === "preference") {
|
||||
console.log(" question_id: " + pr.question_id);
|
||||
console.log(" preference: " + pr.preference);
|
||||
} else if (pr.kind === "declared-nudge") {
|
||||
console.log(" dimension: " + pr.dimension);
|
||||
console.log(" direction: " + pr.direction + " (" + (pr.magnitude || "?") + ")");
|
||||
} else if (pr.kind === "memory-nugget") {
|
||||
console.log(" nugget: " + pr.nugget);
|
||||
console.log(" signal_keys: " + JSON.stringify(pr.applies_to_signal_keys || []));
|
||||
}
|
||||
if (pr.source_quotes && pr.source_quotes.length) {
|
||||
console.log(" quotes:");
|
||||
pr.source_quotes.forEach((q) => console.log(" - \"" + q + "\""));
|
||||
}
|
||||
});
|
||||
'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$PROPOSAL_IDX" ]; then
|
||||
echo "--proposal <N> required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Apply via bun. Each kind has its own surface.
|
||||
mkdir -p "$PROJECT_DIR"
|
||||
PROPOSAL_IDX="$PROPOSAL_IDX" \
|
||||
PROPOSAL_FILE_PATH="$PROPOSAL_FILE" \
|
||||
MEMORY_FILE_PATH="$MEMORY_FILE" \
|
||||
PROFILE_FILE_PATH="$PROFILE_FILE" \
|
||||
PREF_BIN="$SCRIPT_DIR/gstack-question-preference" \
|
||||
GBRAIN_PUBLISHED="$GBRAIN_PUBLISHED" \
|
||||
bun -e '
|
||||
const fs = require("fs");
|
||||
const { spawnSync } = require("child_process");
|
||||
const idx = parseInt(process.env.PROPOSAL_IDX, 10);
|
||||
const p = JSON.parse(fs.readFileSync(process.env.PROPOSAL_FILE_PATH, "utf-8"));
|
||||
const proposals = p.proposals || [];
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= proposals.length) {
|
||||
process.stderr.write("invalid --proposal index " + idx + " (have " + proposals.length + ")\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const pr = proposals[idx];
|
||||
|
||||
const stamp = new Date().toISOString();
|
||||
|
||||
// Memory-nugget: always write to local file (durable source-of-truth even
|
||||
// when gbrain is configured — gbrain is mirror, file is canon for the
|
||||
// PreToolUse hook injection path in Layer 8).
|
||||
if (pr.kind === "memory-nugget") {
|
||||
const memPath = process.env.MEMORY_FILE_PATH;
|
||||
let mem = { nuggets: [] };
|
||||
try { mem = JSON.parse(fs.readFileSync(memPath, "utf-8")); } catch {}
|
||||
if (!Array.isArray(mem.nuggets)) mem.nuggets = [];
|
||||
mem.nuggets.push({
|
||||
nugget: pr.nugget,
|
||||
applies_to_signal_keys: pr.applies_to_signal_keys || [],
|
||||
applied_at: stamp,
|
||||
gbrain_published: process.env.GBRAIN_PUBLISHED === "true",
|
||||
source_quotes: pr.source_quotes || [],
|
||||
});
|
||||
const tmp = memPath + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(mem, null, 2));
|
||||
fs.renameSync(tmp, memPath);
|
||||
console.log("APPLIED: memory-nugget appended to " + memPath);
|
||||
}
|
||||
|
||||
// Preference: route through gstack-question-preference for the user-origin
|
||||
// gate + event audit trail. source=plan-tune is the allowed value since
|
||||
// the user opt-in came from inside /plan-tune.
|
||||
if (pr.kind === "preference") {
|
||||
const res = spawnSync(process.env.PREF_BIN, [
|
||||
"--write",
|
||||
JSON.stringify({
|
||||
question_id: pr.question_id,
|
||||
preference: pr.preference,
|
||||
source: "plan-tune",
|
||||
free_text: (pr.source_quotes || []).join(" | ").slice(0, 300),
|
||||
}),
|
||||
], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000 });
|
||||
if (res.status !== 0) {
|
||||
process.stderr.write("preference apply failed: " + (res.stderr || res.stdout) + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("APPLIED: preference " + pr.question_id + " → " + pr.preference);
|
||||
}
|
||||
|
||||
// Declared-nudge: atomic update to developer-profile.json declared. Magnitude
|
||||
// tiers: small=0.05, medium=0.10, large=0.15. Clamp to [0, 1].
|
||||
if (pr.kind === "declared-nudge") {
|
||||
const mag = { small: 0.05, medium: 0.10, large: 0.15 }[pr.magnitude || "small"] || 0.05;
|
||||
const delta = pr.direction === "down" ? -mag : mag;
|
||||
const profilePath = process.env.PROFILE_FILE_PATH;
|
||||
let profile = {};
|
||||
try { profile = JSON.parse(fs.readFileSync(profilePath, "utf-8")); } catch {}
|
||||
profile.declared = profile.declared || {};
|
||||
const cur = typeof profile.declared[pr.dimension] === "number" ? profile.declared[pr.dimension] : 0.5;
|
||||
const next = Math.max(0, Math.min(1, cur + delta));
|
||||
profile.declared[pr.dimension] = +next.toFixed(3);
|
||||
profile.declared_at = stamp;
|
||||
const tmp = profilePath + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(profile, null, 2));
|
||||
fs.renameSync(tmp, profilePath);
|
||||
console.log("APPLIED: declared." + pr.dimension + " " + cur + " → " + profile.declared[pr.dimension]);
|
||||
}
|
||||
|
||||
// Mark the proposal as applied so /plan-tune list shows it consumed.
|
||||
pr.applied_at = stamp;
|
||||
pr.gbrain_published = process.env.GBRAIN_PUBLISHED === "true";
|
||||
const tmp = process.env.PROPOSAL_FILE_PATH + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(p, null, 2));
|
||||
fs.renameSync(tmp, process.env.PROPOSAL_FILE_PATH);
|
||||
'
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
/**
|
||||
* gstack-distill-apply — Layer 8 proposal application (plan-tune cathedral T11).
|
||||
*
|
||||
* Verifies the three apply paths:
|
||||
* - memory-nugget → appended to ~/.gstack/free-text-memory.json (local
|
||||
* source-of-truth; gbrain is mirror when configured).
|
||||
* - preference → routed through gstack-question-preference with
|
||||
* source=plan-tune (user-origin gate cleared).
|
||||
* - declared-nudge → atomic update to developer-profile.json declared dim,
|
||||
* small=0.05, medium=0.10, large=0.15, clamped to [0,1].
|
||||
* Plus:
|
||||
* - --list shows proposals with kind, confidence, rationale, quotes.
|
||||
* - Applied proposals get applied_at + gbrain_published flag.
|
||||
* - Bad --proposal index errors with non-zero exit.
|
||||
*/
|
||||
|
||||
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-apply');
|
||||
|
||||
let stateRoot: string;
|
||||
let fixtureCwd: string;
|
||||
let cwdSlug: string;
|
||||
let proposalFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-apply-'));
|
||||
cwdSlug = 'apply-fixture';
|
||||
fixtureCwd = path.join(stateRoot, cwdSlug);
|
||||
fs.mkdirSync(fixtureCwd, { recursive: true });
|
||||
fs.mkdirSync(path.join(stateRoot, 'projects', cwdSlug), { recursive: true });
|
||||
proposalFile = path.join(stateRoot, 'projects', cwdSlug, 'distillation-proposals.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(stateRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeProposals(proposals: Array<Record<string, unknown>>): void {
|
||||
fs.writeFileSync(
|
||||
proposalFile,
|
||||
JSON.stringify(
|
||||
{ generated_at: new Date().toISOString(), source_event_count: 1, proposals },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function run(args: string[]): { stdout: string; stderr: string; status: number } {
|
||||
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;
|
||||
const res = spawnSync(BIN, args, { env, encoding: 'utf-8', cwd: fixtureCwd });
|
||||
return {
|
||||
stdout: res.stdout ?? '',
|
||||
stderr: res.stderr ?? '',
|
||||
status: res.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// --list
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('--list', () => {
|
||||
test('handles missing proposals file', () => {
|
||||
const r = run(['--list']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toMatch(/NO_PROPOSALS/);
|
||||
});
|
||||
|
||||
test('renders all 3 kinds + source quotes', () => {
|
||||
writeProposals([
|
||||
{
|
||||
kind: 'preference',
|
||||
confidence: 0.9,
|
||||
question_id: 'ship-changelog-voice-polish',
|
||||
preference: 'never-ask',
|
||||
rationale: 'user repeatedly skipped this',
|
||||
source_quotes: ['skip the polish for typo PRs'],
|
||||
},
|
||||
{
|
||||
kind: 'declared-nudge',
|
||||
confidence: 0.85,
|
||||
dimension: 'scope_appetite',
|
||||
direction: 'up',
|
||||
magnitude: 'medium',
|
||||
},
|
||||
{
|
||||
kind: 'memory-nugget',
|
||||
confidence: 0.95,
|
||||
nugget: 'User prefers complete edge cases',
|
||||
applies_to_signal_keys: ['scope-appetite'],
|
||||
},
|
||||
]);
|
||||
const r = run(['--list']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('preference');
|
||||
expect(r.stdout).toContain('declared-nudge');
|
||||
expect(r.stdout).toContain('memory-nugget');
|
||||
expect(r.stdout).toContain('skip the polish for typo PRs');
|
||||
expect(r.stdout).toContain('scope-appetite');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// memory-nugget application
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('memory-nugget apply', () => {
|
||||
test('appends to ~/.gstack/free-text-memory.json with full metadata', () => {
|
||||
writeProposals([
|
||||
{
|
||||
kind: 'memory-nugget',
|
||||
confidence: 0.9,
|
||||
nugget: 'User prefers verbose explanations with tradeoffs',
|
||||
applies_to_signal_keys: ['detail-preference'],
|
||||
source_quotes: ['always explain the tradeoffs'],
|
||||
},
|
||||
]);
|
||||
const r = run(['--proposal', '0', '--gbrain-published', 'true']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('APPLIED: memory-nugget');
|
||||
|
||||
const memPath = path.join(stateRoot, 'free-text-memory.json');
|
||||
const mem = JSON.parse(fs.readFileSync(memPath, 'utf-8'));
|
||||
expect(mem.nuggets.length).toBe(1);
|
||||
expect(mem.nuggets[0].nugget).toContain('verbose explanations');
|
||||
expect(mem.nuggets[0].applies_to_signal_keys).toEqual(['detail-preference']);
|
||||
expect(mem.nuggets[0].gbrain_published).toBe(true);
|
||||
expect(mem.nuggets[0].source_quotes).toEqual(['always explain the tradeoffs']);
|
||||
});
|
||||
|
||||
test('appends without clobbering existing nuggets', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(stateRoot, 'free-text-memory.json'),
|
||||
JSON.stringify({ nuggets: [{ nugget: 'pre-existing', applies_to_signal_keys: [] }] }),
|
||||
);
|
||||
writeProposals([
|
||||
{
|
||||
kind: 'memory-nugget',
|
||||
confidence: 0.9,
|
||||
nugget: 'new nugget',
|
||||
applies_to_signal_keys: [],
|
||||
},
|
||||
]);
|
||||
run(['--proposal', '0']);
|
||||
const mem = JSON.parse(
|
||||
fs.readFileSync(path.join(stateRoot, 'free-text-memory.json'), 'utf-8'),
|
||||
);
|
||||
expect(mem.nuggets.length).toBe(2);
|
||||
expect(mem.nuggets[0].nugget).toBe('pre-existing');
|
||||
expect(mem.nuggets[1].nugget).toBe('new nugget');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// preference application
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('preference apply', () => {
|
||||
test('routes through gstack-question-preference with source=plan-tune', () => {
|
||||
writeProposals([
|
||||
{
|
||||
kind: 'preference',
|
||||
confidence: 0.9,
|
||||
question_id: 'ship-changelog-voice-polish',
|
||||
preference: 'never-ask',
|
||||
source_quotes: ['skip the polish for typo PRs'],
|
||||
},
|
||||
]);
|
||||
const r = run(['--proposal', '0']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('APPLIED: preference');
|
||||
|
||||
const prefPath = path.join(stateRoot, 'projects', cwdSlug, 'question-preferences.json');
|
||||
const prefs = JSON.parse(fs.readFileSync(prefPath, 'utf-8'));
|
||||
expect(prefs['ship-changelog-voice-polish']).toBe('never-ask');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// declared-nudge application
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('declared-nudge apply', () => {
|
||||
test('medium up nudge on unset dim → 0.5 + 0.10 = 0.6', () => {
|
||||
writeProposals([
|
||||
{
|
||||
kind: 'declared-nudge',
|
||||
confidence: 0.9,
|
||||
dimension: 'scope_appetite',
|
||||
direction: 'up',
|
||||
magnitude: 'medium',
|
||||
},
|
||||
]);
|
||||
run(['--proposal', '0']);
|
||||
const profile = JSON.parse(
|
||||
fs.readFileSync(path.join(stateRoot, 'developer-profile.json'), 'utf-8'),
|
||||
);
|
||||
expect(profile.declared.scope_appetite).toBe(0.6);
|
||||
});
|
||||
|
||||
test('small down nudge on existing value', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(stateRoot, 'developer-profile.json'),
|
||||
JSON.stringify({ declared: { scope_appetite: 0.8 } }),
|
||||
);
|
||||
writeProposals([
|
||||
{
|
||||
kind: 'declared-nudge',
|
||||
confidence: 0.9,
|
||||
dimension: 'scope_appetite',
|
||||
direction: 'down',
|
||||
magnitude: 'small',
|
||||
},
|
||||
]);
|
||||
run(['--proposal', '0']);
|
||||
const profile = JSON.parse(
|
||||
fs.readFileSync(path.join(stateRoot, 'developer-profile.json'), 'utf-8'),
|
||||
);
|
||||
expect(profile.declared.scope_appetite).toBe(0.75);
|
||||
});
|
||||
|
||||
test('clamps to [0, 1]', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(stateRoot, 'developer-profile.json'),
|
||||
JSON.stringify({ declared: { scope_appetite: 0.95 } }),
|
||||
);
|
||||
writeProposals([
|
||||
{
|
||||
kind: 'declared-nudge',
|
||||
confidence: 0.9,
|
||||
dimension: 'scope_appetite',
|
||||
direction: 'up',
|
||||
magnitude: 'large',
|
||||
},
|
||||
]);
|
||||
run(['--proposal', '0']);
|
||||
const profile = JSON.parse(
|
||||
fs.readFileSync(path.join(stateRoot, 'developer-profile.json'), 'utf-8'),
|
||||
);
|
||||
expect(profile.declared.scope_appetite).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Proposal marked applied
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('proposal marked applied', () => {
|
||||
test('applied_at + gbrain_published written back to proposals.json', () => {
|
||||
writeProposals([
|
||||
{
|
||||
kind: 'memory-nugget',
|
||||
confidence: 0.9,
|
||||
nugget: 'something',
|
||||
applies_to_signal_keys: [],
|
||||
},
|
||||
]);
|
||||
run(['--proposal', '0', '--gbrain-published', 'true']);
|
||||
const p = JSON.parse(fs.readFileSync(proposalFile, 'utf-8'));
|
||||
expect(p.proposals[0].applied_at).toBeTruthy();
|
||||
expect(p.proposals[0].gbrain_published).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Error paths
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('error paths', () => {
|
||||
test('bad --proposal index exits non-zero', () => {
|
||||
writeProposals([
|
||||
{ kind: 'memory-nugget', confidence: 0.9, nugget: 'x', applies_to_signal_keys: [] },
|
||||
]);
|
||||
const r = run(['--proposal', '99']);
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr).toContain('invalid --proposal');
|
||||
});
|
||||
|
||||
test('missing --proposal exits non-zero', () => {
|
||||
writeProposals([
|
||||
{ kind: 'memory-nugget', confidence: 0.9, nugget: 'x', applies_to_signal_keys: [] },
|
||||
]);
|
||||
const r = run([]);
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr).toContain('--proposal');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue