Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions skills/chatgpt-review/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ The generated upload is stored in a permission-restricted temporary directory an
```text
--question-file <path> Add focused project and acceptance context
--output-file <path> Absolute canonical plan path (plan-author only)
--session <handle> Continue the exact saved ChatGPT conversation
--session <handle> Continue the exact saved ChatGPT conversation (same mode+target)
--seed-from-session <h> Start a NEW mode's session, but reopen an existing conversation
from a DIFFERENT mode/target's session <h> instead of a fresh
chat. Mutually exclusive with --session.
--timeout <seconds> Completion timeout; default 1800 (30 minutes)
--format json|text Output format; default json
--cdp-url <url> Chrome CDP endpoint
Expand Down Expand Up @@ -237,7 +240,11 @@ Only `completed` means a complete response. `timed_out` may include partial resp

## Browser and permission behavior

- Starting without `--session` creates a fresh ChatGPT conversation.
- Starting without `--session` or `--seed-from-session` creates a fresh ChatGPT conversation.
- `--seed-from-session <handle>` creates this mode's own new session (its own pass counter)
but reopens `<handle>`'s conversation instead of a fresh one — for threading one unit of
work (e.g. plan authoring, then that plan's PR review) through DIFFERENT CLI modes without
losing conversational context. Once that new session exists, resume it with `--session`.
- A matching open tab is reused for a session; otherwise the saved conversation URL is reopened.
- Completion requires a new non-empty assistant response, no active generation control, and stable text for seven seconds.
- A visible **Continue generating** control is handled automatically.
Expand Down
2 changes: 2 additions & 0 deletions skills/chatgpt-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Leave ChatGPT's predefined model and effort unchanged; the script must not open

For a PR fix review, retain the returned `session` handle and invoke the same PR with `--session <handle>`. The script reuses that conversation and permits at most three total passes. Ask it only after accepted findings have been fixed and pushed.

To keep one whole unit of work (e.g. plan authoring, then its PR's code review) in a single ChatGPT conversation across DIFFERENT modes, pass `--seed-from-session <handle>` — naming a session from any earlier mode/target — the FIRST time the new mode starts, instead of `--session`. This creates that mode's own new session (its own pass counter) but reopens the same browser tab/conversation rather than starting a fresh chat. `--session` and `--seed-from-session` are mutually exclusive; once the new mode's own session exists, resume it with `--session` like any other, never re-seed.

If a run ends after submission with an incomplete typed status, retry with its returned `session` handle. The script resumes an active or already-finished uncollected response instead of sending the prompt twice.

For `plan-author`, use the canonical issue URL and keep the absolute output path
Expand Down
21 changes: 19 additions & 2 deletions skills/chatgpt-review/scripts/chatgpt-review.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,19 @@ export async function run(argv, dependencies = {}) {
session = await store.load(options.session);
if (session.mode !== options.mode || session.targetIdentity !== prepared.targetIdentity) throw new CliError('Session does not match this mode and target');
} else {
session = await store.create({ mode: options.mode, targetIdentity: prepared.targetIdentity, canonicalUrl: prepared.target?.canonicalUrl });
// --seed-from-session threads an EXISTING ChatGPT conversation (from a different mode,
// e.g. this unit's own plan-author session) into a brand-new session record for this
// mode+target, so pageFor() reopens that conversation instead of a fresh chat. Copying
// lastResponseFingerprint too is required, not cosmetic: without it, review()'s
// hasUncollected check would see the seed conversation's last (unrelated) assistant
// message as an "uncollected response" and return it without ever sending this mode's
// prompt.
let seed = {};
if (options.seedFromSession) {
const seedSession = await store.load(options.seedFromSession);
seed = { conversationUrl: seedSession.conversationUrl, lastResponseFingerprint: seedSession.lastResponseFingerprint };
}
session = await store.create({ mode: options.mode, targetIdentity: prepared.targetIdentity, canonicalUrl: prepared.target?.canonicalUrl, ...seed });
}
passNumber = (session.passCount ?? 0) + 1;
if (options.mode === 'pr' && passNumber > 3) throw new CliError('PR review sessions permit at most three total passes');
Expand Down Expand Up @@ -72,8 +84,13 @@ export async function run(argv, dependencies = {}) {
previousSha: session.reportedReviewedSha,
uploadName: uploadPath ? path.basename(uploadPath) : null,
});
// A genuinely fresh conversation (neither --session nor --seed-from-session) passes
// null so pageFor() opens chatgpt.com from scratch. Both --session (resuming this exact
// mode+target, even if an earlier pass never got far enough to record a conversationUrl)
// and --seed-from-session (a brand-new session record pre-populated with a prior,
// different-mode conversation) must pass the real session through.
const review = await driver.review({
session: options.session ? session : null,
session: (options.session || options.seedFromSession) ? session : null,
prompt,
uploadPath,
timeoutMs: options.timeoutMs,
Expand Down
19 changes: 13 additions & 6 deletions skills/chatgpt-review/scripts/lib/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,25 @@ export const EXIT_CODES = Object.freeze({
});

const VALUE_FLAGS = new Set([
'--question-file', '--session', '--timeout', '--format', '--repo', '--base',
'--question-file', '--session', '--seed-from-session', '--timeout', '--format', '--repo', '--base',
'--cdp-url', '--diagnostics-dir', '--output-file',
]);
const BOOL_FLAGS = new Set(['--publish', '--no-publish', '--working-tree', '--include-untracked']);

export function usage() {
return `Usage:
chatgpt-review.mjs doctor [--cdp-url <url>] [--format json|text]
chatgpt-review.mjs pr <url> [--question-file <path>] [--session <handle>] [--no-publish] [--timeout 1800]
chatgpt-review.mjs issue <url> [--question-file <path>] [--session <handle>] [--publish] [--timeout 1800]
chatgpt-review.mjs plan <plan-file> [--question-file <path>] [--session <handle>] [--timeout 1800]
chatgpt-review.mjs plan-author <issue-url> --output-file <absolute-plan-path> --question-file <path> [--session <handle>] [--timeout 1800]
chatgpt-review.mjs local [--repo <path>] [--base <ref>] [--working-tree] [--include-untracked] [--question-file <path>] [--session <handle>] [--timeout 1800]`;
chatgpt-review.mjs pr <url> [--question-file <path>] [--session <handle>|--seed-from-session <handle>] [--no-publish] [--timeout 1800]
chatgpt-review.mjs issue <url> [--question-file <path>] [--session <handle>|--seed-from-session <handle>] [--publish] [--timeout 1800]
chatgpt-review.mjs plan <plan-file> [--question-file <path>] [--session <handle>|--seed-from-session <handle>] [--timeout 1800]
chatgpt-review.mjs plan-author <issue-url> --output-file <absolute-plan-path> --question-file <path> [--session <handle>|--seed-from-session <handle>] [--timeout 1800]
chatgpt-review.mjs local [--repo <path>] [--base <ref>] [--working-tree] [--include-untracked] [--question-file <path>] [--session <handle>|--seed-from-session <handle>] [--timeout 1800]

--session <handle>: resume THIS exact mode+target's own prior session (same conversation, same pass counter).
--seed-from-session <handle>: start a NEW session for this mode+target, but continue an EXISTING
ChatGPT conversation from a prior session of a DIFFERENT mode (e.g. thread a plan-author
conversation into this PR's own pr-mode review) instead of opening a fresh chat. The new
session gets its own pass counter; only one of --session/--seed-from-session may be given.`;
}

