@gethmy/agent 1.6.1 → 1.7.1

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.
@@ -1,6 +1,12 @@
1
1
  import { log } from "./log.js";
2
2
  const TAG = "episode-writer";
3
3
  const MAX_APPROACH_SUMMARY_CHARS = 400;
4
+ // Richer approach summary cap (#272). The single-turn trim used a 400-char cap;
5
+ // the assembled multi-block summary is allowed to run longer but stays bounded
6
+ // so it doesn't bloat the recall prompt.
7
+ const MAX_RICH_APPROACH_CHARS = 1500;
8
+ // Cap on the changed-file list persisted per episode (#272).
9
+ const MAX_CHANGED_FILES = 30;
4
10
  /**
5
11
  * Rule-derived quality score (0..1) for an implement run. Failures default to 0.
6
12
  * Plan §"Quality score": +0.4 if build passed, +0.2 if lint passed, +0.2 if no
@@ -41,6 +47,75 @@ export function trimApproachSummary(text) {
41
47
  return trimmed;
42
48
  return `${trimmed.slice(0, MAX_APPROACH_SUMMARY_CHARS - 1).trimEnd()}…`;
43
49
  }
50
+ // Review rationale cap (#272 task 5). The reviewer's verdict reasoning is the
51
+ // signal we want recallable, so it gets a far higher cap than the 400-char
52
+ // implement trim (the upstream review summary is already sliced to ~2000).
53
+ const MAX_REVIEW_RATIONALE_CHARS = 2000;
54
+ /**
55
+ * Cap the review rationale ("why approved / rejected") richly (#272 task 5)
56
+ * rather than re-trimming to the 400-char implement bound. Empty input
57
+ * collapses to a marker so the episode still surfaces as a recallable hit.
58
+ */
59
+ export function trimReviewRationale(text) {
60
+ const trimmed = text.trim();
61
+ if (trimmed.length === 0)
62
+ return "(no review rationale captured)";
63
+ if (trimmed.length <= MAX_REVIEW_RATIONALE_CHARS)
64
+ return trimmed;
65
+ return `${trimmed.slice(0, MAX_REVIEW_RATIONALE_CHARS - 1).trimEnd()}…`;
66
+ }
67
+ // Lines that read like a root-cause / gotcha / lesson — used to surface a cheap
68
+ // deterministic `key_insight` without an LLM call (#272 task 3).
69
+ const INSIGHT_RE = /\b(root cause|turned out|the (?:issue|problem|bug) (?:was|is)|the fix (?:was|is)|gotcha|caused by|because|the key (?:was|insight)|note that|caveat|the trick (?:was|is))\b/i;
70
+ /**
71
+ * Assemble a richer approach summary from the collected assistant text blocks
72
+ * (#272). Joins the trailing blocks (most relevant context lives near the end
73
+ * of a run) up to a longer bounded cap than the single-turn trim. Falls back to
74
+ * the last-turn `fallback` text when no blocks were collected. No LLM call.
75
+ */
76
+ export function buildRichApproachSummary(blocks, fallback) {
77
+ const cleaned = (blocks ?? [])
78
+ .map((b) => b.trim())
79
+ .filter((b) => b.length > 0);
80
+ if (cleaned.length === 0)
81
+ return trimApproachSummary(fallback);
82
+ // Walk backwards accumulating blocks until we hit the cap, then restore order.
83
+ const picked = [];
84
+ let total = 0;
85
+ for (let i = cleaned.length - 1; i >= 0; i--) {
86
+ const block = cleaned[i];
87
+ const cost = block.length + (picked.length > 0 ? 2 : 0);
88
+ if (total + cost > MAX_RICH_APPROACH_CHARS && picked.length > 0)
89
+ break;
90
+ picked.unshift(block);
91
+ total += cost;
92
+ if (total >= MAX_RICH_APPROACH_CHARS)
93
+ break;
94
+ }
95
+ const joined = picked.join("\n\n").trim();
96
+ if (joined.length === 0)
97
+ return trimApproachSummary(fallback);
98
+ if (joined.length <= MAX_RICH_APPROACH_CHARS)
99
+ return joined;
100
+ return `${joined.slice(0, MAX_RICH_APPROACH_CHARS - 1).trimEnd()}…`;
101
+ }
102
+ /**
103
+ * Extract a cheap deterministic "key insight" line from the run's assistant
104
+ * text (#272 task 3). Scans for a sentence/line matching a root-cause / gotcha
105
+ * pattern. Returns undefined when nothing matches — never fabricated, no LLM.
106
+ */
107
+ export function extractKeyInsight(blocks, fallback) {
108
+ const source = blocks && blocks.length > 0 ? blocks.join("\n") : fallback;
109
+ const lines = source
110
+ .split(/\n|(?<=[.!?])\s+/)
111
+ .map((l) => l.trim())
112
+ .filter((l) => l.length >= 20 && l.length <= 280);
113
+ for (const line of lines) {
114
+ if (INSIGHT_RE.test(line))
115
+ return line;
116
+ }
117
+ return undefined;
118
+ }
44
119
  /**
45
120
  * Build the entity payload for one episode. Pure — returned object can be
46
121
  * snapshotted in tests without hitting the network.
@@ -53,7 +128,11 @@ export function buildEpisodePayload(input, projectId) {
53
128
  });
54
129
  const type = input.outcome === "success" ? "solution" : "error";
55
130
  const importance = input.outcome === "success" ? 7 : 5;
56
- const approachSummary = trimApproachSummary(input.approachSummary);
131
+ // Richer approach summary (#272): assemble from all collected assistant
132
+ // blocks when available; fall back to the single last-turn trim otherwise.
133
+ const approachSummary = buildRichApproachSummary(input.approachBlocks, input.approachSummary);
134
+ const keyInsight = extractKeyInsight(input.approachBlocks, input.approachSummary);
135
+ const changedFiles = (input.changedFiles ?? []).slice(0, MAX_CHANGED_FILES);
57
136
  const outcomeRationale = input.outcome === "success"
58
137
  ? `Build ${input.result.buildErrors.length === 0 ? "passed" : "failed"}, lint ${input.result.lintWarnings.length === 0 ? "clean" : "issues"}.`
59
138
  : `Verification failed: ${input.errorMessage ?? "see findings"}.`;
@@ -72,11 +151,32 @@ export function buildEpisodePayload(input, projectId) {
72
151
  },
73
152
  files_touched: input.filesEdited,
74
153
  num_turns: input.cost?.numTurns ?? 0,
154
+ // Provenance (#273, task 3). Agent episodes are always agent-run; stamp
155
+ // the card + session ids we already carry so downstream hygiene + UI can
156
+ // tell auto-written episodes from human-curated patterns.
157
+ origin: {
158
+ source: "agent-run",
159
+ source_card_id: input.card.id,
160
+ author: "harmony-agent",
161
+ ...(input.agentSessionId
162
+ ? { source_session_id: input.agentSessionId }
163
+ : {}),
164
+ },
75
165
  };
76
166
  if (input.errorMessage)
77
167
  metadata.error = input.errorMessage;
168
+ if (changedFiles.length > 0)
169
+ metadata.changed_files = changedFiles;
170
+ if (input.churn)
171
+ metadata.churn = input.churn;
172
+ if (keyInsight)
173
+ metadata.key_insight = keyInsight;
78
174
  if (input.agentSessionId)
79
175
  metadata.agent_session_id = input.agentSessionId;
176
+ const changedFilesLine = changedFiles.length > 0
177
+ ? `\n\nChanged files (${changedFiles.length}): ${changedFiles.join(", ")}`
178
+ : "";
179
+ const keyInsightLine = keyInsight ? `\n\nKey insight: ${keyInsight}` : "";
80
180
  return {
81
181
  workspace_id: input.workspaceId,
82
182
  project_id: projectId,
@@ -84,7 +184,7 @@ export function buildEpisodePayload(input, projectId) {
84
184
  memory_tier: "episode",
85
185
  scope: "project",
86
186
  title: `Agent run implement — #${input.card.short_id}: ${input.card.title}`,
87
- content: `${approachSummary}\n\nOutcome: ${outcomeRationale}`,
187
+ content: `${approachSummary}\n\nOutcome: ${outcomeRationale}${keyInsightLine}${changedFilesLine}`,
88
188
  metadata,
89
189
  importance,
90
190
  confidence: clampConfidence(qualityScore),
@@ -94,7 +194,7 @@ export function buildEpisodePayload(input, projectId) {
94
194
  }
95
195
  // Review episode
96
196
  const qualityScore = input.verdict === "approved" ? 1 : 0.4;
97
- const summary = trimApproachSummary(input.summary || "(no summary captured)");
197
+ const summary = trimReviewRationale(input.summary || "");
98
198
  const metadata = {
99
199
  episode_kind: "review",
100
200
  card_short_id: input.card.short_id,
@@ -110,6 +210,19 @@ export function buildEpisodePayload(input, projectId) {
110
210
  },
111
211
  files_touched: 0,
112
212
  num_turns: input.cost?.numTurns ?? 0,
213
+ // Provenance (#273, task 3). Review episodes are agent-run too. Prefer the
214
+ // review session id as the originating session when present.
215
+ origin: {
216
+ source: "agent-run",
217
+ source_card_id: input.card.id,
218
+ author: "harmony-agent",
219
+ ...((input.reviewSessionId ?? input.agentSessionId)
220
+ ? {
221
+ source_session_id: (input.reviewSessionId ??
222
+ input.agentSessionId),
223
+ }
224
+ : {}),
225
+ },
113
226
  };
114
227
  if (input.agentSessionId)
115
228
  metadata.agent_session_id = input.agentSessionId;
@@ -132,6 +245,10 @@ export function buildEpisodePayload(input, projectId) {
132
245
  agent_identifier: "harmony-agent",
133
246
  };
134
247
  }
248
+ // DEFERRED (#272 task 6, on-plan): an optional best-effort Claude-CLI
249
+ // distillation of the approach summary / key insight is explicitly out of scope
250
+ // for this pass — no Anthropic SDK, no extra LLM spawn. The deterministic
251
+ // assembly above is the v1 surface; revisit distillation in a later card.
135
252
  /**
136
253
  * Write one episode entity. Best-effort: any failure is logged and swallowed
137
254
  * so the calling pipeline can complete (plan D8: episode writes never block
@@ -0,0 +1,24 @@
1
+ /** Default cap on the number of changed-file paths captured per episode. */
2
+ export declare const MAX_CHANGED_FILES = 30;
3
+ export interface DiffStat {
4
+ /** Changed file paths (authoritative — derived from the diff itself). */
5
+ files: string[];
6
+ /** Total lines added across the diff (best-effort, may be 0). */
7
+ insertions: number;
8
+ /** Total lines removed across the diff (best-effort, may be 0). */
9
+ deletions: number;
10
+ }
11
+ /**
12
+ * Parse `git diff --numstat` output into a changed-file list + churn totals.
13
+ * Pure — exported for testing. numstat lines look like:
14
+ * `12\t3\tpath/to/file.ts`
15
+ * Binary files report `-\t-\t<path>`; those contribute 0 churn but still count
16
+ * as a changed file. The file list is capped at `maxFiles`.
17
+ */
18
+ export declare function parseNumstat(raw: string, maxFiles?: number): DiffStat;
19
+ /**
20
+ * Capture a changed-file list + churn for the work on a branch versus its base,
21
+ * via `git diff --numstat`. Best-effort and guarded: returns null on any
22
+ * failure so callers can fall back to tracked paths (#272). Never throws.
23
+ */
24
+ export declare function captureDiffStat(worktreePath: string, baseBranch: string, maxFiles?: number): DiffStat | null;
@@ -0,0 +1,56 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { log } from "./log.js";
3
+ const TAG = "git-diff-stat";
4
+ /** Default cap on the number of changed-file paths captured per episode. */
5
+ export const MAX_CHANGED_FILES = 30;
6
+ /**
7
+ * Parse `git diff --numstat` output into a changed-file list + churn totals.
8
+ * Pure — exported for testing. numstat lines look like:
9
+ * `12\t3\tpath/to/file.ts`
10
+ * Binary files report `-\t-\t<path>`; those contribute 0 churn but still count
11
+ * as a changed file. The file list is capped at `maxFiles`.
12
+ */
13
+ export function parseNumstat(raw, maxFiles = MAX_CHANGED_FILES) {
14
+ const files = [];
15
+ let insertions = 0;
16
+ let deletions = 0;
17
+ for (const line of raw.split("\n")) {
18
+ const trimmed = line.trim();
19
+ if (trimmed.length === 0)
20
+ continue;
21
+ const parts = trimmed.split("\t");
22
+ if (parts.length < 3)
23
+ continue;
24
+ const [add, del, ...pathParts] = parts;
25
+ const path = pathParts.join("\t");
26
+ if (!path)
27
+ continue;
28
+ const addN = Number.parseInt(add, 10);
29
+ const delN = Number.parseInt(del, 10);
30
+ if (Number.isFinite(addN))
31
+ insertions += addN;
32
+ if (Number.isFinite(delN))
33
+ deletions += delN;
34
+ if (files.length < maxFiles)
35
+ files.push(path);
36
+ }
37
+ return { files, insertions, deletions };
38
+ }
39
+ /**
40
+ * Capture a changed-file list + churn for the work on a branch versus its base,
41
+ * via `git diff --numstat`. Best-effort and guarded: returns null on any
42
+ * failure so callers can fall back to tracked paths (#272). Never throws.
43
+ */
44
+ export function captureDiffStat(worktreePath, baseBranch, maxFiles = MAX_CHANGED_FILES) {
45
+ try {
46
+ const raw = execFileSync("git", ["diff", "--numstat", `${baseBranch}...HEAD`], { cwd: worktreePath, encoding: "utf-8", timeout: 30_000 });
47
+ return parseNumstat(raw, maxFiles);
48
+ }
49
+ catch (err) {
50
+ log.warn(TAG, "git diff --numstat failed", {
51
+ event: "diff_stat_failed",
52
+ error: err instanceof Error ? err.message : String(err),
53
+ });
54
+ return null;
55
+ }
56
+ }
@@ -30,12 +30,6 @@ export interface StatusSnapshot {
30
30
  priority: number;
31
31
  enqueuedAt: number;
32
32
  }>;
