@hasna/loops 0.4.6 → 0.4.7

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
@@ -5,6 +5,19 @@ documented in this file. Version entries are generated from the
5
5
  conventional-commit git history; one commit maps to one released patch version
6
6
  unless noted.
7
7
 
8
+ ## 0.4.7 (2026-07-04)
9
+
10
+ Routing hardening for PR merge/drain automation.
11
+
12
+ ### Fixed
13
+
14
+ - **Todos drain:** admit registered repo-project task sources while pinning
15
+ routed work to the scanned source project, preventing task-controlled nested
16
+ route paths from moving worker execution into another repository.
17
+ - **PR lifecycle routing:** propagate PR author and reviewer evidence into
18
+ reviewer and merger lifecycle prompts so merge routing can select a valid
19
+ non-author reviewer and keep reviewer/merger steps separate.
20
+
8
21
  ## 0.4.6 (2026-07-03)
9
22
 
10
23
  Reliability hardening from a full audit of the runtime, control surfaces, and
package/dist/api/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.6",
6
+ version: "0.4.7",
7
7
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8
8
  type: "module",
9
9
  main: "dist/index.js",
package/dist/cli/index.js CHANGED
@@ -726,7 +726,7 @@ function buildAgentInvocation(target) {
726
726
  if (target.model)
727
727
  args.push("--model", target.model);
728
728
  args.push(...target.extraArgs ?? []);
729
- args.push("agent", "start");
729
+ args.push("agent", "start", "--json");
730
730
  if (target.cwd)
731
731
  args.push("--cwd", target.cwd);
732
732
  args.push(target.prompt);
@@ -4219,7 +4219,7 @@ class Store {
4219
4219
  // package.json
4220
4220
  var package_default = {
4221
4221
  name: "@hasna/loops",
4222
- version: "0.4.6",
4222
+ version: "0.4.7",
4223
4223
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4224
4224
  type: "module",
4225
4225
  main: "dist/index.js",
@@ -5264,18 +5264,60 @@ function codewithAgentControlArgs(target, command, agentId) {
5264
5264
  "agent",
5265
5265
  command,
5266
5266
  ...command === "logs" ? ["--limit", "20"] : [],
5267
+ "--json",
5267
5268
  agentId
5268
5269
  ];
5269
5270
  }
5271
+ function extractFirstJsonObject(text) {
5272
+ let depth = 0;
5273
+ let start = -1;
5274
+ let inString = false;
5275
+ let escaped = false;
5276
+ for (let i = 0;i < text.length; i += 1) {
5277
+ const ch = text[i];
5278
+ if (inString) {
5279
+ if (escaped)
5280
+ escaped = false;
5281
+ else if (ch === "\\")
5282
+ escaped = true;
5283
+ else if (ch === '"')
5284
+ inString = false;
5285
+ continue;
5286
+ }
5287
+ if (ch === '"') {
5288
+ inString = true;
5289
+ } else if (ch === "{") {
5290
+ if (depth === 0)
5291
+ start = i;
5292
+ depth += 1;
5293
+ } else if (ch === "}") {
5294
+ if (depth > 0) {
5295
+ depth -= 1;
5296
+ if (depth === 0 && start !== -1)
5297
+ return text.slice(start, i + 1);
5298
+ }
5299
+ }
5300
+ }
5301
+ return;
5302
+ }
5270
5303
  function parseJsonOutput(stdout, label) {
5271
- try {
5272
- const value = JSON.parse(stdout || "{}");
5273
- if (!value || typeof value !== "object" || Array.isArray(value))
5274
- throw new Error("not an object");
5275
- return value;
5276
- } catch (error) {
5277
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
5304
+ const raw = stdout || "{}";
5305
+ const candidates = [raw.trim()];
5306
+ const scanned = extractFirstJsonObject(raw);
5307
+ if (scanned && scanned !== raw.trim())
5308
+ candidates.push(scanned);
5309
+ let lastError;
5310
+ for (const candidate of candidates) {
5311
+ try {
5312
+ const value = JSON.parse(candidate);
5313
+ if (!value || typeof value !== "object" || Array.isArray(value))
5314
+ throw new Error("not an object");
5315
+ return value;
5316
+ } catch (error) {
5317
+ lastError = error;
5318
+ }
5278
5319
  }
5320
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
5279
5321
  }
5280
5322
  function recordField(value, key) {
5281
5323
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -9973,6 +10015,28 @@ function importCustomLoopTemplate(file, reservedKeys, opts = {}) {
9973
10015
  function compactJson(value) {
9974
10016
  return JSON.stringify(value);
9975
10017
  }
10018
+ function prReviewRoutingContext(input) {
10019
+ return input.prReviewRouting?.required ? input.prReviewRouting : undefined;
10020
+ }
10021
+ function prReviewFollowUpFragment(input) {
10022
+ const routing = prReviewRoutingContext(input);
10023
+ const author = routing?.author?.trim();
10024
+ const reviewers = routing?.reviewers?.map((reviewer) => reviewer.trim()).filter(Boolean) ?? [];
10025
+ const evidenceLines = [
10026
+ author ? `- Source PR author evidence: GitHub author is ${author}` : undefined,
10027
+ reviewers.length ? `- Source PR reviewer evidence: GitHub reviewer pool: ${reviewers.join(", ")}` : undefined,
10028
+ routing?.selectedReviewer ? `- Selected non-author reviewer: ${routing.selectedReviewer}` : undefined
10029
+ ].filter(Boolean);
10030
+ return [
10031
+ "PR-derived follow-up todos: If any lifecycle step creates a follow-up todo that references a GitHub PR, PR approval, PR review, or PR merge work, the todo description must include parser-compatible routing evidence so downstream drains can select a non-author reviewer.",
10032
+ ...evidenceLines,
10033
+ "Copy these exact evidence lines from the source task when present, or derive them from the referenced PR before creating the follow-up todo:",
10034
+ "GitHub author is <login>",
10035
+ "GitHub reviewer pool: <login>, <login>",
10036
+ "When the source PR author or reviewer pool cannot be determined, do not create an auto-routable PR-derived follow-up todo; comment the source task with the blocker instead."
10037
+ ].join(`
10038
+ `);
10039
+ }
9976
10040
  function taskLabel(input) {
9977
10041
  const head = input.taskTitle?.trim() || input.taskId;
9978
10042
  return head.length > 160 ? `${head.slice(0, 157)}...` : head;
@@ -10428,6 +10492,7 @@ function renderTaskLifecycleWorkflow(input) {
10428
10492
  routeProjectPath: input.routeProjectPath,
10429
10493
  projectGroup: input.projectGroup,
10430
10494
  todosProjectPath,
10495
+ prReviewRouting: prReviewRoutingContext(input),
10431
10496
  worktree: worktreeContextFragment(plan)
10432
10497
  };
10433
10498
  const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
@@ -10445,6 +10510,7 @@ function renderTaskLifecycleWorkflow(input) {
10445
10510
  ]),
10446
10511
  NO_TMUX_DISPATCH_FRAGMENT,
10447
10512
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
10513
+ prReviewFollowUpFragment(input),
10448
10514
  "",
10449
10515
  `Task context JSON: ${compactJson(taskContext)}`,
10450
10516
  prHandoffGuidance
@@ -11630,12 +11696,30 @@ function routeEvidenceText(records) {
11630
11696
  const values = [];
11631
11697
  for (const record of records) {
11632
11698
  for (const field of fields)
11633
- values.push(...recordFieldValues(record, field));
11699
+ values.push(...routeTextFieldValues(record, field));
11634
11700
  values.push(...tagsFromValue2(record.tags ?? record.task_tags ?? record.taskTags));
11635
11701
  }
11636
11702
  return values.join(`
11637
11703
  `);
11638
11704
  }
11705
+ function textValuesFromUnknown(value) {
11706
+ if (Array.isArray(value))
11707
+ return value.flatMap((entry) => textValuesFromUnknown(entry));
11708
+ if (typeof value === "string" && value.trim())
11709
+ return [value.trim()];
11710
+ if (typeof value === "number" || typeof value === "boolean")
11711
+ return [String(value)];
11712
+ return [];
11713
+ }
11714
+ function routeTextFieldValues(record, field) {
11715
+ const expected = canonicalRouteField(field);
11716
+ const values = [];
11717
+ for (const [key, value] of Object.entries(record)) {
11718
+ if (canonicalRouteField(key) === expected)
11719
+ values.push(...textValuesFromUnknown(value));
11720
+ }
11721
+ return values;
11722
+ }
11639
11723
  function authorFromPrText(text) {
11640
11724
  const patterns = [
11641
11725
  /\bauthor\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
@@ -11650,6 +11734,18 @@ function authorFromPrText(text) {
11650
11734
  }
11651
11735
  return;
11652
11736
  }
11737
+ function reviewersFromPrText(text) {
11738
+ const reviewers = [];
11739
+ const patterns = [
11740
+ /\bgithub\s+reviewer\s+pool\s*(?:is|=|:)\s*([^\r\n]+)/gi,
11741
+ /\bgithub\s+reviewers?\s*(?:are|is|=|:)\s*([^\r\n]+)/gi
11742
+ ];
11743
+ for (const pattern of patterns) {
11744
+ for (const match of text.matchAll(pattern))
11745
+ reviewers.push(...splitList(match[1]) ?? []);
11746
+ }
11747
+ return githubLogins(reviewers);
11748
+ }
11653
11749
  function prReviewRoutingDecision(data, metadata, opts) {
11654
11750
  const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
11655
11751
  const text = routeEvidenceText(records);
@@ -11675,7 +11771,8 @@ function prReviewRoutingDecision(data, metadata, opts) {
11675
11771
  opts.githubReviewer,
11676
11772
  ...splitList(opts.githubReviewerPool) ?? [],
11677
11773
  ...PR_REVIEWER_FIELDS.flatMap((field) => routeFieldValues(records, field)),
11678
- ...PR_REVIEWER_POOL_FIELDS.flatMap((field) => routeFieldValues(records, field))
11774
+ ...PR_REVIEWER_POOL_FIELDS.flatMap((field) => routeFieldValues(records, field)),
11775
+ ...reviewersFromPrText(text)
11679
11776
  ]);
11680
11777
  const selectedReviewer = author ? reviewers.find((reviewer) => reviewer.toLowerCase() !== author.toLowerCase()) : undefined;
11681
11778
  if (!author) {
@@ -12063,7 +12160,16 @@ function routeTodosTaskEvent(event, opts) {
12063
12160
  }
12064
12161
  const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
12065
12162
  const taskDescription = taskEventField(data, ["description", "body"]);
12066
- const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
12163
+ const sourceTodosProjectPath = opts.sourceTodosProjectPath?.trim();
12164
+ const dataProjectPath = taskEventField(data, [
12165
+ "route_project_path",
12166
+ "routeProjectPath",
12167
+ "project_path",
12168
+ "projectPath",
12169
+ "working_dir",
12170
+ "workingDir",
12171
+ "cwd"
12172
+ ]);
12067
12173
  const metadataProjectPath = taskEventField(metadata, [
12068
12174
  "working_dir",
12069
12175
  "workingDir",
@@ -12076,7 +12182,8 @@ function routeTodosTaskEvent(event, opts) {
12076
12182
  const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve7(projectPath);
12077
12183
  const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
12078
12184
  const throttleLimits = routeThrottleLimitsFromOpts(opts);
12079
- const idempotencyKey = `todos-task:${taskId}`;
12185
+ const sourceProjectIdempotencyPrefix = sourceTodosProjectPath ? normalizeRoutePath(sourceTodosProjectPath) ?? resolve7(sourceTodosProjectPath) : undefined;
12186
+ const idempotencyKey = sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
12080
12187
  const idempotencySuffix = stableSuffix(idempotencyKey);
12081
12188
  const namePrefix = opts.namePrefix ?? "event:todos-task";
12082
12189
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
@@ -12150,9 +12257,10 @@ function routeTodosTaskEvent(event, opts) {
12150
12257
  worktreeRoot: opts.worktreeRoot,
12151
12258
  worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
12152
12259
  prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
12260
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
12153
12261
  eventId: event.id,
12154
12262
  eventType: event.type,
12155
- todosProjectPath: opts.todosProject
12263
+ todosProjectPath: sourceTodosProjectPath || opts.todosProject
12156
12264
  };
12157
12265
  const workflowContext = {
12158
12266
  name: workflowName,
@@ -12420,8 +12528,8 @@ function taskField(task, keys) {
12420
12528
  }
12421
12529
  return;
12422
12530
  }
12423
- function taskListId(task) {
12424
- return taskField(task, ["task_list_id", "taskListId"]) ?? stringField2(task.task_list?.id);
12531
+ function taskRoutePathFromRegistry(sourceProjectPath) {
12532
+ return normalizeRoutePath(sourceProjectPath) ?? resolve8(sourceProjectPath);
12425
12533
  }
12426
12534
  function taskProjectId(task) {
12427
12535
  return taskField(task, ["project_id", "projectId"]);
@@ -12435,12 +12543,28 @@ function taskProjectPath(task) {
12435
12543
  const metadata = objectField2(task.metadata) ?? {};
12436
12544
  return taskField(task, ["project_path", "projectPath"]) ?? taskEventField(metadata, ["project_path", "projectPath", "project_canonical_path"]) ?? taskDescriptionProjectPath(task) ?? taskField(task, ["working_dir", "workingDir", "cwd"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "cwd"]);
12437
12545
  }
12438
- function taskDrainEvent(task) {
12546
+ function taskListValues(task) {
12547
+ const taskList = objectField2(task.task_list);
12548
+ return [
12549
+ taskField(task, ["task_list_id", "taskListId"]),
12550
+ stringField2(task.task_list?.id),
12551
+ stringField2(task.task_list?.slug),
12552
+ stringField2(taskList?.name),
12553
+ taskField(task, ["task_list", "taskList"])
12554
+ ].filter((value) => Boolean(value));
12555
+ }
12556
+ function taskDrainEvent(task, sourceProjectPath) {
12439
12557
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
12440
12558
  if (!taskId)
12441
12559
  throw new Error("todos ready returned a task without an id");
12442
- const metadata = objectField2(task.metadata) ?? {};
12560
+ const metadata = { ...objectField2(task.metadata) ?? {} };
12443
12561
  const workingDir = taskProjectPath(task);
12562
+ const routeWorkingDir = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
12563
+ const sourceProject = sourceProjectPath?.trim();
12564
+ if (!sourceProject) {
12565
+ delete metadata.source_project_path;
12566
+ delete metadata.sourceProjectPath;
12567
+ }
12444
12568
  const data = {
12445
12569
  ...task,
12446
12570
  id: taskId,
@@ -12450,10 +12574,23 @@ function taskDrainEvent(task) {
12450
12574
  tags: tagsFromValue2(task.tags),
12451
12575
  metadata
12452
12576
  };
12577
+ if (!sourceProject) {
12578
+ delete data.source_project_path;
12579
+ delete data.sourceProjectPath;
12580
+ }
12453
12581
  if (workingDir) {
12454
- data.working_dir = workingDir;
12455
- data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
12456
- data.cwd = taskField(task, ["cwd"]) ?? workingDir;
12582
+ data.working_dir = routeWorkingDir;
12583
+ data.project_path = routeWorkingDir;
12584
+ data.cwd = taskField(task, ["cwd"]) ?? routeWorkingDir;
12585
+ }
12586
+ if (sourceProject) {
12587
+ data.source_project_path = sourceProject;
12588
+ if (!data.project_path)
12589
+ data.project_path = sourceProject;
12590
+ data.route_project_path = data.project_path;
12591
+ data.routeProjectPath = data.project_path;
12592
+ metadata.route_project_path = data.project_path;
12593
+ metadata.routeProjectPath = data.project_path;
12457
12594
  }
12458
12595
  const time = new Date().toISOString();
12459
12596
  return {
@@ -12467,6 +12604,7 @@ function taskDrainEvent(task) {
12467
12604
  schemaVersion: "1.0",
12468
12605
  metadata: {
12469
12606
  ...metadata,
12607
+ ...sourceProject ? { source_project_path: sourceProject } : {},
12470
12608
  ...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
12471
12609
  drained_by: "@hasna/loops",
12472
12610
  drained_from: "todos ready"
@@ -12497,23 +12635,74 @@ function compactDrainResult(result) {
12497
12635
  fatal: value.fatal === true ? true : undefined
12498
12636
  };
12499
12637
  }
12500
- function loadReadyTodosTasks(opts, scanLimit) {
12501
- const todosProject = opts.todosProject ?? defaultLoopsProject();
12502
- const args = ["--project", todosProject, "--json", "ready", "--limit", String(scanLimit)];
12503
- const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
12504
- if (!result.ok)
12505
- throw new Error(result.stderr || result.error || "todos ready failed");
12506
- let parsed;
12638
+ function parseTodosReadyJson(stdout) {
12507
12639
  try {
12508
- parsed = JSON.parse(result.stdout || "[]");
12640
+ return JSON.parse(stdout || "[]");
12509
12641
  } catch (error) {
12510
12642
  const message = error instanceof Error ? error.message : String(error);
12511
- throw new Error(`failed to parse todos ready --json output (${result.stdout.length} bytes): ${message}`);
12643
+ throw new Error(`failed to parse todos ready --json output (${stdout.length} bytes): ${message}`);
12512
12644
  }
12645
+ }
12646
+ function parseTodoProjectsJson(stdout) {
12647
+ try {
12648
+ return JSON.parse(stdout || "[]");
12649
+ } catch (error) {
12650
+ const message = error instanceof Error ? error.message : String(error);
12651
+ throw new Error(`failed to parse todos projects --json output (${stdout.length} bytes): ${message}`);
12652
+ }
12653
+ }
12654
+ function loadTodoProjectPathsFromRegistry(opts) {
12655
+ const result = runLocalCommand("todos", ["projects", "--json"], { timeoutMs: 60000 });
12656
+ if (!result.ok)
12657
+ throw new Error(result.stderr || result.error || "todos projects failed");
12658
+ const payload = parseTodoProjectsJson(result.stdout);
12659
+ const projectsPayload = Array.isArray(payload) ? payload : objectField2(payload)?.projects;
12660
+ if (!Array.isArray(projectsPayload))
12661
+ throw new Error("todos projects --json returned a non-array value");
12662
+ const includeFilters = listFromRepeatedOpts(opts.todosProjectInclude)?.map((entry) => normalizeRoutePath(entry) ?? resolve8(entry)) ?? [];
12663
+ const includesPrefix = normalizeRoutePath(opts.projectPathPrefix) ?? (opts.projectPathPrefix ? resolve8(opts.projectPathPrefix) : undefined);
12664
+ const fromProjects = projectsPayload.map((entry) => objectField2(entry)).filter((project) => Boolean(project)).map((project) => {
12665
+ const path = stringField2(project.path) ?? stringField2(project.projectPath) ?? stringField2(project.project_path) ?? stringField2(project.dir) ?? stringField2(project.root) ?? stringField2(project.cwd);
12666
+ if (!path)
12667
+ return;
12668
+ return { path };
12669
+ }).filter((project) => Boolean(project));
12670
+ const paths = fromProjects.map((project) => project.path).filter(Boolean).filter((path) => {
12671
+ const normalizedPath = normalizeRoutePath(path) ?? resolve8(path);
12672
+ if (includesPrefix && !(normalizedPath === includesPrefix || normalizedPath.startsWith(`${includesPrefix}/`)))
12673
+ return false;
12674
+ if (includeFilters.length === 0)
12675
+ return true;
12676
+ return includeFilters.some((prefix) => normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
12677
+ });
12678
+ return [...new Set(paths)];
12679
+ }
12680
+ function loadReadyTodosTasksForProject(projectPath, scanLimit) {
12681
+ const args = ["--project", projectPath, "--json", "ready", "--limit", String(scanLimit)];
12682
+ const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
12683
+ if (!result.ok)
12684
+ throw new Error(result.stderr || result.error || "todos ready failed");
12685
+ const parsed = parseTodosReadyJson(result.stdout);
12513
12686
  if (!Array.isArray(parsed))
12514
12687
  throw new Error("todos ready --json returned a non-array value");
12515
12688
  return parsed;
12516
12689
  }
12690
+ function loadReadyTodosTasks(opts, scanLimit) {
12691
+ if (!opts.todosProjectsFromRegistry) {
12692
+ const todosProject = opts.todosProject ?? defaultLoopsProject();
12693
+ return loadReadyTodosTasksForProject(todosProject, scanLimit);
12694
+ }
12695
+ const projectPaths = loadTodoProjectPathsFromRegistry(opts);
12696
+ const ready = projectPaths.flatMap((projectPath) => loadReadyTodosTasksForProject(projectPath, scanLimit).map((task) => {
12697
+ const sourceProject = projectPath;
12698
+ return {
12699
+ ...task,
12700
+ source_project_path: sourceProject,
12701
+ project_path: taskRoutePathFromRegistry(sourceProject)
12702
+ };
12703
+ }));
12704
+ return ready;
12705
+ }
12517
12706
  function resolveTaskListFilter(todosProject, filter) {
12518
12707
  const wanted = filter?.trim();
12519
12708
  if (!wanted)
@@ -12528,7 +12717,7 @@ function resolveTaskListFilter(todosProject, filter) {
12528
12717
  function taskMatchesDrainFilters(task, filters) {
12529
12718
  if (filters.projectId && taskProjectId(task) !== filters.projectId)
12530
12719
  return false;
12531
- if (filters.taskListId && taskListId(task) !== filters.taskListId)
12720
+ if (filters.taskList && !taskListValues(task).includes(filters.taskList))
12532
12721
  return false;
12533
12722
  if (filters.projectPathPrefix) {
12534
12723
  const path = taskProjectPath(task);
@@ -12566,14 +12755,14 @@ function skippedDrainTask(task, event, reason, extra = {}) {
12566
12755
  function isSkippableDrainRouteError(message) {
12567
12756
  return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
12568
12757
  }
12569
- function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
12758
+ function markInvalidDrainTaskNonRouteable(sourceTodosProject, task, reason) {
12570
12759
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
12571
12760
  if (!taskId)
12572
12761
  return { attempted: false, reason: "task id missing" };
12573
12762
  const comment = `OpenLoops route blocked for task ${taskId}: ${reason}. Added no-auto and removed auto:route so route drains do not repeatedly route this task until its project path is fixed.`;
12574
- const commentResult = runLocalCommand("todos", ["--project", todosProject, "comment", taskId, comment], { timeoutMs: 30000 });
12575
- const tagResult = runLocalCommand("todos", ["--project", todosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
12576
- const untagResult = runLocalCommand("todos", ["--project", todosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
12763
+ const commentResult = runLocalCommand("todos", ["--project", sourceTodosProject, "comment", taskId, comment], { timeoutMs: 30000 });
12764
+ const tagResult = runLocalCommand("todos", ["--project", sourceTodosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
12765
+ const untagResult = runLocalCommand("todos", ["--project", sourceTodosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
12577
12766
  const ok = commentResult.ok && tagResult.ok && untagResult.ok;
12578
12767
  return {
12579
12768
  ok,
@@ -12589,7 +12778,7 @@ function drainTodosTaskRoutes(opts) {
12589
12778
  const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
12590
12779
  const todosProject = opts.todosProject ?? defaultLoopsProject();
12591
12780
  const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
12592
- const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
12781
+ const taskListFilter = opts.todosProjectsFromRegistry ? opts.taskList?.trim() : resolveTaskListFilter(todosProject, opts.taskList);
12593
12782
  const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
12594
12783
  const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
12595
12784
  const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
@@ -12597,7 +12786,7 @@ function drainTodosTaskRoutes(opts) {
12597
12786
  const ready = loadReadyTodosTasks(opts, scanLimit);
12598
12787
  const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
12599
12788
  projectId: opts.todosProjectId,
12600
- taskListId: taskListFilter,
12789
+ taskList: taskListFilter,
12601
12790
  projectPathPrefix: opts.projectPathPrefix,
12602
12791
  tags: requiredTags
12603
12792
  }));
@@ -12610,12 +12799,17 @@ function drainTodosTaskRoutes(opts) {
12610
12799
  let event;
12611
12800
  let result;
12612
12801
  try {
12613
- event = taskDrainEvent(task);
12614
- result = routeTodosTaskEvent(event, opts);
12802
+ const sourceProject = opts.todosProjectsFromRegistry ? taskField(task, ["source_project_path", "sourceProjectPath"]) : undefined;
12803
+ event = taskDrainEvent(task, sourceProject);
12804
+ result = routeTodosTaskEvent(event, {
12805
+ ...opts,
12806
+ ...sourceProject ? { sourceTodosProjectPath: sourceProject } : {}
12807
+ });
12615
12808
  } catch (error) {
12616
12809
  const message = error instanceof Error ? error.message : String(error);
12617
12810
  if (isSkippableDrainRouteError(message)) {
12618
- const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message);
12811
+ const sourceTaskProject = opts.todosProjectsFromRegistry ? taskField(task, ["source_project_path", "sourceProjectPath"]) ?? todosProject : todosProject;
12812
+ const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(sourceTaskProject, task, message);
12619
12813
  result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
12620
12814
  } else {
12621
12815
  result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { fatal: true });
@@ -12960,6 +13154,19 @@ var EVENT_INPUT_OPTION_SPECS = [
12960
13154
  { flags: "--event-json <json>", key: "eventJson", kind: "value", description: "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON" }
12961
13155
  ];
12962
13156
  var DRAIN_FILTER_OPTION_SPECS = [
13157
+ {
13158
+ flags: "--todos-projects-from-registry",
13159
+ key: "todosProjectsFromRegistry",
13160
+ kind: "boolean",
13161
+ description: "scan registered todos projects from `todos projects --json` instead of one --todos-project"
13162
+ },
13163
+ {
13164
+ flags: "--todos-project-include <path>",
13165
+ key: "todosProjectInclude",
13166
+ kind: "repeat",
13167
+ description: "include additional registered project path prefixes when scanning via --todos-projects-from-registry",
13168
+ serializeValue: (opts) => listFromRepeatedOpts(opts.todosProjectInclude)
13169
+ },
12963
13170
  { flags: "--todos-project-id <id>", key: "todosProjectId", kind: "value", description: "filter todos ready output to one todos project id" },
12964
13171
  { flags: "--task-list <id-or-slug>", key: "taskList", kind: "value", description: "filter ready tasks to one task-list id, slug, or name" },
12965
13172
  { flags: "--project-path-prefix <path>", key: "projectPathPrefix", kind: "value", description: "filter ready tasks to a project/repo path prefix" },
@@ -726,7 +726,7 @@ function buildAgentInvocation(target) {
726
726
  if (target.model)
727
727
  args.push("--model", target.model);
728
728
  args.push(...target.extraArgs ?? []);
729
- args.push("agent", "start");
729
+ args.push("agent", "start", "--json");
730
730
  if (target.cwd)
731
731
  args.push("--cwd", target.cwd);
732
732
  args.push(target.prompt);
@@ -5425,18 +5425,60 @@ function codewithAgentControlArgs(target, command, agentId) {
5425
5425
  "agent",
5426
5426
  command,
5427
5427
  ...command === "logs" ? ["--limit", "20"] : [],
5428
+ "--json",
5428
5429
  agentId
5429
5430
  ];
5430
5431
  }
5432
+ function extractFirstJsonObject(text) {
5433
+ let depth = 0;
5434
+ let start = -1;
5435
+ let inString = false;
5436
+ let escaped = false;
5437
+ for (let i = 0;i < text.length; i += 1) {
5438
+ const ch = text[i];
5439
+ if (inString) {
5440
+ if (escaped)
5441
+ escaped = false;
5442
+ else if (ch === "\\")
5443
+ escaped = true;
5444
+ else if (ch === '"')
5445
+ inString = false;
5446
+ continue;
5447
+ }
5448
+ if (ch === '"') {
5449
+ inString = true;
5450
+ } else if (ch === "{") {
5451
+ if (depth === 0)
5452
+ start = i;
5453
+ depth += 1;
5454
+ } else if (ch === "}") {
5455
+ if (depth > 0) {
5456
+ depth -= 1;
5457
+ if (depth === 0 && start !== -1)
5458
+ return text.slice(start, i + 1);
5459
+ }
5460
+ }
5461
+ }
5462
+ return;
5463
+ }
5431
5464
  function parseJsonOutput(stdout, label) {
5432
- try {
5433
- const value = JSON.parse(stdout || "{}");
5434
- if (!value || typeof value !== "object" || Array.isArray(value))
5435
- throw new Error("not an object");
5436
- return value;
5437
- } catch (error) {
5438
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
5465
+ const raw = stdout || "{}";
5466
+ const candidates = [raw.trim()];
5467
+ const scanned = extractFirstJsonObject(raw);
5468
+ if (scanned && scanned !== raw.trim())
5469
+ candidates.push(scanned);
5470
+ let lastError;
5471
+ for (const candidate of candidates) {
5472
+ try {
5473
+ const value = JSON.parse(candidate);
5474
+ if (!value || typeof value !== "object" || Array.isArray(value))
5475
+ throw new Error("not an object");
5476
+ return value;
5477
+ } catch (error) {
5478
+ lastError = error;
5479
+ }
5439
5480
  }
5481
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
5440
5482
  }
5441
5483
  function recordField(value, key) {
5442
5484
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -7961,7 +8003,7 @@ function enableStartup(result) {
7961
8003
  // package.json
7962
8004
  var package_default = {
7963
8005
  name: "@hasna/loops",
7964
- version: "0.4.6",
8006
+ version: "0.4.7",
7965
8007
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7966
8008
  type: "module",
7967
8009
  main: "dist/index.js",
package/dist/index.js CHANGED
@@ -724,7 +724,7 @@ function buildAgentInvocation(target) {
724
724
  if (target.model)
725
725
  args.push("--model", target.model);
726
726
  args.push(...target.extraArgs ?? []);
727
- args.push("agent", "start");
727
+ args.push("agent", "start", "--json");
728
728
  if (target.cwd)
729
729
  args.push("--cwd", target.cwd);
730
730
  args.push(target.prompt);
@@ -4217,7 +4217,7 @@ class Store {
4217
4217
  // package.json
4218
4218
  var package_default = {
4219
4219
  name: "@hasna/loops",
4220
- version: "0.4.6",
4220
+ version: "0.4.7",
4221
4221
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4222
4222
  type: "module",
4223
4223
  main: "dist/index.js",
@@ -5371,18 +5371,60 @@ function codewithAgentControlArgs(target, command, agentId) {
5371
5371
  "agent",
5372
5372
  command,
5373
5373
  ...command === "logs" ? ["--limit", "20"] : [],
5374
+ "--json",
5374
5375
  agentId
5375
5376
  ];
5376
5377
  }
5378
+ function extractFirstJsonObject(text) {
5379
+ let depth = 0;
5380
+ let start = -1;
5381
+ let inString = false;
5382
+ let escaped = false;
5383
+ for (let i = 0;i < text.length; i += 1) {
5384
+ const ch = text[i];
5385
+ if (inString) {
5386
+ if (escaped)
5387
+ escaped = false;
5388
+ else if (ch === "\\")
5389
+ escaped = true;
5390
+ else if (ch === '"')
5391
+ inString = false;
5392
+ continue;
5393
+ }
5394
+ if (ch === '"') {
5395
+ inString = true;
5396
+ } else if (ch === "{") {
5397
+ if (depth === 0)
5398
+ start = i;
5399
+ depth += 1;
5400
+ } else if (ch === "}") {
5401
+ if (depth > 0) {
5402
+ depth -= 1;
5403
+ if (depth === 0 && start !== -1)
5404
+ return text.slice(start, i + 1);
5405
+ }
5406
+ }
5407
+ }
5408
+ return;
5409
+ }
5377
5410
  function parseJsonOutput(stdout, label) {
5378
- try {
5379
- const value = JSON.parse(stdout || "{}");
5380
- if (!value || typeof value !== "object" || Array.isArray(value))
5381
- throw new Error("not an object");
5382
- return value;
5383
- } catch (error) {
5384
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
5411
+ const raw = stdout || "{}";
5412
+ const candidates = [raw.trim()];
5413
+ const scanned = extractFirstJsonObject(raw);
5414
+ if (scanned && scanned !== raw.trim())
5415
+ candidates.push(scanned);
5416
+ let lastError;
5417
+ for (const candidate of candidates) {
5418
+ try {
5419
+ const value = JSON.parse(candidate);
5420
+ if (!value || typeof value !== "object" || Array.isArray(value))
5421
+ throw new Error("not an object");
5422
+ return value;
5423
+ } catch (error) {
5424
+ lastError = error;
5425
+ }
5385
5426
  }
5427
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
5386
5428
  }
5387
5429
  function recordField(value, key) {
5388
5430
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -10729,6 +10771,28 @@ function importCustomLoopTemplate(file, reservedKeys, opts = {}) {
10729
10771
  function compactJson(value) {
10730
10772
  return JSON.stringify(value);
10731
10773
  }
10774
+ function prReviewRoutingContext(input) {
10775
+ return input.prReviewRouting?.required ? input.prReviewRouting : undefined;
10776
+ }
10777
+ function prReviewFollowUpFragment(input) {
10778
+ const routing = prReviewRoutingContext(input);
10779
+ const author = routing?.author?.trim();
10780
+ const reviewers = routing?.reviewers?.map((reviewer) => reviewer.trim()).filter(Boolean) ?? [];
10781
+ const evidenceLines = [
10782
+ author ? `- Source PR author evidence: GitHub author is ${author}` : undefined,
10783
+ reviewers.length ? `- Source PR reviewer evidence: GitHub reviewer pool: ${reviewers.join(", ")}` : undefined,
10784
+ routing?.selectedReviewer ? `- Selected non-author reviewer: ${routing.selectedReviewer}` : undefined
10785
+ ].filter(Boolean);
10786
+ return [
10787
+ "PR-derived follow-up todos: If any lifecycle step creates a follow-up todo that references a GitHub PR, PR approval, PR review, or PR merge work, the todo description must include parser-compatible routing evidence so downstream drains can select a non-author reviewer.",
10788
+ ...evidenceLines,
10789
+ "Copy these exact evidence lines from the source task when present, or derive them from the referenced PR before creating the follow-up todo:",
10790
+ "GitHub author is <login>",
10791
+ "GitHub reviewer pool: <login>, <login>",
10792
+ "When the source PR author or reviewer pool cannot be determined, do not create an auto-routable PR-derived follow-up todo; comment the source task with the blocker instead."
10793
+ ].join(`
10794
+ `);
10795
+ }
10732
10796
  function taskLabel(input) {
10733
10797
  const head = input.taskTitle?.trim() || input.taskId;
10734
10798
  return head.length > 160 ? `${head.slice(0, 157)}...` : head;
@@ -11184,6 +11248,7 @@ function renderTaskLifecycleWorkflow(input) {
11184
11248
  routeProjectPath: input.routeProjectPath,
11185
11249
  projectGroup: input.projectGroup,
11186
11250
  todosProjectPath,
11251
+ prReviewRouting: prReviewRoutingContext(input),
11187
11252
  worktree: worktreeContextFragment(plan)
11188
11253
  };
11189
11254
  const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
@@ -11201,6 +11266,7 @@ function renderTaskLifecycleWorkflow(input) {
11201
11266
  ]),
11202
11267
  NO_TMUX_DISPATCH_FRAGMENT,
11203
11268
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
11269
+ prReviewFollowUpFragment(input),
11204
11270
  "",
11205
11271
  `Task context JSON: ${compactJson(taskContext)}`,
11206
11272
  prHandoffGuidance
package/dist/lib/mode.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // package.json
3
3
  var package_default = {
4
4
  name: "@hasna/loops",
5
- version: "0.4.6",
5
+ version: "0.4.7",
6
6
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7
7
  type: "module",
8
8
  main: "dist/index.js",
@@ -42,11 +42,15 @@ export interface TodosTaskRouteOptions {
42
42
  preflight?: boolean;
43
43
  dryRun?: boolean;
44
44
  todosProject?: string;
45
+ /** Internal drain context; never derived from CLI/event fields. */
46
+ sourceTodosProjectPath?: string;
45
47
  }
46
48
  export interface TodosReadyTask {
47
49
  id?: string;
48
50
  task_id?: string;
49
51
  taskId?: string;
52
+ source_project_path?: string;
53
+ sourceProjectPath?: string;
50
54
  title?: string;
51
55
  description?: string;
52
56
  body?: string;
@@ -70,6 +74,8 @@ export interface TodosReadyTask {
70
74
  }
71
75
  export interface TodosDrainOptions extends TodosTaskRouteOptions {
72
76
  todosProject?: string;
77
+ todosProjectsFromRegistry?: boolean;
78
+ todosProjectInclude?: string[];
73
79
  todosProjectId?: string;
74
80
  taskList?: string;
75
81
  tags?: string;
@@ -724,7 +724,7 @@ function buildAgentInvocation(target) {
724
724
  if (target.model)
725
725
  args.push("--model", target.model);
726
726
  args.push(...target.extraArgs ?? []);
727
- args.push("agent", "start");
727
+ args.push("agent", "start", "--json");
728
728
  if (target.cwd)
729
729
  args.push("--cwd", target.cwd);
730
730
  args.push(target.prompt);
@@ -724,7 +724,7 @@ function buildAgentInvocation(target) {
724
724
  if (target.model)
725
725
  args.push("--model", target.model);
726
726
  args.push(...target.extraArgs ?? []);
727
- args.push("agent", "start");
727
+ args.push("agent", "start", "--json");
728
728
  if (target.cwd)
729
729
  args.push("--cwd", target.cwd);
730
730
  args.push(target.prompt);
package/dist/lib/store.js CHANGED
@@ -724,7 +724,7 @@ function buildAgentInvocation(target) {
724
724
  if (target.model)
725
725
  args.push("--model", target.model);
726
726
  args.push(...target.extraArgs ?? []);
727
- args.push("agent", "start");
727
+ args.push("agent", "start", "--json");
728
728
  if (target.cwd)
729
729
  args.push("--cwd", target.cwd);
730
730
  args.push(target.prompt);
@@ -39,9 +39,19 @@ export interface TodosTaskWorkflowTemplateInput extends AgentWorkflowTemplateBas
39
39
  taskDescription?: string;
40
40
  todosProjectPath?: string;
41
41
  prHandoff?: boolean;
42
+ prReviewRouting?: PrReviewRoutingTemplateContext;
42
43
  eventId?: string;
43
44
  eventType?: string;
44
45
  }
46
+ export interface PrReviewRoutingTemplateContext {
47
+ required?: boolean;
48
+ allowed?: boolean;
49
+ reason?: string;
50
+ author?: string;
51
+ reviewers?: string[];
52
+ selectedReviewer?: string;
53
+ signals?: string[];
54
+ }
45
55
  export interface EventWorkflowTemplateInput extends AgentWorkflowTemplateBaseInput {
46
56
  eventId: string;
47
57
  eventType: string;
package/dist/mcp/index.js CHANGED
@@ -726,7 +726,7 @@ function buildAgentInvocation(target) {
726
726
  if (target.model)
727
727
  args.push("--model", target.model);
728
728
  args.push(...target.extraArgs ?? []);
729
- args.push("agent", "start");
729
+ args.push("agent", "start", "--json");
730
730
  if (target.cwd)
731
731
  args.push("--cwd", target.cwd);
732
732
  args.push(target.prompt);
@@ -5106,18 +5106,60 @@ function codewithAgentControlArgs(target, command, agentId) {
5106
5106
  "agent",
5107
5107
  command,
5108
5108
  ...command === "logs" ? ["--limit", "20"] : [],
5109
+ "--json",
5109
5110
  agentId
5110
5111
  ];
5111
5112
  }
5113
+ function extractFirstJsonObject(text) {
5114
+ let depth = 0;
5115
+ let start = -1;
5116
+ let inString = false;
5117
+ let escaped = false;
5118
+ for (let i = 0;i < text.length; i += 1) {
5119
+ const ch = text[i];
5120
+ if (inString) {
5121
+ if (escaped)
5122
+ escaped = false;
5123
+ else if (ch === "\\")
5124
+ escaped = true;
5125
+ else if (ch === '"')
5126
+ inString = false;
5127
+ continue;
5128
+ }
5129
+ if (ch === '"') {
5130
+ inString = true;
5131
+ } else if (ch === "{") {
5132
+ if (depth === 0)
5133
+ start = i;
5134
+ depth += 1;
5135
+ } else if (ch === "}") {
5136
+ if (depth > 0) {
5137
+ depth -= 1;
5138
+ if (depth === 0 && start !== -1)
5139
+ return text.slice(start, i + 1);
5140
+ }
5141
+ }
5142
+ }
5143
+ return;
5144
+ }
5112
5145
  function parseJsonOutput(stdout, label) {
5113
- try {
5114
- const value = JSON.parse(stdout || "{}");
5115
- if (!value || typeof value !== "object" || Array.isArray(value))
5116
- throw new Error("not an object");
5117
- return value;
5118
- } catch (error) {
5119
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
5146
+ const raw = stdout || "{}";
5147
+ const candidates = [raw.trim()];
5148
+ const scanned = extractFirstJsonObject(raw);
5149
+ if (scanned && scanned !== raw.trim())
5150
+ candidates.push(scanned);
5151
+ let lastError;
5152
+ for (const candidate of candidates) {
5153
+ try {
5154
+ const value = JSON.parse(candidate);
5155
+ if (!value || typeof value !== "object" || Array.isArray(value))
5156
+ throw new Error("not an object");
5157
+ return value;
5158
+ } catch (error) {
5159
+ lastError = error;
5160
+ }
5120
5161
  }
5162
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
5121
5163
  }
5122
5164
  function recordField(value, key) {
5123
5165
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -7677,7 +7719,7 @@ async function tick(deps) {
7677
7719
  // package.json
7678
7720
  var package_default = {
7679
7721
  name: "@hasna/loops",
7680
- version: "0.4.6",
7722
+ version: "0.4.7",
7681
7723
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7682
7724
  type: "module",
7683
7725
  main: "dist/index.js",
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.6",
6
+ version: "0.4.7",
7
7
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8
8
  type: "module",
9
9
  main: "dist/index.js",
@@ -510,7 +510,7 @@ function buildAgentInvocation(target) {
510
510
  if (target.model)
511
511
  args.push("--model", target.model);
512
512
  args.push(...target.extraArgs ?? []);
513
- args.push("agent", "start");
513
+ args.push("agent", "start", "--json");
514
514
  if (target.cwd)
515
515
  args.push("--cwd", target.cwd);
516
516
  args.push(target.prompt);
@@ -1403,18 +1403,60 @@ function codewithAgentControlArgs(target, command, agentId) {
1403
1403
  "agent",
1404
1404
  command,
1405
1405
  ...command === "logs" ? ["--limit", "20"] : [],
1406
+ "--json",
1406
1407
  agentId
1407
1408
  ];
1408
1409
  }
1410
+ function extractFirstJsonObject(text) {
1411
+ let depth = 0;
1412
+ let start = -1;
1413
+ let inString = false;
1414
+ let escaped = false;
1415
+ for (let i = 0;i < text.length; i += 1) {
1416
+ const ch = text[i];
1417
+ if (inString) {
1418
+ if (escaped)
1419
+ escaped = false;
1420
+ else if (ch === "\\")
1421
+ escaped = true;
1422
+ else if (ch === '"')
1423
+ inString = false;
1424
+ continue;
1425
+ }
1426
+ if (ch === '"') {
1427
+ inString = true;
1428
+ } else if (ch === "{") {
1429
+ if (depth === 0)
1430
+ start = i;
1431
+ depth += 1;
1432
+ } else if (ch === "}") {
1433
+ if (depth > 0) {
1434
+ depth -= 1;
1435
+ if (depth === 0 && start !== -1)
1436
+ return text.slice(start, i + 1);
1437
+ }
1438
+ }
1439
+ }
1440
+ return;
1441
+ }
1409
1442
  function parseJsonOutput(stdout, label) {
1410
- try {
1411
- const value = JSON.parse(stdout || "{}");
1412
- if (!value || typeof value !== "object" || Array.isArray(value))
1413
- throw new Error("not an object");
1414
- return value;
1415
- } catch (error) {
1416
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
1443
+ const raw = stdout || "{}";
1444
+ const candidates = [raw.trim()];
1445
+ const scanned = extractFirstJsonObject(raw);
1446
+ if (scanned && scanned !== raw.trim())
1447
+ candidates.push(scanned);
1448
+ let lastError;
1449
+ for (const candidate of candidates) {
1450
+ try {
1451
+ const value = JSON.parse(candidate);
1452
+ if (!value || typeof value !== "object" || Array.isArray(value))
1453
+ throw new Error("not an object");
1454
+ return value;
1455
+ } catch (error) {
1456
+ lastError = error;
1457
+ }
1417
1458
  }
1459
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
1418
1460
  }
1419
1461
  function recordField(value, key) {
1420
1462
  if (!value || typeof value !== "object" || Array.isArray(value))
package/dist/sdk/index.js CHANGED
@@ -724,7 +724,7 @@ function buildAgentInvocation(target) {
724
724
  if (target.model)
725
725
  args.push("--model", target.model);
726
726
  args.push(...target.extraArgs ?? []);
727
- args.push("agent", "start");
727
+ args.push("agent", "start", "--json");
728
728
  if (target.cwd)
729
729
  args.push("--cwd", target.cwd);
730
730
  args.push(target.prompt);
@@ -4217,7 +4217,7 @@ class Store {
4217
4217
  // package.json
4218
4218
  var package_default = {
4219
4219
  name: "@hasna/loops",
4220
- version: "0.4.6",
4220
+ version: "0.4.7",
4221
4221
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4222
4222
  type: "module",
4223
4223
  main: "dist/index.js",
@@ -5366,18 +5366,60 @@ function codewithAgentControlArgs(target, command, agentId) {
5366
5366
  "agent",
5367
5367
  command,
5368
5368
  ...command === "logs" ? ["--limit", "20"] : [],
5369
+ "--json",
5369
5370
  agentId
5370
5371
  ];
5371
5372
  }
5373
+ function extractFirstJsonObject(text) {
5374
+ let depth = 0;
5375
+ let start = -1;
5376
+ let inString = false;
5377
+ let escaped = false;
5378
+ for (let i = 0;i < text.length; i += 1) {
5379
+ const ch = text[i];
5380
+ if (inString) {
5381
+ if (escaped)
5382
+ escaped = false;
5383
+ else if (ch === "\\")
5384
+ escaped = true;
5385
+ else if (ch === '"')
5386
+ inString = false;
5387
+ continue;
5388
+ }
5389
+ if (ch === '"') {
5390
+ inString = true;
5391
+ } else if (ch === "{") {
5392
+ if (depth === 0)
5393
+ start = i;
5394
+ depth += 1;
5395
+ } else if (ch === "}") {
5396
+ if (depth > 0) {
5397
+ depth -= 1;
5398
+ if (depth === 0 && start !== -1)
5399
+ return text.slice(start, i + 1);
5400
+ }
5401
+ }
5402
+ }
5403
+ return;
5404
+ }
5372
5405
  function parseJsonOutput(stdout, label) {
5373
- try {
5374
- const value = JSON.parse(stdout || "{}");
5375
- if (!value || typeof value !== "object" || Array.isArray(value))
5376
- throw new Error("not an object");
5377
- return value;
5378
- } catch (error) {
5379
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
5406
+ const raw = stdout || "{}";
5407
+ const candidates = [raw.trim()];
5408
+ const scanned = extractFirstJsonObject(raw);
5409
+ if (scanned && scanned !== raw.trim())
5410
+ candidates.push(scanned);
5411
+ let lastError;
5412
+ for (const candidate of candidates) {
5413
+ try {
5414
+ const value = JSON.parse(candidate);
5415
+ if (!value || typeof value !== "object" || Array.isArray(value))
5416
+ throw new Error("not an object");
5417
+ return value;
5418
+ } catch (error) {
5419
+ lastError = error;
5420
+ }
5380
5421
  }
5422
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
5381
5423
  }
5382
5424
  function recordField(value, key) {
5383
5425
  if (!value || typeof value !== "object" || Array.isArray(value))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",