@hasna/loops 0.4.6 → 0.4.8

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,29 @@ 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.8 (2026-07-04)
9
+
10
+ Lifecycle prompt hardening for routed task workflows.
11
+
12
+ ### Fixed
13
+
14
+ - **Task lifecycle routing:** render copy-safe triage and planner marker comment
15
+ commands so agents can advance or block deterministic lifecycle gates without
16
+ emitting a separate placeholder evidence comment first.
17
+
18
+ ## 0.4.7 (2026-07-04)
19
+
20
+ Routing hardening for PR merge/drain automation.
21
+
22
+ ### Fixed
23
+
24
+ - **Todos drain:** admit registered repo-project task sources while pinning
25
+ routed work to the scanned source project, preventing task-controlled nested
26
+ route paths from moving worker execution into another repository.
27
+ - **PR lifecycle routing:** propagate PR author and reviewer evidence into
28
+ reviewer and merger lifecycle prompts so merge routing can select a valid
29
+ non-author reviewer and keep reviewer/merger steps separate.
30
+
8
31
  ## 0.4.6 (2026-07-03)
9
32
 
10
33
  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.8",
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.8",
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);
@@ -10440,11 +10505,11 @@ function renderTaskLifecycleWorkflow(input) {
10440
10505
  `) : "";
10441
10506
  const shared = [
10442
10507
  worktreePrompt(plan),
10443
- ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
10444
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
10445
- ]),
10508
+ ...todosExactCommandsFragment(todosProjectPath, input.taskId, []),
10509
+ "Use concrete task-specific text in lifecycle comments. Do not copy placeholder text into lifecycle comments; triage and planner comments must start with the exact stage marker when advancing or blocking the workflow.",
10446
10510
  NO_TMUX_DISPATCH_FRAGMENT,
10447
10511
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
10512
+ prReviewFollowUpFragment(input),
10448
10513
  "",
10449
10514
  `Task context JSON: ${compactJson(taskContext)}`,
10450
10515
  prHandoffGuidance
@@ -10452,6 +10517,8 @@ function renderTaskLifecycleWorkflow(input) {
10452
10517
  `);
10453
10518
  const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
10454
10519
  const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
10520
+ const markerCommentCommand = (stage, state, evidencePlaceholder) => `todos --project ${todosProjectPath} comment ${input.taskId} "${gateMarker(stage, state)}
10521
+ <${evidencePlaceholder}>"`;
10455
10522
  const gateStopFragment = (stage, stops) => `The deterministic ${stage} gate will stop ${stops} unless the latest ${stage} marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state.`;
