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
5 changes: 5 additions & 0 deletions .changeset/business-knowledge-537.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gemstack/framework": minor
---

Collect business knowledge in the repo (#537). Every run now puts `.the-framework/README.md`, `.the-framework/DECISIONS.md` and `.the-framework/KNOWLEDGE-BASE.md` on the `Context:` line, so the agent reads whatever the project already knows about itself, and the post-merge prompt gained a `## Business knowledge` section asking it to fold back what the session taught that the code cannot show. The docs go with the built-in system prompt: `--vanilla` still injects nothing but the user's own dirs. `--eco-auto-maintenance` now drops the post-merge prompt's `## Maintenance` section instead of skipping the whole run, which would have taken business knowledge with it.
10 changes: 10 additions & 0 deletions packages/framework/prompts/post_merge_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,13 @@ If the changes introduced by ${{ tf.session_name }} aren't trivial and have refa
${{ 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 }}"


## Business knowledge

Consider whether the changes introduced by ${{ tf.session_name }} taught you something that belongs in these documents, and update them if so (create one if it doesn't exist yet):
- `.the-framework/README.md` (whole repo overview)
- `.the-framework/DECISIONS.md` (decisions taken, and why)
- `.the-framework/KNOWLEDGE-BASE.md` (business knowledge about the project)

Only write what a future agent would need and cannot get from the code itself.
5 changes: 3 additions & 2 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ test('runPostMerge queues the follow-ups in ONE run instead of running the prese
seen.push(prompt)
return Promise.resolve(true)
}
await runPostMerge('/work/app', '/bin/framework', io, { session_name: 'add-oauth' }, undefined, run)
await runPostMerge('/work/app', '/bin/framework', io, { session_name: 'add-oauth' }, undefined, 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]!
Expand All @@ -117,6 +117,7 @@ test('runPostMerge gates the readability entry on technical_control (#326)', asy
io,
{ session_name: 'add-oauth', settings: { technical_control } },
undefined,
undefined,
p => {
seen.push(p)
return Promise.resolve(true)
Expand All @@ -130,7 +131,7 @@ test('runPostMerge gates the readability entry on technical_control (#326)', asy

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, () =>
await runPostMerge('/work/app', '/bin/framework', io, { session_name: 'add-oauth' }, undefined, undefined, () =>
Promise.resolve(false),
)
assert.ok(out.some(l => /post-merge queueing did not complete/.test(l)))
Expand Down
11 changes: 6 additions & 5 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,10 +957,9 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
// 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
// --eco-auto-maintenance (#314) no longer skips the whole run: since #537 this prompt
// also carries `## Business knowledge`, which the flag does not name. It drops just
// `## Maintenance` inside renderPostMergePrompt() instead.
// 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) {
Expand All @@ -975,6 +974,7 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
io,
{ session_name: sessionName, settings: { technical_control: opts.technical } },
opts.maxCost,
opts.eco,
)
}

Expand Down Expand Up @@ -1424,10 +1424,11 @@ export async function runPostMerge(
io: CliIO,
tf: PostMergeContext,
maxCost?: number,
eco?: EcoOptions,
run: PromptRunner = spawnPromptRun,
): Promise<void> {
io.out(`\n◆ post-merge: queueing quality follow-ups for ${tf.session_name}`)
const ok = await run(renderPostMergePrompt(tf), cwd, binPath, maxCost)
const ok = await run(renderPostMergePrompt(tf, eco), cwd, binPath, maxCost)
if (!ok) io.out(` ! post-merge queueing did not complete cleanly.`)
}

Expand Down
24 changes: 24 additions & 0 deletions packages/framework/src/post-merge-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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'
import { KNOWLEDGE_DOCS } from './system-prompt.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`'))
Expand All @@ -11,6 +12,29 @@ test('POST_MERGE_PROMPT_TEMPLATE carries the #326 post-merge block', () => {
}
})