export function parseArgs(argv, env = process.env) {
Expand All @@ -50,6 +56,7 @@ export function parseArgs(argv, env = process.env) {
}
}
if (options.publish && options.noPublish) throw new CliError('Use only one of --publish and --no-publish');
if (options.session && options.seedFromSession) throw new CliError('Use only one of --session and --seed-from-session');
if (options.format && !['json', 'text'].includes(options.format)) throw new CliError('--format must be json or text');
const timeout = Number(options.timeout ?? 1800);
if (!Number.isFinite(timeout) || timeout <= 0) throw new CliError('--timeout must be a positive number of seconds');
Expand Down
45 changes: 44 additions & 1 deletion skills/chatgpt-review/tests/core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,16 @@ test('CLI parses documented modes, defaults, environment, and publication rules'
});

test('CLI rejects invalid combinations', () => {
for (const args of [[], ['wat'], ['pr'], ['doctor', 'x'], ['local', 'x'], ['issue', 'x', '--publish', '--no-publish'], ['plan', 'x', '--timeout', 'nope'], ['doctor', '--wat'], ['plan-author', 'https://github.com/o/r/issues/1'], ['plan-author', 'https://github.com/o/r/issues/1', '--output-file', 'relative.md', '--question-file', '/tmp/q'], ['plan-author', 'https://github.com/o/r/issues/1', '--output-file', '/tmp/p', '--question-file', '/tmp/q', '--publish']]) {
for (const args of [[], ['wat'], ['pr'], ['doctor', 'x'], ['local', 'x'], ['issue', 'x', '--publish', '--no-publish'], ['plan', 'x', '--timeout', 'nope'], ['doctor', '--wat'], ['plan-author', 'https://github.com/o/r/issues/1'], ['plan-author', 'https://github.com/o/r/issues/1', '--output-file', 'relative.md', '--question-file', '/tmp/q'], ['plan-author', 'https://github.com/o/r/issues/1', '--output-file', '/tmp/p', '--question-file', '/tmp/q', '--publish'], ['pr', 'https://github.com/o/r/pull/1', '--session', 'a', '--seed-from-session', 'b']]) {
assert.throws(() => parseArgs(args), CliError);
}
});

test('CLI accepts --seed-from-session as a documented value flag', () => {
const parsed = parseArgs(['pr', 'https://github.com/o/r/pull/7', '--seed-from-session', '00000000-0000-4000-8000-000000000009'], {});
assert.equal(parsed.seedFromSession, '00000000-0000-4000-8000-000000000009');
});

test('GitHub targets are canonical and kind checked', () => {
assert.deepEqual(normalizeGithubTarget('https://github.com/Owner/repo/pull/007', 'pr'), {
kind: 'pr', owner: 'Owner', repo: 'repo', number: 7,
Expand Down Expand Up @@ -230,6 +235,44 @@ test('run retains a session and enforces three PR passes', async () => {
assert.match(result.error, /at most three/);
});

test('--seed-from-session threads a prior, different-mode conversation into a brand-new session', async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chatgpt-review-seed-'));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const store = new SessionStore(dir);
// A prior plan-author session for this SAME unit, already carrying a real conversation
// and the fingerprint of ChatGPT's last message in it (as a completed plan-author pass
// would leave behind).
const seed = await store.create({
mode: 'plan-author', targetIdentity: 'plan-author:o/r#8:/tmp/plan.md',
conversationUrl: 'https://chatgpt.com/c/seed-conversation', lastResponseFingerprint: 'seed-fingerprint',
});
const seenSessions = [];
const driver = {
async review({ session }) {
seenSessions.push(session ? { conversationUrl: session.conversationUrl, lastResponseFingerprint: session.lastResponseFingerprint } : null);
return { responseText: `VERDICT: SHIP\nReviewed head ${'c'.repeat(40)}`, conversationUrl: session?.conversationUrl ?? 'https://chatgpt.com/c/new' };
},
};
const result = await run(['pr', 'https://github.com/o/r/pull/1', '--seed-from-session', seed.handle, '--no-publish'], { store, driver });
assert.equal(result.status, 'completed');
assert.equal(result.pass_number, 1);
assert.notEqual(result.session, seed.handle);
assert.equal(result.conversation_url, 'https://chatgpt.com/c/seed-conversation');
// review() must have been called with a session whose conversationUrl/lastResponseFingerprint
// were already the seed's — proving the new pr-mode session reopened that exact conversation
// (rather than opening a fresh chat) and was seeded with the fingerprint needed so the
// "uncollected response" recovery path does not mistake the seed's last message for this
// mode's own answer.
assert.deepEqual(seenSessions, [{ conversationUrl: 'https://chatgpt.com/c/seed-conversation', lastResponseFingerprint: 'seed-fingerprint' }]);
const newSession = await store.load(result.session);
assert.equal(newSession.mode, 'pr');
assert.equal(newSession.passCount, 1);
// The seed session itself is untouched — seeding copies its conversation, it does not
// consume or mutate the original record.
const reloadedSeed = await store.load(seed.handle);
assert.equal(reloadedSeed.passCount, 0);
});

test('plan mode uploads a pass-numbered copy (never the literal session-identity path) and never authorizes publication', async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chatgpt-review-plan-'));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
Expand Down
12 changes: 10 additions & 2 deletions skills/ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,12 +471,20 @@ in the main tree, per pass:
Workflow {
scriptPath: "skills/ship/references/code-review-pass.workflow.mjs",
args: { prUrl, questionFile, session: <handle|null>, pass: <n>,
integrationBranch: "<this unit's branch>", issueRef: "<issue>" }
integrationBranch: "<this unit's branch>", issueRef: "<issue>",
seedFromSession: <this unit's plan-loop session handle, pass-1 only> }
}
```

(`integrationBranch` is the script's existing argument name — pass this unit's own
branch into it; no script edit is required.)
branch into it; no script edit is required.) **On pass 1 only** (`session: null`), pass
`seedFromSession` as the session handle returned by this unit's plan loop (step 2.2) —
`chatgpt-review.mjs pr`'s own `--seed-from-session` flag threads that existing ChatGPT
conversation into the brand-new pr-mode session instead of opening a fresh chat, so
planning and code review land in the one conversation `references/review-loops.md`'s
"one unit, one ChatGPT session" rule requires. On pass 2/3 (`session` is now the
code-review loop's own returned handle from pass 1), omit `seedFromSession` — `session`
alone resumes that conversation.

The pass reviews (publishing a PR comment), verifies every finding with parallel
read-only agents, and — when findings are accepted — applies the fixes with tests,
Expand Down
Loading