Skip to content

refactor: split CoS task metadata.context into prompt vs note fields (#4153) - #4257

Merged
atomantic merged 2 commits into
mainfrom
claim/issue-4153
Aug 15, 2026
Merged

refactor: split CoS task metadata.context into prompt vs note fields (#4153)#4257
atomantic merged 2 commits into
mainfrom
claim/issue-4153

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

metadata.context on a CoS task carried two unrelated kinds of content: a one-line human note, and a multi-thousand-character agent prompt (the generator's Phase 1–7 body, a /do:* claim prompt, a repo-study brief). The prompt landed there because generateTasksMarkdown flattens description onto one line, so metadata.context — newline-escaped through the JSON sentinel — was the only field that survived the COS-TASKS.md round trip.

The two are now separate fields:

  • metadata.prompt — the full agent-facing payload.
  • metadata.context — the one-line human note.

One classification rule, shared by the writer and the migration

server/lib/cosTaskPrompt.js (new, pure, dependency-free) owns the contract. isPromptPayload(value) is the single discriminator — a newline means it's a prompt, because the context contract is explicitly one line — and both cosTaskStore.addTask and the migration call it, so a task can't be sorted one way at creation and the other way on disk. Classification happens at create only: updateTask deliberately leaves both fields alone, because the task editor's textarea is seeded from the note, and re-classifying a multi-line edit of it would overwrite the task's real prompt.

Producers keep their existing call shape — addTask({ context }) still works and routes a multi-line body to prompt — so the reference-watch filer, the repo-study filer, the auto-fixer and the worktree-cleanup filer needed no change. cosTaskGenerator and the two prompt-building routes (/tasks/slashdo, /tasks/jira-ticket) now name the field explicitly.

Compatibility

  • Reads stay tolerant. Every reader goes through getTaskPrompt, which prefers metadata.prompt and falls back to metadata.context. A task written before the split — or synced from a peer still on the old code — resolves exactly as it did. Under-migrating is therefore harmless.
  • Templates are untouched. The shipped cos-agent-briefing.md and every copy an install has customized address {{task.metadata.context}}. Rather than push a template change (and a prompt migration) onto every install, buildAgentPrompt folds prompt + note back into that key at render time. metadata.prompt still travels for a custom template that wants to address it directly.
  • Migration scripts/migrations/270-cos-task-prompt-split.js renames the prompt-carrying - context: line to - prompt: in data/TASKS.md / data/COS-TASKS.md (honouring a relocated queue path from data/cos/state.json), re-stamping updatedAt so the migrated copy wins the LWW federation merge. Text-level rewrite rather than parse/regenerate, following migration 234 — regenerating would reorder, re-escape and re-sort the user's live queue. Idempotent.
  • PORTOS_SCHEMA_VERSIONS.cosTasks 4 → 5. Same execution-semantics break as the v3/v4 bumps: prompt rides the permissive metadata map, so a ≤v4 receiver validates and stores the task fine and then mis-runs it — its prompt builder only knows metadata.context, so the agent gets a one-line description with the whole body missing and LWW-pushes that damaged state back. The gate makes an unmigrated peer skip cos-task sync instead.
  • metadata.prompt needs no special case in cosTaskMergecontentSignature already walks every non-claim key; a test pins that it converges and that a split/legacy pair doesn't lose the payload.

Side effect worth naming

cos-evaluate.md (the triage prompt) renders {{metadata.context}} per pending task. Post-split it sees the one-line note instead of an inlined multi-thousand-character body — a strict improvement for a selection prompt, and the reason no prompt migration is needed there.

UI

TaskItem renders the prompt and the note as separate clamped blocks and offers a Prompt textarea in edit mode — but only when the task actually carries one, and the key is omitted from the PATCH otherwise, so editing a legacy task can't write an empty prompt key the markdown store would then serialize.

Test plan

  • server/lib/cosTaskPrompt.test.js (new, 16 tests) — the discriminator, absent-vs-present-but-empty on every getter, the render block, and non-mutation of the caller's metadata.
  • scripts/migrations/270-cos-task-prompt-split.test.js (new, 14 tests) — multi-line → prompt, one-line note left alone, JSON-sentinel and legacy \n-escaped values, JSON-encoded arrays ignored, updatedAt re-stamp/insert, idempotency, a description that spilled onto its own lines, relocated queue file, and both up() no-op paths.
  • cosTaskStore.test.js — direct-write and raw pre-built classification, explicit prompt + note, no re-classification on update, prompt as a direct update field (edit stamp + clear), markdown round-trip.
  • agentPromptBuilder.test.jsreconcileSplitContext on prompt (note preserved), prompt preferred over a same-shaped legacy context, light-path rendering of prompt + note, the swarm double-header regression re-pinned for the split shape, and the briefing-template fold (plus a legacy task left untouched).
  • cosTaskMerge.test.js, taskParser.test.js, routes/cos.test.js, services/cos.test.js, cosTasksSync.test.js updated/extended.
  • client/.../TaskItem.test.jsx — separate blocks, the Prompt textarea + PATCH, the unsaved-edits branch, and the legacy task that must not gain a prompt key.
  • Full suites: cd client && npm test643 files / 7843 tests passing. cd server && npm test → no new failures; the 55 failing files are the pre-existing requires PostgreSQL set, a strict subset of the same run on main (57), which has no DB available in this environment.

Closes #4153

@atomantic

Copy link
Copy Markdown
Owner Author

Reviewer status

claude — ran (1 round, effort high) against the full branch diff. Two findings, both in scripts/migrations/270-cos-task-prompt-split.js, both fixed in e8be64f with regression tests that were verified to fail before the fix:

  1. Legacy Title-Case - Context: keys were never migrated. parseMetadataLine normalizes Title-Case keys to camelCase, so an older install's queue file can legitimately hold - Context:. The migration compared the key case-sensitively, so those blocks were skipped — and because the migration is then recorded applied and never retried, those installs would have stayed unsplit permanently (and never got the updatedAt re-stamp that makes the migrated copy win the LWW merge). Now normalized exactly as the parser does.
  2. A #-leading spilled description line aborted the block scan. The scan treated any # line as a section heading that ends the task block — but the payloads this migration targets are generated agent bodies whose continuation lines are markdown headings (## Phase 1). When one spilled, the following - context: line was skipped. The block now ends only at the next task header, mirroring parseTasksMarkdown, which likewise never clears currentTask on a heading.

Impact of both was bounded (the reader fallback to metadata.context kept those tasks running correctly), but the migration would have silently under-covered older installs.

codexUNSATISFIED. codex is on PATH (codex-cli 0.144.4) but the account is out of credits: every invocation, including a trivial probe, returns ERROR: You've hit your usage limit … try again at Aug 20th, 2026. No codex review was obtained; this is not substituted with a self-review.

@atomantic

Copy link
Copy Markdown
Owner Author

Merge held by the orchestrator: the configured review gate is only half satisfied.

The configured reviewers for this run are claude then codex. claude ran (1 round, effort high) and its two real findings were fixed with regression tests. codex never ran: the CLI is on PATH (codex-cli 0.144.4) but the account is out of credits — the orchestrator independently re-probed and got You've hit your usage limit … try again at Aug 20th, 2026 on a trivial prompt.

Per the run's review contract, an unsatisfied reviewer is not a clean review and is not substituted with a self-review, so this PR is left open rather than merged. That bar is worth holding here specifically: this change carries an on-disk migration (scripts/migrations/270-cos-task-prompt-split.js) and a PORTOS_SCHEMA_VERSIONS.cosTasks 4 → 5 bump, which affect other installs and federated peers.

State: CI green on all 7 checks, MERGEABLE/CLEAN, branch claim/issue-4153 and the issue's in-progress claim left in place. To land it: re-run codex review --base origin/main once credits reset (or drop codex from the reviewer list), address anything it finds, then merge.

The other two PRs in this swarm batch (#4253, #4254) merged — codex completed its review on both before the quota was exhausted.

@atomantic
atomantic merged commit 8951cd2 into main Aug 15, 2026
7 checks passed
@atomantic
atomantic deleted the claim/issue-4153 branch August 15, 2026 06:16

@atomantic atomantic left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by /do:review — 0 critical, 4 improvements, 0 nits.

CI was green at review time. No local files were changed; this was a PR-mode review.

Out-of-diff policy note: the PR's commit metadata contains non-durable AI-session URL references. Repository policy prohibits those references in commits and other published artifacts; remove them before merge.

Generated by /do:review

// The full agent-facing payload, when the producer names it explicitly
// (#4153). Producers that still pass a multi-line `context` are classified
// by `splitTaskPromptFields` below, so both call shapes converge.
if (taskData.prompt) metadata.prompt = taskData.prompt;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPROVEMENT] prompt is optional, but this truthiness guard drops an intentional empty string on create. For a request such as { prompt: "", context: "keep this note" }, no metadata.prompt marker is persisted, so readers treat the note as a legacy agent payload and the UI cannot distinguish an explicit clear. Preserve present string values.

Suggested change
if (taskData.prompt) metadata.prompt = taskData.prompt;
if (typeof taskData.prompt === 'string') metadata.prompt = taskData.prompt;

Comment on lines +79 to +81
it('ignores a JSON-encoded array value that happens to serialize with a newline escape', () => {
const md = queue(task('sys-1', { context: 'short note', reviewers: ['claude', 'codex'] }));
expect(splitPromptMetadata(md, { stamp: STAMP })).toEqual({ markdown: md, split: [] });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPROVEMENT] This test title says it exercises a JSON-encoded array in context, but the array is attached to reviewers and context remains 'short note'. The migration's non-string-context branch is therefore untested; a regression could pass. Put the array/newline-escape fixture in context.

Suggested change
it('ignores a JSON-encoded array value that happens to serialize with a newline escape', () => {
const md = queue(task('sys-1', { context: 'short note', reviewers: ['claude', 'codex'] }));
expect(splitPromptMetadata(md, { stamp: STAMP })).toEqual({ markdown: md, split: [] });
it('ignores a JSON-encoded context array that happens to serialize with a newline escape', () => {
const md = queue(task('sys-1', { context: ['line one\nline two'] }));
expect(splitPromptMetadata(md, { stamp: STAMP })).toEqual({ markdown: md, split: [] });
});

// Mirror firstLine() in cosTaskStore.js: first non-empty, trimmed line.
const firstNonEmpty = payload.split('\n').map(l => l.trim()).find(Boolean) || '';
if (firstNonEmpty !== description) continue;
const { [key]: _dropped, ...restMeta } = task.metadata;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPROVEMENT] reconcileSplitContext() removes whichever payload key matched the one-line description. On the queue path that is metadata.prompt, and buildAgentPrompt() calls this before constructing briefingTask; a customized cos-agent-briefing template using {{task.metadata.prompt}} therefore receives no prompt even though the later comment says it "travels untouched." The built-in template still works through description, making this easy to miss. Preserve the raw prompt for custom templates (or explicitly define and test the normalized queue-path contract).

const hasPromptField = typeof task.metadata?.prompt === 'string';
const [editData, setEditData] = useState({
description: task.description,
prompt: task.metadata?.prompt || '',

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[IMPROVEMENT] This useState initializer runs only on mount. TasksTab keeps TaskItem keyed by id, so a live refresh or federation update can add metadata.prompt to a mounted legacy task while editData.prompt remains ''. hasPromptField then becomes true, and saving any edit sends that stale empty prompt via the payload at line 184, clearing the agent body. Re-seed the draft when the task changes while not actively editing (or when edit mode opens), and add a regression test for this prop transition.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Split overloaded CoS task metadata.context into prompt vs note fields

1 participant