@task0/cli 0.9.0 → 0.10.0

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.
Files changed (2) hide show
  1. package/dist/main.js +645 -11
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -207,6 +207,8 @@ function scanProject(projectPath, sourceName) {
207
207
  const summary = raw.summary || void 0;
208
208
  const displayTitle = summary?.title || title;
209
209
  const displayTags = summary?.tags ?? tags;
210
+ const agentRuns = Array.isArray(raw.agent_runs) ? raw.agent_runs.filter((v) => typeof v === "string") : void 0;
211
+ const runtimes = Array.isArray(raw.runtimes) ? raw.runtimes.filter((v) => typeof v === "string") : void 0;
210
212
  tasks.push({
211
213
  id,
212
214
  object_id: objectId,
@@ -227,6 +229,8 @@ function scanProject(projectPath, sourceName) {
227
229
  workflow: raw.workflow || void 0,
228
230
  summary,
229
231
  comments: raw.comments || void 0,
232
+ agent_runs: agentRuns,
233
+ runtimes,
230
234
  display_title: displayTitle,
231
235
  display_tags: displayTags
232
236
  });
@@ -4817,8 +4821,10 @@ function pickRegisterAuth(flagToken) {
4817
4821
 
4818
4822
  // src/core/daemon-rpc-handlers.ts
4819
4823
  init_node();
4824
+ import { execSync as execSync3 } from "child_process";
4820
4825
  import fs28 from "fs";
4821
4826
  import path26 from "path";
4827
+ import yaml10 from "js-yaml";
4822
4828
 
4823
4829
  // src/core/daemon-agent-run-runner.ts
4824
4830
  import { execFileSync } from "child_process";
@@ -5040,6 +5046,44 @@ function optionalBoolean(value) {
5040
5046
  function listProjects() {
5041
5047
  return loadConfig().sources.filter((source2) => source2.type === "project");
5042
5048
  }
5049
+ function resolveTaskDir(params, opts = {}) {
5050
+ const projectName = ensureString(params.projectName, "projectName");
5051
+ const taskId = ensureSafeTaskId(params.taskId);
5052
+ const project2 = listProjects().find((p) => p.name === projectName);
5053
+ if (!project2) {
5054
+ throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
5055
+ }
5056
+ const projectAbs = path26.resolve(project2.path);
5057
+ const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
5058
+ if (!projectConfig) {
5059
+ throw Object.assign(
5060
+ new Error(`invalid project: missing task0.yml at ${projectAbs}`),
5061
+ { code: "invalid_project" }
5062
+ );
5063
+ }
5064
+ const tasksRoot = path26.resolve(projectAbs, projectConfig.tasks_dir);
5065
+ const taskDir = path26.resolve(tasksRoot, taskId);
5066
+ assertContained(tasksRoot, taskDir);
5067
+ const taskYml = path26.join(taskDir, "task0.yml");
5068
+ if (opts.requireExists !== false && !fs28.existsSync(taskDir)) {
5069
+ throw Object.assign(new Error(`task not found: ${taskId}`), { code: "not_found" });
5070
+ }
5071
+ return { projectName, taskId, projectAbs, tasksRoot, taskDir, taskYml };
5072
+ }
5073
+ async function withTaskYaml(ctx, resolved, mutate) {
5074
+ const outcome = await withTaskYamlLock(resolved.taskDir, async () => {
5075
+ const raw = readYaml(resolved.taskYml);
5076
+ if (!raw) {
5077
+ throw Object.assign(new Error(`task yaml missing or unreadable: ${resolved.taskYml}`), { code: "not_found" });
5078
+ }
5079
+ const { yaml: yaml12, result } = await mutate(raw);
5080
+ if (yaml12) writeYaml(resolved.taskYml, yaml12);
5081
+ const objectId = typeof (yaml12 ?? raw).object_id === "string" ? (yaml12 ?? raw).object_id : void 0;
5082
+ return { result, object_id: objectId, mutated: yaml12 !== void 0 };
5083
+ });
5084
+ if (outcome.mutated) ctx.notifyManifestChanged();
5085
+ return { result: outcome.result, object_id: outcome.object_id };
5086
+ }
5043
5087
  function applyRepair(r) {
5044
5088
  const raw = readYaml(r.taskYml);
5045
5089
  if (!raw) return;
@@ -5166,12 +5210,12 @@ var rpcHandlers = {
5166
5210
  throw Object.assign(new Error(`task not found: ${taskId}`), { code: "not_found" });
5167
5211
  }
5168
5212
  const taskYml = path26.join(taskDir, "task0.yml");
5169
- const yaml11 = readYaml(taskYml);
5170
- if (!yaml11) {
5213
+ const yaml12 = readYaml(taskYml);
5214
+ if (!yaml12) {
5171
5215
  throw Object.assign(new Error(`task yaml missing or unreadable: ${taskYml}`), { code: "not_found" });
5172
5216
  }
5173
5217
  const files = fs28.readdirSync(taskDir).filter((name) => name !== "task0.yml");
5174
- return { task_dir: taskDir, yaml: yaml11, files };
5218
+ return { task_dir: taskDir, yaml: yaml12, files };
5175
5219
  },
5176
5220
  // Create a task directory + write task0.yml + write any additional named
5177
5221
  // files. Hub decides the taskId and yaml content (slug/object_id are
@@ -5179,8 +5223,8 @@ var rpcHandlers = {
5179
5223
  async task_create(params, ctx) {
5180
5224
  const projectName = ensureString(params.projectName, "projectName");
5181
5225
  const taskId = ensureSafeTaskId(params.taskId);
5182
- const yaml11 = params.yaml;
5183
- if (!yaml11 || typeof yaml11 !== "object") {
5226
+ const yaml12 = params.yaml;
5227
+ if (!yaml12 || typeof yaml12 !== "object") {
5184
5228
  throw Object.assign(new Error("yaml (object) is required"), { code: "invalid_params" });
5185
5229
  }
5186
5230
  const files = params.files ?? {};
@@ -5200,7 +5244,7 @@ var rpcHandlers = {
5200
5244
  throw Object.assign(new Error(`task already exists: ${taskId}`), { code: "already_exists" });
5201
5245
  }
5202
5246
  fs28.mkdirSync(taskDir, { recursive: true });
5203
- writeYaml(path26.join(taskDir, "task0.yml"), yaml11);
5247
+ writeYaml(path26.join(taskDir, "task0.yml"), yaml12);
5204
5248
  for (const [name, content] of Object.entries(files)) {
5205
5249
  if (name.includes("/") || name.includes("\\") || name === ".." || name === ".") {
5206
5250
  throw Object.assign(new Error(`invalid file name: ${name}`), { code: "invalid_params" });
@@ -5217,8 +5261,8 @@ var rpcHandlers = {
5217
5261
  async task_write_yaml(params, ctx) {
5218
5262
  const projectName = ensureString(params.projectName, "projectName");
5219
5263
  const taskId = ensureSafeTaskId(params.taskId);
5220
- const yaml11 = params.yaml;
5221
- if (!yaml11 || typeof yaml11 !== "object") {
5264
+ const yaml12 = params.yaml;
5265
+ if (!yaml12 || typeof yaml12 !== "object") {
5222
5266
  throw Object.assign(new Error("yaml (object) is required"), { code: "invalid_params" });
5223
5267
  }
5224
5268
  const project2 = listProjects().find((p) => p.name === projectName);
@@ -5237,7 +5281,7 @@ var rpcHandlers = {
5237
5281
  if (!fs28.existsSync(taskYml)) {
5238
5282
  throw Object.assign(new Error(`task yaml not found: ${taskYml}`), { code: "not_found" });
5239
5283
  }
5240
- writeYaml(taskYml, yaml11);
5284
+ writeYaml(taskYml, yaml12);
5241
5285
  ctx.notifyManifestChanged();
5242
5286
  return { task_dir: taskDir };
5243
5287
  },
@@ -5265,6 +5309,596 @@ var rpcHandlers = {
5265
5309
  ctx.notifyManifestChanged();
5266
5310
  return { task_dir: taskDir, deleted: true };
5267
5311
  },
5312
+ // Archive a task directory: append it to `<tasks_dir>.tar`, then remove
5313
+ // the active directory. Returns the task's object_id so the hub can emit
5314
+ // a `task.archived` event. After the archive completes, notifyManifestChanged
5315
+ // re-pushes the daemon's manifest so the hub's daemon_tasks cache drops
5316
+ // the archived row.
5317
+ async task_archive(params, ctx) {
5318
+ const projectName = ensureString(params.projectName, "projectName");
5319
+ const taskId = ensureSafeTaskId(params.taskId);
5320
+ const project2 = listProjects().find((p) => p.name === projectName);
5321
+ if (!project2) {
5322
+ throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
5323
+ }
5324
+ const projectAbs = path26.resolve(project2.path);
5325
+ const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
5326
+ if (!projectConfig) {
5327
+ throw Object.assign(new Error(`invalid project: missing task0.yml at ${projectAbs}`), { code: "invalid_project" });
5328
+ }
5329
+ const tasksRoot = path26.resolve(projectAbs, projectConfig.tasks_dir);
5330
+ const taskDir = path26.resolve(tasksRoot, taskId);
5331
+ assertContained(tasksRoot, taskDir);
5332
+ const tarFile = tasksRoot + ".tar";
5333
+ if (fs28.existsSync(tarFile) && !fs28.statSync(tarFile).isFile()) {
5334
+ throw Object.assign(
5335
+ new Error(`${tarFile} exists but is not a file. Remove it manually first.`),
5336
+ { code: "invalid_target" }
5337
+ );
5338
+ }
5339
+ if (!fs28.existsSync(taskDir)) {
5340
+ if (fs28.existsSync(tarFile)) {
5341
+ const listing = execSync3(`tar -tf ${JSON.stringify(tarFile)}`).toString();
5342
+ const dirs = new Set(listing.split("\n").map((line) => line.split("/")[0]).filter(Boolean));
5343
+ if (dirs.has(taskId)) {
5344
+ return { task_dir: taskDir, already_archived: true };
5345
+ }
5346
+ }
5347
+ throw Object.assign(new Error(`task directory missing on disk: ${taskId}`), { code: "not_found" });
5348
+ }
5349
+ const taskYaml = readYaml(path26.join(taskDir, "task0.yml"));
5350
+ const objectId = typeof taskYaml?.object_id === "string" ? taskYaml.object_id : void 0;
5351
+ const tarFlag = fs28.existsSync(tarFile) ? "-rf" : "-cf";
5352
+ execSync3(`tar ${tarFlag} ${JSON.stringify(tarFile)} -C ${JSON.stringify(tasksRoot)} ${JSON.stringify(taskId)}`);
5353
+ fs28.rmSync(taskDir, { recursive: true });
5354
+ ctx.notifyManifestChanged();
5355
+ return { task_dir: taskDir, object_id: objectId, already_archived: false };
5356
+ },
5357
+ // Reverse of task_archive: extract `<taskId>` from `<tasks_dir>.tar`, then
5358
+ // rewrite the tar without it (or delete the tar if it becomes empty). Refuses
5359
+ // when an active directory already exists. Returns the extracted task's
5360
+ // object_id (read from the freshly-restored task0.yml) so the hub can emit
5361
+ // a `task.unarchived` event.
5362
+ async task_unarchive(params, ctx) {
5363
+ const resolved = resolveTaskDir(params, { requireExists: false });
5364
+ const { tasksRoot, taskDir, taskId, projectAbs } = resolved;
5365
+ const tarFile = tasksRoot + ".tar";
5366
+ if (!fs28.existsSync(tarFile)) {
5367
+ throw Object.assign(new Error("no archive found"), { code: "not_found" });
5368
+ }
5369
+ if (fs28.existsSync(taskDir)) {
5370
+ throw Object.assign(
5371
+ new Error(`task already exists in active tasks: ${taskId}`),
5372
+ { code: "already_exists" }
5373
+ );
5374
+ }
5375
+ execSync3(`tar -xf ${JSON.stringify(tarFile)} -C ${JSON.stringify(tasksRoot)} ${JSON.stringify(taskId)}`);
5376
+ const listing = execSync3(`tar -tf ${JSON.stringify(tarFile)}`).toString();
5377
+ const remainingDirs = [
5378
+ ...new Set(
5379
+ listing.split("\n").map((line) => line.split("/")[0]).filter((dir) => dir && dir !== taskId)
5380
+ )
5381
+ ];
5382
+ if (remainingDirs.length === 0) {
5383
+ fs28.unlinkSync(tarFile);
5384
+ } else {
5385
+ const tmpTar = tarFile + ".tmp";
5386
+ const tmpDir = fs28.mkdtempSync(path26.join(projectAbs, ".task0", ".unarchive-"));
5387
+ try {
5388
+ execSync3(`tar -xf ${JSON.stringify(tarFile)} -C ${JSON.stringify(tmpDir)}`);
5389
+ fs28.rmSync(path26.join(tmpDir, taskId), { recursive: true, force: true });
5390
+ const dirs = fs28.readdirSync(tmpDir).filter((dir) => fs28.statSync(path26.join(tmpDir, dir)).isDirectory());
5391
+ if (dirs.length === 0) {
5392
+ fs28.unlinkSync(tarFile);
5393
+ } else {
5394
+ execSync3(
5395
+ `tar -cf ${JSON.stringify(tmpTar)} -C ${JSON.stringify(tmpDir)} ${dirs.map((dir) => JSON.stringify(dir)).join(" ")}`
5396
+ );
5397
+ fs28.renameSync(tmpTar, tarFile);
5398
+ }
5399
+ } finally {
5400
+ fs28.rmSync(tmpDir, { recursive: true, force: true });
5401
+ }
5402
+ }
5403
+ const taskYaml = readYaml(path26.join(taskDir, "task0.yml"));
5404
+ const objectId = typeof taskYaml?.object_id === "string" ? taskYaml.object_id : void 0;
5405
+ ctx.notifyManifestChanged();
5406
+ return { task_dir: taskDir, object_id: objectId };
5407
+ },
5408
+ // ---------------------------------------------------------------------
5409
+ // Task-yaml field mutations. Each handler is a single read-modify-write
5410
+ // transaction under the task's file lock, then re-pushes the manifest so
5411
+ // the hub's daemon_tasks cache catches up immediately. Hub controllers
5412
+ // call these instead of writing task0.yml directly.
5413
+ // ---------------------------------------------------------------------
5414
+ async task_link_issue(params, ctx) {
5415
+ const resolved = resolveTaskDir(params);
5416
+ const link = params.link;
5417
+ if (!link || typeof link !== "object") {
5418
+ throw Object.assign(new Error("link (object) is required"), { code: "invalid_params" });
5419
+ }
5420
+ const identifier = ensureString(link.identifier, "link.identifier");
5421
+ const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
5422
+ const linked = Array.isArray(raw.linked_issues) ? raw.linked_issues : [];
5423
+ if (linked.some((issue2) => issue2.identifier === identifier)) {
5424
+ return { result: { linked: false } };
5425
+ }
5426
+ linked.push({ ...link, linked_at: (/* @__PURE__ */ new Date()).toISOString() });
5427
+ raw.linked_issues = linked;
5428
+ return { yaml: raw, result: { linked: true } };
5429
+ });
5430
+ return { object_id, linked: true };
5431
+ },
5432
+ async task_unlink_issue(params, ctx) {
5433
+ const resolved = resolveTaskDir(params);
5434
+ const identifier = ensureString(params.identifier, "identifier");
5435
+ const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
5436
+ const linked = Array.isArray(raw.linked_issues) ? raw.linked_issues : [];
5437
+ const next = linked.filter((issue2) => issue2.identifier !== identifier);
5438
+ if (next.length === linked.length) {
5439
+ return { result: { removed: false } };
5440
+ }
5441
+ if (next.length === 0) delete raw.linked_issues;
5442
+ else raw.linked_issues = next;
5443
+ return { yaml: raw, result: { removed: true } };
5444
+ });
5445
+ return { object_id, removed: true };
5446
+ },
5447
+ async task_set_description(params, ctx) {
5448
+ const resolved = resolveTaskDir(params);
5449
+ const description2 = typeof params.description === "string" ? params.description : null;
5450
+ if (description2 === null) {
5451
+ throw Object.assign(new Error("description (string) is required"), { code: "invalid_params" });
5452
+ }
5453
+ const { object_id, result } = await withTaskYaml(ctx, resolved, (raw) => {
5454
+ const previous = typeof raw.description === "string" ? raw.description : void 0;
5455
+ if (description2.length === 0) delete raw.description;
5456
+ else raw.description = description2;
5457
+ return { yaml: raw, result: { previous } };
5458
+ });
5459
+ return { object_id, description: description2, previous: result.previous };
5460
+ },
5461
+ async task_set_summary(params, ctx) {
5462
+ const resolved = resolveTaskDir(params);
5463
+ const title = ensureString(params.title, "title");
5464
+ const tagsRaw = params.tags;
5465
+ if (!Array.isArray(tagsRaw) || tagsRaw.some((t) => typeof t !== "string")) {
5466
+ throw Object.assign(new Error("tags (string[]) is required"), { code: "invalid_params" });
5467
+ }
5468
+ const tags = tagsRaw;
5469
+ const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
5470
+ raw.title = title;
5471
+ raw.tags = [...tags];
5472
+ delete raw.summary;
5473
+ return { yaml: raw, result: null };
5474
+ });
5475
+ return { object_id, title, tags };
5476
+ },
5477
+ async task_set_metadata(params, ctx) {
5478
+ const resolved = resolveTaskDir(params);
5479
+ const patch = params.patch;
5480
+ if (!patch || typeof patch !== "object") {
5481
+ throw Object.assign(new Error("patch (object) is required"), { code: "invalid_params" });
5482
+ }
5483
+ const { object_id, result: metadata } = await withTaskYaml(
5484
+ ctx,
5485
+ resolved,
5486
+ (raw) => {
5487
+ const current = raw.metadata || {};
5488
+ const next = { ...current };
5489
+ for (const [key, value] of Object.entries(patch)) {
5490
+ if (value === null) delete next[key];
5491
+ else if (typeof value === "string") next[key] = value;
5492
+ }
5493
+ if (Object.keys(next).length === 0) delete raw.metadata;
5494
+ else raw.metadata = next;
5495
+ return { yaml: raw, result: next };
5496
+ }
5497
+ );
5498
+ return { object_id, metadata };
5499
+ },
5500
+ async task_add_agent_run(params, ctx) {
5501
+ const resolved = resolveTaskDir(params);
5502
+ const agentRunObjectId = ensureString(params.agentRunObjectId, "agentRunObjectId");
5503
+ const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
5504
+ if (raw.kind !== "task") return { result: null };
5505
+ const fromCanonical = Array.isArray(raw.agent_runs) ? raw.agent_runs.filter((v) => typeof v === "string") : [];
5506
+ const fromLegacy = Array.isArray(raw.runtimes) ? raw.runtimes.filter((v) => typeof v === "string") : [];
5507
+ const merged = [.../* @__PURE__ */ new Set([...fromCanonical, ...fromLegacy])];
5508
+ if (!merged.includes(agentRunObjectId)) merged.push(agentRunObjectId);
5509
+ raw.agent_runs = merged;
5510
+ delete raw.runtimes;
5511
+ return { yaml: raw, result: null };
5512
+ });
5513
+ return { object_id };
5514
+ },
5515
+ async task_remove_agent_run(params, ctx) {
5516
+ const resolved = resolveTaskDir(params);
5517
+ const agentRunObjectId = ensureString(params.agentRunObjectId, "agentRunObjectId");
5518
+ const { object_id } = await withTaskYaml(ctx, resolved, (raw) => {
5519
+ if (raw.kind !== "task") return { result: null };
5520
+ const fromCanonical = Array.isArray(raw.agent_runs) ? raw.agent_runs.filter((v) => typeof v === "string") : [];
5521
+ const fromLegacy = Array.isArray(raw.runtimes) ? raw.runtimes.filter((v) => typeof v === "string") : [];
5522
+ const merged = [.../* @__PURE__ */ new Set([...fromCanonical, ...fromLegacy])].filter((id) => id !== agentRunObjectId);
5523
+ delete raw.runtimes;
5524
+ if (merged.length === 0) delete raw.agent_runs;
5525
+ else raw.agent_runs = merged;
5526
+ return { yaml: raw, result: null };
5527
+ });
5528
+ return { object_id };
5529
+ },
5530
+ // Append a comment (or a reply to parentCommentId). The hub provides the
5531
+ // pre-generated comment record (id, author, timestamps, body, metadata).
5532
+ // Daemon serializes it into task0.yml.comments under the file lock.
5533
+ async task_add_comment(params, ctx) {
5534
+ const resolved = resolveTaskDir(params);
5535
+ const comment2 = params.comment;
5536
+ if (!comment2 || typeof comment2 !== "object") {
5537
+ throw Object.assign(new Error("comment (object) is required"), { code: "invalid_params" });
5538
+ }
5539
+ const parentCommentId = typeof params.parentCommentId === "string" ? params.parentCommentId : void 0;
5540
+ const dedupeMetadata = params.dedupeMetadata;
5541
+ const { object_id, result } = await withTaskYaml(
5542
+ ctx,
5543
+ resolved,
5544
+ (raw) => {
5545
+ const comments = Array.isArray(raw.comments) ? raw.comments : [];
5546
+ if (dedupeMetadata && typeof dedupeMetadata === "object") {
5547
+ const dedupeEntries = Object.entries(dedupeMetadata);
5548
+ if (dedupeEntries.length > 0) {
5549
+ const matches = (m) => {
5550
+ if (!m) return false;
5551
+ return dedupeEntries.every(([k, v]) => m[k] === v);
5552
+ };
5553
+ const existing = comments.find((c) => {
5554
+ if (matches(c.metadata)) return true;
5555
+ const replies = Array.isArray(c.replies) ? c.replies : [];
5556
+ return replies.some((r) => matches(r.metadata));
5557
+ });
5558
+ if (existing) {
5559
+ const replies = Array.isArray(existing.replies) ? existing.replies : [];
5560
+ const matched = matches(existing.metadata) ? existing : replies.find((r) => matches(r.metadata));
5561
+ return { result: { comment: matched, skipped: true } };
5562
+ }
5563
+ }
5564
+ }
5565
+ if (parentCommentId) {
5566
+ const idx = comments.findIndex((c) => c.id === parentCommentId);
5567
+ if (idx < 0) {
5568
+ throw Object.assign(
5569
+ new Error(`parent comment ${parentCommentId} not found`),
5570
+ { code: "not_found" }
5571
+ );
5572
+ }
5573
+ const parent = comments[idx];
5574
+ const replies = Array.isArray(parent.replies) ? parent.replies : [];
5575
+ const reply = { ...comment2, parent_comment_id: parent.id };
5576
+ const updated = [...comments];
5577
+ updated[idx] = { ...parent, replies: [...replies, reply] };
5578
+ raw.comments = updated;
5579
+ return { yaml: raw, result: { comment: reply, skipped: false } };
5580
+ }
5581
+ raw.comments = [...comments, comment2];
5582
+ return { yaml: raw, result: { comment: comment2, skipped: false } };
5583
+ }
5584
+ );
5585
+ return { object_id, comment: result.comment, skipped: result.skipped };
5586
+ },
5587
+ async task_update_comment(params, ctx) {
5588
+ const resolved = resolveTaskDir(params);
5589
+ const commentId = ensureString(params.commentId, "commentId");
5590
+ const patch = params.patch;
5591
+ if (!patch || typeof patch !== "object") {
5592
+ throw Object.assign(new Error("patch (object) is required"), { code: "invalid_params" });
5593
+ }
5594
+ const { object_id, result } = await withTaskYaml(
5595
+ ctx,
5596
+ resolved,
5597
+ (raw) => {
5598
+ const comments = Array.isArray(raw.comments) ? raw.comments : [];
5599
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5600
+ for (let i = 0; i < comments.length; i++) {
5601
+ if (comments[i].id === commentId) {
5602
+ const updated = { ...comments[i], ...patch, updated_at: now };
5603
+ const next = [...comments];
5604
+ next[i] = updated;
5605
+ raw.comments = next;
5606
+ return { yaml: raw, result: { comment: updated } };
5607
+ }
5608
+ const replies = Array.isArray(comments[i].replies) ? comments[i].replies : [];
5609
+ for (let j = 0; j < replies.length; j++) {
5610
+ if (replies[j].id === commentId) {
5611
+ const updatedReply = { ...replies[j], ...patch, updated_at: now };
5612
+ const nextReplies = [...replies];
5613
+ nextReplies[j] = updatedReply;
5614
+ const next = [...comments];
5615
+ next[i] = { ...comments[i], replies: nextReplies };
5616
+ raw.comments = next;
5617
+ return { yaml: raw, result: { comment: updatedReply } };
5618
+ }
5619
+ }
5620
+ }
5621
+ throw Object.assign(new Error(`comment ${commentId} not found`), { code: "not_found" });
5622
+ }
5623
+ );
5624
+ return { object_id, comment: result.comment };
5625
+ },
5626
+ async task_delete_comment(params, ctx) {
5627
+ const resolved = resolveTaskDir(params);
5628
+ const commentId = ensureString(params.commentId, "commentId");
5629
+ const { object_id, result } = await withTaskYaml(
5630
+ ctx,
5631
+ resolved,
5632
+ (raw) => {
5633
+ const comments = Array.isArray(raw.comments) ? raw.comments : [];
5634
+ for (let i = 0; i < comments.length; i++) {
5635
+ if (comments[i].id === commentId) {
5636
+ const deleted = comments[i];
5637
+ const next = comments.filter((_, idx) => idx !== i);
5638
+ if (next.length === 0) delete raw.comments;
5639
+ else raw.comments = next;
5640
+ return { yaml: raw, result: { deleted } };
5641
+ }
5642
+ const replies = Array.isArray(comments[i].replies) ? comments[i].replies : [];
5643
+ for (let j = 0; j < replies.length; j++) {
5644
+ if (replies[j].id === commentId) {
5645
+ const deleted = replies[j];
5646
+ const nextReplies = replies.filter((_, idx) => idx !== j);
5647
+ const next = [...comments];
5648
+ next[i] = { ...comments[i], replies: nextReplies };
5649
+ if (nextReplies.length === 0) delete next[i].replies;
5650
+ raw.comments = next;
5651
+ return { yaml: raw, result: { deleted } };
5652
+ }
5653
+ }
5654
+ }
5655
+ throw Object.assign(new Error(`comment ${commentId} not found`), { code: "not_found" });
5656
+ }
5657
+ );
5658
+ return { object_id, deleted: result.deleted };
5659
+ },
5660
+ // ---------------------------------------------------------------------
5661
+ // Task-directory file ops. task_write_file supports auto_index: { prefix }
5662
+ // for "next IDEA-NN.md" semantics — daemon scans the dir and picks the next
5663
+ // 2-digit number atomically (under taskDir lock) so concurrent saves don't
5664
+ // collide.
5665
+ // ---------------------------------------------------------------------
5666
+ async task_write_file(params, ctx) {
5667
+ const resolved = resolveTaskDir(params);
5668
+ const content = ensureString(params.content, "content");
5669
+ const explicitName = optionalString(params.name);
5670
+ const autoIndex = params.auto_index;
5671
+ if (!explicitName && !autoIndex) {
5672
+ throw Object.assign(new Error("either name or auto_index is required"), { code: "invalid_params" });
5673
+ }
5674
+ return withTaskYamlLock(resolved.taskDir, () => {
5675
+ let fileName;
5676
+ if (explicitName) {
5677
+ if (explicitName.includes("/") || explicitName.includes("\\") || explicitName === "." || explicitName === ".." || explicitName.startsWith(".")) {
5678
+ throw Object.assign(new Error(`invalid file name: ${explicitName}`), { code: "invalid_params" });
5679
+ }
5680
+ fileName = explicitName;
5681
+ } else {
5682
+ const prefix = ensureString(autoIndex.prefix, "auto_index.prefix");
5683
+ const suffix = autoIndex.suffix ?? ".md";
5684
+ const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\d+)${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`);
5685
+ const existing = fs28.readdirSync(resolved.taskDir);
5686
+ const maxIdx = existing.reduce((max, name) => {
5687
+ const m = name.match(pattern);
5688
+ if (!m) return max;
5689
+ const n = parseInt(m[1], 10);
5690
+ return n > max ? n : max;
5691
+ }, 0);
5692
+ const nextIdx = String(maxIdx + 1).padStart(2, "0");
5693
+ fileName = `${prefix}${nextIdx}${suffix}`;
5694
+ }
5695
+ const target = path26.resolve(resolved.taskDir, fileName);
5696
+ assertContained(resolved.taskDir, target);
5697
+ fs28.writeFileSync(target, content, "utf-8");
5698
+ ctx.notifyManifestChanged();
5699
+ return { file: fileName, path: target };
5700
+ });
5701
+ },
5702
+ async task_delete_file(params, ctx) {
5703
+ const resolved = resolveTaskDir(params);
5704
+ const fileName = ensureString(params.name, "name");
5705
+ if (fileName.includes("/") || fileName.includes("\\") || fileName === "." || fileName === ".." || fileName === "task0.yml") {
5706
+ throw Object.assign(new Error(`invalid file name: ${fileName}`), { code: "invalid_params" });
5707
+ }
5708
+ const target = path26.resolve(resolved.taskDir, fileName);
5709
+ assertContained(resolved.taskDir, target);
5710
+ if (!fs28.existsSync(target)) {
5711
+ throw Object.assign(new Error(`file not found: ${fileName}`), { code: "not_found" });
5712
+ }
5713
+ fs28.unlinkSync(target);
5714
+ ctx.notifyManifestChanged();
5715
+ return { deleted: true };
5716
+ },
5717
+ // Build the four-section context blob (yaml subset + IDEA + ISSUE + PLAN)
5718
+ // hub uses for summarize / agent run prompts. Reads files daemon-side so
5719
+ // remote projects work the same as local ones. Sections are truncated to
5720
+ // 4000 chars to keep prompts bounded.
5721
+ async task_build_context(params) {
5722
+ const resolved = resolveTaskDir(params);
5723
+ const SECTION_CHAR_LIMIT = 4e3;
5724
+ function truncate2(text, max) {
5725
+ if (text.length <= max) return text;
5726
+ return text.slice(0, max) + "\n\n[...truncated]";
5727
+ }
5728
+ function latestMatching(pattern) {
5729
+ const matches = fs28.readdirSync(resolved.taskDir).filter((n) => pattern.test(n)).sort();
5730
+ return matches.length === 0 ? null : matches[matches.length - 1];
5731
+ }
5732
+ function readOrEmpty(file) {
5733
+ try {
5734
+ return fs28.readFileSync(file, "utf-8");
5735
+ } catch {
5736
+ return "";
5737
+ }
5738
+ }
5739
+ let ymlSection = "";
5740
+ if (fs28.existsSync(resolved.taskYml)) {
5741
+ const raw = yaml10.load(fs28.readFileSync(resolved.taskYml, "utf-8"));
5742
+ if (raw) {
5743
+ const slim = {};
5744
+ for (const key of ["title", "tags", "status", "current_step", "linked_issues"]) {
5745
+ if (raw[key] !== void 0) slim[key] = raw[key];
5746
+ }
5747
+ ymlSection = yaml10.dump(slim, { lineWidth: 120 }).trim();
5748
+ }
5749
+ }
5750
+ const ideaFile = latestMatching(/^IDEA-\d+\.md$/);
5751
+ const ideaSection = ideaFile ? truncate2(readOrEmpty(path26.join(resolved.taskDir, ideaFile)), SECTION_CHAR_LIMIT) : "";
5752
+ let issueSection = "";
5753
+ const issueOverview = path26.join(resolved.taskDir, "ISSUE.md");
5754
+ if (fs28.existsSync(issueOverview)) {
5755
+ issueSection = truncate2(readOrEmpty(issueOverview), SECTION_CHAR_LIMIT);
5756
+ } else if (fs28.existsSync(resolved.taskDir)) {
5757
+ const details = fs28.readdirSync(resolved.taskDir).filter((n) => /^ISSUE-\d+\.md$/.test(n)).sort();
5758
+ if (details.length > 0) {
5759
+ const concatenated = details.map((name) => `## ${name}
5760
+ ${readOrEmpty(path26.join(resolved.taskDir, name))}`).join("\n\n");
5761
+ issueSection = truncate2(concatenated, SECTION_CHAR_LIMIT);
5762
+ }
5763
+ }
5764
+ const refinedPlan = latestMatching(/^PLAN-\d+-refined\.md$/);
5765
+ const anyPlan = refinedPlan ?? latestMatching(/^PLAN-\d+.*\.md$/);
5766
+ const planSection = anyPlan ? truncate2(readOrEmpty(path26.join(resolved.taskDir, anyPlan)), SECTION_CHAR_LIMIT) : "";
5767
+ return { yaml: ymlSection, idea: ideaSection, issue: issueSection, plan: planSection };
5768
+ },
5769
+ // List files (non-yaml) in a task directory with size + mtime. Hub calls
5770
+ // this from GET /api/tasks/:taskId/files so the dashboard can render the
5771
+ // task-detail file list regardless of which daemon hosts the project.
5772
+ async task_list_files(params) {
5773
+ const resolved = resolveTaskDir(params);
5774
+ const entries = fs28.readdirSync(resolved.taskDir, { withFileTypes: true });
5775
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => {
5776
+ const full = path26.join(resolved.taskDir, entry.name);
5777
+ const stat = fs28.statSync(full);
5778
+ return { name: entry.name, size: stat.size, modified: stat.mtime.toISOString(), path: full };
5779
+ }).sort((a, b) => a.name.localeCompare(b.name));
5780
+ return { files };
5781
+ },
5782
+ // List archived tasks (one per top-level directory inside `<tasks_dir>.tar`)
5783
+ // along with a summary derived from each task's archived task0.yml + first
5784
+ // matching markdown file. Hub uses this to populate the archived-tasks view.
5785
+ async archive_list(params) {
5786
+ const projectName = ensureString(params.projectName, "projectName");
5787
+ const project2 = listProjects().find((p) => p.name === projectName);
5788
+ if (!project2) {
5789
+ throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
5790
+ }
5791
+ const projectAbs = path26.resolve(project2.path);
5792
+ const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
5793
+ if (!projectConfig) {
5794
+ throw Object.assign(
5795
+ new Error(`invalid project: missing task0.yml at ${projectAbs}`),
5796
+ { code: "invalid_project" }
5797
+ );
5798
+ }
5799
+ const tasksRoot = path26.resolve(projectAbs, projectConfig.tasks_dir);
5800
+ const tarFile = tasksRoot + ".tar";
5801
+ if (!fs28.existsSync(tarFile)) return { tasks: [] };
5802
+ const listing = execSync3(`tar -tf ${JSON.stringify(tarFile)}`).toString();
5803
+ const lines = listing.split("\n");
5804
+ const dirs = [...new Set(lines.map((line) => line.split("/")[0]).filter(Boolean))];
5805
+ const tasks = [];
5806
+ for (const dir of dirs) {
5807
+ try {
5808
+ const ymlContent = execSync3(
5809
+ `tar -xf ${JSON.stringify(tarFile)} -O ${JSON.stringify(dir + "/task0.yml")}`
5810
+ ).toString();
5811
+ const raw = yaml10.load(ymlContent);
5812
+ if (!raw || raw.kind !== "task") continue;
5813
+ const mdFiles = lines.filter((line) => line.startsWith(dir + "/") && line.endsWith(".md"));
5814
+ let context = "";
5815
+ if (mdFiles.length > 0) {
5816
+ try {
5817
+ const md = execSync3(
5818
+ `tar -xf ${JSON.stringify(tarFile)} -O ${JSON.stringify(mdFiles[0])}`
5819
+ ).toString();
5820
+ context = md.replace(/^---[\s\S]*?---\s*/, "").trim();
5821
+ } catch {
5822
+ }
5823
+ }
5824
+ tasks.push({
5825
+ id: raw.id || dir,
5826
+ object_id: raw.object_id,
5827
+ title: raw.title || dir,
5828
+ status: "archived",
5829
+ created_at: raw.created_at || "",
5830
+ context
5831
+ });
5832
+ } catch {
5833
+ }
5834
+ }
5835
+ return { tasks };
5836
+ },
5837
+ // List files inside a specific archived task. Returns name + size; mtime is
5838
+ // not preserved by tar's default flags so we omit it.
5839
+ async archive_list_task_files(params) {
5840
+ const projectName = ensureString(params.projectName, "projectName");
5841
+ const taskId = ensureSafeTaskId(params.taskId);
5842
+ const project2 = listProjects().find((p) => p.name === projectName);
5843
+ if (!project2) {
5844
+ throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
5845
+ }
5846
+ const projectAbs = path26.resolve(project2.path);
5847
+ const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
5848
+ if (!projectConfig) {
5849
+ throw Object.assign(
5850
+ new Error(`invalid project: missing task0.yml at ${projectAbs}`),
5851
+ { code: "invalid_project" }
5852
+ );
5853
+ }
5854
+ const tarFile = path26.resolve(projectAbs, projectConfig.tasks_dir) + ".tar";
5855
+ if (!fs28.existsSync(tarFile)) return { files: [] };
5856
+ const listing = execSync3(`tar -tvf ${JSON.stringify(tarFile)}`).toString();
5857
+ const prefix = taskId + "/";
5858
+ const files = listing.split("\n").filter((line) => {
5859
+ const parts = line.trim().split(/\s+/);
5860
+ const filePath = parts[parts.length - 1];
5861
+ return filePath && filePath.startsWith(prefix) && filePath !== prefix && !filePath.endsWith("/");
5862
+ }).map((line) => {
5863
+ const parts = line.trim().split(/\s+/);
5864
+ const filePath = parts[parts.length - 1];
5865
+ const size = parseInt(parts[2], 10) || 0;
5866
+ const name = filePath.slice(prefix.length);
5867
+ return { name, size, modified: "", path: filePath };
5868
+ }).sort((a, b) => a.name.localeCompare(b.name));
5869
+ return { files };
5870
+ },
5871
+ // Read a specific file (path is relative to a directory inside the tar:
5872
+ // e.g., `<taskId>/task0.yml` or `<taskId>/IDEA-01.md`). Hub uses this for
5873
+ // the archived-task detail view.
5874
+ async archive_read_file(params) {
5875
+ const projectName = ensureString(params.projectName, "projectName");
5876
+ const archivePath = ensureString(params.path, "path");
5877
+ if (archivePath.includes("..") || archivePath.startsWith("/")) {
5878
+ throw Object.assign(new Error(`invalid archive path: ${archivePath}`), { code: "invalid_params" });
5879
+ }
5880
+ const project2 = listProjects().find((p) => p.name === projectName);
5881
+ if (!project2) {
5882
+ throw Object.assign(new Error(`project not found: ${projectName}`), { code: "not_found" });
5883
+ }
5884
+ const projectAbs = path26.resolve(project2.path);
5885
+ const projectConfig = readYaml(path26.join(projectAbs, "task0.yml"));
5886
+ if (!projectConfig) {
5887
+ throw Object.assign(
5888
+ new Error(`invalid project: missing task0.yml at ${projectAbs}`),
5889
+ { code: "invalid_project" }
5890
+ );
5891
+ }
5892
+ const tasksRoot = path26.resolve(projectAbs, projectConfig.tasks_dir);
5893
+ const tarFile = tasksRoot + ".tar";
5894
+ if (!fs28.existsSync(tarFile)) {
5895
+ throw Object.assign(new Error("no archive found"), { code: "not_found" });
5896
+ }
5897
+ const content = execSync3(
5898
+ `tar -xOf ${JSON.stringify(tarFile)} ${JSON.stringify(archivePath)}`
5899
+ ).toString("utf-8");
5900
+ return { content, path: archivePath };
5901
+ },
5268
5902
  // ---------------------------------------------------------------------
5269
5903
  // Agent run lifecycle (P3.A scaffolding — handlers reject until P3.B
5270
5904
  // moves the actual spawn / tmux / supervise logic from hub's
@@ -6683,7 +7317,7 @@ import fs32 from "fs";
6683
7317
  import path31 from "path";
6684
7318
  import { Command as Command23 } from "commander";
6685
7319
  import chalk23 from "chalk";
6686
- import yaml10 from "js-yaml";
7320
+ import yaml11 from "js-yaml";
6687
7321
  var PROFILE_SCALAR_KEYS = ["api_url"];
6688
7322
  function isProfileScalarKey(key) {
6689
7323
  return PROFILE_SCALAR_KEYS.includes(key);
@@ -6705,7 +7339,7 @@ function readProfileApiUrl(name) {
6705
7339
  const file = path31.join(profileDir(name), "config.yml");
6706
7340
  if (!fs32.existsSync(file)) return void 0;
6707
7341
  try {
6708
- const data = yaml10.load(fs32.readFileSync(file, "utf-8"));
7342
+ const data = yaml11.load(fs32.readFileSync(file, "utf-8"));
6709
7343
  return data?.api_url;
6710
7344
  } catch {
6711
7345
  return void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@task0/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "task0 — task-centric agent workflow CLI",
5
5
  "homepage": "https://github.com/cy0-labs/task0#readme",
6
6
  "repository": {