10456
10523
  const triagePrompt = [
10457
10524
  ...boundedStepHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
@@ -10459,9 +10526,12 @@ function renderTaskLifecycleWorkflow(input) {
10459
10526
  "Decide whether the task is eligible for loop execution. Check status, dependencies, duplicate tasks, no-auto/manual/approval metadata, project path, acceptance criteria, and whether the requested work should be split before implementation.",
10460
10527
  "Do not implement repo changes in this step.",
10461
10528
  `If the task is eligible for automated planning, add a task comment whose first line is exactly: ${gateMarker("triage", "go")}`,
10462
- "Include the triage decision, duplicates/dependencies found, and any follow-up tasks created in that same comment.",
10529
+ `Use this copy-safe marker comment command for triage go: ${markerCommentCommand("triage", "go", "task-specific triage evidence")}`,
10530
+ "Do not run a separate generic evidence comment before the marker; include the triage decision, duplicates/dependencies found, and any follow-up tasks created in that same marker comment.",
10463
10531
  `If the task should not proceed automatically, run: ${blockTaskCommand}`,
10464
10532
  `Then add a task comment whose first line is exactly: ${gateMarker("triage", "blocked")}`,
10533
+ `Use this copy-safe marker comment command for triage blocked: ${markerCommentCommand("triage", "blocked", "task-specific triage evidence")}`,
10534
+ "Do not run a separate generic blocker comment before the marker; include the blocker evidence in that same marker comment.",
10465
10535
  gateStopFragment("triage", "later steps")
10466
10536
  ].join(`
10467
10537
  `);
@@ -10470,17 +10540,19 @@ function renderTaskLifecycleWorkflow(input) {
10470
10540
  shared,
10471
10541
  "Read the triage comment and current task details.",
10472
10542
  `If the task is ready for implementation, add a task comment whose first line is exactly: ${gateMarker("planner", "go")}`,
10473
- "In that same comment, include a concise implementation plan: files/areas to inspect, validation commands, risk checks, expected commit/PR behavior, and any cross-repo tasks that should be created separately.",
10543
+ `Use this copy-safe marker comment command for planner go: ${markerCommentCommand("planner", "go", "task-specific plan/evidence")}`,
10544
+ "Do not run a separate generic evidence comment before the marker; in that same marker comment, include a concise implementation plan: files/areas to inspect, validation commands, risk checks, expected commit/PR behavior, and any cross-repo tasks that should be created separately.",
10474
10545
  `Do not implement repo changes in this step. If the task is too broad or unsafe for automation, run: ${blockTaskCommand}`,
10475
10546
  `Then add a task comment whose first line is exactly: ${gateMarker("planner", "blocked")}`,
10476
- `Create smaller deduped tasks and record evidence. ${gateStopFragment("planner", "the worker")}`
10547
+ `Use this copy-safe marker comment command for planner blocked: ${markerCommentCommand("planner", "blocked", "task-specific plan/evidence")}`,
10548
+ `Do not run a separate generic blocker comment before the marker; create smaller deduped tasks and record blocker evidence in that same marker comment. ${gateStopFragment("planner", "the worker")}`
10477
10549
  ].join(`
10478
10550
  `);
10479
10551
  const workerPrompt = [
10480
10552
  ...boundedStepHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
10481
10553
  shared,
10482
10554
  todosStartLine(todosProjectPath, input.taskId),
10483
- "Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record changed files, commits, evidence, blockers, and residual risks.",
10555
+ "Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record concrete worker evidence in todos: changed files, commits, validation results, blockers, and residual risks.",
10484
10556
  input.prHandoff ? `When only GitHub network access is blocked after a successful commit/validation, record the handoff artifact at ${handoffArtifactPath} instead of repeatedly retrying push/PR creation.` : undefined,
10485
10557
  WORKER_LEAVES_COMPLETION_FRAGMENT
10486
10558
  ].filter(Boolean).join(`
@@ -10488,7 +10560,7 @@ function renderTaskLifecycleWorkflow(input) {
10488
10560
  const verifierPrompt = [
10489
10561
  ...boundedStepHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
10490
10562
  shared,
10491
- todosVerificationLine(todosProjectPath, input.taskId),
10563
+ "Before completion, record concrete verification evidence in todos with changed files, validation results, findings, and the task decision.",
10492
10564
  todosDoneLine(todosProjectPath, input.taskId),
10493
10565
  adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
10494
10566
  verifierRuntimeGuidance(input),
@@ -11630,12 +11702,30 @@ function routeEvidenceText(records) {
11630
11702
  const values = [];
11631
11703
  for (const record of records) {
11632
11704
  for (const field of fields)
11633
- values.push(...recordFieldValues(record, field));
11705
+ values.push(...routeTextFieldValues(record, field));
11634
11706
  values.push(...tagsFromValue2(record.tags ?? record.task_tags ?? record.taskTags));
11635
11707
  }
11636
11708
  return values.join(`
11637
11709
  `);
11638
11710
  }
11711
+ function textValuesFromUnknown(value) {
11712
+ if (Array.isArray(value))
11713
+ return value.flatMap((entry) => textValuesFromUnknown(entry));
11714
+ if (typeof value === "string" && value.trim())
11715
+ return [value.trim()];
11716
+ if (typeof value === "number" || typeof value === "boolean")
11717
+ return [String(value)];
11718
+ return [];
11719
+ }
11720
+ function routeTextFieldValues(record, field) {
11721
+ const expected = canonicalRouteField(field);
11722
+ const values = [];
11723
+ for (const [key, value] of Object.entries(record)) {
11724
+ if (canonicalRouteField(key) === expected)
11725
+ values.push(...textValuesFromUnknown(value));
11726
+ }
11727
+ return values;
11728
+ }
11639
11729
  function authorFromPrText(text) {
11640
11730
  const patterns = [
11641
11731
  /\bauthor\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
@@ -11650,6 +11740,18 @@ function authorFromPrText(text) {
11650
11740
  }
11651
11741
  return;
11652
11742
  }
11743
+ function reviewersFromPrText(text) {
11744
+ const reviewers = [];
11745
+ const patterns = [
11746
+ /\bgithub\s+reviewer\s+pool\s*(?:is|=|:)\s*([^\r\n]+)/gi,
11747
+ /\bgithub\s+reviewers?\s*(?:are|is|=|:)\s*([^\r\n]+)/gi
11748
+ ];
11749
+ for (const pattern of patterns) {
11750
+ for (const match of text.matchAll(pattern))
11751
+ reviewers.push(...splitList(match[1]) ?? []);
11752
+ }
11753
+ return githubLogins(reviewers);
11754
+ }
11653
11755
  function prReviewRoutingDecision(data, metadata, opts) {
11654
11756
  const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
11655
11757
  const text = routeEvidenceText(records);
@@ -11675,7 +11777,8 @@ function prReviewRoutingDecision(data, metadata, opts) {
11675
11777
  opts.githubReviewer,
11676
11778
  ...splitList(opts.githubReviewerPool) ?? [],
11677
11779
  ...PR_REVIEWER_FIELDS.flatMap((field) => routeFieldValues(records, field)),
11678
- ...PR_REVIEWER_POOL_FIELDS.flatMap((field) => routeFieldValues(records, field))
11780
+ ...PR_REVIEWER_POOL_FIELDS.flatMap((field) => routeFieldValues(records, field)),
11781
+ ...reviewersFromPrText(text)
11679
11782
  ]);
11680
11783
  const selectedReviewer = author ? reviewers.find((reviewer) => reviewer.toLowerCase() !== author.toLowerCase()) : undefined;
11681
11784
  if (!author) {
@@ -12063,7 +12166,16 @@ function routeTodosTaskEvent(event, opts) {
12063
12166
  }
12064
12167
  const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
12065
12168
  const taskDescription = taskEventField(data, ["description", "body"]);
12066
- const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
12169
+ const sourceTodosProjectPath = opts.sourceTodosProjectPath?.trim();
12170
+ const dataProjectPath = taskEventField(data, [
12171
+ "route_project_path",
12172
+ "routeProjectPath",
12173
+ "project_path",
12174
+ "projectPath",
12175
+ "working_dir",
12176
+ "workingDir",
12177
+ "cwd"
12178
+ ]);
12067
12179
  const metadataProjectPath = taskEventField(metadata, [
12068
12180
  "working_dir",
12069
12181
  "workingDir",
@@ -12076,7 +12188,8 @@ function routeTodosTaskEvent(event, opts) {
12076
12188
  const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve7(projectPath);
12077
12189
  const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
12078
12190
  const throttleLimits = routeThrottleLimitsFromOpts(opts);
12079
- const idempotencyKey = `todos-task:${taskId}`;
12191
+ const sourceProjectIdempotencyPrefix = sourceTodosProjectPath ? normalizeRoutePath(sourceTodosProjectPath) ?? resolve7(sourceTodosProjectPath) : undefined;
12192
+ const idempotencyKey = sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
12080
12193
  const idempotencySuffix = stableSuffix(idempotencyKey);
12081
12194
  const namePrefix = opts.namePrefix ?? "event:todos-task";
12082
12195
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
@@ -12150,9 +12263,10 @@ function routeTodosTaskEvent(event, opts) {
12150
12263
  worktreeRoot: opts.worktreeRoot,
12151
12264
  worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
12152
12265
  prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
12266
+ prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
12153
12267
  eventId: event.id,
12154
12268
  eventType: event.type,
12155
- todosProjectPath: opts.todosProject
12269
+ todosProjectPath: sourceTodosProjectPath || opts.todosProject
12156
12270
  };
12157
12271
  const workflowContext = {
12158
12272
  name: workflowName,
@@ -12420,8 +12534,8 @@ function taskField(task, keys) {
12420
12534
  }
12421
12535
  return;
12422
12536
  }
12423
- function taskListId(task) {
12424
- return taskField(task, ["task_list_id", "taskListId"]) ?? stringField2(task.task_list?.id);
12537
+ function taskRoutePathFromRegistry(sourceProjectPath) {
12538
+ return normalizeRoutePath(sourceProjectPath) ?? resolve8(sourceProjectPath);
12425
12539
  }
12426
12540
  function taskProjectId(task) {
12427
12541
  return taskField(task, ["project_id", "projectId"]);
@@ -12435,12 +12549,28 @@ function taskProjectPath(task) {
12435
12549
  const metadata = objectField2(task.metadata) ?? {};
12436
12550
  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
12551
  }
12438
- function taskDrainEvent(task) {
12552
+ function taskListValues(task) {
12553
+ const taskList = objectField2(task.task_list);
12554
+ return [
12555
+ taskField(task, ["task_list_id", "taskListId"]),
12556
+ stringField2(task.task_list?.id),
12557
+ stringField2(task.task_list?.slug),
12558
+ stringField2(taskList?.name),
12559
+ taskField(task, ["task_list", "taskList"])
12560
+ ].filter((value) => Boolean(value));
12561
+ }
12562
+ function taskDrainEvent(task, sourceProjectPath) {
12439
12563
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
12440
12564
  if (!taskId)
12441
12565
  throw new Error("todos ready returned a task without an id");
12442
- const metadata = objectField2(task.metadata) ?? {};
12566
+ const metadata = { ...objectField2(task.metadata) ?? {} };
12443
12567
  const workingDir = taskProjectPath(task);
12568
+ const routeWorkingDir = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
12569
+ const sourceProject = sourceProjectPath?.trim();
12570
+ if (!sourceProject) {
12571
+ delete metadata.source_project_path;
12572
+ delete metadata.sourceProjectPath;
12573
+ }
12444
12574
  const data = {
12445
12575
  ...task,
12446
12576
  id: taskId,
@@ -12450,10 +12580,23 @@ function taskDrainEvent(task) {
12450
12580
  tags: tagsFromValue2(task.tags),
12451
12581
  metadata
12452
12582
  };
12583
+ if (!sourceProject) {
12584
+ delete data.source_project_path;
12585
+ delete data.sourceProjectPath;
12586
+ }
12453
12587
  if (workingDir) {
12454
- data.working_dir = workingDir;
12455
- data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
12456
- data.cwd = taskField(task, ["cwd"]) ?? workingDir;
12588
+ data.working_dir = routeWorkingDir;
12589
+ data.project_path = routeWorkingDir;
12590
+ data.cwd = taskField(task, ["cwd"]) ?? routeWorkingDir;
12591
+ }
12592
+ if (sourceProject) {
12593
+ data.source_project_path = sourceProject;
12594
+ if (!data.project_path)
12595
+ data.project_path = sourceProject;
12596
+ data.route_project_path = data.project_path;
12597
+ data.routeProjectPath = data.project_path;
12598
+ metadata.route_project_path = data.project_path;
12599
+ metadata.routeProjectPath = data.project_path;
12457
12600
  }
12458
12601
  const time = new Date().toISOString();
12459
12602
  return {
@@ -12467,6 +12610,7 @@ function taskDrainEvent(task) {
12467
12610
  schemaVersion: "1.0",
12468
12611
  metadata: {
12469
12612
  ...metadata,
12613
+ ...sourceProject ? { source_project_path: sourceProject } : {},
12470
12614
  ...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
12471
12615
  drained_by: "@hasna/loops",
12472
12616
  drained_from: "todos ready"
@@ -12497,23 +12641,74 @@ function compactDrainResult(result) {
12497
12641
  fatal: value.fatal === true ? true : undefined
12498
12642
  };
12499
12643
  }
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;
12644
+ function parseTodosReadyJson(stdout) {
12507
12645
  try {
12508
- parsed = JSON.parse(result.stdout || "[]");
12646
+ return JSON.parse(stdout || "[]");
12509
12647
  } catch (error) {
12510
12648
  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}`);
12649
+ throw new Error(`failed to parse todos ready --json output (${stdout.length} bytes): ${message}`);
12512
12650
  }