33
- dlq: Array<{
34
- cardId: string;
35
- reason: string;
36
- attempts: number;
37
- totalCostCents: number;
38
- }>;
39
33
  budget: {
40
34
  todayCents: number;
41
35
  dailyCapCents: number;
@@ -48,12 +42,6 @@ export interface HttpServerOptions {
48
42
  getStatus: () => StatusSnapshot;
49
43
  getHealth: () => HealthSnapshot;
50
44
  handleCommand: (cmd: Command, cardId: string) => Promise<void>;
51
- /**
52
- * Clear a card's DLQ marker. Routed through the daemon (not CLI
53
- * direct-write) so we never have two processes racing to persist
54
- * the same state-store file.
55
- */
56
- clearDlq: (cardId: string) => Promise<void>;
57
45
  }
58
46
  /**
59
47
  * Tiny introspection + control surface for the running daemon.
@@ -61,7 +49,7 @@ export interface HttpServerOptions {
61
49
  *
62
50
  * GET /health — 200 when healthy, 503 otherwise. Simple liveness check
63
51
  * suitable for process supervisors (systemd, pm2, docker healthcheck).
64
- * GET /status — full JSON snapshot: workers, queues, DLQ, budget.
52
+ * GET /status — full JSON snapshot: workers, queues, budget.
65
53
  * POST /pause/:cardId, /resume/:cardId, /stop/:cardId — command path
66
54
  * so an operator can nudge a stuck worker without killing the daemon.
67
55
  */
@@ -72,7 +60,6 @@ export declare class HttpServer {
72
60
  start(): Promise<void>;
73
61
  stop(): Promise<void>;
74
62
  private route;
75
- private respondDlqClear;
76
63
  private respondHealth;
77
64
  private respondStatus;
78
65
  private respondCommand;
@@ -7,7 +7,7 @@ const TAG = "http";
7
7
  *
8
8
  * GET /health — 200 when healthy, 503 otherwise. Simple liveness check
9
9
  * suitable for process supervisors (systemd, pm2, docker healthcheck).
10
- * GET /status — full JSON snapshot: workers, queues, DLQ, budget.
10
+ * GET /status — full JSON snapshot: workers, queues, budget.
11
11
  * POST /pause/:cardId, /resume/:cardId, /stop/:cardId — command path
12
12
  * so an operator can nudge a stuck worker without killing the daemon.
13
13
  */
@@ -53,10 +53,6 @@ export class HttpServer {
53
53
  return this.respondStatus(res);
54
54
  }
55
55
  if (method === "POST") {
56
- const dlq = path.match(/^\/dlq\/clear\/([^/]+)$/);
57
- if (dlq) {
58
- return this.respondDlqClear(res, decodeURIComponent(dlq[1]));
59
- }
60
56
  const cmd = parseCommand(path);
61
57
  if (cmd) {
62
58
  return this.respondCommand(res, cmd.command, cmd.cardId);
@@ -65,20 +61,6 @@ export class HttpServer {
65
61
  res.writeHead(404, { "content-type": "application/json" });
66
62
  res.end(JSON.stringify({ error: "not_found", path }));
67
63
  }
68
- async respondDlqClear(res, cardId) {
69
- try {
70
- await this.opts.clearDlq(cardId);
71
- res.writeHead(200, { "content-type": "application/json" });
72
- res.end(JSON.stringify({ ok: true, cardId }));
73
- }
74
- catch (err) {
75
- res.writeHead(500, { "content-type": "application/json" });
76
- res.end(JSON.stringify({
77
- error: "clear_dlq_failed",
78
- detail: err instanceof Error ? err.message : String(err),
79
- }));
80
- }
81
- }
82
64
  respondHealth(res) {
83
65
  const health = this.opts.getHealth();
84
66
  res.writeHead(health.healthy ? 200 : 503, {
package/dist/index.js CHANGED
@@ -173,12 +173,6 @@ export async function main() {
173
173
  },
174
174
  getStatus: () => {
175
175
  const queues = pool.snapshotQueues();
176
- const dlq = stateStore.listDlq().map((c) => ({
177
- cardId: c.cardId,
178
- reason: c.dlqReason ?? "unknown",
179
- attempts: c.attempts,
180
- totalCostCents: c.totalCostCents,
181
- }));
182
176
  return {
183
177
  daemonId,
184
178
  daemonPid: process.pid,
@@ -200,7 +194,6 @@ export async function main() {
200
194
  priority: i.priority,
201
195
  enqueuedAt: i.enqueuedAt,
202
196
  })),
203
- dlq,
204
197
  budget: {
205
198
  todayCents: stateStore.getDailyCostCents(),
206
199
  dailyCapCents: config.agent.budget.dailyBudgetCents,
@@ -208,7 +201,6 @@ export async function main() {
208
201
  };
209
202
  },
210
203
  handleCommand: (cmd, cardId) => pool.handleAgentCommand(cardId, cmd),
211
- clearDlq: (cardId) => stateStore.clearDlq(cardId),
212
204
  })
213
205
  : null;
214
206
  // Create watcher (broadcast events from harmony-api + agent commands from UI)
@@ -267,7 +259,9 @@ export async function main() {
267
259
  }
268
260
  // Banner check lines for service intervals + pool. Compose into one
269
261
  // line each so the banner stays compact.
270
- const reviewCount = config.agent.review.enabled ? 1 : 0;
262
+ const reviewCount = config.agent.review.enabled
263
+ ? config.agent.review.poolSize
264
+ : 0;
271
265
  banner.check(`Pool: ${config.agent.poolSize} impl${reviewCount > 0 ? ` + ${reviewCount} review` : ""}`);
272
266
  const services = [
273
267
  `Heartbeat ${config.agent.timing.reconcileIntervalMs / 1000}s`,
@@ -369,4 +363,4 @@ async function tryEnqueueCard(cardId, client, pool, config) {
369
363
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
370
364
  }
371
365
  // The daemon is now launched from cli.ts so other subcommands
372
- // (status, doctor, gc, dlq) can load this module without side effects.
366
+ // (status, doctor, gc) can load this module without side effects.
package/dist/pool.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { StateStore } from "./state-store.js";
5
5
  import { type AgentConfig, type WorkMode } from "./types.js";
6
6
  export declare class Pool {
7
7
  private client;
8
+ private stateStore;
8
9
  private implWorkers;
9
10
  private reviewWorkers;
10
11
  private implQueue;
@@ -14,9 +15,9 @@ export declare class Pool {
14
15
  /**
15
16
  * Enqueue a card for processing with the given mode.
16
17
  *
17
- * Returns async so callers can await the DLQ side-effects on skip.
18
- * Budget/DLQ checks happen here so the reconciler, realtime watcher,
19
- * and manual API calls all go through the same gate.
18
+ * Returns async so callers can await the give-up comment / waiting
19
+ * signal on skip. Budget checks happen here so the reconciler, realtime
20
+ * watcher, and manual API calls all go through the same gate.
20
21
  */
21
22
  enqueue(card: Card, column: Column, labels: Label[], subtasks: Subtask[], mode?: WorkMode): Promise<void>;
22
23
  /**
package/dist/pool.js CHANGED
@@ -7,6 +7,7 @@ import { Worker } from "./worker.js";
7
7
  const TAG = "pool";
8
8
  export class Pool {
9
9
  client;
10
+ stateStore;
10
11
  implWorkers = [];
11
12
  reviewWorkers = [];
12
13
  implQueue;
@@ -14,29 +15,34 @@ export class Pool {
14
15
  budget;
15
16
  constructor(config, client, userEmail, workspaceId, projectId, stateStore) {
16
17
  this.client = client;
18
+ this.stateStore = stateStore;
17
19
  this.implQueue = new PriorityQueue(config);
18
20
  this.reviewQueue = new PriorityQueue(config);
19
- this.budget = new BudgetGuard(config.budget, stateStore);
21
+ this.budget = new BudgetGuard(config.budget, this.stateStore);
20
22
  // Create implementation workers
21
23
  for (let i = 0; i < config.poolSize; i++) {
22
24
  this.implWorkers.push(new Worker(i, config, client, userEmail, () => {
23
25
  this.tryDispatchFor(this.implWorkers, this.implQueue, "impl");
24
26
  }, workspaceId, projectId, stateStore));
25
27
  }
26
- // Create review worker(s) 1 review worker per pool
28
+ // Create review workers. IDs offset by `config.poolSize` so impl and
29
+ // review worker IDs never collide (verification + review ports both
30
+ // derive from the worker ID, so collision would mean port collision).
27
31
  if (config.review.enabled) {
28
- const reviewWorkerId = config.poolSize; // offset to avoid ID collision
29
- this.reviewWorkers.push(new ReviewWorker(reviewWorkerId, config, client, userEmail, () => {
30
- this.tryDispatchFor(this.reviewWorkers, this.reviewQueue, "review");
31
- }, stateStore, workspaceId, projectId));
32
+ for (let i = 0; i < config.review.poolSize; i++) {
33
+ const reviewWorkerId = config.poolSize + i;
34
+ this.reviewWorkers.push(new ReviewWorker(reviewWorkerId, config, client, userEmail, () => {
35
+ this.tryDispatchFor(this.reviewWorkers, this.reviewQueue, "review");
36
+ }, stateStore, workspaceId, projectId));
37
+ }
32
38
  }
33
39
  }
34
40
  /**
35
41
  * Enqueue a card for processing with the given mode.
36
42
  *
37
- * Returns async so callers can await the DLQ side-effects on skip.
38
- * Budget/DLQ checks happen here so the reconciler, realtime watcher,
39
- * and manual API calls all go through the same gate.
43
+ * Returns async so callers can await the give-up comment / waiting
44
+ * signal on skip. Budget checks happen here so the reconciler, realtime
45
+ * watcher, and manual API calls all go through the same gate.
40
46
  */
41
47
  async enqueue(card, column, labels, subtasks, mode = "implement") {
42
48
  // Don't enqueue if already in any queue or actively being worked on
@@ -51,22 +57,18 @@ export class Pool {
51
57
  if (mode === "implement") {
52
58
  const decision = this.budget.check(card.id);
53
59
  if (!decision.allow) {
54
- // Already-DLQ cards are expected noise on every reconcile tick;
55
- // only the terminal decision itself deserves a warn.
56
- const wasAlreadyDlq = decision.reason === "dlq";
57
- if (!wasAlreadyDlq) {
58
- log.warn(TAG, `#${card.short_id} skipped (${decision.reason}): ${decision.detail}`);
59
- if (this.budget.isTerminal(decision.reason)) {
60
- await this.budget.markDlq(this.client, card, decision.reason, decision.detail);
61
- }
62
- else if (decision.reason === "daily_budget") {
63
- // Soft pause — surface as `waiting` so the board signals that
64
- // the daemon will pick this card up after the budget resets.
65
- await this.emitWaiting(card.id, `Daily budget reached — waiting for reset (${decision.detail})`);
66
- }
60
+ if (decision.reason === "daily_budget") {
61
+ // Soft pause surface as `waiting` so the board signals that
62
+ // the daemon will pick this card up after the budget resets.
63
+ log.warn(TAG, `#${card.short_id} skipped (daily_budget): ${decision.detail}`);
64
+ await this.emitWaiting(card.id, `Daily budget reached — waiting for reset (${decision.detail})`);
67
65
  }
68
66
  else {
69
- log.debug(TAG, `#${card.short_id} in DLQ: ${decision.detail}`);
67
+ // max_attempts: the daemon has given up on this card. The worker
68
+ // already posted the one-shot give-up comment at the crossing, so
69
+ // every later reconcile tick is expected noise — stay quiet until
70
+ // the card is reassigned (which resets the attempt counter).
71
+ log.debug(TAG, `#${card.short_id} gave up: ${decision.detail}`);
70
72
  }
71
73
  return;
72
74
  }
@@ -109,6 +111,9 @@ export class Pool {
109
111
  * Remove a card from any queue or cancel an active worker.
110
112
  */
111
113
  async removeCard(cardId) {
114
+ // Unassigning is the human's "try again" signal — clear any exhausted
115
+ // attempt counter so a reassign starts the card fresh (no DLQ to clear).
116
+ await this.stateStore.resetAttempts(cardId);
112
117
  // Try both queues
113
118
  for (const queue of [this.implQueue, this.reviewQueue]) {
114
119
  const removed = queue.remove(cardId);
@@ -31,6 +31,7 @@ export declare class ProgressTracker {
31
31
  private logBuffer;
32
32
  private sessionId;
33
33
  private lastAssistantText;
34
+ private assistantTextBlocks;
34
35
  constructor(client: HarmonyApiClient, cardId: string, workerId: number, subtasks: {
35
36
  completed: boolean;
36
37
  }[]);
@@ -46,10 +47,12 @@ export declare class ProgressTracker {
46
47
  /** Get a summary of the session stats. */
47
48
  get stats(): {
48
49
  filesEdited: number;
50
+ filesEditedPaths: string[];
49
51
  filesRead: number;
50
52
  toolCalls: number;
51
53
  cost: CostUpdate | null;
52
54
  lastAssistantText: string;
55
+ assistantTextBlocks: string[];
53
56
  };
54
57
  private onToolStart;
55
58
  private onToolEnd;
@@ -5,6 +5,9 @@ const THROTTLE_MS = 5_000;
5
5
  const HEARTBEAT_MS = 60_000;
6
6
  const MAX_TASK_LENGTH = 120;
7
7
  const MAX_LOG_BUFFER = 500;
8
+ // Cap on retained assistant text blocks so a long run can't grow the buffer
9
+ // unbounded (#272). The episode write hook reads from the tail.
10
+ const MAX_TEXT_BLOCKS = 40;
8
11
  // Hoisted regexes — avoids recompilation on every call
9
12
  const SENTENCE_SPLIT = /\.\s|\n/;
10
13
  const ACTION_PREFIX = /^(Let me|I'll|I need to|Now|First|Next|Looking|Checking|Creating|Adding|Updating|Fixing|Refactoring|Moving|The |This )/i;
@@ -65,6 +68,9 @@ export class ProgressTracker {
65
68
  // Last assistant text block — used by the episode write hook to
66
69
  // capture an approach summary without re-running an LLM (plan §"Write hook").
67
70
  lastAssistantText = "";
71
+ // All non-trivial assistant text blocks, in order, used to assemble a richer
72
+ // structured approach summary at episode-write time (#272). Bounded.
73
+ assistantTextBlocks = [];
68
74
  constructor(client, cardId, workerId, subtasks) {
69
75
  this.client = client;
70
76
  this.cardId = cardId;
@@ -129,10 +135,12 @@ export class ProgressTracker {
129
135
  get stats() {
130
136
  return {
131
137
  filesEdited: this.filesEdited.size,
138
+ filesEditedPaths: [...this.filesEdited],
132
139
  filesRead: this.filesRead.size,
133
140
  toolCalls: this.toolCallCount,
134
141
  cost: this.lastCost,
135
142
  lastAssistantText: this.lastAssistantText,
143
+ assistantTextBlocks: [...this.assistantTextBlocks],
136
144
  };
137
145
  }
138
146
  onToolStart(name, input) {
@@ -212,6 +220,13 @@ export class ProgressTracker {
212
220
  // Always remember the latest non-trivial assistant turn for the episode
213
221
  // write hook — last-turn trim, no LLM rewrite (plan §"Write hook").
214
222
  this.lastAssistantText = trimmed;
223
+ // Accumulate all non-trivial assistant blocks so the episode write hook can
224
+ // assemble a richer approach summary than the last turn alone (#272).
225
+ // Bounded: drop the oldest once over the cap.
226
+ this.assistantTextBlocks.push(trimmed);
227
+ if (this.assistantTextBlocks.length > MAX_TEXT_BLOCKS) {
228
+ this.assistantTextBlocks.shift();
229
+ }
215
230
  // Extract first sentence or line as a brief description
216
231
  const end = trimmed.search(SENTENCE_SPLIT);
217
232
  const firstLine = (end === -1 ? trimmed : trimmed.slice(0, end)).trim();
package/dist/prompt.d.ts CHANGED
@@ -10,6 +10,11 @@ import type { EnrichedCard } from "./types.js";
10
10
  * Falls back to a minimal local prompt if the API call fails.
11
11
  */
12
12
  export declare function buildPrompt(enriched: EnrichedCard, branchName: string, worktreePath: string, client: HarmonyApiClient, workspaceId: string, projectId?: string): Promise<string>;
13
+ /**
14
+ * Fetch and serialize the card's comment thread for the agent prompt. Returns
15
+ * the empty string on no comments or fetch failure — never throws.
16
+ */
17
+ export declare function renderCommentsSection(client: HarmonyApiClient, cardId: string): Promise<string>;
13
18
  /**
14
19
  * Recall similar past episodes (implement solution/error type) and render them
15
20
  * as a "Similar past tasks" section. Returns the empty string on no hits or
package/dist/prompt.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { serializeCommentThread } from "@harmony/shared";
1
2
  import { log } from "./log.js";
2
3
  const TAG = "prompt";
3
4
  /**
@@ -26,15 +27,42 @@ Do NOT push to main. All your work stays on \`${branchName}\`.
26
27
  When finished, call harmony_end_agent_session with status="completed".`,
27
28
  });
28
29
  log.info(TAG, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
30
+ // generateCardPrompt already appends the comment thread centrally.
29
31
  return result.prompt + pastEpisodesSection;
30
32
  }
31
33
  catch (err) {
32
34
  const msg = err instanceof Error ? err.message : String(err);
33
35
  log.warn(TAG, `Failed to generate prompt via API, using fallback: ${msg}`);
36
+ // Fallback bypasses generateCardPrompt, so inject the thread here.
37
+ const commentsSection = await renderCommentsSection(client, card.id);
34
38
  return (buildFallbackPrompt(enriched, branchName, worktreePath) +
39
+ commentsSection +
35
40
  pastEpisodesSection);
36
41
  }
37
42
  }
43
+ /**
44
+ * Fetch and serialize the card's comment thread for the agent prompt. Returns
45
+ * the empty string on no comments or fetch failure — never throws.
46
+ */
47
+ export async function renderCommentsSection(client, cardId) {
48
+ try {
49
+ const { comments } = await client.getComments(cardId, { limit: 200 });
50
+ if (!Array.isArray(comments) || comments.length === 0)
51
+ return "";
52
+ const section = serializeCommentThread(comments, {
53
+ heading: "Comments",
54
+ maxComments: 40,
55
+ });
56
+ return section ? `\n\n${section}` : "";
57
+ }
58
+ catch (err) {
59
+ log.warn(TAG, "comment-thread fetch failed", {
60
+ event: "comment_fetch_failed",
61
+ error: err instanceof Error ? err.message : String(err),
62
+ });
63
+ return "";
64
+ }
65
+ }
38
66
  /**
39
67
  * Recall similar past episodes (implement solution/error type) and render them
40
68
  * as a "Similar past tasks" section. Returns the empty string on no hits or
@@ -62,7 +90,22 @@ export async function renderPastEpisodesSection(client, title, description, work
62
90
  const meta = e.metadata ?? {};
63
91
  const outcomeTag = meta.outcome ? `[${meta.outcome}]` : "[?]";
64
92
  const approach = meta.approach_summary ?? "";
65
- return `- ${outcomeTag} ${e.title ?? "(untitled episode)"}\n Approach: ${approach}`;
93
+ const lines = [
94
+ `- ${outcomeTag} ${e.title ?? "(untitled episode)"}`,
95
+ ` Approach: ${approach}`,
96
+ ];
97
+ // Surface the cheap deterministic root-cause line when present (#272).
98
+ if (meta.key_insight)
99
+ lines.push(` Key insight: ${meta.key_insight}`);
100
+ // Show a compact changed-files list — cap the rendered count so the
101
+ // prompt stays small even if the stored list is long (#272).
102
+ if (meta.changed_files && meta.changed_files.length > 0) {
103
+ const shown = meta.changed_files.slice(0, 8);
104
+ const extra = meta.changed_files.length - shown.length;
105
+ const suffix = extra > 0 ? ` (+${extra} more)` : "";
106
+ lines.push(` Changed files: ${shown.join(", ")}${suffix}`);
107
+ }
108
+ return lines.join("\n");
66
109
  })
67
110
  .join("\n");
68
111
  return `\n\n## Similar past tasks\n${bullets}`;