test('the business-knowledge section names every knowledge doc (#537)', () => {
// The two halves of #537 are authored apart: `## Context` lists the docs from the
// KNOWLEDGE_DOCS const, this prompt names them as markdown. Pin them together, or the
// agent gets told to read one set of files and update another.
assert.ok(POST_MERGE_PROMPT_TEMPLATE.includes('## Business knowledge'))
for (const doc of KNOWLEDGE_DOCS) {
assert.ok(POST_MERGE_PROMPT_TEMPLATE.includes(doc), `missing ${doc}`)
}
})

test('eco.autoMaintenance drops the maintenance section and keeps the rest (#314/#537)', () => {
const prompt = renderPostMergePrompt({ session_name: 'add-oauth' }, { autoMaintenance: true })
assert.ok(!prompt.includes('## Maintenance'))
assert.ok(!prompt.includes('Apply preset'), 'the preset entries go with the section')
// The flag names maintenance only, so business knowledge must survive it.
assert.ok(prompt.includes('## Business knowledge'))
assert.ok(prompt.includes('add-oauth'))
assert.ok(!prompt.includes('${{'), 'the dropped section takes its fragments with it')
// Absent/off eco leaves the prompt whole.
assert.ok(renderPostMergePrompt({ session_name: 'x' }, {}).includes('## Maintenance'))
assert.ok(renderPostMergePrompt({ session_name: 'x' }).includes('## Maintenance'))
})

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
Expand Down
20 changes: 17 additions & 3 deletions packages/framework/src/post-merge-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { renderTemplate } from './prompt-template.js'
import { POST_MERGE_PROMPT } from './prompts.generated.js'
import type { TfContext } from './system-prompt.js'
import { dropSection, type EcoOptions, type TfContext } from './system-prompt.js'

/**
* The post-merge prompt (#326), in `prompts/post_merge_prompt.md` (#551).
Expand All @@ -16,9 +16,15 @@ import type { TfContext } from './system-prompt.js'
* inside a backtick template literal. {@link renderTemplate}'s fragment regex is
* non-greedy, so the outer fragment closes on the inner `}}` and the remainder is not
* valid JS. Same branch, same output, one fragment.
*
* Two sections: `## Maintenance` queues the quality presets, and `## Business knowledge`
* (#537) asks the agent to fold what it learned back into {@link KNOWLEDGE_DOCS}.
*/
export const POST_MERGE_PROMPT_TEMPLATE = POST_MERGE_PROMPT

/** The section `EcoOptions.autoMaintenance` drops (#314). */
const MAINTENANCE_HEADING = '## Maintenance'

