mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
Fix five small browse and question-hook bugs (#4192)
* Keep the scroll selector when --wait is absent Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Skip a PATH browse that links to another program such as xdg-open Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Give each project its own browse slug on installs without ~/.claude Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep a question marker when its signal hint is malformed Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Read the lowercased question id in the never-ask preference hook Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep a marker's tail in the one-way door check Review-Finding: p1/targeted#1 reader=targeted Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep a marker's tail apart from the next word in the one-way door check Review-Finding: p2/outside-voice#1 reader=outside-voice Review-Finding: p2/adversarial-native#1 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Say where old domain skills live and narrow the marker note in the changelog Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Say how the browse daemon picks a project's domain skill file Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
88c76ebefa
commit
68b1d056a4
13 files changed
+245
-31
No files matched your search
@@ -101,7 +101,6 @@ The 158 older open issues were re-checked against main 5cb5e1c (v1.91.83.0). Of
|
||||
Each item was decided in the Wave 9 plan and parked with its reason. Credit the reporter or contributor when it is picked up.
|
||||
|
||||
- **Fold `triggers:` into skill descriptions (#2118)** — `scripts/gen-skill-docs.ts` folds only `voice-triggers:`, and the trimmed description keeps one sentence, so natural-language triggers never reach the listing. Parked because it conflicts with the catalog-trim design and would regenerate every skill. Mechanism when picked up: a `when_to_use:` frontmatter key for the Claude host, built from `triggers:`, deduped, and kept inside the catalog byte budget (Claude Code's 1,536-character combined listing limit); hosts that allowlist name and description get a bounded "Use when…" sentence or nothing. **Effort:** M. **Priority:** P2.
|
||||
- **Plan-tune qid marker follow-ups (#3442 review notes, filed 2026-11-02)**: `QID_MARKER_RE` in `scripts/qid-marker.ts` drops the whole marker when the `;signal=` tail is malformed (an underscore, a space, an empty key). Match the id first and drop only a bad hint. `question-preference-hook.ts` still looks up the raw marker id, while the log hook lowercases it. Use `parseQidMarker(...).id` in both hooks. Add hinted-marker cases to `test/question-preference-hook.test.ts`. **Effort:** S. **Priority:** P3.
|
||||
- **Stateless, instruction-only host mode (#3061, @m-check1B)** — hosted multi-tenant runtimes have no install, state root or helper binaries, but skill bodies still read and write `$GSTACK_STATE_ROOT`, call `bin/gstack-*` and log telemetry. Deferred to the new-hosts theme below. Requirements kept on record: tenant-owned state (a `capabilities.state: 'none'` host renders host-provided hooks instead of shell steps), named artifact hooks (`ARTIFACT_SAVE`/`ARTIFACT_FIND` per kind, `WEB_RESEARCH`, `SECRET_SCAN`, `USER_PREF`) mapped the way the gbrain resolvers already are, a generator check that fails a stateless render containing `~/.gstack`, `$GSTACK_`, `bin/gstack-*` or `--event-type`, and the `claude` host's output byte-identical. The contributor offered an office-hours overlay plus the generator check as a first PR. **Effort:** L. **Priority:** P3.
|
||||
- **Legacy unprefixed Cursor skill folders (#3084)** — 1.31 Cursor installs created `~/.cursor/skills/<skill>/SKILL.md` links; 1.91 links `gstack-*` folders and `_prune_stale_generated` in `setup` only looks at `gstack-*` names, so Cursor can load two skills with the same name. `setup` already removes old unprefixed Claude entries; Cursor needs the same owned-link cleanup (remove only links that point into a gstack checkout). Split from #3084's data-loss fix, which Wave 9 Lane A0 ships. **Effort:** S. **Priority:** P3.
|
||||
- **`/retro global` says what a "session" is (#1315, third problem)** — `bin/gstack-global-discover.ts` counts transcript files and rollouts, so cron- or agent-driven repos show hundreds of "sessions" with no interactive work. Annotate the retro output that sessions are tool invocations or files, not distinct human working sessions. Problems 1 and 2 are fixed (v1.91.66.0 and Wave 9 Lane F). **Effort:** S. **Priority:** P3.
|
||||
|
||||
@@ -4,11 +4,19 @@
|
||||
* Used by domain-skills (per-project storage) and sidebar prompt-context
|
||||
* injection. Cached after first call — slug is derived from the daemon's
|
||||
* git remote (or env override) and doesn't change between commands.
|
||||
*
|
||||
* gstack-slug is read from the checkout this daemon runs from first, then
|
||||
* from the Claude Code install. Codex, repo-local and other host installs
|
||||
* have no ~/.claude copy; when no gstack-slug runs, the slug comes from the
|
||||
* same remote rule (getRemoteSlug) instead of one shared "unknown" bucket
|
||||
* that would mix every project's domain skills.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { getRemoteSlug } from './config';
|
||||
|
||||
let cachedSlug: string | null = null;
|
||||
|
||||
@@ -19,14 +27,24 @@ export function getCurrentProjectSlug(): string {
|
||||
cachedSlug = explicit;
|
||||
return explicit;
|
||||
}
|
||||
try {
|
||||
const slugBin = path.join(os.homedir(), '.claude/skills/gstack/bin/gstack-slug');
|
||||
const out = execSync(slugBin, { encoding: 'utf8', timeout: 2000, windowsHide: true }).trim();
|
||||
const m = out.match(/SLUG="?([^"\n]+)"?/);
|
||||
cachedSlug = m ? m[1]! : (out || 'unknown');
|
||||
} catch {
|
||||
cachedSlug = 'unknown';
|
||||
const candidates = [
|
||||
path.resolve(import.meta.dir, '../../bin/gstack-slug'),
|
||||
path.join(os.homedir(), '.claude/skills/gstack/bin/gstack-slug'),
|
||||
];
|
||||
for (const slugBin of candidates) {
|
||||
if (!fs.existsSync(slugBin)) continue;
|
||||
try {
|
||||
const out = execFileSync(slugBin, [], { encoding: 'utf8', timeout: 2000, windowsHide: true }).trim();
|
||||
const m = out.match(/SLUG="?([^"\n]+)"?/);
|
||||
if (m) {
|
||||
cachedSlug = m[1]!;
|
||||
return cachedSlug;
|
||||
}
|
||||
} catch {
|
||||
// next candidate
|
||||
}
|
||||
}
|
||||
cachedSlug = getRemoteSlug() || 'unknown';
|
||||
return cachedSlug;
|
||||
}
|
||||
|
||||
|
||||
@@ -461,11 +461,14 @@ export async function handleWriteCommand(
|
||||
|
||||
case 'scroll': {
|
||||
// Parse --times N and --wait ms flags
|
||||
const timesIdx = args.indexOf('--times');
|
||||
const times = timesIdx >= 0 ? parseInt(args[timesIdx + 1], 10) || 1 : 0;
|
||||
const waitIdx = args.indexOf('--wait');
|
||||
const waitMs = waitIdx >= 0 ? parseInt(args[waitIdx + 1], 10) || 1000 : 1000;
|
||||
const selector = args.find(a => !a.startsWith('--') && args.indexOf(a) !== timesIdx + 1 && args.indexOf(a) !== waitIdx + 1);
|
||||
let times = 0;
|
||||
let waitMs = 1000;
|
||||
let selector: string | undefined;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--times') times = parseInt(args[++i], 10) || 1;
|
||||
else if (args[i] === '--wait') waitMs = parseInt(args[++i], 10) || 1000;
|
||||
else if (!args[i].startsWith('--') && selector === undefined) selector = args[i];
|
||||
}
|
||||
|
||||
if (times > 0) {
|
||||
// Repeated scroll mode
|
||||
|
||||
@@ -535,7 +535,15 @@ describe('Interaction', () => {
|
||||
|
||||
test('scroll works', async () => {
|
||||
const result = await handleWriteCommand('scroll', ['footer'], bm);
|
||||
expect(result).toContain('Scrolled');
|
||||
expect(result).toBe('Scrolled footer into view');
|
||||
});
|
||||
|
||||
test('scroll keeps its selector beside --times and --wait', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||
expect(await handleWriteCommand('scroll', ['footer', '--times', '2', '--wait', '10'], bm)).toBe('Scrolled 2 times (footer) with 10ms delay');
|
||||
expect(await handleWriteCommand('scroll', ['footer', '--times', '2'], bm)).toBe('Scrolled 2 times (footer) with 1000ms delay');
|
||||
expect(await handleWriteCommand('scroll', ['--times', '2', 'footer'], bm)).toBe('Scrolled 2 times (footer) with 1000ms delay');
|
||||
expect(await handleWriteCommand('scroll', ['--wait', '5', '--times', '2'], bm)).toBe('Scrolled 2 times with 5ms delay');
|
||||
});
|
||||
|
||||
test('viewport changes size', async () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The daemon's project slug names the per-project domain-skill bucket. An
|
||||
* install without ~/.claude/skills/gstack (Codex, repo-local, other hosts)
|
||||
* used to fall back to one shared "unknown" bucket for every project.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const MODULE = path.resolve(import.meta.dir, '../src/project-slug.ts');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-project-slug-'));
|
||||
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function slugIn(cwd: string, extra: Record<string, string> = {}): string {
|
||||
const env: Record<string, string> = { ...(process.env as Record<string, string>), HOME: path.join(root, 'home'), GSTACK_STATE_ROOT: path.join(root, 'state') };
|
||||
delete env.GSTACK_PROJECT_SLUG;
|
||||
delete env.GSTACK_HOME;
|
||||
Object.assign(env, extra);
|
||||
const r = spawnSync(process.execPath, ['-e', `import { getCurrentProjectSlug } from ${JSON.stringify(MODULE)}; console.log(getCurrentProjectSlug());`], { cwd, env, encoding: 'utf8', timeout: 30_000 });
|
||||
return (r.stdout ?? '').trim();
|
||||
}
|
||||
|
||||
describe('getCurrentProjectSlug', () => {
|
||||
test('a repository resolves to its remote slug with no ~/.claude install', () => {
|
||||
const repo = path.join(root, 'repo');
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 30_000 });
|
||||
spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/acme/widgets.git'], { cwd: repo, timeout: 30_000 });
|
||||
expect(slugIn(repo)).toBe('acme-widgets');
|
||||
});
|
||||
|
||||
test('two projects never share a bucket', () => {
|
||||
const a = path.join(root, 'alpha');
|
||||
const b = path.join(root, 'beta');
|
||||
fs.mkdirSync(a, { recursive: true });
|
||||
fs.mkdirSync(b, { recursive: true });
|
||||
const sa = slugIn(a);
|
||||
const sb = slugIn(b);
|
||||
expect(sa).not.toBe('unknown');
|
||||
expect(sa).not.toBe(sb);
|
||||
});
|
||||
|
||||
test('GSTACK_PROJECT_SLUG wins', () => {
|
||||
expect(slugIn(root, { GSTACK_PROJECT_SLUG: 'pinned' })).toBe('pinned');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
- `browse scroll <selector>` brings the element into view again when `--wait` is absent. The flag parser treated the first argument as the `--wait` value and dropped it, so `scroll footer` and `scroll footer --times 2` scrolled to the page bottom and replied "Scrolled to bottom".
|
||||
- gstack's renderer no longer mistakes Debian and Ubuntu's `/usr/bin/browse`, a link to xdg-open, for gstack's own browser. A `browse` on PATH whose real file has another name is now skipped, so you get the clear "no browser" message instead of a desktop opener launching.
|
||||
- The browse daemon no longer files every project's domain skills in one shared "unknown" bucket on installs without `~/.claude` (Codex, repo-local and other hosts). It now tries the checkout's own `gstack-slug`, then the Claude install, then the git remote rule, so skills saved in one repository stop showing up in another. Skills saved before this change stay in `~/.gstack/projects/unknown/learnings.jsonl` and no longer appear in a project's list; copy the lines you want into that project's `learnings.jsonl`.
|
||||
- A question marker with a malformed signal hint, such as `<gstack-qid:ship-x;signal=scope_appetite>`, keeps its question id in the never-ask hook and the question log. Only the bad hint is dropped, so a never-ask preference still applies and the question log no longer records it as unmarked.
|
||||
- The never-ask preference hook reads the question id lowercased, as the log hook and stored preferences already do, so a marker written with capitals no longer asks a question you silenced.
|
||||
@@ -70,6 +70,11 @@ Skills live in two places:
|
||||
|
||||
- **Per-project**: `~/.gstack/projects/<slug>/learnings.jsonl` — same JSONL
|
||||
file the `/learn` skill uses. Domain skills are `type:"domain"` rows.
|
||||
The daemon takes `<slug>` from `$GSTACK_PROJECT_SLUG` when set, else from the
|
||||
`gstack-slug` in its own checkout, then the Claude Code install's copy,
|
||||
then the git remote. Rows saved by older builds on installs without
|
||||
`~/.claude` sit in `~/.gstack/projects/unknown/learnings.jsonl`; copy the
|
||||
ones you want into the project's file.
|
||||
- **Global**: `~/.gstack/global-domain-skills.jsonl` — only `state:"global"`
|
||||
rows.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ import { SPAWNED_ESCAPE_SENTENCE, CONDUCTOR_SPAWNED_DENY_REASON, spawnedByEnv }
|
||||
import { resolveStateRoot } from '../../../lib/state-root';
|
||||
import { readVersionedSlugCache } from '../../../lib/bin-context';
|
||||
import { logHookError as sharedLogHookError } from './hook-log';
|
||||
import { QID_MARKER_RE as MARKER_RE } from '../../../scripts/qid-marker';
|
||||
import { QID_MARKER_RE as MARKER_RE, parseQidMarker } from '../../../scripts/qid-marker';
|
||||
|
||||
interface HookStdin {
|
||||
session_id?: string;
|
||||
@@ -403,9 +403,9 @@ async function main(): Promise<void> {
|
||||
const contextNuggets: string[] = [];
|
||||
for (const q of questions) {
|
||||
const qText = q.question || '';
|
||||
const marker = qText.match(MARKER_RE);
|
||||
const marker = parseQidMarker(qText);
|
||||
if (!marker) continue;
|
||||
const entry = registry[marker[1]];
|
||||
const entry = registry[marker.id];
|
||||
if (!entry?.signal_key) continue;
|
||||
const hits = nuggetsForSignal(memoryNuggets, entry.signal_key);
|
||||
for (const h of hits) {
|
||||
@@ -426,9 +426,9 @@ async function main(): Promise<void> {
|
||||
let fullyAutoDecidable = true;
|
||||
for (const q of questions) {
|
||||
const qText = q.question || '';
|
||||
const marker = qText.match(MARKER_RE);
|
||||
const marker = parseQidMarker(qText);
|
||||
if (!marker) { fullyAutoDecidable = false; break; }
|
||||
const questionId = marker[1];
|
||||
const questionId = marker.id;
|
||||
if ('unresolved' in project) {
|
||||
logHookError(`never-ask not applied to ${questionId}, asking instead: project identity unresolved (${project.unresolved})`);
|
||||
fullyAutoDecidable = false;
|
||||
@@ -446,7 +446,7 @@ async function main(): Promise<void> {
|
||||
// regex pass over the question text; on any failure keep the default
|
||||
// (enforcement still requires an explicit stored preference).
|
||||
try {
|
||||
if (classifyQuestion({ summary: qText.replace(MARKER_RE, '').trim() }).oneWay) {
|
||||
if (classifyQuestion({ summary: qText.replace(MARKER_RE, (_m, _id, tail = '') => ` ${tail} `).trim() }).oneWay) {
|
||||
doorType = 'one-way';
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -515,14 +515,15 @@ async function main(): Promise<void> {
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const rawText = questions[i]?.question || '';
|
||||
const qText = rawText.replace(MARKER_RE, '').trim();
|
||||
const doorText = rawText.replace(MARKER_RE, (_m, _id, tail = '') => ` ${tail} `).trim();
|
||||
const opts = optionLabels(questions[i]?.options || []);
|
||||
const marker = rawText.match(MARKER_RE);
|
||||
const entry = marker ? registry[marker[1]] : undefined;
|
||||
const marker = parseQidMarker(rawText);
|
||||
const entry = marker ? registry[marker.id] : undefined;
|
||||
let oneWay = entry?.door_type === 'one-way';
|
||||
if (!entry) {
|
||||
const optText = opts.join(' / ');
|
||||
try {
|
||||
oneWay = classifyQuestion({ summary: optText ? `${qText} options: ${optText}` : qText }).oneWay;
|
||||
oneWay = classifyQuestion({ summary: optText ? `${doorText} options: ${optText}` : doorText }).oneWay;
|
||||
} catch (e) {
|
||||
logHookError(`spawned one-way classifier failed: ${(e as Error).message}`);
|
||||
}
|
||||
@@ -537,7 +538,7 @@ async function main(): Promise<void> {
|
||||
// NO trace of a machine-resolved gate. Log every question.
|
||||
const { recommended } = extractRecommended(rawText, opts);
|
||||
logAutoDecided(
|
||||
marker?.[1] ?? 'unmarked',
|
||||
marker?.id ?? 'unmarked',
|
||||
qText,
|
||||
recommended ?? 'unrecorded',
|
||||
opts.length,
|
||||
|
||||
+10
-2
@@ -456,7 +456,9 @@ function executable(p: string): string | null {
|
||||
/**
|
||||
* Locate gstack's own browse binary: $GSTACK_BROWSE_BIN → $BROWSE_BIN →
|
||||
* <root>/browse/dist/browse → <root>/browse/bin/find-browse (per root, repo
|
||||
* then install) → `browse` on PATH. Null when nothing resolves.
|
||||
* then install) → `browse` on PATH. Null when nothing resolves. A PATH
|
||||
* `browse` that resolves to a file with another name is skipped: Debian and
|
||||
* Ubuntu ship /usr/bin/browse as a link to xdg-open, which is not gstack's.
|
||||
*/
|
||||
export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env, roots: string[] = BROWSE_ROOTS): string | null {
|
||||
const PATH = env.PATH ?? env.Path ?? '';
|
||||
@@ -474,7 +476,13 @@ export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env, roots: st
|
||||
const found = r.status === 0 ? executable((r.stdout ?? '').trim()) : null;
|
||||
if (found) return found;
|
||||
}
|
||||
return Bun.which('browse', { PATH }) ?? null;
|
||||
const onPath = Bun.which('browse', { PATH });
|
||||
if (!onPath) return null;
|
||||
try {
|
||||
return path.basename(fs.realpathSync(onPath)).replace(/\.(exe|cmd|bat)$/i, '') === 'browse' ? onPath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** PdfStepOptions (CDP, inches) → the browse `pdf --from-file` payload (Playwright shapes, string lengths). */
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
* `<gstack-qid:{id}>` names the question; an unregistered id may add a
|
||||
* signal hint, `<gstack-qid:{id};signal={signal_key}>`, naming the
|
||||
* SIGNAL_MAP key its answer speaks to. Ids and keys are read lowercase.
|
||||
* Whether a hint is a real SIGNAL_MAP key is checked where the row is
|
||||
* logged (bin/gstack-question-log), not here.
|
||||
* A malformed hint (an underscore, a space, an empty or unknown key name)
|
||||
* is dropped on its own; the id still names the question. Whether a hint
|
||||
* is a real SIGNAL_MAP key is checked where the row is logged
|
||||
* (bin/gstack-question-log), not here.
|
||||
*/
|
||||
export const QID_MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})(?:;signal=([a-z0-9-]{1,64}))?>/i;
|
||||
export const QID_MARKER_RE = /<gstack-qid:([a-z0-9-]{1,64})(?:;([^<>\n]{0,128}))?>/i;
|
||||
|
||||
const SIGNAL_HINT_RE = /^signal=([a-z0-9-]{1,64})$/i;
|
||||
|
||||
export interface QidMarker {
|
||||
id: string;
|
||||
@@ -18,9 +22,10 @@ export interface QidMarker {
|
||||
export function parseQidMarker(text: string): QidMarker | undefined {
|
||||
const m = QID_MARKER_RE.exec(text);
|
||||
if (!m) return undefined;
|
||||
const hint = m[2] === undefined ? undefined : SIGNAL_HINT_RE.exec(m[2])?.[1];
|
||||
return {
|
||||
id: m[1]!.toLowerCase(),
|
||||
...(m[2] ? { signal_key: m[2].toLowerCase() } : {}),
|
||||
...(hint ? { signal_key: hint.toLowerCase() } : {}),
|
||||
stripped: text.replace(QID_MARKER_RE, '').trim(),
|
||||
};
|
||||
}
|
||||
@@ -187,6 +187,21 @@ describe('aside-render: browse fallback — binary resolution', () => {
|
||||
expect(resolveBrowseBin(noPath, [rootC])).toBe(builtB);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('a PATH browse that links to another program (Debian xdg-open) is not gstack\'s', () => {
|
||||
const binDir = path.join(home, 'path-bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const xdg = fakeBin(binDir, 'xdg-open');
|
||||
fs.symlinkSync(xdg, path.join(binDir, 'browse'));
|
||||
expect(resolveBrowseBin({ PATH: binDir }, [empty])).toBeNull();
|
||||
const realDir = path.join(home, 'path-real');
|
||||
fakeBin(realDir, 'browse');
|
||||
expect(resolveBrowseBin({ PATH: realDir }, [empty])).toBe(path.join(realDir, 'browse'));
|
||||
const linkDir = path.join(home, 'path-link');
|
||||
fs.mkdirSync(linkDir, { recursive: true });
|
||||
fs.symlinkSync(builtA, path.join(linkDir, 'browse'));
|
||||
expect(resolveBrowseBin({ PATH: linkDir }, [empty])).toBe(path.join(linkDir, 'browse'));
|
||||
});
|
||||
|
||||
test('directories are never "executables"', () => {
|
||||
const rootD = path.join(home, 'd');
|
||||
fs.mkdirSync(path.join(rootD, 'browse/dist/browse'), { recursive: true });
|
||||
|
||||
@@ -19,6 +19,13 @@ describe('parseQidMarker (#3442)', () => {
|
||||
expect(parseQidMarker('<gstack-qid:Plan-CEO-X;signal=Scope-Appetite> q')).toMatchObject({ id: 'plan-ceo-x', signal_key: 'scope-appetite' });
|
||||
});
|
||||
|
||||
test('a malformed signal hint drops only the hint; the id still names the question', () => {
|
||||
expect(parseQidMarker('Q? <gstack-qid:plan-x;signal=scope_appetite>')).toEqual({ id: 'plan-x', stripped: 'Q?' });
|
||||
expect(parseQidMarker('Q? <gstack-qid:plan-x;signal=>')).toEqual({ id: 'plan-x', stripped: 'Q?' });
|
||||
expect(parseQidMarker('Q? <gstack-qid:plan-x;signal=scope appetite>')).toEqual({ id: 'plan-x', stripped: 'Q?' });
|
||||
expect(parseQidMarker('Q? <gstack-qid:plan-x;signl=scope-appetite>')).toEqual({ id: 'plan-x', stripped: 'Q?' });
|
||||
});
|
||||
|
||||
test('no marker returns undefined', () => {
|
||||
expect(parseQidMarker('plain question')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -260,6 +260,43 @@ describe('enforces never-ask preferences', () => {
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('Fix now');
|
||||
});
|
||||
|
||||
test('a malformed signal hint keeps the marker, so the stored preference still applies', () => {
|
||||
writeProjectPref('ship-pre-landing-review-fix', 'never-ask');
|
||||
const r = runHook({
|
||||
session_id: 's5h',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'tu-5h',
|
||||
tool_input: {
|
||||
questions: [
|
||||
{
|
||||
question: '<gstack-qid:ship-pre-landing-review-fix;signal=scope_appetite> Pre-landing review flagged issue.',
|
||||
options: ['A) Fix now (recommended)', 'B) Skip'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
});
|
||||
|
||||
test('an uppercase marker reads the same lowercase id the log hook records', () => {
|
||||
writeProjectPref('ship-pre-landing-review-fix', 'never-ask');
|
||||
const r = runHook({
|
||||
session_id: 's5u',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'tu-5u',
|
||||
tool_input: {
|
||||
questions: [
|
||||
{
|
||||
question: '<gstack-qid:Ship-Pre-Landing-Review-Fix> Pre-landing review flagged issue.',
|
||||
options: ['A) Fix now (recommended)', 'B) Skip'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('ship-pre-landing-review-fix → A) Fix now');
|
||||
});
|
||||
|
||||
test('one-way door → pass-through even with never-ask (safety override)', () => {
|
||||
writeProjectPref('ship-test-failure-triage', 'never-ask');
|
||||
const r = runHook({
|
||||
@@ -354,6 +391,61 @@ describe('enforces never-ask preferences', () => {
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('plan-tune auto-decide');
|
||||
});
|
||||
|
||||
test('destructive text inside a marker tail still reaches the keyword net → pass-through', () => {
|
||||
writeProjectPref('repo-cleanup', 'never-ask');
|
||||
const r = runHook({
|
||||
session_id: 's-kw-3',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'tu-kw-3',
|
||||
tool_input: {
|
||||
questions: [
|
||||
{
|
||||
question: '<gstack-qid:repo-cleanup; delete the production database and force-push to main> Proceed?',
|
||||
options: ['A) Yes (recommended)', 'B) No'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expectPassThrough(r);
|
||||
});
|
||||
|
||||
test('a marker tail right against the question text still reaches the keyword net → pass-through', () => {
|
||||
writeProjectPref('x', 'never-ask');
|
||||
const r = runHook({
|
||||
session_id: 's-kw-4',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'tu-kw-4',
|
||||
tool_input: {
|
||||
questions: [
|
||||
{
|
||||
question: '<gstack-qid:x;signal=y>delete production database?',
|
||||
options: ['A) Yes (recommended)', 'B) No'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expectPassThrough(r);
|
||||
});
|
||||
|
||||
test('a marker tail right against benign question text → still deny (control)', () => {
|
||||
writeProjectPref('x', 'never-ask');
|
||||
const r = runHook({
|
||||
session_id: 's-kw-5',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'tu-kw-5',
|
||||
tool_input: {
|
||||
questions: [
|
||||
{
|
||||
question: '<gstack-qid:x;signal=y>Reorganize the TODOs file?',
|
||||
options: ['A) Yes (recommended)', 'B) No'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('plan-tune auto-decide');
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user