@hasna/loops 0.3.13 → 0.3.14

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/dist/cli/index.js CHANGED
@@ -2074,6 +2074,7 @@ class Store {
2074
2074
  }
2075
2075
 
2076
2076
  // src/cli/index.ts
2077
+ import { createHash } from "crypto";
2077
2078
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2078
2079
  import { Command } from "commander";
2079
2080
 
@@ -4494,7 +4495,7 @@ function runDoctor(store) {
4494
4495
  // package.json
4495
4496
  var package_default = {
4496
4497
  name: "@hasna/loops",
4497
- version: "0.3.13",
4498
+ version: "0.3.14",
4498
4499
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4499
4500
  type: "module",
4500
4501
  main: "dist/index.js",
@@ -4558,7 +4559,7 @@ var package_default = {
4558
4559
  bun: ">=1.0.0"
4559
4560
  },
4560
4561
  dependencies: {
4561
- "@hasna/events": "^0.1.8",
4562
+ "@hasna/events": "^0.1.9",
4562
4563
  "@hasna/machines": "0.0.49",
4563
4564
  "@openrouter/ai-sdk-provider": "2.9.1",
4564
4565
  ai: "6.0.204",
@@ -4956,12 +4957,21 @@ function eventData(event) {
4956
4957
  return data;
4957
4958
  return {};
4958
4959
  }
4960
+ function eventMetadata(event) {
4961
+ const metadata = event.metadata;
4962
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata))
4963
+ return metadata;
4964
+ return {};
4965
+ }
4959
4966
  function stringField(value) {
4960
4967
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
4961
4968
  }
4962
4969
  function slugSegment(value, fallback = "event") {
4963
4970
  return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
4964
4971
  }
4972
+ function stableSuffix(value) {
4973
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
4974
+ }
4965
4975
  function taskEventField(data, keys) {
4966
4976
  for (const key of keys) {
4967
4977
  const direct = stringField(data[key]);
@@ -5142,12 +5152,23 @@ var eventsHandle = events.command("handle").description("handle a Hasna event en
5142
5152
  eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--account <profile>", "OpenAccounts profile name").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
5143
5153
  const event = await readEventEnvelopeFromStdin();
5144
5154
  const data = eventData(event);
5155
+ const metadata = eventMetadata(event);
5145
5156
  const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
5146
5157
  if (!taskId)
5147
5158
  throw new Error("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
5148
5159
  const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
5149
5160
  const taskDescription = taskEventField(data, ["description", "body"]);
5150
- const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]) ?? process.cwd();
5161
+ const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
5162
+ const metadataProjectPath = taskEventField(metadata, [
5163
+ "working_dir",
5164
+ "workingDir",
5165
+ "project_path",
5166
+ "projectPath",
5167
+ "project_canonical_path",
5168
+ "cwd"
5169
+ ]);
5170
+ const projectPath = opts.projectPath ?? dataProjectPath ?? metadataProjectPath ?? process.cwd();
5171
+ const idempotencyKey = `todos-task:${taskId}:${event.type}`;
5151
5172
  const provider = opts.provider;
5152
5173
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
5153
5174
  throw new Error("unsupported provider");
@@ -5170,13 +5191,14 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5170
5191
  eventId: event.id,
5171
5192
  eventType: event.type
5172
5193
  });
5173
- const eventSuffix = event.id.slice(0, 8);
5174
- workflowBody.name = `${opts.namePrefix}:${taskId.slice(0, 8)}:${eventSuffix}:workflow`;
5175
- workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}`;
5176
- const loopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${eventSuffix}:run`;
5194
+ const idempotencySuffix = stableSuffix(idempotencyKey);
5195
+ workflowBody.name = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
5196
+ workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}`;
5197
+ const loopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
5198
+ const legacyLoopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
5177
5199
  const loopInput = {
5178
5200
  name: loopName,
5179
- description: `Run ${workflowBody.name} once for task ${taskId}`,
5201
+ description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
5180
5202
  schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
5181
5203
  target: { type: "workflow", workflowId: "<created-workflow-id>" },
5182
5204
  overlap: "skip",
@@ -5185,15 +5207,22 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5185
5207
  leaseMs: 90 * 60000
5186
5208
  };
5187
5209
  if (opts.dryRun) {
5188
- print({ event, workflow: workflowBody, loop: loopInput }, `dry-run ${loopName}`);
5210
+ print({ deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput }, `dry-run ${loopName}`);
5189
5211
  return;
5190
5212
  }
5191
5213
  const store = new Store;
5192
5214
  try {
5193
- const existingLoop = store.findLoopByName(loopName);
5215
+ const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
5194
5216
  if (existingLoop) {
5195
5217
  const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
5196
- print({ deduped: true, event, workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
5218
+ print({
5219
+ deduped: true,
5220
+ idempotencyKey,
5221
+ dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
5222
+ event,
5223
+ workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined,
5224
+ loop: publicLoop(existingLoop)
5225
+ }, `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
5197
5226
  return;
5198
5227
  }
5199
5228
  const existingWorkflow = store.findWorkflowByName(workflowBody.name);
@@ -5202,7 +5231,7 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
5202
5231
  ...loopInput,
5203
5232
  target: { type: "workflow", workflowId: workflow.id }
5204
5233
  });
5205
- print({ deduped: false, event, workflow: publicWorkflow(workflow), loop: publicLoop(loop) }, `created ${loop.id} (${loop.name}) workflow=${workflow.name}`);
5234
+ print({ deduped: false, idempotencyKey, event, workflow: publicWorkflow(workflow), loop: publicLoop(loop) }, `created ${loop.id} (${loop.name}) workflow=${workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
5206
5235
  } finally {
5207
5236
  store.close();
5208
5237
  }
@@ -4276,7 +4276,7 @@ function enableStartup(result) {
4276
4276
  // package.json
4277
4277
  var package_default = {
4278
4278
  name: "@hasna/loops",
4279
- version: "0.3.13",
4279
+ version: "0.3.14",
4280
4280
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4281
4281
  type: "module",
4282
4282
  main: "dist/index.js",
@@ -4340,7 +4340,7 @@ var package_default = {
4340
4340
  bun: ">=1.0.0"
4341
4341
  },
4342
4342
  dependencies: {
4343
- "@hasna/events": "^0.1.8",
4343
+ "@hasna/events": "^0.1.9",
4344
4344
  "@hasna/machines": "0.0.49",
4345
4345
  "@openrouter/ai-sdk-provider": "2.9.1",
4346
4346
  ai: "6.0.204",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
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",
@@ -64,7 +64,7 @@
64
64
  "bun": ">=1.0.0"
65
65
  },
66
66
  "dependencies": {
67
- "@hasna/events": "^0.1.8",
67
+ "@hasna/events": "^0.1.9",
68
68
  "@hasna/machines": "0.0.49",
69
69
  "@openrouter/ai-sdk-provider": "2.9.1",
70
70
  "ai": "6.0.204",