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
13 changes: 13 additions & 0 deletions .changeset/post-merge-todo-entries-556.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@gemstack/framework': minor
---

Post-merge now queues the quality follow-ups instead of running them (#556), which is what the #326 doc says and what composes with the backlog loop (#323/#538). On `setReadyForMerge()`, a `--post-merge` run used to fire maintainability, readability and security-audit as three child `framework prompt` runs back to back, on the spot. It now fires one short turn that appends "Apply preset X on the changes introduced by <session>" entries to the session's TODO file, and lets the loop pick them up. Cheaper by a lot: a few TODO lines rather than three full preset passes serialized on the same git index.

The readability entry is gated on Technical control, per the doc. That setting already existed as a preference, a `--technical` flag and a Global options toggle; it just never reached the prompts, so `TfContext` gains `settings.technical_control` and `session_name`. The session name is carried on run state: the agent sets it before its first change and the post-merge prompt reads it afterwards.

`--eco-auto-maintenance` does something again. #326 moved the maintenance section out of the system prompt, which left the flag inert (#555); the post-merge prompt is exactly that section, so the flag now skips it.

The post-merge prompt is flattened rather than verbatim from the doc, and this is the one place the prompts depart from it. The doc nests `${{ tf.session_name }}` inside the outer `${{ ... }}` and puts backticks inside a backtick template literal; the fragment regex is non-greedy, so the outer fragment closes on the inner `}}` and what is left is not valid JS. It throws `TemplateFragmentError` today, with or without the new context fields. Same branch, same output, one fragment, and a test now rejects any nested fragment so the prompt cannot regress into a shape that will not render.

`POST_MERGE_PASSES` and `runPostMergeSuite` are replaced by `runPostMerge` and `renderPostMergePrompt`. The three preset modules are untouched: the dashboard buttons still use them.
9 changes: 9 additions & 0 deletions packages/framework/prompts/post_merge_prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
TODO_FILE: `TODO_<SESSION_NAME>.agent.md`

## Maintenance

If the changes introduced by ${{ tf.session_name }} aren't trivial and have refactor potential, add the following to <TODO_FILE>
- "Apply preset `maintainability` on the changes introduced by ${{ tf.session_name }}"
${{ tf.settings.technical_control ? '- "Apply preset `readability` on the changes introduced by ' + tf.session_name + '"\n' : '' }}
If the changes introduced by ${{ tf.session_name }} can potentially lead to security issues, add the following to <TODO_FILE>
- "Apply preset `security_audit` on the changes introduced by ${{ tf.session_name }}"
60 changes: 43 additions & 17 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ import {
runCli,
runLogEntry,
runLogKind,
runPostMergeSuite,
POST_MERGE_PASSES,
runPostMerge,
unguardedNotices,
withBrowser,
BROWSER_MCP_SERVERS,
Expand Down Expand Up @@ -83,31 +82,58 @@ test('withBrowser folds chrome-devtools-mcp into driver options only when enable
test('promptRunArgs runs a headless prompt and carries NO --post-merge (recursion guard, #326)', () => {
const args = promptRunArgs('audit this', '/work/app', '/bin/framework', 3)
assert.deepEqual(args, ['/bin/framework', 'prompt', 'audit this', '--no-dashboard', '--cwd', '/work/app', '--max-cost', '3'])
// The guard: a quality pass must not trigger its own post-merge suite.
// The guard: a queued pass must not trigger its own post-merge prompt.
assert.equal(args.includes('--post-merge'), false)
// maxCost is optional.
assert.equal(promptRunArgs('x', '/w', '/bin/f').includes('--max-cost'), false)
})

test('runPostMergeSuite runs maintainability -> readability -> security-audit in order, best-effort (#326)', async () => {
const { io, out } = capture()
test('runPostMerge queues the follow-ups in ONE run instead of running the presets (#326/#556)', async () => {
const { io } = capture()
const seen: string[] = []
// Middle pass "fails" (resolves false): the suite must continue past it.
const run = (prompt: string) => {
seen.push(prompt)
return Promise.resolve(!/humans to read/.test(prompt)) // the readability pass "fails"
return Promise.resolve(true)
}
await runPostMerge('/work/app', '/bin/framework', io, { session_name: 'add-oauth' }, undefined, run)
// One child run, not three: it asks for TODO entries rather than doing the passes.
assert.equal(seen.length, 1)
const prompt = seen[0]!
assert.match(prompt, /add the following to <TODO_FILE>/)
assert.match(prompt, /Apply preset `maintainability` on the changes introduced by add-oauth/)
assert.match(prompt, /Apply preset `security_audit` on the changes introduced by add-oauth/)
// The preset prompts themselves are not what gets sent any more.
assert.doesNotMatch(prompt, /easy as possible for humans to read/)
assert.ok(!prompt.includes('${{'), 'fully rendered')
})

test('runPostMerge gates the readability entry on technical_control (#326)', async () => {
const { io } = capture()
const render = async (technical_control: boolean) => {
const seen: string[] = []
await runPostMerge(
'/work/app',
'/bin/framework',
io,
{ session_name: 'add-oauth', settings: { technical_control } },
undefined,
p => {
seen.push(p)
return Promise.resolve(true)
},
)
return seen[0]!
}
await runPostMergeSuite('/work/app', '/bin/framework', io, undefined, run)
assert.equal(seen.length, 3)
assert.match(seen[0]!, /maintainable/)
assert.match(seen[1]!, /easy as possible for humans to read/)
assert.match(seen[2]!, /^Security audit/)
assert.deepEqual(
POST_MERGE_PASSES.map(p => p.label),
['maintainability', 'readability', 'security-audit'],
assert.match(await render(true), /Apply preset `readability` on the changes introduced by add-oauth/)
assert.doesNotMatch(await render(false), /readability/)
})

test('runPostMerge is best-effort: a failed queueing run is reported, never thrown (#326)', async () => {
const { io, out } = capture()
await runPostMerge('/work/app', '/bin/framework', io, { session_name: 'add-oauth' }, undefined, () =>
Promise.resolve(false),
)
// The failed middle pass is reported, and the last pass still ran.
assert.ok(out.some(l => /readability did not complete/.test(l)))
assert.ok(out.some(l => /post-merge queueing did not complete/.test(l)))
})

test('parseArgs reads the maintain subcommand + its bounds (#298)', () => {
Expand Down
74 changes: 44 additions & 30 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ import {
type RepoReview,
} from './maintenance.js'
import { renderMaintainabilityPrompt } from './maintainability-preset.js'
import { renderReadabilityPrompt } from './readability-preset.js'
import { renderSecurityAuditPrompt } from './security-audit-preset.js'
import { renderPostMergePrompt, type PostMergeContext } from './post-merge-prompt.js'
import { runPrompt } from './prompt-run.js'
import { renderResearchPrompt } from './research-preset.js'

Expand Down Expand Up @@ -153,8 +152,9 @@ Options:
"Context: <dirs>" line to the system prompt; the agent can
still reach every repo, this just narrows where it looks.
--post-merge When the run signals setReadyForMerge(), fire the post-merge
quality suite: maintainability, readability, and security-audit
prompts, one after another (#326).
prompt: queue the maintainability and security-audit follow-ups
(plus readability under --technical) as TODO entries for the
backlog loop to pick up (#326).
--browser Give the agent a real browser during the run via
chrome-devtools-mcp: navigate pages, read console + network,
inspect the DOM, and screenshot. Off by default (#452).
Expand Down Expand Up @@ -227,7 +227,7 @@ export interface CliOptions {
eco: Required<EcoOptions>
/** `--context <dir>` (repeatable): in-context directories added as one `Context:` line (#439). */
context: string[]
/** `--post-merge`: fire the #326 post-merge quality suite (maintainability/readability/security-audit) when the run signals setReadyForMerge(). */
/** `--post-merge`: fire the #326 post-merge prompt when the run signals setReadyForMerge(), queueing the quality follow-ups as TODO entries. */
postMerge: boolean
/** `--browser`: give the agent a real browser via chrome-devtools-mcp (navigate, console, network, DOM, screenshot) during the run (#452). */
browser: boolean
Expand Down Expand Up @@ -914,8 +914,12 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
// aborted, since a budget stop trips an internal signal the CLI never sees.
let stoppedCleanly = false
// The agent signalled setReadyForMerge() this run (#326): with --post-merge on, fire the
// quality suite once the run settles (not mid-run — it would race the agent's own git work).
// post-merge prompt once the run settles (not mid-run — it would race the agent's own git work).
let sawReadyForMerge = false
// The session the agent named via setSessionName() (#326). Carried on run state because the
// post-merge prompt names it on every line: it is set before the first change and read here,
// after the run.
let sessionName: string | undefined
// Record the finished run in the project log `.the-framework/LOGS.md` (#379). The
// kind + title are known up front; the session id/link arrive mid-run, and the
// `end` event (fired once by both run paths) closes the entry. Best-effort: the
Expand All @@ -931,6 +935,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
if (event.sessionLink) logSessionLink = event.sessionLink
}
if (event.kind === 'ready-for-merge') sawReadyForMerge = true
if (event.kind === 'session-name') sessionName = event.name
if (event.kind === 'end') {
if (event.stopped) stoppedCleanly = true
const entry = runLogEntry({
Expand All @@ -948,13 +953,29 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
publisher?.publish(event)
}

// Fire the #326 post-merge quality suite once a --post-merge run has settled and the agent
// Fire the #326 post-merge prompt once a --post-merge run has settled and the agent
// signalled setReadyForMerge(). Skipped for a fake/offline run and when the run was stopped.
const maybeFirePostMerge = async (): Promise<void> => {
if (!opts.postMerge || !sawReadyForMerge || stoppedCleanly || fake) return
// The post-merge prompt is exactly the maintenance section, so --eco-auto-maintenance
// (#314) leaves nothing to queue. This is the flag's target now that #326 moved that
// section out of the system prompt, where it had gone inert (#555).
if (opts.eco.autoMaintenance) return
// Every line of the prompt names the session, so there is nothing to queue without one.
// An agent that made changes has one; this is the agent that ignored the instruction.
if (!sessionName) {
io.out(' ! post-merge skipped: the run never called setSessionName().')
return
}
const binPath = process.argv[1]
if (!binPath) return
await runPostMergeSuite(cwd, binPath, io, opts.maxCost)
await runPostMerge(
cwd,
binPath,
io,
{ session_name: sessionName, settings: { technical_control: opts.technical } },
opts.maxCost,
)
}

// A mode/kind given with no preset in effect has nothing to act on: note it.
Expand Down Expand Up @@ -1358,7 +1379,7 @@ function spawnMaintenanceRun(review: RepoReview, binPath: string, maxCost?: numb
* Run one direct prompt by spawning `framework prompt "<prompt>" --cwd <dir> --no-dashboard`,
* reusing the whole run path (preflight, driver, budget cap, LOGS.md). The child inherits
* stdio so its run streams to the terminal. Note it carries no `--post-merge`, so a quality
* pass never triggers its own post-merge suite (the recursion guard). Resolves true on a
* pass never triggers its own post-merge prompt (the recursion guard). Resolves true on a
* clean exit (0). Never re-execs a test entry (fork-bomb guard).
*/
/**
Expand All @@ -1384,37 +1405,30 @@ function spawnPromptRun(prompt: string, cwd: string, binPath: string, maxCost?:
})
}

/** How the suite spawns one pass; injectable so tests observe order without spawning. */
/** How the post-merge prompt is spawned; injectable so tests observe it without spawning. */
export type PromptRunner = (prompt: string, cwd: string, binPath: string, maxCost?: number) => Promise<boolean>

/** The post-merge quality passes (#326), in the order they run. */
export const POST_MERGE_PASSES: readonly { label: string; render: () => string }[] = [
{ label: 'maintainability', render: () => renderMaintainabilityPrompt() },
{ label: 'readability', render: () => renderReadabilityPrompt() },
{ label: 'security-audit', render: () => renderSecurityAuditPrompt() },
]

/**
* Fire the #326 post-merge quality suite after a run signalled setReadyForMerge(): the
* maintainability, readability, and security-audit prompts, each a `framework prompt` child
* on the same workspace. Runs them **one after another** (not in parallel): the three passes
* edit and commit the same git tree, so concurrent writers would race on the index lock —
* sequential is the safe MVP (worktree-isolated parallelism is a follow-up). Best-effort:
* a failed pass is logged and the suite continues.
* Fire the #326 post-merge prompt after a run signalled setReadyForMerge(): one
* `framework prompt` child on the same workspace that appends the quality follow-ups to the
* session's TODO file, for the backlog loop (#323/#538) to pick up.
*
* It used to run maintainability, readability and security-audit inline instead, as three
* child runs back to back (#556). Queueing is both what the doc says and the cheaper thing:
* one short turn that writes a few TODO lines, rather than three full preset passes serialized
* on the same git index. Best-effort, like the suite was: a failure is logged, never thrown.
*/
export async function runPostMergeSuite(
export async function runPostMerge(
cwd: string,
binPath: string,
io: CliIO,
tf: PostMergeContext,
maxCost?: number,
run: PromptRunner = spawnPromptRun,
): Promise<void> {
io.out(`\n◆ post-merge quality suite: ${POST_MERGE_PASSES.map(p => p.label).join(' → ')}`)
for (const pass of POST_MERGE_PASSES) {
io.out(`\n◆ post-merge: ${pass.label}`)
const ok = await run(pass.render(), cwd, binPath, maxCost)
if (!ok) io.out(` ! post-merge ${pass.label} did not complete cleanly; continuing.`)
}
io.out(`\n◆ post-merge: queueing quality follow-ups for ${tf.session_name}`)
const ok = await run(renderPostMergePrompt(tf), cwd, binPath, maxCost)
if (!ok) io.out(` ! post-merge queueing did not complete cleanly.`)
}

async function runRelayServer(opts: CliOptions, io: CliIO): Promise<number> {
Expand Down
2 changes: 1 addition & 1 deletion packages/framework/src/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ test('startOptionFlags maps only enabled Global options to CLI flags (#314)', ()
'--context',
'/work/ui',
])
// Post-merge quality suite (#326): maps to --post-merge.
// Post-merge prompt (#326): maps to --post-merge.
assert.deepEqual(startOptionFlags({ postMerge: true }), ['--post-merge'])
// Browser via chrome-devtools-mcp (#452): maps to --browser.
assert.deepEqual(startOptionFlags({ browser: true }), ['--browser'])
Expand Down
2 changes: 1 addition & 1 deletion packages/framework/src/dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export interface StartRunOptions {
eco?: EcoOptions
/** In-context directories (#439): each becomes a `--context <dir>` flag on the spawned run. */
context?: string[]
/** Post-merge quality suite (#326): on setReadyForMerge(), fire maintainability/readability/security-audit; maps to `--post-merge`. */
/** Post-merge prompt (#326): on setReadyForMerge(), queue the quality follow-ups as TODO entries; maps to `--post-merge`. */
postMerge?: boolean
/** Give the agent a real browser via chrome-devtools-mcp during the run (#452); maps to `--browser`. */
browser?: boolean
Expand Down
3 changes: 2 additions & 1 deletion packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ export {
type VersionFetcher,
type UpdateStatus,
} from './update-check.js'
export { runCli, parseArgs, buildDeployTarget, runPostMergeSuite, promptRunArgs, POST_MERGE_PASSES, type PromptRunner, type CliIO, type CliOptions } from './cli.js'
export { runCli, parseArgs, buildDeployTarget, runPostMerge, promptRunArgs, type PromptRunner, type CliIO, type CliOptions } from './cli.js'
export { renderPostMergePrompt, POST_MERGE_PROMPT_TEMPLATE, type PostMergeContext } from './post-merge-prompt.js'
export {
loadFrameworkConfig,
parseFrameworkConfig,
Expand Down
58 changes: 58 additions & 0 deletions packages/framework/src/post-merge-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { renderPostMergePrompt, POST_MERGE_PROMPT_TEMPLATE } from './post-merge-prompt.js'
import { TemplateFragmentError } from './prompt-template.js'

test('POST_MERGE_PROMPT_TEMPLATE carries the #326 post-merge block', () => {
assert.ok(POST_MERGE_PROMPT_TEMPLATE.includes('TODO_FILE: `TODO_<SESSION_NAME>.agent.md`'))
assert.ok(POST_MERGE_PROMPT_TEMPLATE.includes('## Maintenance'))
for (const preset of ['maintainability', 'readability', 'security_audit']) {
assert.ok(POST_MERGE_PROMPT_TEMPLATE.includes(`Apply preset \`${preset}\``), `missing ${preset}`)
}
})

test('the template never nests a fragment inside another (#556)', () => {
// The one place this prompt departs from the doc, and the reason why. `renderTemplate`'s
// fragment regex is non-greedy, so an inner `${{ ... }}` closes the outer fragment early and
// the leftover is not valid JS. The doc's version throws "Unexpected identifier". Pin it:
// every fragment must be flat, or the whole prompt stops rendering at run time.
for (const fragment of POST_MERGE_PROMPT_TEMPLATE.match(/\$\{\{[\s\S]*?\}\}/g) ?? []) {
assert.ok(!fragment.slice(3).includes('${{'), `nested fragment: ${fragment}`)
}
})

test('renderPostMergePrompt names the session on every entry', () => {
const prompt = renderPostMergePrompt({ session_name: 'add-oauth' })
assert.ok(!prompt.includes('${{'), 'fully rendered')
assert.match(prompt, /Apply preset `maintainability` on the changes introduced by add-oauth/)
assert.match(prompt, /Apply preset `security_audit` on the changes introduced by add-oauth/)
})

test('renderPostMergePrompt adds the readability entry only under technical control (#326)', () => {
const on = renderPostMergePrompt({ session_name: 'add-oauth', settings: { technical_control: true } })
const off = renderPostMergePrompt({ session_name: 'add-oauth', settings: { technical_control: false } })
assert.match(on, /Apply preset `readability` on the changes introduced by add-oauth/)
assert.doesNotMatch(off, /readability/)
// The other two entries are unconditional either way.
for (const prompt of [on, off]) {
assert.match(prompt, /`maintainability`/)
assert.match(prompt, /`security_audit`/)
}
})

test('renderPostMergePrompt defaults absent settings to off rather than throwing (#556)', () => {
// The template reads `tf.settings.technical_control`, so an absent `settings` would throw
// on the property access rather than read as off.
const prompt = renderPostMergePrompt({ session_name: 'add-oauth' })
assert.doesNotMatch(prompt, /readability/)
assert.equal(prompt, renderPostMergePrompt({ session_name: 'add-oauth', settings: {} }))
})

test('renderPostMergePrompt throws a useful error when the session name is missing (#556)', () => {
// Rather than queueing "changes introduced by undefined". The CLI checks first; this is the
// backstop for any other caller.
assert.throws(
() => renderPostMergePrompt({ session_name: undefined as unknown as string }),
(err: unknown) => err instanceof TemplateFragmentError && /session_name/.test(err.message),
)
})
Loading
Loading