12651
+ }
12652
+ function parseTodoProjectsJson(stdout) {
12653
+ try {
12654
+ return JSON.parse(stdout || "[]");
12655
+ } catch (error) {
12656
+ const message = error instanceof Error ? error.message : String(error);
12657
+ throw new Error(`failed to parse todos projects --json output (${stdout.length} bytes): ${message}`);
12658
+ }
12659
+ }
12660
+ function loadTodoProjectPathsFromRegistry(opts) {
12661
+ const result = runLocalCommand("todos", ["projects", "--json"], { timeoutMs: 60000 });
12662
+ if (!result.ok)
12663
+ throw new Error(result.stderr || result.error || "todos projects failed");
12664
+ const payload = parseTodoProjectsJson(result.stdout);
12665
+ const projectsPayload = Array.isArray(payload) ? payload : objectField2(payload)?.projects;
12666
+ if (!Array.isArray(projectsPayload))
12667
+ throw new Error("todos projects --json returned a non-array value");
12668
+ const includeFilters = listFromRepeatedOpts(opts.todosProjectInclude)?.map((entry) => normalizeRoutePath(entry) ?? resolve8(entry)) ?? [];
12669
+ const includesPrefix = normalizeRoutePath(opts.projectPathPrefix) ?? (opts.projectPathPrefix ? resolve8(opts.projectPathPrefix) : undefined);
12670
+ const fromProjects = projectsPayload.map((entry) => objectField2(entry)).filter((project) => Boolean(project)).map((project) => {
12671
+ const path = stringField2(project.path) ?? stringField2(project.projectPath) ?? stringField2(project.project_path) ?? stringField2(project.dir) ?? stringField2(project.root) ?? stringField2(project.cwd);
12672
+ if (!path)
12673
+ return;
12674
+ return { path };
12675
+ }).filter((project) => Boolean(project));
12676
+ const paths = fromProjects.map((project) => project.path).filter(Boolean).filter((path) => {
12677
+ const normalizedPath = normalizeRoutePath(path) ?? resolve8(path);
12678
+ if (includesPrefix && !(normalizedPath === includesPrefix || normalizedPath.startsWith(`${includesPrefix}/`)))
12679
+ return false;
12680
+ if (includeFilters.length === 0)
12681
+ return true;
12682
+ return includeFilters.some((prefix) => normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
12683
+ });
12684
+ return [...new Set(paths)];
12685
+ }
12686
+ function loadReadyTodosTasksForProject(projectPath, scanLimit) {
12687
+ const args = ["--project", projectPath, "--json", "ready", "--limit", String(scanLimit)];
12688
+ const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
12689
+ if (!result.ok)
12690
+ throw new Error(result.stderr || result.error || "todos ready failed");
12691
+ const parsed = parseTodosReadyJson(result.stdout);
12513
12692
  if (!Array.isArray(parsed))
12514
12693
  throw new Error("todos ready --json returned a non-array value");
12515
12694
  return parsed;
12516
12695
  }
12696
+ function loadReadyTodosTasks(opts, scanLimit) {
12697
+ if (!opts.todosProjectsFromRegistry) {
12698
+ const todosProject = opts.todosProject ?? defaultLoopsProject();
12699
+ return loadReadyTodosTasksForProject(todosProject, scanLimit);
12700
+ }
12701
+ const projectPaths = loadTodoProjectPathsFromRegistry(opts);
12702
+ const ready = projectPaths.flatMap((projectPath) => loadReadyTodosTasksForProject(projectPath, scanLimit).map((task) => {
12703
+ const sourceProject = projectPath;
12704
+ return {
12705
+ ...task,
12706
+ source_project_path: sourceProject,
12707
+ project_path: taskRoutePathFromRegistry(sourceProject)
12708
+ };
12709
+ }));
12710
+ return ready;
12711
+ }
12517
12712
  function resolveTaskListFilter(todosProject, filter) {
12518
12713
  const wanted = filter?.trim();
12519
12714
  if (!wanted)
@@ -12528,7 +12723,7 @@ function resolveTaskListFilter(todosProject, filter) {
12528
12723
  function taskMatchesDrainFilters(task, filters) {
12529
12724
  if (filters.projectId && taskProjectId(task) !== filters.projectId)
12530
12725
  return false;
12531
- if (filters.taskListId && taskListId(task) !== filters.taskListId)
12726
+ if (filters.taskList && !taskListValues(task).includes(filters.taskList))
12532
12727
  return false;
12533
12728
  if (filters.projectPathPrefix) {
12534
12729
  const path = taskProjectPath(task);
@@ -12566,14 +12761,14 @@ function skippedDrainTask(task, event, reason, extra = {}) {
12566
12761
  function isSkippableDrainRouteError(message) {
12567
12762
  return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
12568
12763
  }
12569
- function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
12764
+ function markInvalidDrainTaskNonRouteable(sourceTodosProject, task, reason) {
12570
12765
  const taskId = taskField(task, ["id", "task_id", "taskId"]);
12571
12766
  if (!taskId)
12572
12767
  return { attempted: false, reason: "task id missing" };
12573
12768
  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 });
12769
+ const commentResult = runLocalCommand("todos", ["--project", sourceTodosProject, "comment", taskId, comment], { timeoutMs: 30000 });
12770
+ const tagResult = runLocalCommand("todos", ["--project", sourceTodosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
12771
+ const untagResult = runLocalCommand("todos", ["--project", sourceTodosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
12577
12772
  const ok = commentResult.ok && tagResult.ok && untagResult.ok;
12578
12773
  return {
12579
12774
  ok,
@@ -12589,7 +12784,7 @@ function drainTodosTaskRoutes(opts) {
12589
12784
  const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
12590
12785
  const todosProject = opts.todosProject ?? defaultLoopsProject();
12591
12786
  const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
12592
- const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
12787
+ const taskListFilter = opts.todosProjectsFromRegistry ? opts.taskList?.trim() : resolveTaskListFilter(todosProject, opts.taskList);
12593
12788
  const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
12594
12789
  const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
12595
12790
  const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
@@ -12597,7 +12792,7 @@ function drainTodosTaskRoutes(opts) {
12597
12792
  const ready = loadReadyTodosTasks(opts, scanLimit);
12598
12793
  const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
12599
12794
  projectId: opts.todosProjectId,
12600
- taskListId: taskListFilter,
12795
+ taskList: taskListFilter,
12601
12796
  projectPathPrefix: opts.projectPathPrefix,
12602
12797
  tags: requiredTags
12603
12798
  }));
@@ -12610,12 +12805,17 @@ function drainTodosTaskRoutes(opts) {
12610
12805
  let event;
12611
12806
  let result;
12612
12807
  try {
12613
- event = taskDrainEvent(task);
12614
- result = routeTodosTaskEvent(event, opts);
12808
+ const sourceProject = opts.todosProjectsFromRegistry ? taskField(task, ["source_project_path", "sourceProjectPath"]) : undefined;
12809
+ event = taskDrainEvent(task, sourceProject);
12810
+ result = routeTodosTaskEvent(event, {
12811
+ ...opts,
12812
+ ...sourceProject ? { sourceTodosProjectPath: sourceProject } : {}
12813
+ });
12615
12814
  } catch (error) {
12616
12815
  const message = error instanceof Error ? error.message : String(error);
12617
12816
  if (isSkippableDrainRouteError(message)) {
12618
- const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message);
12817
+ const sourceTaskProject = opts.todosProjectsFromRegistry ? taskField(task, ["source_project_path", "sourceProjectPath"]) ?? todosProject : todosProject;
12818
+ const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(sourceTaskProject, task, message);
12619
12819
  result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
12620
12820
  } else {
12621
12821
  result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { fatal: true });
@@ -12960,6 +13160,19 @@ var EVENT_INPUT_OPTION_SPECS = [
12960
13160
  { flags: "--event-json <json>", key: "eventJson", kind: "value", description: "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON" }
12961
13161
  ];
12962
13162
  var DRAIN_FILTER_OPTION_SPECS = [
13163
+ {
13164
+ flags: "--todos-projects-from-registry",
13165
+ key: "todosProjectsFromRegistry",
13166
+ kind: "boolean",
13167
+ description: "scan registered todos projects from `todos projects --json` instead of one --todos-project"
13168
+ },
13169
+ {
13170
+ flags: "--todos-project-include <path>",
13171
+ key: "todosProjectInclude",
13172
+ kind: "repeat",
13173
+ description: "include additional registered project path prefixes when scanning via --todos-projects-from-registry",
13174
+ serializeValue: (opts) => listFromRepeatedOpts(opts.todosProjectInclude)
13175
+ },
12963
13176
  { flags: "--todos-project-id <id>", key: "todosProjectId", kind: "value", description: "filter todos ready output to one todos project id" },
12964
13177
  { flags: "--task-list <id-or-slug>", key: "taskList", kind: "value", description: "filter ready tasks to one task-list id, slug, or name" },
12965
13178
  { 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.8",
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.8",
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);
@@ -11196,11 +11261,11 @@ function renderTaskLifecycleWorkflow(input) {
11196
11261
  `) : "";
11197
11262
  const shared = [
11198
11263
  worktreePrompt(plan),
11199
- ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
11200
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
11201
- ]),
11264
+ ...todosExactCommandsFragment(todosProjectPath, input.taskId, []),
11265
+ "Use concrete task-specific text in lifecycle comments. Do not copy placeholder text into lifecycle comments; triage and planner comments must start with the exact stage marker when advancing or blocking the workflow.",
11202
11266
  NO_TMUX_DISPATCH_FRAGMENT,
11203
11267
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
11268
+ prReviewFollowUpFragment(input),
11204
11269
  "",
11205
11270
  `Task context JSON: ${compactJson(taskContext)}`,
11206
11271
  prHandoffGuidance
@@ -11208,6 +11273,8 @@ function renderTaskLifecycleWorkflow(input) {
11208
11273
  `);
11209
11274
  const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
11210
11275
  const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
11276
+ const markerCommentCommand = (stage, state, evidencePlaceholder) => `todos --project ${todosProjectPath} comment ${input.taskId} "${gateMarker(stage, state)}
11277
+ <${evidencePlaceholder}>"`;
11211
11278
  const gateStopFragment = (stage, stops) => `The deterministic ${stage} gate will stop ${stops} unless the latest ${stage} marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state.`;
11212
11279
  const triagePrompt = [
11213
11280
  ...boundedStepHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
@@ -11215,9 +11282,12 @@ function renderTaskLifecycleWorkflow(input) {
11215
11282
  "Decide whether the task is eligible for loop execution. Check status, dependencies, duplicate tasks, no-auto/manual/approval metadata, project path, acceptance criteria, and whether the requested work should be split before implementation.",
11216
11283
  "Do not implement repo changes in this step.",
11217
11284
  `If the task is eligible for automated planning, add a task comment whose first line is exactly: ${gateMarker("triage", "go")}`,
11218
- "Include the triage decision, duplicates/dependencies found, and any follow-up tasks created in that same comment.",
11285
+ `Use this copy-safe marker comment command for triage go: ${markerCommentCommand("triage", "go", "task-specific triage evidence")}`,
11286
+ "Do not run a separate generic evidence comment before the marker; include the triage decision, duplicates/dependencies found, and any follow-up tasks created in that same marker comment.",
11219
11287
  `If the task should not proceed automatically, run: ${blockTaskCommand}`,
11220
11288
  `Then add a task comment whose first line is exactly: ${gateMarker("triage", "blocked")}`,
11289
+ `Use this copy-safe marker comment command for triage blocked: ${markerCommentCommand("triage", "blocked", "task-specific triage evidence")}`,
11290
+ "Do not run a separate generic blocker comment before the marker; include the blocker evidence in that same marker comment.",
11221
11291
  gateStopFragment("triage", "later steps")
11222
11292
  ].join(`
11223
11293
  `);
@@ -11226,17 +11296,19 @@ function renderTaskLifecycleWorkflow(input) {
11226
11296
  shared,
11227
11297
  "Read the triage comment and current task details.",
11228
11298
  `If the task is ready for implementation, add a task comment whose first line is exactly: ${gateMarker("planner", "go")}`,
11229
- "In that same comment, include a concise implementation plan: files/areas to inspect, validation commands, risk checks, expected commit/PR behavior, and any cross-repo tasks that should be created separately.",
11299
+ `Use this copy-safe marker comment command for planner go: ${markerCommentCommand("planner", "go", "task-specific plan/evidence")}`,
11300
+ "Do not run a separate generic evidence comment before the marker; in that same marker comment, include a concise implementation plan: files/areas to inspect, validation commands, risk checks, expected commit/PR behavior, and any cross-repo tasks that should be created separately.",
11230
11301
  `Do not implement repo changes in this step. If the task is too broad or unsafe for automation, run: ${blockTaskCommand}`,
11231
11302
  `Then add a task comment whose first line is exactly: ${gateMarker("planner", "blocked")}`,
11232
- `Create smaller deduped tasks and record evidence. ${gateStopFragment("planner", "the worker")}`
11303
+ `Use this copy-safe marker comment command for planner blocked: ${markerCommentCommand("planner", "blocked", "task-specific plan/evidence")}`,
11304
+ `Do not run a separate generic blocker comment before the marker; create smaller deduped tasks and record blocker evidence in that same marker comment. ${gateStopFragment("planner", "the worker")}`
11233
11305
  ].join(`
11234
11306
  `);
11235
11307
  const workerPrompt = [
11236
11308
  ...boundedStepHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
11237
11309
  shared,
11238
11310
  todosStartLine(todosProjectPath, input.taskId),
11239
- "Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record changed files, commits, evidence, blockers, and residual risks.",
11311
+ "Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record concrete worker evidence in todos: changed files, commits, validation results, blockers, and residual risks.",
11240
11312
  input.prHandoff ? `When only GitHub network access is blocked after a successful commit/validation, record the handoff artifact at ${handoffArtifactPath} instead of repeatedly retrying push/PR creation.` : undefined,
11241
11313
  WORKER_LEAVES_COMPLETION_FRAGMENT
11242
11314
  ].filter(Boolean).join(`
@@ -11244,7 +11316,7 @@ function renderTaskLifecycleWorkflow(input) {
11244
11316
  const verifierPrompt = [
11245
11317
  ...boundedStepHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
11246
11318
  shared,
11247
- todosVerificationLine(todosProjectPath, input.taskId),
11319
+ "Before completion, record concrete verification evidence in todos with changed files, validation results, findings, and the task decision.",
11248
11320
  todosDoneLine(todosProjectPath, input.taskId),
11249
11321
  adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
11250
11322
  verifierRuntimeGuidance(input),
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.8",
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.8",
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.8",
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.8",
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.8",
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",