@hasna/loops 0.3.50 → 0.3.52

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.
@@ -623,7 +623,7 @@ function writeWorkflowRunManifest(args) {
623
623
  var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
624
624
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
625
625
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
626
- var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
626
+ var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
627
627
  var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
628
628
  function rowToLoop(row) {
629
629
  return {
@@ -5240,7 +5240,7 @@ function enableStartup(result) {
5240
5240
  // package.json
5241
5241
  var package_default = {
5242
5242
  name: "@hasna/loops",
5243
- version: "0.3.50",
5243
+ version: "0.3.52",
5244
5244
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5245
5245
  type: "module",
5246
5246
  main: "dist/index.js",
package/dist/index.js CHANGED
@@ -621,7 +621,7 @@ function writeWorkflowRunManifest(args) {
621
621
  var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
622
622
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
623
623
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
624
- var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
624
+ var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
625
625
  var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
626
626
  function rowToLoop(row) {
627
627
  return {
@@ -5087,6 +5087,10 @@ var TEMPLATE_SUMMARIES = [
5087
5087
  { name: "taskId", required: true, description: "Todos task id." },
5088
5088
  { name: "projectPath", required: true, description: "Repository or project working directory." },
5089
5089
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
5090
+ { name: "triageAuthProfile", description: "Provider-native auth profile for the triage step." },
5091
+ { name: "plannerAuthProfile", description: "Provider-native auth profile for the planner step." },
5092
+ { name: "workerAuthProfile", description: "Provider-native auth profile for the worker step." },
5093
+ { name: "verifierAuthProfile", description: "Provider-native auth profile for the verifier step." },
5090
5094
  { name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
5091
5095
  { name: "provider", default: "codewith", description: "Agent provider." },
5092
5096
  { name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
@@ -5183,6 +5187,11 @@ var CUSTOM_TEMPLATE_VARIABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
5183
5187
  var CUSTOM_TEMPLATE_VARIABLE_TYPES = new Set(["string", "number", "boolean", "json", "string[]"]);
5184
5188
  var CUSTOM_TEMPLATE_PLACEHOLDER = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
5185
5189
  var CUSTOM_TEMPLATE_EXACT_PLACEHOLDER = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/;
5190
+ var CUSTOM_TEMPLATE_DANGEROUS_ARG_PATTERNS = [
5191
+ "danger-full-access",
5192
+ "dangerously-bypass",
5193
+ "dangerously-skip"
5194
+ ];
5186
5195
  function compactJson(value) {
5187
5196
  return JSON.stringify(value);
5188
5197
  }
@@ -5204,9 +5213,17 @@ function rolePoolValue(pool, seed, role) {
5204
5213
  const workerIndex = stableIndex(seed, pool.length);
5205
5214
  if (role === "worker" || pool.length === 1)
5206
5215
  return pool[workerIndex];
5207
- return pool[(workerIndex + 1) % pool.length];
5216
+ if (role === "verifier")
5217
+ return pool[(workerIndex + 1) % pool.length];
5218
+ if (role === "planner")
5219
+ return pool[(workerIndex + 2) % pool.length];
5220
+ return pool[(workerIndex + 3) % pool.length];
5208
5221
  }
5209
5222
  function authProfileForRole(input, role, seed) {
5223
+ if (role === "triage" && input.triageAuthProfile)
5224
+ return input.triageAuthProfile;
5225
+ if (role === "planner" && input.plannerAuthProfile)
5226
+ return input.plannerAuthProfile;
5210
5227
  if (role === "worker" && input.workerAuthProfile)
5211
5228
  return input.workerAuthProfile;
5212
5229
  if (role === "verifier" && input.verifierAuthProfile)
@@ -5214,6 +5231,10 @@ function authProfileForRole(input, role, seed) {
5214
5231
  return rolePoolValue(input.authProfilePool, seed, role) ?? input.authProfile;
5215
5232
  }
5216
5233
  function accountForRole(input, role, seed) {
5234
+ if (role === "triage" && input.triageAccount)
5235
+ return input.triageAccount;
5236
+ if (role === "planner" && input.plannerAccount)
5237
+ return input.plannerAccount;
5217
5238
  if (role === "worker" && input.workerAccount)
5218
5239
  return input.workerAccount;
5219
5240
  if (role === "verifier" && input.verifierAccount)
@@ -5400,10 +5421,10 @@ function worktreePrompt(plan) {
5400
5421
  function assertNativeAuthProfileSupport(input, provider) {
5401
5422
  if (provider === "codewith")
5402
5423
  return;
5403
- const hasNativeAuthProfiles = Boolean(input.authProfile || input.authProfilePool?.length || input.workerAuthProfile || input.verifierAuthProfile);
5424
+ const hasNativeAuthProfiles = Boolean(input.authProfile || input.authProfilePool?.length || input.triageAuthProfile || input.plannerAuthProfile || input.workerAuthProfile || input.verifierAuthProfile);
5404
5425
  if (!hasNativeAuthProfiles)
5405
5426
  return;
5406
- throw new Error(`authProfile, authProfilePool, workerAuthProfile, and verifierAuthProfile are supported only for provider codewith; use account/accountPool for ${provider} profile isolation`);
5427
+ throw new Error(`authProfile, authProfilePool, triageAuthProfile, plannerAuthProfile, workerAuthProfile, and verifierAuthProfile are supported only for provider codewith; use account/accountPool for ${provider} profile isolation`);
5407
5428
  }
5408
5429
  function failClosedSandbox(input, provider, sandbox) {
5409
5430
  if (!["codewith", "codex"].includes(provider))
@@ -5455,9 +5476,10 @@ function agentTarget(input, prompt, role, seed, plan) {
5455
5476
  function workflowStepsWithWorktree(plan, steps) {
5456
5477
  if (!plan.prepareStep)
5457
5478
  return steps;
5479
+ const firstStepId = steps[0]?.id;
5458
5480
  return [
5459
5481
  plan.prepareStep,
5460
- ...steps.map((step) => step.id === "worker" ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
5482
+ ...steps.map((step) => step.id === firstStepId ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
5461
5483
  ];
5462
5484
  }
5463
5485
  function assertRecord(value, label) {
@@ -5505,6 +5527,13 @@ function validateCustomTemplateId(id, label) {
5505
5527
  throw new Error(`${label} must match ${CUSTOM_TEMPLATE_ID_PATTERN.source}`);
5506
5528
  }
5507
5529
  }
5530
+ function optionalTemplateBoolean(value, label) {
5531
+ if (value === undefined)
5532
+ return;
5533
+ if (typeof value !== "boolean")
5534
+ throw new Error(`${label} must be a boolean`);
5535
+ return value;
5536
+ }
5508
5537
  function validateCustomTemplateVariables(value, label) {
5509
5538
  if (value === undefined)
5510
5539
  return [];
@@ -5527,31 +5556,56 @@ function validateCustomTemplateVariables(value, label) {
5527
5556
  if (type && !CUSTOM_TEMPLATE_VARIABLE_TYPES.has(type)) {
5528
5557
  throw new Error(`${entryLabel}.type must be one of ${[...CUSTOM_TEMPLATE_VARIABLE_TYPES].join(", ")}`);
5529
5558
  }
5530
- if (defaultValue === "danger-full-access") {
5531
- throw new Error(`${entryLabel}.default cannot be danger-full-access in a custom template`);
5559
+ if (defaultValue && CUSTOM_TEMPLATE_DANGEROUS_ARG_PATTERNS.some((pattern) => defaultValue.includes(pattern))) {
5560
+ throw new Error(`${entryLabel}.default cannot contain dangerous sandbox or bypass flags in a custom template`);
5532
5561
  }
5533
5562
  return {
5534
5563
  name,
5535
5564
  description,
5536
- required: entry.required === undefined ? undefined : Boolean(entry.required),
5565
+ required: optionalTemplateBoolean(entry.required, `${entryLabel}.required`),
5537
5566
  default: defaultValue,
5538
5567
  type
5539
5568
  };
5540
5569
  });
5541
5570
  }
5542
- function assertNoDangerFullAccess(value, label) {
5571
+ function hasDangerousArg(value) {
5572
+ return CUSTOM_TEMPLATE_DANGEROUS_ARG_PATTERNS.some((pattern) => value.includes(pattern));
5573
+ }
5574
+ function assertNoDangerousCustomTemplateScalars(value, label) {
5575
+ if (typeof value === "string") {
5576
+ if (hasDangerousArg(value)) {
5577
+ throw new Error(`${label} contains a dangerous sandbox or bypass flag; custom templates must not request danger-full-access`);
5578
+ }
5579
+ return;
5580
+ }
5543
5581
  if (!value || typeof value !== "object")
5544
5582
  return;
5545
5583
  if (Array.isArray(value)) {
5546
- value.forEach((entry, index) => assertNoDangerFullAccess(entry, `${label}[${index}]`));
5584
+ value.forEach((entry, index) => assertNoDangerousCustomTemplateScalars(entry, `${label}[${index}]`));
5547
5585
  return;
5548
5586
  }
5549
5587
  for (const [key, entry] of Object.entries(value)) {
5550
- if (key === "sandbox" && entry === "danger-full-access") {
5551
- throw new Error(`${label}.${key} uses danger-full-access; custom templates must not request danger-full-access`);
5552
- }
5553
- assertNoDangerFullAccess(entry, `${label}.${key}`);
5588
+ assertNoDangerousCustomTemplateScalars(entry, `${label}.${key}`);
5589
+ }
5590
+ }
5591
+ function assertNoImplicitDangerFullAccess(value, label) {
5592
+ if (!value || typeof value !== "object")
5593
+ return;
5594
+ if (Array.isArray(value)) {
5595
+ value.forEach((entry, index) => assertNoImplicitDangerFullAccess(entry, `${label}[${index}]`));
5596
+ return;
5597
+ }
5598
+ const object = value;
5599
+ if (object.type === "agent" && (object.provider === "codewith" || object.provider === "codex") && object.permissionMode === "bypass" && object.sandbox === undefined) {
5600
+ throw new Error(`${label} uses permissionMode=bypass for ${object.provider} without an explicit sandbox; set sandbox=workspace-write or read-only`);
5554
5601
  }
5602
+ for (const [key, entry] of Object.entries(object)) {
5603
+ assertNoImplicitDangerFullAccess(entry, `${label}.${key}`);
5604
+ }
5605
+ }
5606
+ function assertCustomTemplateSafety(value, label) {
5607
+ assertNoDangerousCustomTemplateScalars(value, label);
5608
+ assertNoImplicitDangerFullAccess(value, label);
5555
5609
  }
5556
5610
  function customTemplateDefinitionFromJson(value, sourcePath) {
5557
5611
  assertRecord(value, sourcePath);
@@ -5564,7 +5618,7 @@ function customTemplateDefinitionFromJson(value, sourcePath) {
5564
5618
  if (value.workflow === undefined)
5565
5619
  throw new Error(`${sourcePath}.workflow is required`);
5566
5620
  assertRecord(value.workflow, `${sourcePath}.workflow`);
5567
- assertNoDangerFullAccess(value.workflow, `${sourcePath}.workflow`);
5621
+ assertCustomTemplateSafety(value.workflow, `${sourcePath}.workflow`);
5568
5622
  return { id, name, description, kind, variables, workflow: value.workflow };
5569
5623
  }
5570
5624
  function customTemplateSummary(definition, sourcePath) {
@@ -5605,11 +5659,11 @@ function assertNoTemplateCollisions(entries) {
5605
5659
  }
5606
5660
  }
5607
5661
  }
5608
- function loadCustomLoopTemplates() {
5662
+ function loadCustomLoopTemplatesRaw() {
5609
5663
  const dir = customLoopTemplatesDir();
5610
5664
  if (!existsSync3(dir))
5611
5665
  return [];
5612
- const entries = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
5666
+ return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
5613
5667
  const file = join5(dir, entry.name);
5614
5668
  if (entry.isSymbolicLink())
5615
5669
  throw new Error(`refusing symlinked custom template file: ${file}`);
@@ -5617,6 +5671,9 @@ function loadCustomLoopTemplates() {
5617
5671
  throw new Error(`custom template registry entry is not a regular file: ${file}`);
5618
5672
  return readCustomTemplateFile(file);
5619
5673
  });
5674
+ }
5675
+ function loadCustomLoopTemplates() {
5676
+ const entries = loadCustomLoopTemplatesRaw();
5620
5677
  assertNoTemplateCollisions(entries);
5621
5678
  return entries;
5622
5679
  }
@@ -5711,21 +5768,26 @@ function renderCustomTemplateNode(value, values, templateId) {
5711
5768
  function renderCustomLoopTemplate(entry, values) {
5712
5769
  const renderedValues = customTemplateValues(entry.definition, values);
5713
5770
  const rendered = renderCustomTemplateNode(entry.definition.workflow, renderedValues, entry.definition.id);
5714
- assertNoDangerFullAccess(rendered, `custom template ${entry.definition.id}.workflow`);
5715
- return workflowBodyFromJson(rendered);
5771
+ assertCustomTemplateSafety(rendered, `custom template ${entry.definition.id}.workflow`);
5772
+ const workflow = workflowBodyFromJson(rendered);
5773
+ assertCustomTemplateSafety(workflow, `custom template ${entry.definition.id}.workflow`);
5774
+ return workflow;
5716
5775
  }
5717
5776
  function validateCustomLoopTemplateFile(file) {
5718
- const entry = readCustomTemplateFile(resolve(file));
5719
- assertNoTemplateCollisions([entry]);
5777
+ const source = resolve(file);
5778
+ const entry = readCustomTemplateFile(source);
5779
+ const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== source);
5780
+ assertNoTemplateCollisions([...existing, entry]);
5720
5781
  return structuredClone(entry.summary);
5721
5782
  }
5722
5783
  function importCustomLoopTemplate(file, opts = {}) {
5723
5784
  const source = resolve(file);
5724
5785
  const entry = readCustomTemplateFile(source);
5725
- assertNoTemplateCollisions([entry]);
5726
5786
  const dir = ensureCustomLoopTemplatesDir();
5727
5787
  const destination = join5(dir, `${entry.definition.id}.json`);
5728
5788
  const replaced = existsSync3(destination);
5789
+ const existing = loadCustomLoopTemplatesRaw().filter((template) => resolve(template.path) !== resolve(destination));
5790
+ assertNoTemplateCollisions([...existing, { ...entry, path: destination, summary: customTemplateSummary(entry.definition, destination) }]);
5729
5791
  if (replaced) {
5730
5792
  const stat = lstatSync(destination);
5731
5793
  if (!stat.isFile() || stat.isSymbolicLink())
@@ -5852,6 +5914,222 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
5852
5914
  ])
5853
5915
  };
5854
5916
  }
5917
+ function renderTaskLifecycleWorkflow(input) {
5918
+ if (!input.taskId?.trim())
5919
+ throw new Error("taskId is required");
5920
+ if (!input.projectPath?.trim())
5921
+ throw new Error("projectPath is required");
5922
+ const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
5923
+ const plan = worktreePlan(input, input.taskId);
5924
+ const taskContext = {
5925
+ taskId: input.taskId,
5926
+ taskTitle: input.taskTitle,
5927
+ taskDescription: input.taskDescription,
5928
+ eventId: input.eventId,
5929
+ eventType: input.eventType,
5930
+ projectPath: input.projectPath,
5931
+ routeProjectPath: input.routeProjectPath,
5932
+ projectGroup: input.projectGroup,
5933
+ todosProjectPath,
5934
+ worktree: {
5935
+ mode: plan.mode,
5936
+ enabled: plan.enabled,
5937
+ cwd: plan.cwd,
5938
+ path: plan.path,
5939
+ branch: plan.branch,
5940
+ reason: plan.reason
5941
+ }
5942
+ };
5943
+ const shared = [
5944
+ worktreePrompt(plan),
5945
+ `Todos project path: ${todosProjectPath}`,
5946
+ "Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
5947
+ `- Inspect first: todos --project ${todosProjectPath} inspect ${input.taskId}`,
5948
+ `- Record evidence: todos --project ${todosProjectPath} comment ${input.taskId} "<concise evidence, decision, or blocker>"`,
5949
+ "Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
5950
+ "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
5951
+ "",
5952
+ `Task context JSON: ${compactJson(taskContext)}`
5953
+ ].join(`
5954
+ `);
5955
+ const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
5956
+ const gateCommand = (stage) => [
5957
+ "set -euo pipefail",
5958
+ `task_json="$(todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(input.taskId)})"`,
5959
+ `TASK_JSON="$task_json" STAGE=${shellQuote2(stage)} bun - <<'BUN'`,
5960
+ "const raw = process.env.TASK_JSON || '{}';",
5961
+ "const payload = JSON.parse(raw);",
5962
+ "const task = payload.task && typeof payload.task === 'object' ? payload.task : payload;",
5963
+ "const stage = process.env.STAGE || 'lifecycle';",
5964
+ `const goMarker = ${JSON.stringify(gateMarker(stage, "go"))};`,
5965
+ `const blockedMarker = ${JSON.stringify(gateMarker(stage, "blocked"))};`,
5966
+ "const status = String(task.status || '').toLowerCase().replace(/_/g, '-');",
5967
+ "const metadata = task.metadata && typeof task.metadata === 'object' ? task.metadata : {};",
5968
+ "const automation = metadata.automation && typeof metadata.automation === 'object' ? metadata.automation : {};",
5969
+ "const comments = Array.isArray(task.comments) ? task.comments : [];",
5970
+ "const blockedStatuses = new Set(['blocked', 'cancelled', 'canceled', 'failed', 'archived', 'deleted', 'done', 'completed']);",
5971
+ "const truthy = (value) => value === true || value === 1 || value === '1' || String(value).toLowerCase() === 'true' || String(value).toLowerCase() === 'yes';",
5972
+ "const falsey = (value) => value === false || value === 0 || value === '0' || String(value).toLowerCase() === 'false' || String(value).toLowerCase() === 'no';",
5973
+ "const commentText = (comment) => String(comment?.content ?? comment?.text ?? comment?.body ?? comment?.comment ?? '');",
5974
+ "const tagsFrom = (value) => Array.isArray(value) ? value.map(String) : typeof value === 'string' ? value.split(',') : [];",
5975
+ "const records = [task, metadata, automation].filter((entry) => entry && typeof entry === 'object');",
5976
+ "const tags = new Set(records.flatMap((entry) => [entry.tags, entry.task_tags, entry.taskTags].flatMap(tagsFrom)).map((tag) => tag.trim().toLowerCase()).filter(Boolean));",
5977
+ "const markerState = (comment) => {",
5978
+ " const line = commentText(comment).trimStart().split(/\\r?\\n/, 1)[0]?.trimEnd() || '';",
5979
+ " if (line === goMarker) return 'go';",
5980
+ " if (line === blockedMarker) return 'blocked';",
5981
+ " if (line.startsWith(`openloops:${stage}=`)) return `invalid marker: ${line}`;",
5982
+ " return undefined;",
5983
+ "};",
5984
+ "const markerTime = (comment, index) => {",
5985
+ " const rawTime = comment?.created_at ?? comment?.createdAt ?? comment?.updated_at ?? comment?.updatedAt;",
5986
+ " const parsed = rawTime ? Date.parse(String(rawTime)) : Number.NaN;",
5987
+ " return Number.isFinite(parsed) ? parsed : index;",
5988
+ "};",
5989
+ "const markers = comments",
5990
+ " .map((comment, index) => ({ state: markerState(comment), order: markerTime(comment, index), index }))",
5991
+ " .filter((entry) => entry.state)",
5992
+ " .sort((a, b) => a.order - b.order || a.index - b.index);",
5993
+ "const latestMarker = markers.at(-1)?.state;",
5994
+ "const blockers = [];",
5995
+ "if (blockedStatuses.has(status)) blockers.push(`task status is ${status}`);",
5996
+ "for (const tag of ['no-auto', 'manual', 'manual-required', 'approval-required']) {",
5997
+ " if (tags.has(tag)) blockers.push(`task has disallowed tag ${tag}`);",
5998
+ "}",
5999
+ "for (const [key, source] of records.entries()) {",
6000
+ " if (truthy(source.no_auto) || truthy(source.noAuto)) blockers.push(`${key}.no_auto is true`);",
6001
+ " if (truthy(source.manual) || truthy(source.manual_required) || truthy(source.manualRequired) || String(source.mode || '').toLowerCase() === 'manual') blockers.push(`${key}.manual/mode requires manual handling`);",
6002
+ " if (truthy(source.requires_approval) || truthy(source.requiresApproval) || truthy(source.approval_required) || truthy(source.approvalRequired)) blockers.push(`${key}.requires_approval is true`);",
6003
+ " if (falsey(source.auto) || falsey(source.enabled) || falsey(source.allowed) || falsey(source.automation_allowed) || falsey(source.automationAllowed) || falsey(source.loop_allowed) || falsey(source.loopAllowed)) blockers.push(`${key} disallows loop automation`);",
6004
+ "}",
6005
+ "if (latestMarker !== 'go') blockers.push(latestMarker ? `latest ${stage} marker is ${latestMarker}` : `missing exact ${goMarker} comment`);",
6006
+ "if (blockers.length) {",
6007
+ " console.error(`task lifecycle ${stage} gate blocked ${task.id || task.taskId || 'task'}: ${blockers.join('; ')}`);",
6008
+ " process.exit(12);",
6009
+ "}",
6010
+ "console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);",
6011
+ "BUN"
6012
+ ].join(`
6013
+ `);
6014
+ const triagePrompt = [
6015
+ `/goal Triage todos task ${input.taskId} for safe automated execution.`,
6016
+ "",
6017
+ "You are the triage step for a full task-triggered OpenLoops lifecycle.",
6018
+ shared,
6019
+ "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.",
6020
+ "Do not implement repo changes in this step.",
6021
+ `If the task is eligible for automated planning, add a task comment whose first line is exactly: ${gateMarker("triage", "go")}`,
6022
+ "Include the triage decision, duplicates/dependencies found, and any follow-up tasks created in that same comment.",
6023
+ `If the task should not proceed automatically, run: todos --project ${todosProjectPath} update ${input.taskId} --status blocked`,
6024
+ `Then add a task comment whose first line is exactly: ${gateMarker("triage", "blocked")}`,
6025
+ "The deterministic triage gate will stop later steps unless the latest triage marker is the exact go marker and the task has no blocked/no-auto/manual/approval-required state."
6026
+ ].join(`
6027
+ `);
6028
+ const plannerPrompt = [
6029
+ `/goal Plan todos task ${input.taskId} before implementation.`,
6030
+ "",
6031
+ "You are the planner step for a full task-triggered OpenLoops lifecycle.",
6032
+ shared,
6033
+ "Read the triage comment and current task details.",
6034
+ `If the task is ready for implementation, add a task comment whose first line is exactly: ${gateMarker("planner", "go")}`,
6035
+ "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.",
6036
+ `Do not implement repo changes in this step. If the task is too broad or unsafe for automation, run: todos --project ${todosProjectPath} update ${input.taskId} --status blocked`,
6037
+ `Then add a task comment whose first line is exactly: ${gateMarker("planner", "blocked")}`,
6038
+ "Create smaller deduped tasks and record evidence. The deterministic planner gate will stop the worker unless the latest planner marker is the exact go marker and the task has no blocked/no-auto/manual/approval-required state."
6039
+ ].join(`
6040
+ `);
6041
+ const workerPrompt = [
6042
+ `/goal Complete todos task ${input.taskId} according to the planner evidence.`,
6043
+ "",
6044
+ "You are the worker step for a full task-triggered OpenLoops lifecycle.",
6045
+ shared,
6046
+ `- Claim/start if appropriate: todos --project ${todosProjectPath} start ${input.taskId}`,
6047
+ "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.",
6048
+ "Do not mark the task complete in the worker step; the verifier step owns completion after independent validation."
6049
+ ].join(`
6050
+ `);
6051
+ const verifierPrompt = [
6052
+ `/goal Verify todos task ${input.taskId} after the full lifecycle worker step.`,
6053
+ "",
6054
+ "You are the verifier step for a full task-triggered OpenLoops lifecycle.",
6055
+ shared,
6056
+ `- Record verification: todos --project ${todosProjectPath} comment ${input.taskId} "<verification evidence or blocker>"`,
6057
+ `- If valid and complete: todos --project ${todosProjectPath} done ${input.taskId}`,
6058
+ "Use fresh context. Inspect triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria. Act as an adversarial reviewer focused on correctness, regressions, missing tests, security, and incomplete requirements.",
6059
+ "If the work is valid, record verification evidence in todos and mark/leave the task completed according to the todos CLI. If not valid, add precise follow-up tasks or comments and leave the original task open or blocked with clear evidence.",
6060
+ "Do not make broad unrelated changes. Only apply tiny verification fixes when they are necessary and low risk; otherwise create follow-up tasks."
6061
+ ].join(`
6062
+ `);
6063
+ return {
6064
+ name: `task-lifecycle-${input.taskId.slice(0, 8)}-triage-plan-worker-verifier`,
6065
+ description: `Full task lifecycle workflow for ${taskLabel(input)}`,
6066
+ version: 1,
6067
+ steps: workflowStepsWithWorktree(plan, [
6068
+ {
6069
+ id: "triage",
6070
+ name: "Triage",
6071
+ description: "Check task eligibility, duplicates, dependencies, and automation gates.",
6072
+ target: agentTarget(input, triagePrompt, "triage", input.taskId, plan),
6073
+ timeoutMs: 20 * 60000
6074
+ },
6075
+ {
6076
+ id: "triage-gate",
6077
+ name: "Triage Gate",
6078
+ description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
6079
+ dependsOn: ["triage"],
6080
+ target: {
6081
+ type: "command",
6082
+ command: "bash",
6083
+ args: ["-lc", gateCommand("triage")],
6084
+ cwd: plan.cwd,
6085
+ timeoutMs: 2 * 60000
6086
+ },
6087
+ timeoutMs: 2 * 60000
6088
+ },
6089
+ {
6090
+ id: "planner",
6091
+ name: "Planner",
6092
+ description: "Create a concise implementation plan and split unsafe scope before work starts.",
6093
+ dependsOn: ["triage-gate"],
6094
+ target: agentTarget(input, plannerPrompt, "planner", input.taskId, plan),
6095
+ timeoutMs: 25 * 60000
6096
+ },
6097
+ {
6098
+ id: "planner-gate",
6099
+ name: "Planner Gate",
6100
+ description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
6101
+ dependsOn: ["planner"],
6102
+ target: {
6103
+ type: "command",
6104
+ command: "bash",
6105
+ args: ["-lc", gateCommand("planner")],
6106
+ cwd: plan.cwd,
6107
+ timeoutMs: 2 * 60000
6108
+ },
6109
+ timeoutMs: 2 * 60000
6110
+ },
6111
+ {
6112
+ id: "worker",
6113
+ name: "Worker",
6114
+ description: "Implement the todos task according to triage and planner evidence.",
6115
+ dependsOn: ["planner-gate"],
6116
+ target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
6117
+ timeoutMs: 45 * 60000
6118
+ },
6119
+ {
6120
+ id: "verifier",
6121
+ name: "Verifier",
6122
+ description: "Adversarially verify worker output and update todos.",
6123
+ dependsOn: ["worker"],
6124
+ target: {
6125
+ ...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
6126
+ idleTimeoutMs: 10 * 60000
6127
+ },
6128
+ timeoutMs: 30 * 60000
6129
+ }
6130
+ ])
6131
+ };
6132
+ }
5855
6133
  function renderEventWorkerVerifierWorkflow(input) {
5856
6134
  if (!input.eventId?.trim())
5857
6135
  throw new Error("eventId is required");
@@ -6017,11 +6295,39 @@ function renderLifecycleBoundedTemplate(id, values) {
6017
6295
  const taskId = values.taskId ?? "";
6018
6296
  if (!taskId.trim())
6019
6297
  throw new Error("taskId is required");
6020
- return renderBoundedAgentWorkerVerifierWorkflow({
6021
- ...common,
6022
- name: values.name ?? `task-lifecycle-${slugSegment(taskId)}-worker-verifier`,
6023
- objective: values.objective ?? `Run the full task lifecycle for todos task ${taskId}.`,
6024
- prompt: values.prompt ?? "Triage and dedupe the task, verify it is eligible for loop execution, create or update a concise plan artifact/comment, execute only the allowed scope, validate, record evidence, and let the verifier decide final task state. Add follow-up tasks instead of broadening scope."
6298
+ return renderTaskLifecycleWorkflow({
6299
+ taskId,
6300
+ taskTitle: values.taskTitle,
6301
+ taskDescription: values.taskDescription,
6302
+ projectPath,
6303
+ todosProjectPath: values.todosProjectPath ?? values.todosProject,
6304
+ routeProjectPath: values.routeProjectPath,
6305
+ projectGroup: values.projectGroup,
6306
+ provider: values.provider,
6307
+ authProfile: values.authProfile,
6308
+ authProfilePool: listVar(values.authProfilePool),
6309
+ triageAuthProfile: values.triageAuthProfile,
6310
+ plannerAuthProfile: values.plannerAuthProfile,
6311
+ workerAuthProfile: values.workerAuthProfile,
6312
+ verifierAuthProfile: values.verifierAuthProfile,
6313
+ account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
6314
+ accountPool: accountPoolVar(values.accountPool, values.accountTool),
6315
+ triageAccount: values.triageAccount ? { profile: values.triageAccount, tool: values.accountTool } : undefined,
6316
+ plannerAccount: values.plannerAccount ? { profile: values.plannerAccount, tool: values.accountTool } : undefined,
6317
+ workerAccount: values.workerAccount ? { profile: values.workerAccount, tool: values.accountTool } : undefined,
6318
+ verifierAccount: values.verifierAccount ? { profile: values.verifierAccount, tool: values.accountTool } : undefined,
6319
+ model: values.model,
6320
+ variant: values.variant,
6321
+ agent: values.agent,
6322
+ addDirs: listVar(values.addDirs ?? values.addDir),
6323
+ permissionMode: values.permissionMode,
6324
+ sandbox: values.sandbox,
6325
+ manualBreakGlass: booleanVar(values.manualBreakGlass),
6326
+ worktreeMode: values.worktreeMode ?? "required",
6327
+ worktreeRoot: values.worktreeRoot,
6328
+ worktreeBranchPrefix: values.worktreeBranchPrefix,
6329
+ eventId: values.eventId,
6330
+ eventType: values.eventType
6025
6331
  });
6026
6332
  }
6027
6333
  if (id === PR_REVIEW_TEMPLATE_ID) {
package/dist/lib/store.js CHANGED
@@ -621,7 +621,7 @@ function writeWorkflowRunManifest(args) {
621
621
  var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
622
622
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
623
623
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
624
- var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
624
+ var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
625
625
  var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
626
626
  function rowToLoop(row) {
627
627
  return {
@@ -20,10 +20,14 @@ export interface TodosTaskWorkflowTemplateInput {
20
20
  provider?: AgentProvider;
21
21
  authProfile?: string;
22
22
  authProfilePool?: string[];
23
+ triageAuthProfile?: string;
24
+ plannerAuthProfile?: string;
23
25
  workerAuthProfile?: string;
24
26
  verifierAuthProfile?: string;
25
27
  account?: AccountRef;
26
28
  accountPool?: AccountRef[];
29
+ triageAccount?: AccountRef;
30
+ plannerAccount?: AccountRef;
27
31
  workerAccount?: AccountRef;
28
32
  verifierAccount?: AccountRef;
29
33
  model?: string;
@@ -52,10 +56,14 @@ export interface EventWorkflowTemplateInput {
52
56
  provider?: AgentProvider;
53
57
  authProfile?: string;
54
58
  authProfilePool?: string[];
59
+ triageAuthProfile?: string;
60
+ plannerAuthProfile?: string;
55
61
  workerAuthProfile?: string;
56
62
  verifierAuthProfile?: string;
57
63
  account?: AccountRef;
58
64
  accountPool?: AccountRef[];
65
+ triageAccount?: AccountRef;
66
+ plannerAccount?: AccountRef;
59
67
  workerAccount?: AccountRef;
60
68
  verifierAccount?: AccountRef;
61
69
  model?: string;
@@ -79,10 +87,14 @@ export interface BoundedAgentWorkflowTemplateInput {
79
87
  provider?: AgentProvider;
80
88
  authProfile?: string;
81
89
  authProfilePool?: string[];
90
+ triageAuthProfile?: string;
91
+ plannerAuthProfile?: string;
82
92
  workerAuthProfile?: string;
83
93
  verifierAuthProfile?: string;
84
94
  account?: AccountRef;
85
95
  accountPool?: AccountRef[];
96
+ triageAccount?: AccountRef;
97
+ plannerAccount?: AccountRef;
86
98
  workerAccount?: AccountRef;
87
99
  verifierAccount?: AccountRef;
88
100
  model?: string;
@@ -120,6 +132,7 @@ export declare function validateLoopTemplateRegistry(opts?: ListLoopTemplatesOpt
120
132
  export declare function listLoopTemplates(opts?: ListLoopTemplatesOptions): LoopTemplateSummary[];
121
133
  export declare function getLoopTemplate(id: string, opts?: ListLoopTemplatesOptions): LoopTemplateSummary | undefined;
122
134
  export declare function renderTodosTaskWorkerVerifierWorkflow(input: TodosTaskWorkflowTemplateInput): CreateWorkflowInput;
135
+ export declare function renderTaskLifecycleWorkflow(input: TodosTaskWorkflowTemplateInput): CreateWorkflowInput;
123
136
  export declare function renderEventWorkerVerifierWorkflow(input: EventWorkflowTemplateInput): CreateWorkflowInput;
124
137
  export declare function renderBoundedAgentWorkerVerifierWorkflow(input: BoundedAgentWorkflowTemplateInput): CreateWorkflowInput;
125
138
  export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined>, opts?: ListLoopTemplatesOptions): CreateWorkflowInput;
package/dist/sdk/index.js CHANGED
@@ -621,7 +621,7 @@ function writeWorkflowRunManifest(args) {
621
621
  var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
622
622
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
623
623
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
624
- var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
624
+ var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
625
625
  var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
626
626
  function rowToLoop(row) {
627
627
  return {
package/docs/USAGE.md CHANGED
@@ -309,9 +309,11 @@ loops templates create-workflow custom-report \
309
309
  Use `--source builtin`, `--source custom`, or `--source all` on
310
310
  `list`, `show`, `render`, and `create-workflow` when automation needs an
311
311
  explicit source. Custom template ids and names cannot override built-ins.
312
- Custom templates fail closed for `danger-full-access`; use built-in templates
313
- with explicit break-glass handling for emergency workflows that need that
314
- sandbox.
312
+ Custom templates fail closed for `danger-full-access`, dangerous passthrough
313
+ arguments, and implicit Codewith/Codex full-access defaults. If a custom
314
+ Codewith/Codex template uses `permissionMode: "bypass"`, it must also set
315
+ `sandbox` to `workspace-write` or `read-only`. Use built-in templates with
316
+ explicit break-glass handling for emergency workflows that need full access.
315
317
 
316
318
  Repo-mutating task/event routes should set `worktreeMode=required` so the
317
319
  workflow fails fast instead of falling back to the main checkout. When
@@ -343,6 +345,7 @@ into a deduped one-shot workflow loop when route capacity allows:
343
345
 
344
346
  ```bash
345
347
  cat task-created-event.json | loops events handle todos-task \
348
+ --template task-lifecycle \
346
349
  --provider codewith \
347
350
  --auth-profile-pool account004,account005,account006 \
348
351
  --permission-mode bypass \
@@ -360,6 +363,20 @@ approval-required, or `no-auto` tasks. This guard exists even when the upstream
360
363
  `@hasna/events` webhook filter is misconfigured, so task existence alone is not
361
364
  permission to execute agent work.
362
365
 
366
+ By default, `todos-task` routes use `todos-task-worker-verifier` for backwards
367
+ compatibility. Use `--template task-lifecycle` when the task should run the full
368
+ triage -> planner -> worker -> verifier lifecycle. The route rejects unrelated
369
+ workflow templates such as `pr-review` so a todos task cannot accidentally use a
370
+ template with the wrong contract.
371
+ The lifecycle template inserts deterministic gate steps after triage and after
372
+ planning. If either agent marks the task blocked, omits its contextual
373
+ `openloops:triage=go task=<id> event=<event-id>` /
374
+ `openloops:planner=go task=<id> event=<event-id>` marker comment, or the task
375
+ is marked no-auto/manual/approval-required, the next agent step is not started.
376
+ Use `--triage-auth-profile`, `--planner-auth-profile`,
377
+ `--worker-auth-profile`, and `--verifier-auth-profile` for exact Codewith role
378
+ profiles, or use `--auth-profile-pool` for deterministic role rotation.
379
+
363
380
  Use route throttles to avoid stampeding agents when a producer creates many
364
381
  tasks at once:
365
382
 
@@ -424,6 +441,7 @@ locks, or non-pending states stay queued in todos and are not routed:
424
441
  ```bash
425
442
  loops events drain todos-task \
426
443
  --todos-project "$HOME/.hasna/loops" \
444
+ --template task-lifecycle \
427
445
  --task-list repoops-pr-queue \
428
446
  --tags auto:route \
429
447
  --project-path-prefix /home/hasna/workspace/hasna/opensource \
@@ -459,6 +477,7 @@ For the Hasna OSS task-created route, keep the drain deterministic and narrow:
459
477
  loops routes schedule todos-task oss-task-route-drain \
460
478
  --every 5m \
461
479
  --todos-project "$HOME/.hasna/loops" \
480
+ --template task-lifecycle \
462
481
  --project-path-prefix /home/hasna/workspace/hasna/opensource \
463
482
  --tags auto:route \
464
483
  --provider codewith \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.50",
3
+ "version": "0.3.52",
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",