/** What the post-merge prompt's fragments read. A subset of {@link TfContext}. */
export interface PostMergeContext {
/** The session the finished run named via setSessionName(). Every line of the prompt names it. */
Expand All @@ -31,7 +37,15 @@ export interface PostMergeContext {
* Render the post-merge prompt for a finished session. `settings` is defaulted rather than
* left absent: the template reads `tf.settings.technical_control`, so a missing `settings`
* throws a {@link TemplateFragmentError} instead of reading as off.
*
* `eco.autoMaintenance` (#314) drops `## Maintenance` here rather than skipping the whole
* run: since #537 the prompt also carries `## Business knowledge`, which the flag does not
* name and must not silently take with it. Dropped before rendering, so the dropped
* section's fragments never evaluate.
*/
export function renderPostMergePrompt(tf: PostMergeContext): string {
return renderTemplate(POST_MERGE_PROMPT_TEMPLATE, { tf: { ...tf, settings: tf.settings ?? {} } })
export function renderPostMergePrompt(tf: PostMergeContext, eco?: EcoOptions | undefined): string {
const template = eco?.autoMaintenance
? dropSection(POST_MERGE_PROMPT_TEMPLATE, MAINTENANCE_HEADING)
: POST_MERGE_PROMPT_TEMPLATE
return renderTemplate(template, { tf: { ...tf, settings: tf.settings ?? {} } })
}
35 changes: 28 additions & 7 deletions packages/framework/src/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
composeRunSystem,
KNOWLEDGE_DOCS,
renderSystemPrompt,
systemPromptBlock,
SYSTEM_PROMPT_TEMPLATE,
} from './system-prompt.js'
import { loadUserSystemPrompt, SYSTEM_PROMPT_FILE } from './system-prompt-file.js'

/** The `Context:` line the #537 knowledge docs stand up on their own, with no dirs picked. */
const KNOWLEDGE_CONTEXT = `Context: ${KNOWLEDGE_DOCS.join(', ')}`
import { AWAIT_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js'

test('loadUserSystemPrompt reads and trims SYSTEM.md', async () => {
Expand Down Expand Up @@ -80,13 +84,13 @@ test('renderSystemPrompt is not confused by a user prompt containing the heading
assert.equal(user, sneaky)
})

test('systemPromptBlock defaults to the built-in #326 prompt alone', () => {
assert.equal(systemPromptBlock(), renderSystemPrompt().system)
test('systemPromptBlock defaults to the knowledge-doc context line + the built-in #326 prompt', () => {
assert.equal(systemPromptBlock(), [KNOWLEDGE_CONTEXT, renderSystemPrompt().system].join('\n\n'))
})

test('systemPromptBlock appends the user prompt after the built-in one', () => {
const block = systemPromptBlock({ user: 'Ship small PRs.' })
assert.ok(block.startsWith('# System prompt'))
assert.ok(block.startsWith(`${KNOWLEDGE_CONTEXT}\n\n# System prompt`))
assert.ok(block.endsWith('Ship small PRs.'))
assert.match(block, /AWAIT[\s\S]*Ship small PRs\./) // built-in first, then user
})
Expand All @@ -103,16 +107,32 @@ test('systemPromptBlock prepends a Context line for the selected directories (#4
assert.equal(systemPromptBlock({ antiLazyPill: false, user: 'x', context: [' '] }), 'x') // blank entries dropped
})

test('systemPromptBlock puts the knowledge docs in context, after the user dirs (#537)', () => {
const block = systemPromptBlock({ user: 'Only mine.', context: ['/work/api'] })
assert.ok(block.startsWith(`Context: /work/api, ${KNOWLEDGE_DOCS.join(', ')}\n\n`))
// No dirs picked: the docs still stand up a Context line of their own.
assert.ok(systemPromptBlock({}).startsWith(`Context: ${KNOWLEDGE_DOCS.join(', ')}\n\n`))
})

test('systemPromptBlock adds no knowledge docs when antiLazyPill is false (#537/#547)', () => {
// `--vanilla` is "Disable system prompt": the docs are framework-authored context, so
// they go with the built-in prompt. Only the user's own dirs survive it.
assert.equal(systemPromptBlock({ antiLazyPill: false }), '')
assert.equal(systemPromptBlock({ antiLazyPill: false, context: ['/work/api'] }), 'Context: /work/api')
})

test('systemPromptBlock is the #326 prompt and the user prompt, in that order, and nothing else (#457)', () => {
// The bootstrap preamble was the last text here that was neither the #326 doc nor the
// user's own. Measured on four live runs: #326 alone already stops an empty-dir build
// for a plan, so the override earned nothing and outranked the doc.
// The knowledge docs (#537) join the Context line, which is paths, not prompt text.
const block = systemPromptBlock({ user: 'Ship small PRs.', context: ['/work/api'] })
assert.equal(block, ['Context: /work/api', renderSystemPrompt().system, 'Ship small PRs.'].join('\n\n'))
const context = `Context: ${['/work/api', ...KNOWLEDGE_DOCS].join(', ')}`
assert.equal(block, [context, renderSystemPrompt().system, 'Ship small PRs.'].join('\n\n'))
})

test('systemPromptBlock ignores a whitespace-only user prompt', () => {
assert.equal(systemPromptBlock({ user: ' ' }), renderSystemPrompt().system)
assert.equal(systemPromptBlock({ user: ' ' }), [KNOWLEDGE_CONTEXT, renderSystemPrompt().system].join('\n\n'))
assert.equal(systemPromptBlock({ antiLazyPill: false, user: ' \n ' }), '')
})

Expand Down Expand Up @@ -198,9 +218,10 @@ test('vanilla (antiLazyPill false) wins over eco: no built-in prompt at all (#31

test('composeRunSystem is exactly the #326 block + both emit protocols, and nothing else (#547)', () => {
// The one assembly path both runFramework and runPrompt go through. Exact equality is
// the point: no persona, skill, or memory framing may ever be appended again.
// the point: no persona, skill, or memory framing may ever be appended again. The #537
// knowledge docs are in front of that, on the #439 context line: paths, not prompt text.
const system = composeRunSystem()
assert.equal(system, [renderSystemPrompt().system, AWAIT_PROTOCOL, SIGNAL_PROTOCOL].join('\n\n'))
assert.equal(system, [KNOWLEDGE_CONTEXT, renderSystemPrompt().system, AWAIT_PROTOCOL, SIGNAL_PROTOCOL].join('\n\n'))
})

test('composeRunSystem appends nothing after the protocols, whatever the options (#547)', () => {
Expand Down
29 changes: 22 additions & 7 deletions packages/framework/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ export interface EcoOptions {
/** Drop `### Alternatives` (the variability-rating research section). */
autoResearch?: boolean | undefined
/**
* Drop the maintenance section. Nothing to drop *here*: #326 moved that section out of
* the system prompt and into the post-merge prompt, so this flag acts on that prompt
* instead (#556) and the CLI skips it wholesale, the post-merge prompt being exactly the
* maintenance section. Listed here because it is still an {@link EcoOptions} flag.
* Drop `## Maintenance`. Nothing to drop *here*: #326 moved that section out of the
* system prompt and into the post-merge prompt, so this flag acts on that prompt
* instead (#556) — see {@link ./post-merge-prompt.renderPostMergePrompt}. Listed here
* because it is still an {@link EcoOptions} flag.
*/
autoMaintenance?: boolean | undefined
}
Expand Down Expand Up @@ -88,6 +88,18 @@ export interface TfContext {
/** The neutral context used when a caller has none: empty prompt, no modes. */
const DEFAULT_TF: TfContext = { prompt: '', params: {} }

/**
* The project-knowledge documents (#537): what the repo knows about itself, as markdown
* that travels with the code. {@link systemPromptBlock} puts them in front of every run
* as in-context paths; the post-merge prompt asks the agent to update them, which is what
* keeps them current. Workspace-relative, because that is the agent's cwd.
*/
export const KNOWLEDGE_DOCS: readonly string[] = [
'.the-framework/README.md',
'.the-framework/DECISIONS.md',
'.the-framework/KNOWLEDGE-BASE.md',
]

/** The two halves of the rendered {@link SYSTEM_PROMPT_TEMPLATE}. */
export interface RenderedSystemPrompt {
/** The `# System prompt` half: frames the session's system channel. */
Expand All @@ -108,7 +120,7 @@ const USER_PROMPT_HEADING = '\n# User prompt\n'
* `### Scope` has to stop at the next `###` sibling rather than run on to the next `##`
* and swallow it.
*/
function dropSection(md: string, heading: string): string {
export function dropSection(md: string, heading: string): string {
const at = md.indexOf(`\n${heading}`)
if (at === -1) return md
const level = /^#+/.exec(heading)?.[0].length ?? 2
Expand Down Expand Up @@ -183,8 +195,11 @@ export function systemPromptBlock(opts: SystemPromptOptions = {}): string {
const parts: string[] = []
// The #439 context line goes first, so it frames whatever prompt follows (or stands
// alone under `--vanilla`, where there is no built-in prompt to frame).
const context = opts.context?.map(d => d.trim()).filter(Boolean)
if (context && context.length) parts.push(`Context: ${context.join(', ')}`)
const context = opts.context?.map(d => d.trim()).filter(Boolean) ?? []
// The knowledge docs ride with the built-in prompt, not with the user's dirs: they are
// ours, and `--vanilla` means no framework-authored prompt at all (#547 rule 3).
const inContext = opts.antiLazyPill === false ? context : [...context, ...KNOWLEDGE_DOCS]
if (inContext.length) parts.push(`Context: ${inContext.join(', ')}`)
if (opts.antiLazyPill !== false) parts.push(renderSystemPrompt(opts.tf).system)
const user = opts.user?.trim()
if (user) parts.push(user)
Expand Down
Loading