@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 +23 -0
- package/dist/api/index.js +1 -1
- package/dist/cli/index.js +261 -48
- package/dist/daemon/index.js +51 -9
- package/dist/index.js +89 -17
- package/dist/lib/mode.js +1 -1
- package/dist/lib/route/types.d.ts +6 -0
- package/dist/lib/storage/index.js +1 -1
- package/dist/lib/storage/sqlite.js +1 -1
- package/dist/lib/store.js +1 -1
- package/dist/lib/templates.d.ts +10 -0
- package/dist/mcp/index.js +51 -9
- package/dist/runner/index.js +51 -9
- package/dist/sdk/index.js +51 -9
- package/package.json +1 -1
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
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.
|
|
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
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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,
|
|
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
|
-
|
|
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(...
|
|
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
|
|
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
|
|
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
|
|
12424
|
-
return
|
|
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
|
|
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 =
|
|
12455
|
-
data.project_path =
|
|
12456
|
-
data.cwd = taskField(task, ["cwd"]) ??
|
|
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
|
|
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
|
-
|
|
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 (${
|
|
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.
|
|
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(
|
|
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",
|
|
12575
|
-
const tagResult = runLocalCommand("todos", ["--project",
|
|
12576
|
-
const untagResult = runLocalCommand("todos", ["--project",
|
|
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
|
-
|
|
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
|
-
|
|
12614
|
-
|
|
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
|
|
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" },
|
package/dist/daemon/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);
|
|
@@ -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
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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,
|
|
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
|
-
|
|
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
|
@@ -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);
|
package/dist/lib/templates.d.ts
CHANGED
|
@@ -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
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
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.
|
|
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",
|
package/dist/runner/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
|
+
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
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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.
|
|
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
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
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))
|