sidecar-cli 0.1.4 → 0.1.5-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,23 @@ export const SIDECAR_TAGLINE = 'project memory for your work';
3
3
  export function bannerDisabled(argv = process.argv) {
4
4
  return process.env.SIDECAR_NO_BANNER === '1' || argv.includes('--no-banner');
5
5
  }
6
- export function renderBanner(includeTagline = true) {
6
+ export function renderBanner(variant = 'compact', includeTagline = true) {
7
+ if (variant === 'block') {
8
+ const blockLines = [
9
+ ' [■]─[▪]',
10
+ ' ███████╗██╗██████╗ ███████╗ ██████╗ █████╗ ██████╗',
11
+ ' ██╔════╝██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗',
12
+ ' ███████╗██║██║ ██║█████╗ ██║ ███████║██████╔╝',
13
+ ' ╚════██║██║██║ ██║██╔══╝ ██║ ██╔══██║██╔══██╗',
14
+ ' ███████║██║██████╔╝███████╗╚██████╗██║ ██║██║ ██║',
15
+ ' ╚══════╝╚═╝╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝',
16
+ ];
17
+ if (includeTagline) {
18
+ blockLines.push('');
19
+ blockLines.push(SIDECAR_TAGLINE);
20
+ }
21
+ return blockLines.join('\n');
22
+ }
7
23
  const lines = [SIDECAR_MARK, ''];
8
24
  if (includeTagline) {
9
25
  lines.push(SIDECAR_TAGLINE);
@@ -0,0 +1,30 @@
1
+ export function isColorEnabled() {
2
+ const forced = process.env.FORCE_COLOR === '1' || process.env.FORCE_COLOR === 'true';
3
+ const hasTty = Boolean(process.stdout.isTTY) || forced;
4
+ return hasTty && !process.env.NO_COLOR && !process.argv.includes('--json');
5
+ }
6
+ const codes = {
7
+ bold: '\x1b[1m',
8
+ dim: '\x1b[2m',
9
+ red: '\x1b[31m',
10
+ green: '\x1b[32m',
11
+ yellow: '\x1b[33m',
12
+ cyan: '\x1b[36m',
13
+ magenta: '\x1b[35m',
14
+ reset: '\x1b[0m',
15
+ };
16
+ // Evaluate isColorEnabled() on every call so late changes to NO_COLOR,
17
+ // FORCE_COLOR, or --json (e.g. tests flipping env vars) take effect.
18
+ // Cheap: two env-var reads plus an argv scan.
19
+ function wrap(code) {
20
+ return (s) => (isColorEnabled() ? code + s + codes.reset : s);
21
+ }
22
+ export const c = {
23
+ bold: wrap(codes.bold),
24
+ dim: wrap(codes.dim),
25
+ red: wrap(codes.red),
26
+ green: wrap(codes.green),
27
+ yellow: wrap(codes.yellow),
28
+ cyan: wrap(codes.cyan),
29
+ magenta: wrap(codes.magenta),
30
+ };
@@ -2,7 +2,13 @@ export function nowIso() {
2
2
  return new Date().toISOString();
3
3
  }
4
4
  export function humanTime(iso) {
5
- return new Date(iso).toLocaleString();
5
+ const date = new Date(iso);
6
+ const year = date.getFullYear();
7
+ const month = String(date.getMonth() + 1).padStart(2, '0');
8
+ const day = String(date.getDate()).padStart(2, '0');
9
+ const hours = String(date.getHours()).padStart(2, '0');
10
+ const minutes = String(date.getMinutes()).padStart(2, '0');
11
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
6
12
  }
7
13
  export function splitCsv(input) {
8
14
  if (!input)
@@ -0,0 +1,97 @@
1
+ import { isColorEnabled, c } from './color.js';
2
+ function stripAnsi(s) {
3
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
4
+ }
5
+ function visibleLength(s) {
6
+ return stripAnsi(s).length;
7
+ }
8
+ function ellipsizeMiddle(s, maxLen) {
9
+ const stripped = stripAnsi(s);
10
+ if (stripped.length <= maxLen)
11
+ return s;
12
+ const half = Math.floor((maxLen - 1) / 2);
13
+ return stripped.slice(0, half) + '…' + stripped.slice(stripped.length - half);
14
+ }
15
+ function pad(s, width, align = 'left') {
16
+ const visLen = visibleLength(s);
17
+ const padding = Math.max(0, width - visLen);
18
+ if (align === 'left') {
19
+ return s + ' '.repeat(padding);
20
+ }
21
+ return ' '.repeat(padding) + s;
22
+ }
23
+ export function renderTable(columns, rows, opts = {}) {
24
+ const indent = opts.indent || '';
25
+ const isTty = isColorEnabled();
26
+ if (rows.length === 0) {
27
+ return;
28
+ }
29
+ // Compute natural widths
30
+ const widths = {};
31
+ for (const col of columns) {
32
+ let w = col.label.length;
33
+ for (const row of rows) {
34
+ const val = row[col.key] || '';
35
+ w = Math.max(w, visibleLength(val));
36
+ }
37
+ widths[col.key] = w;
38
+ }
39
+ // Clamp by min/max
40
+ for (const col of columns) {
41
+ if (col.minWidth)
42
+ widths[col.key] = Math.max(widths[col.key], col.minWidth);
43
+ if (col.maxWidth)
44
+ widths[col.key] = Math.min(widths[col.key], col.maxWidth);
45
+ }
46
+ // If total > terminal width, reduce widest flexible column via middle-ellipsis
47
+ const terminalWidth = process.stdout.columns || 120;
48
+ const maxContentWidth = Math.max(0, terminalWidth - indent.length - (columns.length - 1) * 2);
49
+ let totalWidth = columns.reduce((sum, col) => sum + widths[col.key], 0) + (columns.length - 1) * 2;
50
+ if (totalWidth > maxContentWidth) {
51
+ // Find widest flexible column
52
+ let widestIdx = -1;
53
+ let widestWidth = -1;
54
+ for (let i = 0; i < columns.length; i++) {
55
+ const col = columns[i];
56
+ if (!col.maxWidth || widths[col.key] < col.maxWidth) {
57
+ if (widths[col.key] > widestWidth) {
58
+ widestWidth = widths[col.key];
59
+ widestIdx = i;
60
+ }
61
+ }
62
+ }
63
+ if (widestIdx >= 0) {
64
+ const col = columns[widestIdx];
65
+ const reductionNeeded = totalWidth - maxContentWidth;
66
+ widths[col.key] = Math.max(col.minWidth || 1, widths[col.key] - reductionNeeded);
67
+ }
68
+ }
69
+ // Render header
70
+ const headerParts = columns.map((col) => pad(col.label, widths[col.key], col.align));
71
+ const headerLine = indent + headerParts.join(' ');
72
+ if (isTty) {
73
+ console.log(c.bold(headerLine));
74
+ const ruleWidth = visibleLength(stripAnsi(headerLine));
75
+ console.log(indent + '─'.repeat(ruleWidth));
76
+ }
77
+ else {
78
+ console.log(headerLine);
79
+ const ruleWidth = visibleLength(stripAnsi(headerLine));
80
+ console.log(indent + '-'.repeat(ruleWidth));
81
+ }
82
+ // Render rows
83
+ for (const row of rows) {
84
+ const parts = columns.map((col) => {
85
+ let val = row[col.key] || '';
86
+ if (col.format) {
87
+ val = col.format(val, row);
88
+ }
89
+ // Apply ellipsis if needed
90
+ if (visibleLength(val) > widths[col.key]) {
91
+ val = ellipsizeMiddle(val, widths[col.key]);
92
+ }
93
+ return pad(val, widths[col.key], col.align);
94
+ });
95
+ console.log(indent + parts.join(' '));
96
+ }
97
+ }
@@ -0,0 +1,203 @@
1
+ // Adapter from TaskPacket → CompileSectionsInput. Mirrors the legacy packet
2
+ // layout exactly so `sidecar run <task-id>` produces byte-identical prompts
3
+ // after the compiler refactor. Snapshot-guarded in `prompts.compat.test`.
4
+ import { nowIso } from '../lib/format.js';
5
+ import { PROMPT_PREFERENCE_DEFAULTS } from '../runners/config.js';
6
+ function finalResponseFormat(runner) {
7
+ if (runner === 'codex') {
8
+ return [
9
+ '- Start with a one-line outcome summary.',
10
+ '- List files changed with concise reasons.',
11
+ '- Include validation commands run and their results.',
12
+ '- Note risks, blockers, or follow-up tasks.',
13
+ ];
14
+ }
15
+ return [
16
+ '- Use a brief plan -> implementation -> summary structure.',
17
+ '- Call out assumptions and tradeoffs explicitly.',
18
+ '- List changed files and validation results.',
19
+ '- End with remaining risks and next steps if any.',
20
+ ];
21
+ }
22
+ function runnerGuidance(runner) {
23
+ if (runner === 'codex') {
24
+ return [
25
+ 'Work directly in this repository and keep changes tightly scoped to the task.',
26
+ 'Prefer existing project helpers and patterns over introducing new abstractions.',
27
+ 'Keep final reporting concise and implementation-focused.',
28
+ ];
29
+ }
30
+ return [
31
+ 'Begin with a short plan, then execute changes in small coherent steps.',
32
+ 'Explain implementation choices and tradeoffs briefly as you go.',
33
+ 'Provide a clear summary with validation and follow-up notes at the end.',
34
+ ];
35
+ }
36
+ function textSection(id, title, content) {
37
+ return { id, title, kind: 'text', content, trim: 'keep' };
38
+ }
39
+ function listSection(id, title, items, options) {
40
+ return {
41
+ id,
42
+ title,
43
+ kind: 'list',
44
+ items,
45
+ ...(options?.empty_placeholder ? { empty_placeholder: options.empty_placeholder } : {}),
46
+ ...(options?.trim ? { trim: options.trim } : { trim: { policy: 'keep' } }),
47
+ };
48
+ }
49
+ function validationLine(v) {
50
+ const label = v.name ? `${v.kind}:${v.name}` : v.kind;
51
+ return v.kind === 'custom' ? v.command : `${label} — \`${v.command}\``;
52
+ }
53
+ // Linked context uses two sub-lists (decisions + notes) under one heading. The
54
+ // legacy layout merged them so we render as a single `text` section whose
55
+ // content is the pre-formatted bullet list, and trim by hand before handing it
56
+ // to the core. That keeps byte-identical output without teaching the core
57
+ // about multi-list sections.
58
+ function renderLinkedContext(relatedDecisions, relatedNotes, mode) {
59
+ const lines = [];
60
+ const decisions = mode === 'full'
61
+ ? relatedDecisions
62
+ : mode === 'trim'
63
+ ? sliceWithOverflow(relatedDecisions, 3, 'decisions')
64
+ : sliceWithOverflow(relatedDecisions, 1, 'decisions');
65
+ const notes = mode === 'full'
66
+ ? relatedNotes
67
+ : mode === 'trim'
68
+ ? sliceWithOverflow(relatedNotes, 2, 'notes')
69
+ : sliceWithOverflow(relatedNotes, 0, 'notes');
70
+ if (decisions.length === 0)
71
+ lines.push('- no related decisions');
72
+ else
73
+ for (const d of decisions)
74
+ lines.push(`- ${d}`);
75
+ if (notes.length === 0)
76
+ lines.push('- no related notes');
77
+ else
78
+ for (const n of notes)
79
+ lines.push(`- ${n}`);
80
+ return lines;
81
+ }
82
+ function sliceWithOverflow(items, limit, label) {
83
+ if (items.length <= limit)
84
+ return items;
85
+ const kept = items.slice(0, limit);
86
+ kept.push(`+ ${items.length - limit} more ${label} (see task packet for full list)`);
87
+ return kept;
88
+ }
89
+ function renderPreviousRuns(runs) {
90
+ const lines = [];
91
+ runs.forEach((prev, idx) => {
92
+ if (idx > 0)
93
+ lines.push('');
94
+ lines.push(`### ${prev.run_id} — ${prev.runner} (${prev.agent_role})`);
95
+ lines.push(`- Status: ${prev.status}`);
96
+ if (prev.validation_summary)
97
+ lines.push(`- Validation: ${prev.validation_summary}`);
98
+ if (prev.summary)
99
+ lines.push(`- Summary: ${prev.summary}`);
100
+ if (prev.changed_files && prev.changed_files.length > 0) {
101
+ const limited = prev.changed_files.slice(0, 12);
102
+ lines.push(`- Changed files (${prev.changed_files.length}):`);
103
+ for (const f of limited)
104
+ lines.push(` - ${f}`);
105
+ if (prev.changed_files.length > limited.length) {
106
+ lines.push(` - + ${prev.changed_files.length - limited.length} more (see run record)`);
107
+ }
108
+ }
109
+ if (prev.log_tail) {
110
+ lines.push('- Log tail:');
111
+ lines.push('```');
112
+ for (const line of prev.log_tail.split('\n'))
113
+ lines.push(line);
114
+ lines.push('```');
115
+ }
116
+ });
117
+ return lines;
118
+ }
119
+ function renderConstraints(technical, design) {
120
+ const lines = [];
121
+ if (technical.length === 0)
122
+ lines.push('- no technical constraints');
123
+ else
124
+ for (const t of technical)
125
+ lines.push(`- ${t}`);
126
+ if (design.length === 0)
127
+ lines.push('- no design constraints');
128
+ else
129
+ for (const d of design)
130
+ lines.push(`- ${d}`);
131
+ return lines;
132
+ }
133
+ export function packetToCompileInput(input) {
134
+ const { task, run, runner, agentRole, linkedContext, budget } = input;
135
+ const pref = budget ?? PROMPT_PREFERENCE_DEFAULTS;
136
+ const header = [
137
+ '# Sidecar Execution Brief',
138
+ '',
139
+ `Runner: ${runner}`,
140
+ `Agent role: ${agentRole}`,
141
+ `Run id: ${run.run_id}`,
142
+ `Task id: ${task.task_id}`,
143
+ `Compiled at: ${nowIso()}`,
144
+ ];
145
+ const relatedDecisions = linkedContext?.related_decisions ?? task.context.related_decisions;
146
+ const relatedNotes = linkedContext?.related_notes ?? task.context.related_notes;
147
+ const sections = [
148
+ textSection('task', 'Task', [
149
+ `- ${task.title}`,
150
+ `- Type: ${task.type}`,
151
+ `- Priority: ${task.priority}`,
152
+ `- Status: ${task.status}`,
153
+ ]),
154
+ textSection('objective', 'Objective', [task.goal]),
155
+ textSection('why', 'Why this matters', [task.summary]),
156
+ listSection('in_scope', 'In scope', task.scope.in_scope, {
157
+ trim: { policy: 'trim-last', limit: 8, limit_strict: 8, overflow_label: 'in-scope items' },
158
+ }),
159
+ listSection('out_of_scope', 'Out of scope', task.scope.out_of_scope, {
160
+ trim: { policy: 'trim-last', limit: 5, limit_strict: 3, overflow_label: 'out-of-scope items' },
161
+ }),
162
+ listSection('files_to_read', 'Read these first', task.implementation.files_to_read, {
163
+ trim: { policy: 'trim-last', limit: 10, limit_strict: 10, overflow_label: 'read-first files' },
164
+ }),
165
+ listSection('files_to_avoid', 'Avoid changing', task.implementation.files_to_avoid, {
166
+ trim: { policy: 'trim-last', limit: 5, limit_strict: 3, overflow_label: 'avoid files' },
167
+ }),
168
+ // Linked context stays a text section so the "no related X" placeholders stay where they were.
169
+ textSection('linked_context', 'Linked context', renderLinkedContext(relatedDecisions, relatedNotes, 'full')),
170
+ // Previous runner context — only when this run is a later step in a pipeline.
171
+ ...(linkedContext?.previous_runs && linkedContext.previous_runs.length > 0
172
+ ? [textSection('previous_runs', 'Previous runner context', renderPreviousRuns(linkedContext.previous_runs))]
173
+ : []),
174
+ textSection('constraints', 'Constraints', renderConstraints(task.constraints.technical, task.constraints.design)),
175
+ listSection('validation', 'Validation', task.execution.commands.validation.map(validationLine)),
176
+ listSection('definition_of_done', 'Definition of done', task.definition_of_done),
177
+ textSection('runner_guidance', 'Runner guidance', runnerGuidance(runner)),
178
+ textSection('final_response_format', 'Final response format', finalResponseFormat(runner)),
179
+ ];
180
+ return {
181
+ header,
182
+ sections,
183
+ budget: { target: pref.budget_target, max: pref.budget_max },
184
+ };
185
+ }
186
+ // Legacy metadata expects `trimmed_sections: string[]` using the historical names.
187
+ // Map the new core metadata back for back-compat.
188
+ export const LEGACY_TRIM_IDS = [
189
+ 'in_scope',
190
+ 'out_of_scope',
191
+ 'files_to_read',
192
+ 'files_to_avoid',
193
+ 'related_decisions',
194
+ 'related_notes',
195
+ ];
196
+ // Rebuild linked_context lines under a trim mode. The core compileSections()
197
+ // can't partially trim a text section, so we run the full pipeline twice in
198
+ // prompt-compiler.ts: first with full linked_context, and again with trimmed
199
+ // linked_context if the baseline is over budget. See prompt-compiler.ts for
200
+ // the wrapper that orchestrates this.
201
+ export function linkedContextForMode(relatedDecisions, relatedNotes, mode) {
202
+ return renderLinkedContext(relatedDecisions, relatedNotes, mode);
203
+ }
@@ -1,83 +1,113 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { nowIso } from '../lib/format.js';
4
3
  import { getSidecarPaths } from '../lib/paths.js';
5
- function section(title, lines) {
6
- return [`## ${title}`, ...lines, ''].join('\n');
4
+ import { PROMPT_PREFERENCE_DEFAULTS } from '../runners/config.js';
5
+ import { compileSections } from './sections.js';
6
+ import { linkedContextForMode, packetToCompileInput, } from './packet-sections.js';
7
+ // Rebuild the packet adapter output with a specific linked_context trim mode.
8
+ // Linked context mixes two sub-lists under a single heading, which the core
9
+ // compiler treats as a text section (can't partial-trim), so the wrapper owns
10
+ // the two-pass escalation the legacy compiler used.
11
+ function packetInputForMode(adapterInput, mode) {
12
+ const baseline = packetToCompileInput(adapterInput);
13
+ if (mode === 'full')
14
+ return baseline;
15
+ const relatedDecisions = adapterInput.linkedContext?.related_decisions ?? adapterInput.task.context.related_decisions;
16
+ const relatedNotes = adapterInput.linkedContext?.related_notes ?? adapterInput.task.context.related_notes;
17
+ const linkedLines = linkedContextForMode(relatedDecisions, relatedNotes, mode);
18
+ const sections = baseline.sections.map((section) => {
19
+ if (section.id !== 'linked_context')
20
+ return section;
21
+ return { ...section, content: linkedLines };
22
+ });
23
+ return { ...baseline, sections };
7
24
  }
8
- function bullets(items, empty = '- none') {
9
- if (items.length === 0)
10
- return [empty];
11
- return items.map((item) => `- ${item}`);
12
- }
13
- function finalResponseFormat(runner) {
14
- if (runner === 'codex') {
15
- return [
16
- '- Start with a one-line outcome summary.',
17
- '- List files changed with concise reasons.',
18
- '- Include validation commands run and their results.',
19
- '- Note risks, blockers, or follow-up tasks.',
20
- ];
25
+ // Preserve the legacy trimmed_sections names (include related_decisions /
26
+ // related_notes) since downstream run records use these keys.
27
+ function buildLegacyTrimmed(adapterInput, listTrimmed, mode) {
28
+ const out = new Set(listTrimmed);
29
+ if (mode !== 'full') {
30
+ const decisions = adapterInput.linkedContext?.related_decisions ?? adapterInput.task.context.related_decisions;
31
+ const notes = adapterInput.linkedContext?.related_notes ?? adapterInput.task.context.related_notes;
32
+ if (mode === 'trim') {
33
+ if (decisions.length > 3)
34
+ out.add('related_decisions');
35
+ if (notes.length > 2)
36
+ out.add('related_notes');
37
+ }
38
+ else {
39
+ if (decisions.length > 1)
40
+ out.add('related_decisions');
41
+ if (notes.length > 0)
42
+ out.add('related_notes');
43
+ }
21
44
  }
22
- return [
23
- '- Use a brief plan -> implementation -> summary structure.',
24
- '- Call out assumptions and tradeoffs explicitly.',
25
- '- List changed files and validation results.',
26
- '- End with remaining risks and next steps if any.',
27
- ];
45
+ return [...out];
28
46
  }
29
- function runnerGuidance(runner) {
30
- if (runner === 'codex') {
31
- return [
32
- 'Work directly in this repository and keep changes tightly scoped to the task.',
33
- 'Prefer existing project helpers and patterns over introducing new abstractions.',
34
- 'Keep final reporting concise and implementation-focused.',
35
- ];
36
- }
37
- return [
38
- 'Begin with a short plan, then execute changes in small coherent steps.',
39
- 'Explain implementation choices and tradeoffs briefly as you go.',
40
- 'Provide a clear summary with validation and follow-up notes at the end.',
41
- ];
47
+ // `policy_overrides` with every list id → 'keep' forces compileSections to
48
+ // render the untrimmed baseline. The legacy compiler chose to trim based on
49
+ // THIS baseline, not the partially-trimmed first pass, so we do the same to
50
+ // preserve byte-identical output for downstream consumers.
51
+ function allKeepOverrides(fullInput) {
52
+ const out = {};
53
+ for (const s of fullInput.sections)
54
+ if (s.kind === 'list')
55
+ out[s.id] = 'keep';
56
+ return out;
42
57
  }
43
58
  export function compilePromptMarkdown(input) {
44
- const { task, run, runner, agentRole, linkedContext } = input;
45
- const lines = [];
46
- lines.push('# Sidecar Execution Brief');
47
- lines.push('');
48
- lines.push(`Runner: ${runner}`);
49
- lines.push(`Agent role: ${agentRole}`);
50
- lines.push(`Run id: ${run.run_id}`);
51
- lines.push(`Task id: ${task.task_id}`);
52
- lines.push(`Compiled at: ${nowIso()}`);
53
- lines.push('');
54
- lines.push(section('Task', [
55
- `- ${task.title}`,
56
- `- Type: ${task.type}`,
57
- `- Priority: ${task.priority}`,
58
- `- Status: ${task.status}`,
59
- ]));
60
- lines.push(section('Objective', [task.goal]));
61
- lines.push(section('Why this matters', [task.summary]));
62
- lines.push(section('In scope', bullets(task.scope.in_scope)));
63
- lines.push(section('Out of scope', bullets(task.scope.out_of_scope)));
64
- lines.push(section('Read these first', bullets(task.implementation.files_to_read)));
65
- lines.push(section('Avoid changing', bullets(task.implementation.files_to_avoid)));
66
- const relatedDecisions = linkedContext?.related_decisions ?? task.context.related_decisions;
67
- const relatedNotes = linkedContext?.related_notes ?? task.context.related_notes;
68
- lines.push(section('Linked context', [
69
- ...bullets(relatedDecisions, '- no related decisions'),
70
- ...bullets(relatedNotes, '- no related notes'),
71
- ]));
72
- lines.push(section('Constraints', [
73
- ...bullets(task.constraints.technical, '- no technical constraints'),
74
- ...bullets(task.constraints.design, '- no design constraints'),
75
- ]));
76
- lines.push(section('Validation', bullets(task.execution.commands.validation)));
77
- lines.push(section('Definition of done', bullets(task.definition_of_done)));
78
- lines.push(section('Runner guidance', runnerGuidance(runner)));
79
- lines.push(section('Final response format', finalResponseFormat(runner)));
80
- return `${lines.join('\n').trim()}\n`;
59
+ const pref = input.budget ?? PROMPT_PREFERENCE_DEFAULTS;
60
+ const adapterInput = {
61
+ task: input.task,
62
+ run: input.run,
63
+ runner: input.runner,
64
+ agentRole: input.agentRole,
65
+ ...(input.linkedContext ? { linkedContext: input.linkedContext } : {}),
66
+ ...(input.budget ? { budget: input.budget } : {}),
67
+ };
68
+ const fullInput = packetInputForMode(adapterInput, 'full');
69
+ const untrimmed = compileSections({ ...fullInput, policy_overrides: allKeepOverrides(fullInput) });
70
+ // Fast path — the fully untrimmed render fits within the target budget.
71
+ if (untrimmed.metadata.estimated_tokens_after <= pref.budget_target) {
72
+ return {
73
+ markdown: untrimmed.markdown,
74
+ metadata: {
75
+ estimated_tokens_before: untrimmed.metadata.estimated_tokens_before,
76
+ estimated_tokens_after: untrimmed.metadata.estimated_tokens_after,
77
+ budget_target: pref.budget_target,
78
+ budget_max: pref.budget_max,
79
+ trimmed_sections: [],
80
+ },
81
+ };
82
+ }
83
+ // Target pass — trim lists + linked_context.
84
+ const trimInput = packetInputForMode(adapterInput, 'trim');
85
+ const trim = compileSections(trimInput);
86
+ // Strict pass (safety valve) if still over max.
87
+ if (trim.metadata.estimated_tokens_after > pref.budget_max) {
88
+ const strictInput = packetInputForMode(adapterInput, 'strict');
89
+ const strict = compileSections(strictInput);
90
+ return {
91
+ markdown: strict.markdown,
92
+ metadata: {
93
+ estimated_tokens_before: untrimmed.metadata.estimated_tokens_before,
94
+ estimated_tokens_after: strict.metadata.estimated_tokens_after,
95
+ budget_target: pref.budget_target,
96
+ budget_max: pref.budget_max,
97
+ trimmed_sections: buildLegacyTrimmed(adapterInput, strict.metadata.trimmed_sections, 'strict'),
98
+ },
99
+ };
100
+ }
101
+ return {
102
+ markdown: trim.markdown,
103
+ metadata: {
104
+ estimated_tokens_before: untrimmed.metadata.estimated_tokens_before,
105
+ estimated_tokens_after: trim.metadata.estimated_tokens_after,
106
+ budget_target: pref.budget_target,
107
+ budget_max: pref.budget_max,
108
+ trimmed_sections: buildLegacyTrimmed(adapterInput, trim.metadata.trimmed_sections, 'trim'),
109
+ },
110
+ };
81
111
  }
82
112
  export function saveCompiledPrompt(rootPath, runId, markdown) {
83
113
  const promptsPath = getSidecarPaths(rootPath).promptsPath;
@@ -1,3 +1,4 @@
1
+ import { loadPromptPreferences } from '../runners/config.js';
1
2
  import { createRunRecordEntry, updateRunRecordEntry } from '../runs/run-service.js';
2
3
  import { getTaskPacket } from '../tasks/task-service.js';
3
4
  import { compilePromptMarkdown, saveCompiledPrompt } from './prompt-compiler.js';
@@ -10,19 +11,29 @@ export function compileTaskPrompt(input) {
10
11
  status: 'preparing',
11
12
  branch: task.tracking.branch,
12
13
  worktree: task.tracking.worktree,
14
+ ...(input.parentRunId ? { parent_run_id: input.parentRunId } : {}),
15
+ ...(input.replayReason ? { replay_reason: input.replayReason } : {}),
16
+ ...(input.pipelineId ? { pipeline_id: input.pipelineId } : {}),
17
+ ...(input.pipelineStep ? { pipeline_step: input.pipelineStep } : {}),
18
+ ...(input.pipelineTotal ? { pipeline_total: input.pipelineTotal } : {}),
13
19
  });
14
- const promptMarkdown = compilePromptMarkdown({
20
+ const compiledPrompt = compilePromptMarkdown({
15
21
  task,
16
22
  run: created.run,
17
23
  runner: input.runner,
18
24
  agentRole: input.agentRole,
19
25
  linkedContext: input.linkedContext,
26
+ budget: loadPromptPreferences(input.rootPath),
20
27
  });
21
- const promptPath = saveCompiledPrompt(input.rootPath, created.run.run_id, promptMarkdown);
28
+ const promptPath = saveCompiledPrompt(input.rootPath, created.run.run_id, compiledPrompt.markdown);
22
29
  updateRunRecordEntry(input.rootPath, created.run.run_id, {
23
30
  status: 'queued',
24
31
  prompt_path: promptPath,
25
- summary: `Compiled prompt for task ${task.task_id}`,
32
+ prompt_tokens_estimated_before: compiledPrompt.metadata.estimated_tokens_before,
33
+ prompt_tokens_estimated_after: compiledPrompt.metadata.estimated_tokens_after,
34
+ prompt_budget_target: compiledPrompt.metadata.budget_target,
35
+ prompt_trimmed_sections: compiledPrompt.metadata.trimmed_sections,
36
+ summary: `Compiled prompt for task ${task.task_id} (${compiledPrompt.metadata.estimated_tokens_before} -> ${compiledPrompt.metadata.estimated_tokens_after} est. tokens)`,
26
37
  });
27
38
  return {
28
39
  run_id: created.run.run_id,
@@ -30,6 +41,7 @@ export function compileTaskPrompt(input) {
30
41
  runner_type: input.runner,
31
42
  agent_role: input.agentRole,
32
43
  prompt_path: promptPath,
33
- prompt_markdown: promptMarkdown,
44
+ prompt_markdown: compiledPrompt.markdown,
45
+ prompt_optimization: compiledPrompt.metadata,
34
46
  };
35
47
  }