@link-assistant/hive-mind 2.11.3 โ†’ 2.11.4

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.11.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 6b3df3c: Stop reporting an empty `--model formal-ai` run as a success. Formal AI sessions are now attributed to Link.Assistant at $0.00, token usage is parsed for all six tools, the two duplicate auto-restart loops are one N/M budget that fails visibly when it is exhausted, and a pull request whose net diff is empty (or holds only the solver's own placeholder) is neither described as changed nor announced as ready to merge. Docker images now pin Formal AI 0.317.0 so the upstream workspace-effect and self-healing fixes are distributed with Hive Mind.
8
+
3
9
  ## 2.11.3
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.3",
3
+ "version": "2.11.4",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -11,7 +11,9 @@ import { resolveCodexReasoningEffort } from './codex.options.lib.mjs';
11
11
  import { mapClaudeSubAgentModelToEnvValue, mapModelForTool } from './models/index.mjs';
12
12
  import { buildCodexDisable1mContextConfigArgs, buildCodexSubSessionSizeConfigArgs, parseSubSessionSize } from './sub-session-size.lib.mjs';
13
13
  import { detectUsageLimit } from './usage-limit.lib.mjs';
14
+ import { applyFormalAiPricingOverride } from './formal-ai-pricing.lib.mjs'; // Issue #2119
14
15
  import { getCacheReadTokenCount, getCumulativeContextInputTokens, getOutputTokenCount } from './context-fill.lib.mjs';
16
+ import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
15
17
 
16
18
  export const AGENT_COMMANDER_TOOLS = new Set(['claude', 'codex', 'opencode', 'agent', 'qwen', 'gemini']);
17
19
 
@@ -257,25 +259,34 @@ const enrichPricingInfoWithTokenUsage = ({ pricingInfo = null, usage = null, too
257
259
  };
258
260
  };
259
261
 
260
- export const summarizeAgentCommanderResult = ({ result, tool }) => {
262
+ export const summarizeAgentCommanderResult = ({ result, tool, model = null }) => {
261
263
  const plainOutput = result?.output?.plain || '';
262
264
  if (result?.metadata && typeof result.metadata === 'object') {
263
265
  const metadata = result.metadata;
264
266
  const streamTokenUsage = metadata.streamTokenUsage || result.usage || null;
265
- const pricingInfo = enrichPricingInfoWithTokenUsage({
267
+ const enrichedPricingInfo = enrichPricingInfoWithTokenUsage({
266
268
  pricingInfo: metadata.pricingInfo || null,
267
269
  usage: streamTokenUsage,
268
270
  tool,
269
271
  publicPricingEstimate: metadata.publicPricingEstimate ?? metadata.pricingInfo?.totalCostUSD ?? null,
270
272
  });
273
+ // Issue #2119: a Formal AI session belongs to Link.Assistant at $0.00, no
274
+ // matter which agentic CLI agent-commander drove it.
275
+ const { pricingInfo, publicPricingEstimate, anthropicTotalCostUSD } = applyFormalAiPricingOverride({
276
+ model,
277
+ pricingInfo: enrichedPricingInfo,
278
+ publicPricingEstimate: metadata.publicPricingEstimate ?? enrichedPricingInfo?.totalCostUSD ?? null,
279
+ anthropicTotalCostUSD: metadata.anthropicTotalCostUSD ?? null,
280
+ tokenUsage: streamTokenUsage,
281
+ });
271
282
  return {
272
283
  success: metadata.success === true,
273
284
  sessionId: metadata.sessionId || result.sessionId || null,
274
285
  limitReached: !!metadata.limitReached,
275
286
  limitResetTime: metadata.limitResetTime || null,
276
287
  limitTimezone: metadata.limitTimezone || null,
277
- anthropicTotalCostUSD: metadata.anthropicTotalCostUSD ?? null,
278
- publicPricingEstimate: metadata.publicPricingEstimate ?? pricingInfo?.totalCostUSD ?? null,
288
+ anthropicTotalCostUSD,
289
+ publicPricingEstimate,
279
290
  pricingInfo,
280
291
  resultSummary: metadata.resultSummary || null,
281
292
  resultModelUsage: metadata.resultModelUsage || null,
@@ -291,12 +302,19 @@ export const summarizeAgentCommanderResult = ({ result, tool }) => {
291
302
  const usage = result?.usage || null;
292
303
  const resultMessage = [...messages].reverse().find(message => message?.type === 'result') || null;
293
304
  const totalCost = typeof resultMessage?.total_cost_usd === 'number' ? resultMessage.total_cost_usd : null;
294
- const publicPricingEstimate = tool === 'agent' && typeof usage?.totalCost === 'number' ? usage.totalCost : null;
295
- const pricingInfo = enrichPricingInfoWithTokenUsage({
296
- pricingInfo: publicPricingEstimate !== null ? { totalCostUSD: publicPricingEstimate, source: 'agent-commander' } : null,
305
+ const rawPublicPricingEstimate = tool === 'agent' && typeof usage?.totalCost === 'number' ? usage.totalCost : null;
306
+ const enrichedPricingInfo = enrichPricingInfoWithTokenUsage({
307
+ pricingInfo: rawPublicPricingEstimate !== null ? { totalCostUSD: rawPublicPricingEstimate, source: 'agent-commander' } : null,
297
308
  usage,
298
309
  tool,
299
- publicPricingEstimate,
310
+ publicPricingEstimate: rawPublicPricingEstimate,
311
+ });
312
+ const { pricingInfo, publicPricingEstimate, anthropicTotalCostUSD } = applyFormalAiPricingOverride({
313
+ model,
314
+ pricingInfo: enrichedPricingInfo,
315
+ publicPricingEstimate: rawPublicPricingEstimate ?? enrichedPricingInfo?.totalCostUSD ?? null,
316
+ anthropicTotalCostUSD: tool === 'claude' ? totalCost : null,
317
+ tokenUsage: usage,
300
318
  });
301
319
 
302
320
  return {
@@ -305,8 +323,8 @@ export const summarizeAgentCommanderResult = ({ result, tool }) => {
305
323
  limitReached: usageLimit.isUsageLimit,
306
324
  limitResetTime: usageLimit.resetTime,
307
325
  limitTimezone: usageLimit.timezone,
308
- anthropicTotalCostUSD: tool === 'claude' ? totalCost : null,
309
- publicPricingEstimate: publicPricingEstimate ?? pricingInfo?.totalCostUSD ?? null,
326
+ anthropicTotalCostUSD,
327
+ publicPricingEstimate,
310
328
  pricingInfo,
311
329
  resultSummary: extractResultSummary(messages, plainOutput),
312
330
  resultModelUsage: null,
@@ -367,13 +385,16 @@ export const executeWithAgentCommander = async params => {
367
385
 
368
386
  const result = await controller.stop();
369
387
  await log(`[agent-commander] ${tool} exited with code ${result.exitCode}`);
370
- return summarizeAgentCommanderResult({ result, tool });
388
+ return summarizeAgentCommanderResult({ result, tool, model: argv.model });
371
389
  };
372
390
 
373
391
  export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log = defaultLog, autoCommit = false, autoRestartEnabled = true) => {
374
392
  await log('\n๐Ÿ” Checking for uncommitted changes...');
393
+ // Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
394
+ // the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
395
+ await ensureAiToolScratchIgnored(tempDir, log);
375
396
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
376
- const statusOutput = gitStatusResult.stdout?.toString().trim() || '';
397
+ const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout?.toString().trim() || '');
377
398
 
378
399
  if (!statusOutput) {
379
400
  await log('โœ… No uncommitted changes found');
@@ -2,6 +2,7 @@
2
2
 
3
3
  import Decimal from 'decimal.js-light';
4
4
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
5
+ import { parseJsonRecords } from './json-stream.lib.mjs';
5
6
  import { getCumulativeContextInputTokens, getRestoredContextInputTokens } from './context-fill.lib.mjs';
6
7
 
7
8
  export const createTokenFieldAvailability = () => ({
@@ -95,15 +96,10 @@ export const accumulateAgentStepFinishUsage = (usage, data) => {
95
96
  export const parseAgentTokenUsage = output => {
96
97
  const usage = createAgentTokenUsage();
97
98
 
98
- for (const rawLine of output.split('\n')) {
99
- const line = rawLine.trim();
100
- if (!line || !line.startsWith('{')) continue;
101
-
102
- try {
103
- accumulateAgentStepFinishUsage(usage, sanitizeObjectStrings(JSON.parse(line)));
104
- } catch {
105
- continue;
106
- }
99
+ // Issue #2119: records are framed by balanced JSON rather than by newlines,
100
+ // so pretty-printed (multi-line) and concatenated records are counted too.
101
+ for (const record of parseJsonRecords(output)) {
102
+ accumulateAgentStepFinishUsage(usage, sanitizeObjectStrings(record));
107
103
  }
108
104
 
109
105
  return usage;
package/src/agent.lib.mjs CHANGED
@@ -21,12 +21,15 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
21
21
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
22
22
  import Decimal from 'decimal.js-light';
23
23
  import semver from 'semver';
24
- import { agentModels, defaultModels, freeToBaseModelMap } from './models/index.mjs';
24
+ import { agentModels, defaultModels, freeToBaseModelMap, isFormalAiModel } from './models/index.mjs';
25
25
  import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
26
+ import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
26
27
  import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
27
28
  import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
29
+ import { createJsonStreamScanner, parseJsonRecords } from './json-stream.lib.mjs';
28
30
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
29
31
  import { attachStreamingInput, finalizeBidirectionalHandler, setupBidirectionalHandler } from './bidirectional-interactive.lib.mjs';
32
+ import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
30
33
 
31
34
  export { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage };
32
35
 
@@ -111,6 +114,10 @@ const getBaseModelForPricing = modelName => {
111
114
  * - opencodeCost: Actual billed cost from OpenCode Zen (free for most models)
112
115
  */
113
116
  export const calculateAgentPricing = async (modelId, tokenUsage) => {
117
+ // Issue #2119: Formal AI requests never reach OpenCode Zen, so neither the
118
+ // provider label nor a models.dev price lookup applies to them.
119
+ if (isFormalAiModel(modelId)) return buildFormalAiPricingInfo(modelId, tokenUsage);
120
+
114
121
  // Extract the model name from provider/model format
115
122
  // e.g., 'opencode/grok-code' -> 'grok-code'
116
123
  const modelName = modelId.includes('/') ? modelId.split('/').pop() : modelId;
@@ -632,80 +639,92 @@ export const executeAgentCommand = async params => {
632
639
  }
633
640
  };
634
641
 
635
- for await (const chunk of execCommand.stream()) {
636
- if (chunk.type === 'stdout') {
637
- const output = chunk.data.toString();
638
- // Split output into individual lines for NDJSON parsing
639
- // Agent outputs NDJSON (newline-delimited JSON) format where each line is a separate JSON object
640
- // This allows us to parse each event independently and extract structured data like session IDs
641
- const lines = output.split('\n');
642
- for (const line of lines) {
643
- if (!line.trim()) continue;
644
- try {
645
- const data = sanitizeObjectStrings(JSON.parse(line));
646
- // Issue #1968: a bare `null`/primitive NDJSON line must not abort
647
- // event processing (any data.X access would throw on null).
648
- if (data === null || typeof data !== 'object') continue;
649
- // Output formatted JSON
650
- await log(JSON.stringify(data, null, 2));
651
- // Capture session ID from the first message
652
- const eventSessionId = data.sessionID || data.session_id || data.sessionId;
653
- if (!sessionId && eventSessionId) {
654
- sessionId = eventSessionId;
655
- await log(`๐Ÿ“Œ Session ID: ${sessionId}`);
656
- }
657
- // Issue #1250: Accumulate token usage during streaming
658
- accumulateTokenUsage(data);
659
- await markBidirectionalStateFromAgentEvent(data);
660
- // Issue #1201: Detect error events during streaming for reliable detection
661
- if (data.type === 'error' || data.type === 'step_error') {
662
- streamingErrorDetected = true;
663
- streamingErrorMessage = data.message || data.error || line.substring(0, 100);
664
- await log(`โš ๏ธ Error event detected in stream: ${streamingErrorMessage}`, { level: 'warning' });
665
- }
666
- // Issue #1263: Track text content for result summary
667
- // Agent outputs text via 'text', 'assistant', or 'message' type events
668
- if (data.type === 'text' && data.text) {
669
- lastTextContent = data.text;
670
- } else if (data.type === 'assistant' && data.message?.content) {
671
- // Extract text from assistant message content
672
- const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
673
- for (const item of content) {
674
- if (item.type === 'text' && item.text) {
675
- lastTextContent = item.text;
676
- }
677
- }
678
- } else if (data.type === 'message' && data.content) {
679
- // Direct message content
680
- if (typeof data.content === 'string') {
681
- lastTextContent = data.content;
682
- } else if (Array.isArray(data.content)) {
683
- for (const item of data.content) {
684
- if (item.type === 'text' && item.text) {
685
- lastTextContent = item.text;
686
- }
687
- }
688
- }
689
- } else if (data.type === 'result' && data.result) {
690
- // Explicit result message (like Claude outputs)
691
- lastTextContent = data.result;
692
- }
693
- // Issue #1276: Detect successful completion events
694
- // When agent emits session.idle or log with "exiting loop" message, it completed successfully
695
- // This means any previous error events were recovered from (e.g., timeout then retry)
696
- if (isAgentSuccessfulCompletionEvent(data)) {
697
- agentCompletedSuccessfully = true;
642
+ // Issue #2119: agentic CLIs do not all emit strict one-record-per-line
643
+ // NDJSON. `formal-ai with agent --verbose` emits pretty-printed,
644
+ // multi-line records, and records can also be concatenated without a
645
+ // separator (issue #1250) or split across process chunks. A line-based
646
+ // JSON.parse dropped every structured event in those cases, which is how
647
+ // a session that really used 21677/22834 tokens was published as
648
+ // "Token usage: 0 input, 0 output" with no session id and no result
649
+ // summary. The scanner frames records by balanced JSON instead of by
650
+ // newlines, and surfaces anything that is not JSON as plain text.
651
+ const stdoutScanner = createJsonStreamScanner();
652
+ const stderrScanner = createJsonStreamScanner();
653
+
654
+ const handleAgentJsonEvent = async (raw, value) => {
655
+ const data = sanitizeObjectStrings(value);
656
+ // Issue #1968: a bare `null`/primitive record must not abort event
657
+ // processing (any data.X access would throw on null).
658
+ if (data === null || typeof data !== 'object') return;
659
+ // Output formatted JSON
660
+ await log(JSON.stringify(data, null, 2));
661
+ // Capture session ID from the first message (agent may use stdout or stderr)
662
+ const eventSessionId = data.sessionID || data.session_id || data.sessionId;
663
+ if (!sessionId && eventSessionId) {
664
+ sessionId = eventSessionId;
665
+ await log(`๐Ÿ“Œ Session ID: ${sessionId}`);
666
+ }
667
+ // Issue #1250: Accumulate token usage during streaming
668
+ accumulateTokenUsage(data);
669
+ await markBidirectionalStateFromAgentEvent(data);
670
+ // Issue #1201: Detect error events during streaming for reliable detection
671
+ if (data.type === 'error' || data.type === 'step_error') {
672
+ streamingErrorDetected = true;
673
+ streamingErrorMessage = data.message || data.error || raw.substring(0, 100);
674
+ await log(`โš ๏ธ Error event detected in stream: ${streamingErrorMessage}`, { level: 'warning' });
675
+ }
676
+ // Issue #1263: Track text content for result summary
677
+ // Agent outputs text via 'text', 'assistant', or 'message' type events
678
+ if (data.type === 'text' && data.text) {
679
+ lastTextContent = data.text;
680
+ } else if (data.type === 'assistant' && data.message?.content) {
681
+ // Extract text from assistant message content
682
+ const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
683
+ for (const item of content) {
684
+ if (item.type === 'text' && item.text) {
685
+ lastTextContent = item.text;
686
+ }
687
+ }
688
+ } else if (data.type === 'message' && data.content) {
689
+ // Direct message content
690
+ if (typeof data.content === 'string') {
691
+ lastTextContent = data.content;
692
+ } else if (Array.isArray(data.content)) {
693
+ for (const item of data.content) {
694
+ if (item.type === 'text' && item.text) {
695
+ lastTextContent = item.text;
698
696
  }
699
- // Issue #1296: Detect step_finish with reason "stop" as successful completion
700
- // This is a clear marker of success - agent finished normally, not due to error or limit
701
- // When this event appears, we should ignore any error events that appeared earlier in the stream
702
- // (e.g., timeout errors that were recovered from via retry logic)
703
- if (data.type === 'step_finish' && data.part?.reason === 'stop') agentCompletedSuccessfully = true;
704
- } catch {
705
- // Not JSON - log as plain text
706
- await log(line);
707
697
  }
708
698
  }
699
+ } else if (data.type === 'result' && data.result) {
700
+ // Explicit result message (like Claude outputs)
701
+ lastTextContent = data.result;
702
+ }
703
+ // Issue #1276: Detect successful completion events
704
+ // When agent emits session.idle or log with "exiting loop" message, it completed successfully
705
+ // This means any previous error events were recovered from (e.g., timeout then retry)
706
+ if (isAgentSuccessfulCompletionEvent(data)) {
707
+ agentCompletedSuccessfully = true;
708
+ }
709
+ // Issue #1296: Detect step_finish with reason "stop" as successful completion
710
+ // This is a clear marker of success - agent finished normally, not due to error or limit
711
+ // When this event appears, we should ignore any error events that appeared earlier in the stream
712
+ // (e.g., timeout errors that were recovered from via retry logic)
713
+ if (data.type === 'step_finish' && data.part?.reason === 'stop') agentCompletedSuccessfully = true;
714
+ };
715
+
716
+ const handleAgentStreamEvents = async events => {
717
+ for (const event of events) {
718
+ if (event.type === 'json') await handleAgentJsonEvent(event.raw, event.value);
719
+ // Not JSON - log as plain text
720
+ else await log(event.value);
721
+ }
722
+ };
723
+
724
+ for await (const chunk of execCommand.stream()) {
725
+ if (chunk.type === 'stdout') {
726
+ const output = chunk.data.toString();
727
+ await handleAgentStreamEvents(stdoutScanner.write(output));
709
728
  lastMessage = output;
710
729
  fullOutput += output; // Collect for both pricing calculation and error detection
711
730
  }
@@ -714,69 +733,8 @@ export const executeAgentCommand = async params => {
714
733
  const errorOutput = chunk.data.toString();
715
734
  if (errorOutput) {
716
735
  // Agent sends all output (including verbose logs and structured events) to stderr
717
- // Process each line as NDJSON, same as stdout handling
718
- const stderrLines = errorOutput.split('\n');
719
- for (const stderrLine of stderrLines) {
720
- if (!stderrLine.trim()) continue;
721
- try {
722
- const stderrData = sanitizeObjectStrings(JSON.parse(stderrLine));
723
- // Issue #1968: skip bare `null`/primitive lines (see stdout handler above).
724
- if (stderrData === null || typeof stderrData !== 'object') continue;
725
- // Output formatted JSON (same formatting as stdout)
726
- await log(JSON.stringify(stderrData, null, 2));
727
- // Capture session ID from stderr too (agent sends it via stderr)
728
- const eventSessionId = stderrData.sessionID || stderrData.session_id || stderrData.sessionId;
729
- if (!sessionId && eventSessionId) {
730
- sessionId = eventSessionId;
731
- await log(`๐Ÿ“Œ Session ID: ${sessionId}`);
732
- }
733
- // Issue #1250: Accumulate token usage during streaming (stderr)
734
- accumulateTokenUsage(stderrData);
735
- await markBidirectionalStateFromAgentEvent(stderrData);
736
- // Issue #1201: Detect error events during streaming (stderr) for reliable detection
737
- if (stderrData.type === 'error' || stderrData.type === 'step_error') {
738
- streamingErrorDetected = true;
739
- streamingErrorMessage = stderrData.message || stderrData.error || stderrLine.substring(0, 100);
740
- await log(`โš ๏ธ Error event detected in stream: ${streamingErrorMessage}`, { level: 'warning' });
741
- }
742
- // Issue #1263: Track text content for result summary (stderr)
743
- if (stderrData.type === 'text' && stderrData.text) {
744
- lastTextContent = stderrData.text;
745
- } else if (stderrData.type === 'assistant' && stderrData.message?.content) {
746
- const content = Array.isArray(stderrData.message.content) ? stderrData.message.content : [stderrData.message.content];
747
- for (const item of content) {
748
- if (item.type === 'text' && item.text) {
749
- lastTextContent = item.text;
750
- }
751
- }
752
- } else if (stderrData.type === 'message' && stderrData.content) {
753
- if (typeof stderrData.content === 'string') {
754
- lastTextContent = stderrData.content;
755
- } else if (Array.isArray(stderrData.content)) {
756
- for (const item of stderrData.content) {
757
- if (item.type === 'text' && item.text) {
758
- lastTextContent = item.text;
759
- }
760
- }
761
- }
762
- } else if (stderrData.type === 'result' && stderrData.result) {
763
- lastTextContent = stderrData.result;
764
- }
765
- // Issue #1276: Detect successful completion events (stderr)
766
- // When agent emits session.idle or log with "exiting loop" message, it completed successfully
767
- if (isAgentSuccessfulCompletionEvent(stderrData)) {
768
- agentCompletedSuccessfully = true;
769
- }
770
- // Issue #1296: Detect step_finish with reason "stop" as successful completion (stderr)
771
- // This is a clear marker of success - agent finished normally, not due to error or limit
772
- if (stderrData.type === 'step_finish' && stderrData.part?.reason === 'stop') {
773
- agentCompletedSuccessfully = true;
774
- }
775
- } catch {
776
- // Not JSON - log as plain text
777
- await log(stderrLine);
778
- }
779
- }
736
+ // Process it exactly like stdout so telemetry is never stream-specific
737
+ await handleAgentStreamEvents(stderrScanner.write(errorOutput));
780
738
  // Also collect stderr for error detection
781
739
  fullOutput += errorOutput;
782
740
  }
@@ -785,6 +743,10 @@ export const executeAgentCommand = async params => {
785
743
  }
786
744
  }
787
745
 
746
+ // Release any record that was still being assembled when the stream ended.
747
+ await handleAgentStreamEvents(stdoutScanner.flush());
748
+ await handleAgentStreamEvents(stderrScanner.flush());
749
+
788
750
  // Simplified error detection for agent tool
789
751
  // Issue #886: Trust exit code - agent now properly returns code 1 on errors with JSON error response
790
752
  // Don't scan output for error patterns as this causes false positives during normal operation
@@ -795,24 +757,17 @@ export const executeAgentCommand = async params => {
795
757
  // 2. Explicit JSON error messages from agent (type: "error")
796
758
  // 3. Usage limit detection (handled separately)
797
759
  const detectAgentErrors = stdoutOutput => {
798
- const lines = stdoutOutput.split('\n');
760
+ // Issue #2119: frame records by balanced JSON, not by newlines, so
761
+ // pretty-printed and concatenated records are still inspected.
762
+ for (const record of parseJsonRecords(stdoutOutput)) {
763
+ const msg = sanitizeObjectStrings(record);
799
764
 
800
- for (const line of lines) {
801
- if (!line.trim()) continue;
765
+ // Issue #1968: ignore bare `null`/primitive records (msg.type would throw on null).
766
+ if (msg === null || typeof msg !== 'object') continue;
802
767
 
803
- try {
804
- const msg = sanitizeObjectStrings(JSON.parse(line));
805
-
806
- // Issue #1968: ignore bare `null`/primitive lines (msg.type would throw on null).
807
- if (msg === null || typeof msg !== 'object') continue;
808
-
809
- // Check for explicit error message types from agent
810
- if (msg.type === 'error' || msg.type === 'step_error') {
811
- return { detected: true, type: 'AgentError', match: msg.message || msg.error || line.substring(0, 100) };
812
- }
813
- } catch {
814
- // Not JSON - ignore for error detection
815
- continue;
768
+ // Check for explicit error message types from agent
769
+ if (msg.type === 'error' || msg.type === 'step_error') {
770
+ return { detected: true, type: 'AgentError', match: msg.message || msg.error || JSON.stringify(msg).substring(0, 100) };
816
771
  }
817
772
  }
818
773
 
@@ -1112,11 +1067,14 @@ export const executeAgentCommand = async params => {
1112
1067
  export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
1113
1068
  // Similar to OpenCode version, check for uncommitted changes
1114
1069
  await log('\n๐Ÿ” Checking for uncommitted changes...');
1070
+ // Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
1071
+ // the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
1072
+ await ensureAiToolScratchIgnored(tempDir, log);
1115
1073
  try {
1116
1074
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
1117
1075
 
1118
1076
  if (gitStatusResult.code === 0) {
1119
- const statusOutput = gitStatusResult.stdout.toString().trim();
1077
+ const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
1120
1078
 
1121
1079
  if (statusOutput) {
1122
1080
  await log('๐Ÿ“ Found uncommitted changes');
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Issue #2119: keep AI tools' own scratch state out of the solver's workspace
5
+ * bookkeeping.
6
+ *
7
+ * AI tools drop working files into the directory they are run in. Formal AI
8
+ * writes `.formal-ai/` (a `general-change-plan.lino` plan file and friends);
9
+ * Playwright MCP writes `.playwright-mcp/` (issue #1124). Neither belongs to the
10
+ * user's change, but `git status --porcelain` reports them all the same, so the
11
+ * solver read them as "the AI left uncommitted changes" and restarted the tool -
12
+ * every iteration, forever, because restarting recreates the same scratch dir:
13
+ *
14
+ * ๐Ÿ” Checking for uncommitted changes...
15
+ * ?? .formal-ai/
16
+ * ๐Ÿ“ Found uncommitted changes
17
+ * ๐Ÿ”„ AUTO-RESTART: Restarting Agent to handle uncommitted changes...
18
+ *
19
+ * (docs/case-studies/issue-2119/data/logs/agent-scala-solution-draft.log:5425)
20
+ *
21
+ * The same state also reached `git add -A` on the auto-commit paths, which would
22
+ * have published a tool's private scratch files in the user's pull request.
23
+ *
24
+ * Every tool integration has its own `checkForUncommittedChanges`
25
+ * (claude/codex/agent/opencode/gemini/qwen/agent-commander), so filtering inside
26
+ * any one of them would fix one caller and leave the rest. Instead the paths are
27
+ * written to `.git/info/exclude`, which makes git itself stop reporting them:
28
+ * every status check, every `git add -A` and every diff agrees, without touching
29
+ * the repository's own `.gitignore` (that would show up in the pull request).
30
+ */
31
+
32
+ import fs from 'fs/promises';
33
+ import path from 'path';
34
+
35
+ /**
36
+ * Scratch paths AI tools create inside the workspace they are run in.
37
+ *
38
+ * Keep this list to directories a tool owns entirely. Anything a user might
39
+ * legitimately want committed must not be here.
40
+ */
41
+ export const AI_TOOL_SCRATCH_PATHS = [
42
+ { path: '.formal-ai/', tool: 'formal-ai' },
43
+ { path: '.playwright-mcp/', tool: 'playwright-mcp' },
44
+ ];
45
+
46
+ const EXCLUDE_HEADER = '# hive-mind: AI tool scratch directories (issue #2119)';
47
+
48
+ /**
49
+ * Does this `git status --porcelain` line describe only AI tool scratch state?
50
+ *
51
+ * @param {string} statusLine - e.g. `?? .formal-ai/`
52
+ * @returns {boolean}
53
+ */
54
+ export const isAiToolScratchPath = statusLine => {
55
+ if (typeof statusLine !== 'string') return false;
56
+ // Porcelain v1: two status characters, a space, then the path.
57
+ const filePath = statusLine.slice(3).trim().replace(/^"|"$/g, '');
58
+ if (!filePath) return false;
59
+ return AI_TOOL_SCRATCH_PATHS.some(({ path: scratchPath }) => {
60
+ const withoutSlash = scratchPath.replace(/\/$/, '');
61
+ return filePath === withoutSlash || filePath.startsWith(`${withoutSlash}/`);
62
+ });
63
+ };
64
+
65
+ /**
66
+ * Drop AI tool scratch entries from `git status --porcelain` output.
67
+ *
68
+ * A fallback for workspaces set up before `ensureAiToolScratchIgnored` ran (an
69
+ * existing clone, a resumed run), so a stale scratch directory cannot restart
70
+ * the restart loop.
71
+ *
72
+ * @param {string} statusOutput - raw `git status --porcelain` output
73
+ * @returns {string} the same output without scratch-only lines
74
+ */
75
+ export const filterAiToolScratchFromStatus = statusOutput => {
76
+ if (!statusOutput) return '';
77
+ return statusOutput
78
+ .split('\n')
79
+ .filter(line => line.trim() && !isAiToolScratchPath(line))
80
+ .join('\n');
81
+ };
82
+
83
+ /**
84
+ * Teach a cloned workspace to ignore AI tool scratch directories.
85
+ *
86
+ * Writes to `.git/info/exclude` rather than `.gitignore`: the exclude file is
87
+ * local to the clone, is never staged, and therefore never appears in the pull
88
+ * request. Idempotent - re-running leaves the file unchanged.
89
+ *
90
+ * @param {string} tempDir - the cloned workspace
91
+ * @param {(msg: string, opts?: object) => Promise<void>} [log]
92
+ * @returns {Promise<{applied: boolean, reason?: string, added?: string[]}>}
93
+ */
94
+ export const ensureAiToolScratchIgnored = async (tempDir, log = null) => {
95
+ const excludePath = path.join(tempDir, '.git', 'info', 'exclude');
96
+ const report = async msg => {
97
+ if (log) await log(msg, { verbose: true });
98
+ };
99
+
100
+ let existing = '';
101
+ try {
102
+ existing = await fs.readFile(excludePath, 'utf8');
103
+ } catch (error) {
104
+ if (error.code !== 'ENOENT') {
105
+ await report(`โš ๏ธ Could not read ${excludePath}: ${error.message}`);
106
+ return { applied: false, reason: 'unreadable' };
107
+ }
108
+ // A worktree or a fresh clone may not have the file yet; creating it is fine.
109
+ }
110
+
111
+ const existingLines = new Set(
112
+ existing
113
+ .split('\n')
114
+ .map(line => line.trim())
115
+ .filter(Boolean)
116
+ );
117
+ const missing = AI_TOOL_SCRATCH_PATHS.map(entry => entry.path).filter(scratchPath => !existingLines.has(scratchPath));
118
+
119
+ if (missing.length === 0) {
120
+ return { applied: true, reason: 'already_present', added: [] };
121
+ }
122
+
123
+ const separator = existing && !existing.endsWith('\n') ? '\n' : '';
124
+ const block = existingLines.has(EXCLUDE_HEADER) ? `${missing.join('\n')}\n` : `${EXCLUDE_HEADER}\n${missing.join('\n')}\n`;
125
+
126
+ try {
127
+ await fs.mkdir(path.dirname(excludePath), { recursive: true });
128
+ await fs.writeFile(excludePath, `${existing}${separator}${block}`, 'utf8');
129
+ } catch (error) {
130
+ await report(`โš ๏ธ Could not update ${excludePath}: ${error.message}`);
131
+ return { applied: false, reason: 'unwritable' };
132
+ }
133
+
134
+ await report(`๐Ÿงน Ignoring AI tool scratch directories in this workspace: ${missing.join(', ')}`);
135
+ return { applied: true, added: missing };
136
+ };
137
+
138
+ export default {
139
+ AI_TOOL_SCRATCH_PATHS,
140
+ ensureAiToolScratchIgnored,
141
+ filterAiToolScratchFromStatus,
142
+ isAiToolScratchPath,
143
+ };