mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
* fix(timeline): reject a missing or malformed --limit with exit 2 (#1723) gstack-timeline-read parsed --limit with parseInt and fell back to 20, so '--limit 1abc' became 1, 'nope', 0 and -5 silently became the default, and a bare --limit crashed on an unbound $2. Validate the argument as a positive integer before the no-timeline early exit and exit 2 with an example. No flag still means 20. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(skill-start): echo SESSIONS, the live session-marker count (#1651) The sessions directory is touched and expired at skill start, but the count was never printed, so CLAUDE.md rules that read the preamble's session count had nothing to read. Echo SESSIONS: N (markers fresher than 120 minutes, this session included). Idea from @0xDevNinja's PR #1747, redone in the consolidated skill-start script. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(office-hours): pick the closing tier from the profile read before this session is logged (#2801) Phase 4.5 appended this session to the builder profile and Phase 6 then read the profile to choose its closing, so every run saw itself as history: a first-time user got welcome_back and was asked how the assignment issued minutes earlier went. Phase 4.5 now reads the profile before --log-session and records the prior tier, count and last-session fields in the transcript; Phase 6 uses those values (this session is prior + 1) and re-reads only the running totals. A failed read proceeds as a first session and says so. Tier headers now name the session numbers each tier actually covers. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(office-hours): print the full design doc inline before asking for approval (#879) Tool output collapses in Claude Code, so users were asked to approve a doc they could not see. Port of @RyanAlberts's PR #1116 into the carved design-and-handoff section. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(office-hours): report success only when this run wrote its design doc (#1049) Before telemetry, check the design doc this run wrote, by its exact path under the state root, never the newest file in the folder. Success needs a non-empty doc there; otherwise the outcome is abort or error with failed step design_doc. Rewrite of @walton-chris's PR #1049, which matched the newest global session marker and hardcoded ~/.gstack. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan reviews): skip the /office-hours prerequisite offer in spawned and headless sessions (#1958) The BENEFITS_FROM offer had no recommended option, so a spawned run had nothing defined to auto-pick and a headless run could only block. Skip it there and continue with the standard review. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: regenerate SKILL.md files Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: office-hours tier order, failed-read fallback, inline doc, design-doc check and offer skip Rendered-block tests run the Phase 4.5 read and log on a temp state root (first session reads introduction, failed read is marked) and the pre-telemetry check against this run's doc, an empty doc, another session's doc and a path outside the state root. Parity caps take the measured growth. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(release): v1.91.37.0 changelog and TODOS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(browse): run watchdog test servers under a private HOME The headed-mode case starts a real server with BROWSE_HEADED=1, and its headed launch writes ~/.gstack/.auth.json for the extension. With the caller's HOME, a slow kill let that write reach the real home and failed the free runner's home-containment check (free-suite shard 5 on #3075). Give each spawned server a private HOME and GSTACK_HOME, keeping Playwright's browser cache path. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(evals): name the office-hours-auto-mode case so the PR lane can select it The PR lane runs each short probe with a Bun name pattern ending in its case id. The office-hours AskUserQuestion-blocked smoke had a descriptive test name and no CASE_TEST_NAMES label, so whenever office-hours changed its shard matched 0 tests and failed in 0 s (E2E Evals slice 6 on #3075). Name the test after its id, keep the description on the describe, and check that every literal-named PR probe has a test the pattern selects. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(office-hours): skip a last-session greeting clause whose value is empty A profile backfilled from the timeline can carry a welcome_back or regular tier with empty LAST_ASSIGNMENT and LAST_PROJECT. The welcome_back and regular greetings now drop the "last time" clause and its question when the value it names is empty, and say only "Welcome back." 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>
226 lines
7.9 KiB
TypeScript
226 lines
7.9 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { execFileSync, execSync, spawnSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin');
|
|
|
|
let tmpDir: string;
|
|
let slugDir: string;
|
|
|
|
function runLog(input: string, opts: { expectFail?: boolean } = {}): { stdout: string; exitCode: number } {
|
|
const execOpts: ExecSyncOptionsWithStringEncoding = {
|
|
cwd: ROOT,
|
|
env: { ...process.env, GSTACK_HOME: tmpDir },
|
|
encoding: 'utf-8',
|
|
timeout: 15000,
|
|
};
|
|
try {
|
|
const stdout = execSync(`${BIN}/gstack-timeline-log '${input.replace(/'/g, "'\\''")}'`, execOpts).trim();
|
|
return { stdout, exitCode: 0 };
|
|
} catch (e: any) {
|
|
if (opts.expectFail) {
|
|
return { stdout: e.stderr?.toString() || '', exitCode: e.status || 1 };
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function runRead(args: string = ''): string {
|
|
const execOpts: ExecSyncOptionsWithStringEncoding = {
|
|
cwd: ROOT,
|
|
env: { ...process.env, GSTACK_HOME: tmpDir },
|
|
encoding: 'utf-8',
|
|
timeout: 15000,
|
|
};
|
|
try {
|
|
return execSync(`${BIN}/gstack-timeline-read ${args}`, execOpts).trim();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function runReadArgs(args: string[] = []): string {
|
|
const execOpts: ExecSyncOptionsWithStringEncoding = {
|
|
cwd: ROOT,
|
|
env: { ...process.env, GSTACK_HOME: tmpDir },
|
|
encoding: 'utf-8',
|
|
timeout: 15000,
|
|
};
|
|
try {
|
|
return execFileSync(path.join(BIN, 'gstack-timeline-read'), args, execOpts).trim(); // timeout via execOpts
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function runReadStatus(args: string[]): { status: number | null; stdout: string; stderr: string } {
|
|
const result = spawnSync(path.join(BIN, 'gstack-timeline-read'), args, {
|
|
cwd: ROOT,
|
|
env: { ...process.env, GSTACK_HOME: tmpDir },
|
|
encoding: 'utf-8',
|
|
timeout: 15000,
|
|
});
|
|
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-timeline-'));
|
|
slugDir = path.join(tmpDir, 'projects');
|
|
fs.mkdirSync(slugDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function findTimelineFile(): string | null {
|
|
const projectDirs = fs.readdirSync(slugDir);
|
|
if (projectDirs.length === 0) return null;
|
|
const f = path.join(slugDir, projectDirs[0], 'timeline.jsonl');
|
|
return fs.existsSync(f) ? f : null;
|
|
}
|
|
|
|
describe('gstack-timeline-log', () => {
|
|
test('accepts valid JSON and appends to timeline.jsonl', () => {
|
|
const input = '{"skill":"review","event":"started","branch":"main"}';
|
|
const result = runLog(input);
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const f = findTimelineFile();
|
|
expect(f).not.toBeNull();
|
|
const content = fs.readFileSync(f!, 'utf-8').trim();
|
|
const parsed = JSON.parse(content);
|
|
expect(parsed.skill).toBe('review');
|
|
expect(parsed.event).toBe('started');
|
|
expect(parsed.branch).toBe('main');
|
|
});
|
|
|
|
test('rejects invalid JSON with exit 0 (non-blocking)', () => {
|
|
const result = runLog('not json at all');
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
// No file should be created
|
|
const f = findTimelineFile();
|
|
expect(f).toBeNull();
|
|
});
|
|
|
|
test('injects timestamp when ts field is missing', () => {
|
|
const input = '{"skill":"review","event":"started","branch":"main"}';
|
|
runLog(input);
|
|
|
|
const f = findTimelineFile();
|
|
expect(f).not.toBeNull();
|
|
const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
|
|
expect(parsed.ts).toBeDefined();
|
|
expect(new Date(parsed.ts).getTime()).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('preserves timestamp when ts field is present', () => {
|
|
const input = '{"skill":"review","event":"completed","branch":"main","ts":"2025-06-15T10:00:00Z"}';
|
|
runLog(input);
|
|
|
|
const f = findTimelineFile();
|
|
expect(f).not.toBeNull();
|
|
const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim());
|
|
expect(parsed.ts).toBe('2025-06-15T10:00:00Z');
|
|
});
|
|
|
|
test('validates required fields (skill, event) - exits 0 if missing skill', () => {
|
|
const result = runLog('{"event":"started","branch":"main"}');
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const f = findTimelineFile();
|
|
expect(f).toBeNull();
|
|
});
|
|
|
|
test('validates required fields (skill, event) - exits 0 if missing event', () => {
|
|
const result = runLog('{"skill":"review","branch":"main"}');
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const f = findTimelineFile();
|
|
expect(f).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('gstack-timeline-read', () => {
|
|
test('returns empty output for missing file (exit 0)', () => {
|
|
const output = runRead();
|
|
expect(output).toBe('');
|
|
});
|
|
|
|
test('filters by --branch', () => {
|
|
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'feature-a', outcome: 'approved', ts: '2026-03-28T10:00:00Z' }));
|
|
runLog(JSON.stringify({ skill: 'ship', event: 'completed', branch: 'feature-b', outcome: 'merged', ts: '2026-03-28T11:00:00Z' }));
|
|
|
|
const output = runRead('--branch feature-a');
|
|
expect(output).toContain('review');
|
|
expect(output).not.toContain('feature-b');
|
|
});
|
|
|
|
test('filters branch names containing single quotes', () => {
|
|
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: "feature/o'hare", outcome: 'approved', ts: '2026-03-28T10:00:00Z' }));
|
|
runLog(JSON.stringify({ skill: 'ship', event: 'completed', branch: 'feature-other', outcome: 'merged', ts: '2026-03-28T11:00:00Z' }));
|
|
|
|
const output = runReadArgs(['--branch', "feature/o'hare"]);
|
|
|
|
expect(output).toContain('review');
|
|
expect(output).toContain("feature/o'hare");
|
|
expect(output).not.toContain('feature-other');
|
|
});
|
|
|
|
test('limits output with --limit', () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', outcome: 'approved', ts: `2026-03-2${i}T10:00:00Z` }));
|
|
}
|
|
|
|
const unlimited = runRead('--limit 20');
|
|
const limited = runRead('--limit 2');
|
|
|
|
// Count event lines (lines starting with "- ")
|
|
const unlimitedEvents = unlimited.split('\n').filter(l => l.startsWith('- ')).length;
|
|
const limitedEvents = limited.split('\n').filter(l => l.startsWith('- ')).length;
|
|
|
|
expect(unlimitedEvents).toBe(5);
|
|
expect(limitedEvents).toBe(2);
|
|
});
|
|
|
|
test('no --limit still shows the 20 most recent events', () => {
|
|
for (let i = 0; i < 25; i++) {
|
|
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: `2026-03-01T10:${String(i).padStart(2, '0')}:00Z` }));
|
|
}
|
|
const result = runReadStatus([]);
|
|
expect(result.status).toBe(0);
|
|
const events = result.stdout.split('\n').filter(l => l.startsWith('- '));
|
|
expect(events.length).toBe(20);
|
|
expect(events[0]).toContain('10:05:00Z');
|
|
});
|
|
|
|
// #1723: a malformed --limit used to be parsed leniently (1abc -> 1) or
|
|
// silently replaced by the default; a bare --limit crashed on unbound $2.
|
|
const badLimits: Array<[string, string[], string]> = [
|
|
['missing value', ['--limit'], 'got no value'],
|
|
['zero', ['--limit', '0'], "got '0'"],
|
|
['negative', ['--limit', '-5'], "got '-5'"],
|
|
['non-numeric', ['--limit', 'abc'], "got 'abc'"],
|
|
['trailing garbage', ['--limit', '1abc'], "got '1abc'"],
|
|
];
|
|
for (const history of ['empty', 'populated'] as const) {
|
|
for (const [label, args, got] of badLimits) {
|
|
test(`--limit ${label} exits 2 with an example on ${history} history`, () => {
|
|
if (history === 'populated') {
|
|
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: '2026-03-28T10:00:00Z' }));
|
|
}
|
|
const result = runReadStatus(args);
|
|
expect(result.status).toBe(2);
|
|
expect(result.stdout).toBe('');
|
|
expect(result.stderr).toContain('--limit must be a positive integer');
|
|
expect(result.stderr).toContain(got);
|
|
expect(result.stderr).toContain('example: --limit 20');
|
|
});
|
|
}
|
|
}
|
|
});
|