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/context-fragment-tickets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/framework': minor
---

Expand the run-start context fragment (#683): alongside `DECISIONS.md` and `KNOWLEDGE-BASE.md` the agent now also sees `GOAL.md`, `tickets/**.md` (pointed at the `.the-framework/ticketing-format.md` spec from #684), and the `TODO-AGENTS.md` task queue. The set is split into `CONTEXT_DOCS` (read at start) and the `BUSINESS_KNOWLEDGE_DOCS` subset the agent also updates at merge, so the roadmap/queue pointers are read-only context.
5 changes: 5 additions & 0 deletions .changeset/ticketing-format-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/framework': minor
---

Add the ticket-format spec (#684): a static `.the-framework/ticketing-format.md` describing the `tickets/<DATE>_<SLUG>.md` and `.spike.md` file shapes, materialized on install beside the quality presets so an agent can open it by path.
51 changes: 51 additions & 0 deletions packages/framework/prompts/ticketing_format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Ticket format

## tickets/<DATE>_<SLUG>.md

DATE: yyyy-mm-dd
SLUG: succinct kebab-case slug of the ticket title
Body:
```md
# Ticket title

## TLDR

...

## Why it matters

...

[Optional: more info (any heading and format you want)]
```

## tickets/<DATE>_<SLUG>.spike.md

For an existing ticket (e.g. `tickets/2042-01-01_some-ticket.md`), a spike can be created (`tickets/2042-01-01_some-ticket.spike.md`).

Body:
```md
# [Spike] Ticket title

## TLDR

...

## Analysis

...

[Optional: more info (any heading and format you want)]
```

Typical spike content:
- High-level overview, for example:
- What it takes to implement it
- List all the ways to implement it
- Potential "laziness" shortcuts
- Full-fledged implementation VS minimal implementation
- Open questions
- ...
- Estimated effort (for each ways to implement it):
- Human intervention effort: trivial/low/medium/high/very-high
- Token consumption: time estimate (minutes, hours, or days)
2 changes: 2 additions & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ export {
FLAT_TODO_FILE,
LEGACY_TODO_FILE,
TICKETS_DIR,
TICKETING_FORMAT_FILE,
materializeTicketingFormat,
DEFAULT_MAX_TODO_ITEMS,
type TodoBacklog,
type TodoLoopOptions,
Expand Down
4 changes: 4 additions & 0 deletions packages/framework/src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { nodeGitRunner, type GitRunner } from './project.js'
import { logsPath, LOGS_HEADER, THE_FRAMEWORK_DIR, gitignorePath, LOGS_GITIGNORE } from './logs.js'
import { nodeStoreFs, type StoreFs } from './store/index.js'
import { materializePresets } from './presets.js'
import { materializeTicketingFormat } from './tickets.js'

/**
* Install/activate a repo for The Framework (#391): create the
Expand Down Expand Up @@ -63,6 +64,9 @@ export async function installProject(cwd: string, deps: InstallDeps = {}): Promi
// of git (only LOGS.md is committed), so they are regenerated on install and track the
// installed framework version rather than going stale in the repo's history.
await materializePresets(cwd, fs)
// The ticket-format spec rides along the same way (#684): gitignored, refreshed on
// install, so the #683 context pointer to it resolves to a real file.
await materializeTicketingFormat(cwd, fs)

await git(['add', '-A'], cwd)
await git(['commit', '-m', '[The Framework] install The Framework'], cwd)
Expand Down
14 changes: 8 additions & 6 deletions packages/framework/src/on-before-mergeable-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { renderOnBeforeMergeablePrompt, ON_BEFORE_MERGEABLE_PROMPT_TEMPLATE } from './on-before-mergeable-prompt.js'
import { TemplateFragmentError } from './prompt-template.js'
import { KNOWLEDGE_DOCS } from './system-prompt.js'
import { BUSINESS_KNOWLEDGE_DOCS } from './system-prompt.js'

test('ON_BEFORE_MERGEABLE_PROMPT_TEMPLATE carries the #326 on-before-mergeable block', () => {
assert.ok(ON_BEFORE_MERGEABLE_PROMPT_TEMPLATE.includes('TODO_FILE: `TODO_<SESSION_NAME>.agent.md`'))
Expand All @@ -12,12 +12,14 @@ test('ON_BEFORE_MERGEABLE_PROMPT_TEMPLATE carries the #326 on-before-mergeable b
}
})

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.
test('the business-knowledge section names every business-knowledge doc (#537)', () => {
// The two halves of #537 are authored apart: the `Context:` block lists the docs from the
// BUSINESS_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. Only the business-knowledge
// subset is pinned: the broader CONTEXT_DOCS (#683) also lists roadmap/queue pointers the
// agent reads but does not update at merge, so they are deliberately absent here.
assert.ok(ON_BEFORE_MERGEABLE_PROMPT_TEMPLATE.includes('## Business knowledge'))
for (const doc of KNOWLEDGE_DOCS) {
for (const doc of BUSINESS_KNOWLEDGE_DOCS) {
assert.ok(ON_BEFORE_MERGEABLE_PROMPT_TEMPLATE.includes(`\`${doc.path}\``), `missing ${doc.path}`)
}
})
Expand Down
2 changes: 1 addition & 1 deletion packages/framework/src/on-before-mergeable-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { dropSection, type EcoOptions, type TfContext } from './system-prompt.js
* 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}.
* (#537) asks the agent to fold what it learned back into {@link BUSINESS_KNOWLEDGE_DOCS}.
*/
export const ON_BEFORE_MERGEABLE_PROMPT_TEMPLATE = ON_BEFORE_MERGEABLE_PROMPT

Expand Down
24 changes: 20 additions & 4 deletions packages/framework/src/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,33 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
composeRunSystem,
KNOWLEDGE_DOCS,
BUSINESS_KNOWLEDGE_DOCS,
CONTEXT_DOCS,
renderSystemPrompt,
systemPromptBlock,
SYSTEM_PROMPT_TEMPLATE,
} from './system-prompt.js'
import { TICKETING_FORMAT_FILE } from './tickets.js'
import { loadUserSystemPrompt, SYSTEM_PROMPT_FILE } from './system-prompt-file.js'

/** The knowledge docs as the commented bullets they render to (#559). */
const KNOWLEDGE_LINES = KNOWLEDGE_DOCS.map(d => `- \`${d.path}\` (${d.comment})`).join('\n')
/** The `Context:` block the #537 knowledge docs stand up on their own, with no dirs picked. */
/** The context docs as the commented bullets they render to (#559/#683). */
const KNOWLEDGE_LINES = CONTEXT_DOCS.map(d => `- \`${d.path}\` (${d.comment})`).join('\n')
/** The `Context:` block the context docs stand up on their own, with no dirs picked. */
const KNOWLEDGE_CONTEXT = `Context:\n${KNOWLEDGE_LINES}`

test('CONTEXT_DOCS is the #683 fragment: business knowledge plus the roadmap/queue pointers', () => {
const paths = CONTEXT_DOCS.map(d => d.path)
assert.deepEqual(paths, ['DECISIONS.md', 'GOAL.md', 'KNOWLEDGE-BASE.md', 'tickets/**.md', 'TODO-AGENTS.md'])
// The business-knowledge docs are a subset the agent also updates at merge.
for (const doc of BUSINESS_KNOWLEDGE_DOCS) assert.ok(paths.includes(doc.path), `missing ${doc.path}`)
// GOAL / tickets / TODO-AGENTS are read-only context, so they are not in the merge-update set.
const businessPaths = BUSINESS_KNOWLEDGE_DOCS.map(d => d.path)
for (const p of ['GOAL.md', 'tickets/**.md', 'TODO-AGENTS.md']) assert.ok(!businessPaths.includes(p))
// The ticket-format pointer is inlined here (this module stays node-free), so pin it to the
// canonical constant in tickets.ts (#684) — they must never drift.
const tickets = CONTEXT_DOCS.find(d => d.path === 'tickets/**.md')
assert.ok(tickets?.comment.includes(TICKETING_FORMAT_FILE), `expected the ${TICKETING_FORMAT_FILE} pointer`)
})
import { AWAIT_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js'

test('loadUserSystemPrompt reads and trims SYSTEM.md', async () => {
Expand Down
51 changes: 39 additions & 12 deletions packages/framework/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,44 @@ export interface TfContext {
/** The neutral context used when a caller has none: empty prompt, no modes. */
const DEFAULT_TF: TfContext = { prompt: '', params: {} }

/** A project-context document: a repo-root path and the one-line gloss shown beside it (#559). */
export interface ContextDoc {
path: string
comment: string
}

const DECISIONS_DOC: ContextDoc = { path: 'DECISIONS.md', comment: 'decisions taken, and why' }
const KNOWLEDGE_BASE_DOC: ContextDoc = {
path: 'KNOWLEDGE-BASE.md',
comment: 'observed facts, non-obvious knowledge, and insights relevant to the project',
}

/**
* The business-knowledge docs (#537): what the repo has learned about itself, which the
* agent both reads at the start of a run and folds new knowledge back into at merge. The
* on-before-mergeable prompt's `## Business knowledge` section names this exact set, so the
* agent is never told to read one set of files and update another (pinned by a test). A
* subset of {@link CONTEXT_DOCS}.
*/
export const BUSINESS_KNOWLEDGE_DOCS: readonly ContextDoc[] = [DECISIONS_DOC, KNOWLEDGE_BASE_DOC]

/**
* 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 on-before-mergeable prompt asks the agent to update them, which is what
* keeps them current. Repo-root, because that is the agent's cwd and where the docs live.
* Each carries the one-line gloss Rom wants shown alongside the path (#559). README is
* left out: a repo's own `README.md` already covers the overview.
* Everything the agent keeps in context at the start of a run (#683), which
* {@link systemPromptBlock} renders as the `Context:` bullets. A superset of
* {@link BUSINESS_KNOWLEDGE_DOCS}: it adds `GOAL.md` and the two roadmap/queue pointers the
* agent reads but does *not* fold knowledge back into — `tickets/**.md` (the potential work,
* whose file shape is `.the-framework/ticketing-format.md`, #684) and the `TODO-AGENTS.md`
* task queue. Repo-root paths, because that is the agent's cwd. README is left out: a repo's
* own `README.md` already covers the overview. The ticket-format path is inlined rather than
* imported from `tickets.ts`: this module must stay free of `node:fs` (it renders in the
* browser, #520), and a test pins the literal to `TICKETING_FORMAT_FILE`.
*/
export const KNOWLEDGE_DOCS: readonly { path: string; comment: string }[] = [
{ path: 'DECISIONS.md', comment: 'decisions taken, and why' },
{ path: 'KNOWLEDGE-BASE.md', comment: 'knowledge and insights related to the project' },
export const CONTEXT_DOCS: readonly ContextDoc[] = [
DECISIONS_DOC,
{ path: 'GOAL.md', comment: 'the goal of the project' },
KNOWLEDGE_BASE_DOC,
{ path: 'tickets/**.md', comment: 'things to potentially work on; format: .the-framework/ticketing-format.md' },
{ path: 'TODO-AGENTS.md', comment: 'the AI task queue' },
]

/** The two halves of the rendered {@link SYSTEM_PROMPT_TEMPLATE}. */
Expand Down Expand Up @@ -201,13 +228,13 @@ export function systemPromptBlock(opts: SystemPromptOptions = {}): 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 dirs = 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
// The context 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). They
// render as commented bullets under the dirs (#559), so the agent sees what each is for.
// `--vanilla` (antiLazyPill === false) drops both the framework's knowledge docs and its
// `--vanilla` (antiLazyPill === false) drops both the framework's context docs and its
// built-in prompt; one boolean drives both so they can't fall out of step.
const includeBuiltin = opts.antiLazyPill !== false
const docs = includeBuiltin ? KNOWLEDGE_DOCS : []
const docs = includeBuiltin ? CONTEXT_DOCS : []
if (dirs.length || docs.length) {
const head = `Context:${dirs.length ? ` ${dirs.join(', ')}` : ''}`
const bullets = docs.map(d => `- \`${d.path}\` (${d.comment})`)
Expand Down
29 changes: 27 additions & 2 deletions packages/framework/src/tickets.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { FLAT_TODO_FILE, LEGACY_TODO_FILE, TICKETS_DIR, findFlatTodo } from './tickets.js'
import {
FLAT_TODO_FILE,
LEGACY_TODO_FILE,
TICKETS_DIR,
TICKETING_FORMAT_FILE,
findFlatTodo,
materializeTicketingFormat,
} from './tickets.js'
import { TICKETING_FORMAT } from './prompts.generated.js'

test('the flat backlog lives at tickets/TODO.md, with the legacy root file named', () => {
assert.equal(TICKETS_DIR, 'tickets')
assert.equal(FLAT_TODO_FILE, 'tickets/TODO.md')
assert.equal(LEGACY_TODO_FILE, 'TODO.md')
})

test('the ticket-format spec materializes under .the-framework, not tickets/ (#684)', async () => {
// It is framework-authored, so it lives beside the presets and never masquerades as a ticket.
assert.equal(TICKETING_FORMAT_FILE, '.the-framework/ticketing-format.md')

const cwd = await mkdtemp(join(tmpdir(), 'framework-ticket-format-'))
try {
await materializeTicketingFormat(cwd)
const written = await readFile(join(cwd, TICKETING_FORMAT_FILE), 'utf8')
assert.equal(written, TICKETING_FORMAT)
// The spec teaches both the ticket and spike file shapes.
assert.ok(written.includes('tickets/<DATE>_<SLUG>.md'))
assert.ok(written.includes('tickets/<DATE>_<SLUG>.spike.md'))
} finally {
await rm(cwd, { recursive: true, force: true })
}
})

test('findFlatTodo prefers tickets/TODO.md, falls back to legacy root TODO.md, else undefined (#629)', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'framework-tickets-'))
try {
Expand Down
23 changes: 23 additions & 0 deletions packages/framework/src/tickets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import { TICKETING_FORMAT } from './prompts.generated.js'
import { THE_FRAMEWORK_DIR } from './logs.js'
import { nodeStoreFs, type StoreFs } from './store/index.js'

/**
* The root `tickets/` directory (#629): a plain repo convention where The Framework
Expand All @@ -9,6 +12,26 @@ import { join } from 'node:path'
*/
export const TICKETS_DIR = 'tickets'

/**
* The ticket-format spec (#684): the static reference an agent opens to learn the
* `tickets/<DATE>_<SLUG>.md` (and `.spike.md`) file shape. Materialized under
* `.the-framework/` beside the presets — not under `tickets/` — because it is
* framework-authored and rides with the installed version, so it must not look like
* a ticket. The #683 context fragment points `tickets/**.md` at this path.
*/
export const TICKETING_FORMAT_FILE = `${THE_FRAMEWORK_DIR}/ticketing-format.md`

/**
* Write the ticket-format spec to `<cwd>/.the-framework/ticketing-format.md` so the
* #683 context pointer resolves to a real file (#684). Mirrors {@link materializePresets}:
* gitignored, overwritten on install, tracks the installed framework version rather than
* going stale in the repo. Creates `.the-framework/` if it is missing.
*/
export async function materializeTicketingFormat(cwd: string, fs: StoreFs = nodeStoreFs()): Promise<void> {
await fs.mkdir(join(cwd, THE_FRAMEWORK_DIR))
await fs.write(join(cwd, TICKETING_FORMAT_FILE), TICKETING_FORMAT)
}

/**
* The flat, durable backlog/roadmap file — the confirmed-task queue. Moved under
* `tickets/` in #629 (it used to sit at the repo root). This is the one a run drains
Expand Down
2 changes: 1 addition & 1 deletion packages/framework/src/todo-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { requestChoices, runAwaitRounds } from './run.js'
import { FLAT_TODO_FILE, findFlatTodo } from './tickets.js'
import { createTurnSignalEmitter } from './turn-gate.js'

export { FLAT_TODO_FILE, LEGACY_TODO_FILE, TICKETS_DIR } from './tickets.js'
export { FLAT_TODO_FILE, LEGACY_TODO_FILE, TICKETS_DIR, TICKETING_FORMAT_FILE, materializeTicketingFormat } from './tickets.js'

/**
* The backlog loop (#323): once the main work settles, consume the agent's own
Expand Down
Loading