@chit-run/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/chit.js +857 -121
  2. package/package.json +1 -1
package/dist/chit.js CHANGED
@@ -9984,12 +9984,12 @@ var init_dist = __esm(() => {
9984
9984
  });
9985
9985
 
9986
9986
  // ../studio/src/server/audit.ts
9987
- import { existsSync as existsSync9, readFileSync as readFileSync12 } from "fs";
9988
- import { homedir as homedir7 } from "os";
9989
- import { join as join10 } from "path";
9987
+ import { existsSync as existsSync11, readFileSync as readFileSync13 } from "fs";
9988
+ import { homedir as homedir9 } from "os";
9989
+ import { join as join12 } from "path";
9990
9990
  function defaultAuditDir2() {
9991
- const xdg = process.env.XDG_STATE_HOME || join10(homedir7(), ".local", "state");
9992
- return join10(xdg, "chit", "audit");
9991
+ const xdg = process.env.XDG_STATE_HOME || join12(homedir9(), ".local", "state");
9992
+ return join12(xdg, "chit", "audit");
9993
9993
  }
9994
9994
  function blobRefs(e) {
9995
9995
  switch (e.type) {
@@ -10008,13 +10008,13 @@ function blobRefs(e) {
10008
10008
  function readAuditRun(auditDir, runId, includeBlobs) {
10009
10009
  if (!SAFE_RUN_ID2.test(runId))
10010
10010
  return { kind: "invalid-id" };
10011
- const runDir = join10(auditDir, "runs", runId);
10012
- const eventsPath = join10(runDir, "events.jsonl");
10013
- if (!existsSync9(eventsPath))
10011
+ const runDir = join12(auditDir, "runs", runId);
10012
+ const eventsPath = join12(runDir, "events.jsonl");
10013
+ if (!existsSync11(eventsPath))
10014
10014
  return { kind: "not-found" };
10015
10015
  let events2;
10016
10016
  try {
10017
- events2 = parseAuditLog(readFileSync12(eventsPath, "utf-8"));
10017
+ events2 = parseAuditLog(readFileSync13(eventsPath, "utf-8"));
10018
10018
  } catch (e) {
10019
10019
  if (e instanceof AuditEventError)
10020
10020
  return { kind: "invalid-log", message: e.message };
@@ -10022,15 +10022,15 @@ function readAuditRun(auditDir, runId, includeBlobs) {
10022
10022
  }
10023
10023
  if (!includeBlobs)
10024
10024
  return { kind: "ok", events: events2 };
10025
- const blobsDir = join10(runDir, "blobs");
10025
+ const blobsDir = join12(runDir, "blobs");
10026
10026
  const blobs = {};
10027
10027
  for (const e of events2) {
10028
10028
  for (const ref of blobRefs(e)) {
10029
10029
  if (!SHA256_HEX2.test(ref) || ref in blobs)
10030
10030
  continue;
10031
- const blobPath = join10(blobsDir, ref);
10032
- if (existsSync9(blobPath))
10033
- blobs[ref] = readFileSync12(blobPath, "utf-8");
10031
+ const blobPath = join12(blobsDir, ref);
10032
+ if (existsSync11(blobPath))
10033
+ blobs[ref] = readFileSync13(blobPath, "utf-8");
10034
10034
  }
10035
10035
  }
10036
10036
  return { kind: "ok", events: events2, blobs };
@@ -10088,12 +10088,12 @@ var init_auth = __esm(() => {
10088
10088
  });
10089
10089
 
10090
10090
  // ../studio/src/server/paths.ts
10091
- import { existsSync as existsSync10, statSync as statSync3 } from "fs";
10091
+ import { existsSync as existsSync12, statSync as statSync3 } from "fs";
10092
10092
  import { isAbsolute as isAbsolute4, resolve as resolve6 } from "path";
10093
10093
  function resolveExplicitPath(userPath, cwd) {
10094
10094
  const candidate = isAbsolute4(userPath) ? userPath : resolve6(cwd, userPath);
10095
10095
  const canonical = resolve6(candidate);
10096
- if (!existsSync10(canonical)) {
10096
+ if (!existsSync12(canonical)) {
10097
10097
  throw new PathError("not-found", `path "${userPath}" does not exist`);
10098
10098
  }
10099
10099
  if (!statSync3(canonical).isFile()) {
@@ -10114,11 +10114,11 @@ var init_paths = __esm(() => {
10114
10114
  });
10115
10115
 
10116
10116
  // ../studio/src/server/discovery.ts
10117
- import { readdirSync as readdirSync4, readFileSync as readFileSync13 } from "fs";
10118
- import { basename, join as join11, relative } from "path";
10117
+ import { readdirSync as readdirSync5, readFileSync as readFileSync14 } from "fs";
10118
+ import { basename, join as join13, relative } from "path";
10119
10119
  function safeParseChit(absolutePath) {
10120
10120
  try {
10121
- const raw2 = JSON.parse(readFileSync13(absolutePath, "utf-8"));
10121
+ const raw2 = JSON.parse(readFileSync14(absolutePath, "utf-8"));
10122
10122
  parseManifest(raw2);
10123
10123
  return true;
10124
10124
  } catch {
@@ -10138,14 +10138,14 @@ function discover(opts) {
10138
10138
  relPath: relPathFromCwd(absolutePath, opts.cwd)
10139
10139
  };
10140
10140
  }
10141
- const entries = readdirSync4(opts.cwd, { withFileTypes: true });
10141
+ const entries = readdirSync5(opts.cwd, { withFileTypes: true });
10142
10142
  const candidates = [];
10143
10143
  for (const entry of entries) {
10144
10144
  if (!entry.isFile())
10145
10145
  continue;
10146
10146
  if (!entry.name.endsWith(".json"))
10147
10147
  continue;
10148
- const absolutePath = join11(opts.cwd, entry.name);
10148
+ const absolutePath = join13(opts.cwd, entry.name);
10149
10149
  if (!safeParseChit(absolutePath))
10150
10150
  continue;
10151
10151
  candidates.push({
@@ -10171,7 +10171,7 @@ var init_discovery = __esm(() => {
10171
10171
 
10172
10172
  // ../studio/src/server/docs.ts
10173
10173
  import { createHash as createHash6 } from "crypto";
10174
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync7 } from "fs";
10174
+ import { readFileSync as readFileSync15, writeFileSync as writeFileSync8 } from "fs";
10175
10175
  import { basename as basename2, relative as relative2 } from "path";
10176
10176
  function canonicalize(draft) {
10177
10177
  return JSON.stringify(draft, null, "\t");
@@ -10204,7 +10204,7 @@ class DocStore {
10204
10204
  if (!entry)
10205
10205
  return null;
10206
10206
  try {
10207
- return hashRaw(readFileSync14(entry.absolutePath, "utf-8"));
10207
+ return hashRaw(readFileSync15(entry.absolutePath, "utf-8"));
10208
10208
  } catch {
10209
10209
  return null;
10210
10210
  }
@@ -10246,7 +10246,7 @@ class DocStore {
10246
10246
  return null;
10247
10247
  let raw2;
10248
10248
  try {
10249
- raw2 = readFileSync14(entry.absolutePath, "utf-8");
10249
+ raw2 = readFileSync15(entry.absolutePath, "utf-8");
10250
10250
  } catch (e) {
10251
10251
  const errorDoc = {
10252
10252
  id: docId,
@@ -10302,7 +10302,7 @@ class DocStore {
10302
10302
  return { kind: "not-found" };
10303
10303
  let currentRaw;
10304
10304
  try {
10305
- currentRaw = readFileSync14(entry.absolutePath, "utf-8");
10305
+ currentRaw = readFileSync15(entry.absolutePath, "utf-8");
10306
10306
  } catch {
10307
10307
  return { kind: "not-found" };
10308
10308
  }
@@ -10314,7 +10314,7 @@ class DocStore {
10314
10314
  const manifest = parseManifest(draft);
10315
10315
  const graphModel = buildGraphModel(manifest, this.registry, surface);
10316
10316
  const canonicalRaw = canonicalize(draft);
10317
- writeFileSync7(entry.absolutePath, canonicalRaw, "utf-8");
10317
+ writeFileSync8(entry.absolutePath, canonicalRaw, "utf-8");
10318
10318
  const newHash = hashRaw(canonicalRaw);
10319
10319
  return {
10320
10320
  kind: "saved",
@@ -10383,8 +10383,8 @@ var init_docs = __esm(() => {
10383
10383
  });
10384
10384
 
10385
10385
  // ../studio/src/server/loops.ts
10386
- import { existsSync as existsSync11, readdirSync as readdirSync5, readFileSync as readFileSync15 } from "fs";
10387
- import { join as join12 } from "path";
10386
+ import { existsSync as existsSync13, readdirSync as readdirSync6, readFileSync as readFileSync16 } from "fs";
10387
+ import { join as join14 } from "path";
10388
10388
  function summarize(loopId, records) {
10389
10389
  const header = records[0];
10390
10390
  if (header?.type !== "loop")
@@ -10402,11 +10402,11 @@ function summarize(loopId, records) {
10402
10402
  };
10403
10403
  }
10404
10404
  function readLoopFrom(dir, loopId) {
10405
- const path = join12(dir, `${loopId}.jsonl`);
10406
- if (!existsSync11(path))
10405
+ const path = join14(dir, `${loopId}.jsonl`);
10406
+ if (!existsSync13(path))
10407
10407
  return { kind: "not-found" };
10408
10408
  try {
10409
- const records = validateLoopLog(parseLoopLog(readFileSync15(path, "utf-8")));
10409
+ const records = validateLoopLog(parseLoopLog(readFileSync16(path, "utf-8")));
10410
10410
  const header = records[0];
10411
10411
  if (header?.type !== "loop" || header.loopId !== loopId) {
10412
10412
  return { kind: "invalid-log", message: "header loopId does not match the file name" };
@@ -10419,10 +10419,10 @@ function readLoopFrom(dir, loopId) {
10419
10419
  }
10420
10420
  }
10421
10421
  function listLoops(loopsDir) {
10422
- if (!loopsDir || !existsSync11(loopsDir))
10422
+ if (!loopsDir || !existsSync13(loopsDir))
10423
10423
  return [];
10424
10424
  const summaries = [];
10425
- for (const name of readdirSync5(loopsDir)) {
10425
+ for (const name of readdirSync6(loopsDir)) {
10426
10426
  if (!name.endsWith(".jsonl"))
10427
10427
  continue;
10428
10428
  const loopId = name.slice(0, -".jsonl".length);
@@ -10479,8 +10479,8 @@ __export(exports_server, {
10479
10479
  buildApp: () => buildApp,
10480
10480
  PathError: () => PathError
10481
10481
  });
10482
- import { existsSync as existsSync12 } from "fs";
10483
- import { join as join13 } from "path";
10482
+ import { existsSync as existsSync14 } from "fs";
10483
+ import { join as join15 } from "path";
10484
10484
  async function startStudio(opts) {
10485
10485
  const hostname3 = opts.hostname ?? "127.0.0.1";
10486
10486
  const requestedPort = opts.port ?? 0;
@@ -10533,8 +10533,8 @@ function buildApp(opts) {
10533
10533
  const asset = c.req.param("asset");
10534
10534
  if (!CLIENT_ASSETS.has(asset))
10535
10535
  return c.text("not found", 404);
10536
- const path = join13(opts.clientDistDir, asset);
10537
- if (!existsSync12(path)) {
10536
+ const path = join15(opts.clientDistDir, asset);
10537
+ if (!existsSync14(path)) {
10538
10538
  return c.text(`client bundle missing at ${path}. Run: bun run studio:build`, 503);
10539
10539
  }
10540
10540
  return new Response(Bun.file(path));
@@ -10709,15 +10709,15 @@ var init_server = __esm(() => {
10709
10709
  init_loops();
10710
10710
  init_token();
10711
10711
  init_paths();
10712
- CLIENT_DIST = join13(import.meta.dir, "..", "..", "dist", "client");
10712
+ CLIENT_DIST = join15(import.meta.dir, "..", "..", "dist", "client");
10713
10713
  CLIENT_ASSETS = new Set(["index.js", "index.css"]);
10714
10714
  });
10715
10715
 
10716
10716
  // src/cli/run.ts
10717
10717
  init_src();
10718
- import { readFileSync as readFileSync16 } from "fs";
10719
- import { homedir as homedir8 } from "os";
10720
- import { basename as basename3, dirname as dirname2, join as join14 } from "path";
10718
+ import { readFileSync as readFileSync17 } from "fs";
10719
+ import { homedir as homedir10 } from "os";
10720
+ import { basename as basename3, dirname as dirname3, join as join16 } from "path";
10721
10721
 
10722
10722
  // src/adapters/sanitize.ts
10723
10723
  var SENSITIVE_KEY = /key|token|secret|password|auth/i;
@@ -13389,7 +13389,7 @@ function uninstall(parentDir, name) {
13389
13389
 
13390
13390
  // src/surfaces/mcp/server.ts
13391
13391
  import { spawn } from "child_process";
13392
- import { readFileSync as readFileSync11 } from "fs";
13392
+ import { readFileSync as readFileSync12 } from "fs";
13393
13393
  import { isAbsolute as isAbsolute3, resolve as resolve4 } from "path";
13394
13394
 
13395
13395
  // ../../node_modules/.bun/zod@4.4.3/node_modules/zod/v3/helpers/util.js
@@ -36205,6 +36205,579 @@ function showAudit(store, runId, opts) {
36205
36205
  return out;
36206
36206
  }
36207
36207
 
36208
+ // src/campaigns/plan.ts
36209
+ class PlanError extends Error {
36210
+ }
36211
+ var SAFE_TASK_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
36212
+ function normalizeClaim(claim, taskId) {
36213
+ const raw = claim.trim();
36214
+ if (raw === "")
36215
+ throw new PlanError(`task ${JSON.stringify(taskId)}: a claimedPath is empty`);
36216
+ if (raw.startsWith("/")) {
36217
+ throw new PlanError(`task ${JSON.stringify(taskId)}: claimedPath must be repo-relative, got ${JSON.stringify(claim)}`);
36218
+ }
36219
+ const dirGlob = raw.endsWith("/**");
36220
+ const dirSlash = !dirGlob && raw.endsWith("/");
36221
+ const body = dirGlob ? raw.slice(0, -3) : dirSlash ? raw.slice(0, -1) : raw;
36222
+ const segments = [];
36223
+ for (const seg of body.split("/")) {
36224
+ if (seg === "" || seg === ".")
36225
+ continue;
36226
+ if (seg === "..") {
36227
+ throw new PlanError(`task ${JSON.stringify(taskId)}: claimedPath may not contain "..": ${JSON.stringify(claim)}`);
36228
+ }
36229
+ segments.push(seg);
36230
+ }
36231
+ if (segments.length === 0) {
36232
+ throw new PlanError(`task ${JSON.stringify(taskId)}: claimedPath is empty after normalization: ${JSON.stringify(claim)}`);
36233
+ }
36234
+ const base = segments.join("/");
36235
+ return dirGlob ? `${base}/**` : dirSlash ? `${base}/` : base;
36236
+ }
36237
+ function planTasks(inputs) {
36238
+ if (inputs.length === 0)
36239
+ throw new PlanError("a campaign needs at least one task");
36240
+ const ids = new Set;
36241
+ for (const t of inputs) {
36242
+ if (!SAFE_TASK_ID.test(t.id)) {
36243
+ throw new PlanError(`invalid task id ${JSON.stringify(t.id)} (use [A-Za-z0-9][A-Za-z0-9_-]*)`);
36244
+ }
36245
+ if (ids.has(t.id))
36246
+ throw new PlanError(`duplicate task id ${JSON.stringify(t.id)}`);
36247
+ ids.add(t.id);
36248
+ if (!t.title?.trim())
36249
+ throw new PlanError(`task ${JSON.stringify(t.id)}: title is required`);
36250
+ if (!t.body?.trim())
36251
+ throw new PlanError(`task ${JSON.stringify(t.id)}: body is required`);
36252
+ }
36253
+ for (const t of inputs) {
36254
+ for (const dep of t.dependencies ?? []) {
36255
+ if (!ids.has(dep)) {
36256
+ throw new PlanError(`task ${JSON.stringify(t.id)} depends on unknown task ${JSON.stringify(dep)}`);
36257
+ }
36258
+ if (dep === t.id)
36259
+ throw new PlanError(`task ${JSON.stringify(t.id)} depends on itself`);
36260
+ }
36261
+ const claims = t.claimedPaths ?? [];
36262
+ if (claims.length === 0 && !t.allowPathOverlap) {
36263
+ throw new PlanError(`task ${JSON.stringify(t.id)}: claimedPaths is required (declare the paths it will touch), ` + "or set allowPathOverlap to run it without a declared footprint (it will run alone)");
36264
+ }
36265
+ }
36266
+ assertAcyclic(inputs);
36267
+ return inputs.map((t) => {
36268
+ const task = {
36269
+ id: t.id,
36270
+ title: t.title,
36271
+ body: t.body,
36272
+ status: "pending",
36273
+ dependencies: [...t.dependencies ?? []],
36274
+ claimedPaths: (t.claimedPaths ?? []).map((c) => normalizeClaim(c, t.id))
36275
+ };
36276
+ if (t.allowPathOverlap)
36277
+ task.allowPathOverlap = true;
36278
+ if (t.manifestPath !== undefined)
36279
+ task.manifestPath = t.manifestPath;
36280
+ return task;
36281
+ });
36282
+ }
36283
+ function assertAcyclic(inputs) {
36284
+ const deps = new Map(inputs.map((t) => [t.id, t.dependencies ?? []]));
36285
+ const state = new Map;
36286
+ const visit = (id, stack) => {
36287
+ const s = state.get(id);
36288
+ if (s === "done")
36289
+ return;
36290
+ if (s === "visiting") {
36291
+ const cycle = [...stack.slice(stack.indexOf(id)), id].join(" -> ");
36292
+ throw new PlanError(`dependency cycle: ${cycle}`);
36293
+ }
36294
+ state.set(id, "visiting");
36295
+ for (const dep of deps.get(id) ?? [])
36296
+ visit(dep, [...stack, id]);
36297
+ state.set(id, "done");
36298
+ };
36299
+ for (const t of inputs)
36300
+ visit(t.id, []);
36301
+ }
36302
+ function resolveManifestPath(task, campaignDefault) {
36303
+ return task.manifestPath ?? campaignDefault;
36304
+ }
36305
+
36306
+ // src/campaigns/overlap.ts
36307
+ function shape(claim) {
36308
+ if (claim.endsWith("/**"))
36309
+ return { base: claim.slice(0, -3), isDir: true };
36310
+ if (claim.endsWith("/"))
36311
+ return { base: claim.slice(0, -1), isDir: true };
36312
+ return { base: claim, isDir: false };
36313
+ }
36314
+ function pathsOverlap(a, b) {
36315
+ const sa = shape(a);
36316
+ const sb = shape(b);
36317
+ if (sa.base === sb.base)
36318
+ return true;
36319
+ if (sa.isDir && (sb.base === sa.base || sb.base.startsWith(`${sa.base}/`)))
36320
+ return true;
36321
+ if (sb.isDir && (sa.base === sb.base || sa.base.startsWith(`${sb.base}/`)))
36322
+ return true;
36323
+ return false;
36324
+ }
36325
+ function tasksClaimsOverlap(a, b) {
36326
+ if (a.allowPathOverlap || b.allowPathOverlap)
36327
+ return true;
36328
+ return a.claimedPaths.some((pa) => b.claimedPaths.some((pb) => pathsOverlap(pa, pb)));
36329
+ }
36330
+
36331
+ // src/campaigns/types.ts
36332
+ var ACTIVE_TASK_STATUSES = new Set(["running"]);
36333
+ var DEPENDENCY_SATISFIED_STATUSES = new Set([
36334
+ "review_ready"
36335
+ ]);
36336
+ var MAX_PARALLEL_CAP = 4;
36337
+
36338
+ // src/campaigns/schedule.ts
36339
+ function byId(tasks) {
36340
+ return new Map(tasks.map((t) => [t.id, t]));
36341
+ }
36342
+ function depsSatisfied(task, index) {
36343
+ return task.dependencies.every((dep) => {
36344
+ const d = index.get(dep);
36345
+ return d !== undefined && DEPENDENCY_SATISFIED_STATUSES.has(d.status);
36346
+ });
36347
+ }
36348
+ function isBlocked(task, campaign) {
36349
+ if (task.status !== "pending")
36350
+ return false;
36351
+ const index = byId(campaign.tasks);
36352
+ return task.dependencies.some((dep) => {
36353
+ const d = index.get(dep);
36354
+ return d !== undefined && (d.status === "failed" || d.status === "cancelled");
36355
+ });
36356
+ }
36357
+ function isStartable(task, campaign) {
36358
+ return task.status === "pending" && depsSatisfied(task, byId(campaign.tasks));
36359
+ }
36360
+ function selectRunnable(campaign) {
36361
+ const index = byId(campaign.tasks);
36362
+ const active = campaign.tasks.filter((t) => ACTIVE_TASK_STATUSES.has(t.status));
36363
+ let freeSlots = Math.max(0, campaign.maxParallel - active.length);
36364
+ if (freeSlots === 0)
36365
+ return [];
36366
+ const selected = [];
36367
+ const blockers = [...active];
36368
+ for (const task of campaign.tasks) {
36369
+ if (freeSlots === 0)
36370
+ break;
36371
+ if (task.status !== "pending")
36372
+ continue;
36373
+ if (!depsSatisfied(task, index))
36374
+ continue;
36375
+ if (blockers.some((b) => tasksClaimsOverlap(task, b)))
36376
+ continue;
36377
+ selected.push(task);
36378
+ blockers.push(task);
36379
+ freeSlots--;
36380
+ }
36381
+ return selected;
36382
+ }
36383
+ function deriveCampaignStatus(campaign) {
36384
+ const tasks = campaign.tasks;
36385
+ if (tasks.length === 0)
36386
+ return "ready_for_review";
36387
+ const anyActive = tasks.some((t) => ACTIVE_TASK_STATUSES.has(t.status));
36388
+ const anyStartable = tasks.some((t) => isStartable(t, campaign));
36389
+ if (anyActive || anyStartable)
36390
+ return "running";
36391
+ const stuckPending = tasks.some((t) => t.status === "pending");
36392
+ const anyReviewReady = tasks.some((t) => t.status === "review_ready");
36393
+ const anyFailed = tasks.some((t) => t.status === "failed");
36394
+ if (stuckPending)
36395
+ return "needs_human";
36396
+ if (anyReviewReady)
36397
+ return "ready_for_review";
36398
+ if (anyFailed)
36399
+ return "failed";
36400
+ return "ready_for_review";
36401
+ }
36402
+
36403
+ // src/campaigns/worktree.ts
36404
+ import { execFileSync as execFileSync2 } from "child_process";
36405
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6 } from "fs";
36406
+ import { homedir as homedir7 } from "os";
36407
+ import { dirname as dirname2, join as join9 } from "path";
36408
+
36409
+ class WorktreeError extends Error {
36410
+ }
36411
+ var realGit = (args, cwd) => {
36412
+ try {
36413
+ const stdout = execFileSync2("git", args, {
36414
+ cwd,
36415
+ encoding: "utf-8",
36416
+ stdio: ["ignore", "pipe", "pipe"]
36417
+ });
36418
+ return { code: 0, stdout, stderr: "" };
36419
+ } catch (e) {
36420
+ const err = e;
36421
+ return {
36422
+ code: typeof err.status === "number" ? err.status : 1,
36423
+ stdout: String(err.stdout ?? ""),
36424
+ stderr: String(err.stderr ?? "")
36425
+ };
36426
+ }
36427
+ };
36428
+ function gitErr(r) {
36429
+ return (r.stderr || r.stdout || `exit ${r.code}`).trim();
36430
+ }
36431
+ function taskWorktree(campaignId, taskId) {
36432
+ return {
36433
+ worktreePath: join9(homedir7(), "worktrees", "chit", campaignId, taskId),
36434
+ branch: `chit-campaign/${campaignId}/${taskId}`
36435
+ };
36436
+ }
36437
+ function resolveBaseSha(git, repo, ref) {
36438
+ const r = git(["rev-parse", ref], repo);
36439
+ if (r.code !== 0) {
36440
+ throw new WorktreeError(`cannot resolve base ref ${JSON.stringify(ref)}: ${gitErr(r)}`);
36441
+ }
36442
+ return r.stdout.trim();
36443
+ }
36444
+ function repoToplevel(git, cwd) {
36445
+ const r = git(["rev-parse", "--show-toplevel"], cwd);
36446
+ if (r.code !== 0) {
36447
+ throw new WorktreeError(`not a git repository at ${JSON.stringify(cwd)}: ${gitErr(r)}`);
36448
+ }
36449
+ return r.stdout.trim();
36450
+ }
36451
+ function createTaskWorktree(git, repo, campaignId, taskId, baseSha) {
36452
+ const { worktreePath, branch } = taskWorktree(campaignId, taskId);
36453
+ if (git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], repo).code === 0) {
36454
+ throw new WorktreeError(`branch ${JSON.stringify(branch)} already exists`);
36455
+ }
36456
+ if (existsSync9(worktreePath)) {
36457
+ throw new WorktreeError(`worktree path already exists: ${worktreePath}`);
36458
+ }
36459
+ mkdirSync6(dirname2(worktreePath), { recursive: true });
36460
+ const r = git(["worktree", "add", "-b", branch, worktreePath, baseSha], repo);
36461
+ if (r.code !== 0) {
36462
+ throw new WorktreeError(`git worktree add failed: ${gitErr(r)}`);
36463
+ }
36464
+ return { worktreePath, branch };
36465
+ }
36466
+
36467
+ // src/campaigns/engine.ts
36468
+ class CampaignEngineError extends Error {
36469
+ }
36470
+ var DEFAULT_MAX_ITERATIONS = 3;
36471
+ function iso3(ms) {
36472
+ return new Date(ms).toISOString();
36473
+ }
36474
+ function startCampaign(store, deps, opts) {
36475
+ const tasks = planTasks(opts.tasks);
36476
+ const repo = repoToplevel(deps.git, opts.cwd);
36477
+ const baseBranch = opts.baseBranch ?? "HEAD";
36478
+ const baseSha = resolveBaseSha(deps.git, repo, baseBranch);
36479
+ const maxParallel = Math.max(1, Math.min(opts.maxParallel, MAX_PARALLEL_CAP));
36480
+ const maxIterations = opts.maxIterations ?? DEFAULT_MAX_ITERATIONS;
36481
+ const now = iso3(deps.now());
36482
+ const campaign = {
36483
+ schema: 1,
36484
+ id: opts.id,
36485
+ repo,
36486
+ repoKey: repoKey(opts.cwd),
36487
+ baseBranch,
36488
+ baseSha,
36489
+ maxParallel,
36490
+ ...opts.manifestPath !== undefined && { manifestPath: opts.manifestPath },
36491
+ status: "planning",
36492
+ tasks,
36493
+ createdAt: now,
36494
+ updatedAt: now
36495
+ };
36496
+ store.create(campaign);
36497
+ return store.update(opts.id, (c) => launchWave(c, deps, maxIterations));
36498
+ }
36499
+ function advanceCampaign(store, deps, id, maxIterations = DEFAULT_MAX_ITERATIONS) {
36500
+ const existing = store.get(id);
36501
+ if (!existing)
36502
+ throw new CampaignEngineError(`no campaign ${JSON.stringify(id)}`);
36503
+ if (existing.status === "cancelled")
36504
+ return existing;
36505
+ return store.update(id, (c) => launchWave(reconcile(c, deps), deps, maxIterations));
36506
+ }
36507
+ function cancelCampaign(store, deps, id) {
36508
+ const existing = store.get(id);
36509
+ if (!existing)
36510
+ throw new CampaignEngineError(`no campaign ${JSON.stringify(id)}`);
36511
+ return store.update(id, (c) => {
36512
+ for (const t of c.tasks) {
36513
+ if (t.status === "running" && t.jobId) {
36514
+ try {
36515
+ deps.cancelJob(t.jobId);
36516
+ } catch {}
36517
+ t.status = "cancelled";
36518
+ } else if (t.status === "pending") {
36519
+ t.status = "cancelled";
36520
+ }
36521
+ }
36522
+ c.status = "cancelled";
36523
+ c.updatedAt = iso3(deps.now());
36524
+ return c;
36525
+ });
36526
+ }
36527
+ function reconcile(c, deps) {
36528
+ for (const t of c.tasks) {
36529
+ if (t.status !== "running" || !t.jobId)
36530
+ continue;
36531
+ const job = deps.getJob(t.jobId);
36532
+ if (!job) {
36533
+ settleTask(t, "failed", deps, { failure: "job record not found" });
36534
+ continue;
36535
+ }
36536
+ const inFlight = job.state === "queued" || job.state === "running";
36537
+ if (inFlight) {
36538
+ if (deps.isStale(job)) {
36539
+ settleTask(t, "failed", deps, { job, failure: "worker appears dead (stale job)" });
36540
+ }
36541
+ continue;
36542
+ }
36543
+ if (job.state === "completed" && job.stopStatus === "converged") {
36544
+ settleTask(t, "review_ready", deps, { job });
36545
+ } else if (job.state === "cancelled") {
36546
+ settleTask(t, "cancelled", deps, { job });
36547
+ } else {
36548
+ settleTask(t, "failed", deps, {
36549
+ job,
36550
+ failure: job.failure ?? `did not converge (${job.stopStatus ?? "failed"})`
36551
+ });
36552
+ }
36553
+ }
36554
+ return c;
36555
+ }
36556
+ function jobIsSettleable(job, deps) {
36557
+ if (job.state === "queued" || job.state === "running")
36558
+ return deps.isStale(job);
36559
+ return true;
36560
+ }
36561
+ function settleTask(t, status, deps, extra) {
36562
+ t.status = status;
36563
+ const detail = t.worktreePath && t.jobId ? deps.loopDetail(t.worktreePath, jobLoopId(t, extra.job)) : undefined;
36564
+ const result = {
36565
+ iterations: extra.job?.iterationsCompleted ?? 0,
36566
+ changedFiles: detail?.changedFiles ?? [],
36567
+ workspaceWarnings: detail?.workspaceWarnings ?? [],
36568
+ auditRefs: extra.job?.auditRefs ?? []
36569
+ };
36570
+ if (extra.job?.stopStatus !== undefined)
36571
+ result.stopStatus = extra.job.stopStatus;
36572
+ if (extra.job?.lastVerdict !== undefined)
36573
+ result.lastVerdict = extra.job.lastVerdict;
36574
+ t.result = result;
36575
+ if (status === "failed" && extra.failure !== undefined)
36576
+ t.error = extra.failure;
36577
+ }
36578
+ function jobLoopId(t, job) {
36579
+ return job?.loopId ?? t.id;
36580
+ }
36581
+ function launchWave(c, deps, maxIterations) {
36582
+ if (c.status === "cancelled")
36583
+ return c;
36584
+ const runnable = selectRunnable(c);
36585
+ for (const task of runnable) {
36586
+ const t = c.tasks.find((x) => x.id === task.id);
36587
+ if (!t)
36588
+ continue;
36589
+ try {
36590
+ const { worktreePath, branch } = deps.createWorktree(c.repo, c.id, t.id, c.baseSha);
36591
+ const loopId = `${c.id}-${t.id}`;
36592
+ const { jobId } = deps.launchJob({
36593
+ cwd: worktreePath,
36594
+ scope: `campaign-${c.id}-${t.id}`,
36595
+ task: t.body,
36596
+ loopId,
36597
+ ...resolveManifestPath(t, c.manifestPath) !== undefined && {
36598
+ manifestPath: resolveManifestPath(t, c.manifestPath)
36599
+ },
36600
+ maxIterations
36601
+ });
36602
+ t.worktreePath = worktreePath;
36603
+ t.branch = branch;
36604
+ t.jobId = jobId;
36605
+ t.status = "running";
36606
+ } catch (e) {
36607
+ t.status = "failed";
36608
+ t.error = e instanceof WorktreeError ? e.message : e.message;
36609
+ }
36610
+ }
36611
+ c.status = deriveCampaignStatus(c);
36612
+ c.updatedAt = iso3(deps.now());
36613
+ return c;
36614
+ }
36615
+ function describeCampaign(c, deps) {
36616
+ const tasks = c.tasks.map((t) => {
36617
+ const view = {
36618
+ id: t.id,
36619
+ title: t.title,
36620
+ status: t.status,
36621
+ dependencies: t.dependencies,
36622
+ ...t.branch !== undefined && { branch: t.branch },
36623
+ ...t.worktreePath !== undefined && { worktreePath: t.worktreePath },
36624
+ ...t.jobId !== undefined && { jobId: t.jobId }
36625
+ };
36626
+ if (t.status === "running" && t.jobId) {
36627
+ const job = deps.getJob(t.jobId);
36628
+ if (job) {
36629
+ view.jobState = job.state === "running" && deps.isStale(job) ? "stale" : job.state;
36630
+ if (job.phase !== undefined)
36631
+ view.phase = job.phase;
36632
+ }
36633
+ }
36634
+ if (t.result) {
36635
+ if (t.result.stopStatus !== undefined)
36636
+ view.stopStatus = t.result.stopStatus;
36637
+ if (t.result.lastVerdict !== undefined)
36638
+ view.lastVerdict = t.result.lastVerdict;
36639
+ view.changedFiles = t.result.changedFiles;
36640
+ view.workspaceWarnings = t.result.workspaceWarnings;
36641
+ view.auditRefs = t.result.auditRefs;
36642
+ }
36643
+ if (t.error !== undefined)
36644
+ view.error = t.error;
36645
+ return view;
36646
+ });
36647
+ const runnable = selectRunnable(c);
36648
+ const anyReconcilable = c.tasks.some((t) => {
36649
+ if (t.status !== "running" || !t.jobId)
36650
+ return false;
36651
+ const job = deps.getJob(t.jobId);
36652
+ return job !== undefined && jobIsSettleable(job, deps);
36653
+ });
36654
+ const startableBlocked = c.tasks.filter((t) => isStartable(t, c)).length;
36655
+ const blocked = c.tasks.filter((t) => isBlocked(t, c)).length;
36656
+ let nextAction;
36657
+ if (c.status === "cancelled") {
36658
+ nextAction = "campaign cancelled";
36659
+ } else if (c.status === "ready_for_review") {
36660
+ nextAction = "all tasks terminal; review the task worktrees (chit_campaign_status lists them)";
36661
+ } else if (c.status === "needs_human") {
36662
+ nextAction = `${blocked} task(s) blocked by a failed/cancelled dependency; inspect and start a fresh campaign for them`;
36663
+ } else if (runnable.length > 0 || anyReconcilable) {
36664
+ const n = runnable.length;
36665
+ nextAction = anyReconcilable && n === 0 ? "a job finished; call chit_campaign_advance to reconcile and launch newly runnable task(s)" : `call chit_campaign_advance to launch ${n} runnable task(s)`;
36666
+ } else {
36667
+ nextAction = "tasks in flight; poll chit_campaign_status, or chit_campaign_cancel to stop";
36668
+ }
36669
+ return {
36670
+ id: c.id,
36671
+ repo: c.repo,
36672
+ baseBranch: c.baseBranch,
36673
+ baseSha: c.baseSha,
36674
+ maxParallel: c.maxParallel,
36675
+ status: c.status,
36676
+ tasks,
36677
+ runnableCount: runnable.length,
36678
+ nextAction,
36679
+ createdAt: c.createdAt,
36680
+ updatedAt: c.updatedAt
36681
+ };
36682
+ }
36683
+
36684
+ // src/campaigns/store.ts
36685
+ import { randomUUID as randomUUID5 } from "crypto";
36686
+ import {
36687
+ existsSync as existsSync10,
36688
+ mkdirSync as mkdirSync7,
36689
+ readdirSync as readdirSync4,
36690
+ readFileSync as readFileSync11,
36691
+ renameSync as renameSync4,
36692
+ rmSync as rmSync7,
36693
+ writeFileSync as writeFileSync6
36694
+ } from "fs";
36695
+ import { homedir as homedir8 } from "os";
36696
+ import { join as join10 } from "path";
36697
+ class CampaignStoreError extends Error {
36698
+ }
36699
+ var SAFE_CAMPAIGN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
36700
+ function campaignsDir(cwd) {
36701
+ const xdg = process.env.XDG_STATE_HOME || join10(homedir8(), ".local", "state");
36702
+ return join10(xdg, "chit", "campaigns", repoKey(cwd));
36703
+ }
36704
+
36705
+ class CampaignStore {
36706
+ cwd;
36707
+ constructor(cwd) {
36708
+ this.cwd = cwd;
36709
+ }
36710
+ dir() {
36711
+ return campaignsDir(this.cwd);
36712
+ }
36713
+ path(id) {
36714
+ if (!SAFE_CAMPAIGN_ID.test(id)) {
36715
+ throw new CampaignStoreError(`invalid campaign id ${JSON.stringify(id)}`);
36716
+ }
36717
+ return join10(this.dir(), `${id}.json`);
36718
+ }
36719
+ lockPath(id) {
36720
+ return `${this.path(id)}.lock`;
36721
+ }
36722
+ create(campaign) {
36723
+ mkdirSync7(this.dir(), { recursive: true });
36724
+ const path = this.path(campaign.id);
36725
+ withFileLock(this.lockPath(campaign.id), () => {
36726
+ if (existsSync10(path)) {
36727
+ throw new CampaignStoreError(`campaign ${JSON.stringify(campaign.id)} already exists`);
36728
+ }
36729
+ writeAtomic2(path, campaign);
36730
+ });
36731
+ }
36732
+ get(id) {
36733
+ const path = this.path(id);
36734
+ if (!existsSync10(path))
36735
+ return;
36736
+ try {
36737
+ return JSON.parse(readFileSync11(path, "utf-8"));
36738
+ } catch {
36739
+ return;
36740
+ }
36741
+ }
36742
+ update(id, mutate) {
36743
+ const path = this.path(id);
36744
+ mkdirSync7(this.dir(), { recursive: true });
36745
+ return withFileLock(this.lockPath(id), () => {
36746
+ if (!existsSync10(path))
36747
+ throw new CampaignStoreError(`no campaign ${JSON.stringify(id)}`);
36748
+ const current = JSON.parse(readFileSync11(path, "utf-8"));
36749
+ const next = mutate(current);
36750
+ writeAtomic2(path, next);
36751
+ return next;
36752
+ });
36753
+ }
36754
+ list() {
36755
+ const dir = this.dir();
36756
+ if (!existsSync10(dir))
36757
+ return [];
36758
+ const out = [];
36759
+ for (const name of readdirSync4(dir)) {
36760
+ if (!name.endsWith(".json"))
36761
+ continue;
36762
+ try {
36763
+ out.push(JSON.parse(readFileSync11(join10(dir, name), "utf-8")));
36764
+ } catch {}
36765
+ }
36766
+ out.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
36767
+ return out;
36768
+ }
36769
+ }
36770
+ function writeAtomic2(path, campaign) {
36771
+ const tmp = `${path}.${randomUUID5()}.tmp`;
36772
+ writeFileSync6(tmp, JSON.stringify(campaign, null, 2));
36773
+ try {
36774
+ renameSync4(tmp, path);
36775
+ } catch (err) {
36776
+ rmSync7(tmp, { force: true });
36777
+ throw err;
36778
+ }
36779
+ }
36780
+
36208
36781
  // src/jobs/health.ts
36209
36782
  var STALE_AFTER_MS = 60000;
36210
36783
  function pidAlive(pid) {
@@ -36842,7 +37415,7 @@ server.registerTool("chit_run_start", {
36842
37415
  const path = isAbsolute3(manifest_path) ? manifest_path : resolve4(process.cwd(), manifest_path);
36843
37416
  let raw;
36844
37417
  try {
36845
- raw = JSON.parse(readFileSync11(path, "utf-8"));
37418
+ raw = JSON.parse(readFileSync12(path, "utf-8"));
36846
37419
  } catch (e) {
36847
37420
  return errorResult(`could not read manifest at ${path}: ${e.message}`);
36848
37421
  }
@@ -36994,7 +37567,7 @@ server.registerTool("chit_converge_start", {
36994
37567
  if (manifest_path) {
36995
37568
  const path = isAbsolute3(manifest_path) ? manifest_path : resolve4(runCwd, manifest_path);
36996
37569
  try {
36997
- raw = JSON.parse(readFileSync11(path, "utf-8"));
37570
+ raw = JSON.parse(readFileSync12(path, "utf-8"));
36998
37571
  } catch (e) {
36999
37572
  return errorResult(`could not read manifest at ${path}: ${e.message}`);
37000
37573
  }
@@ -37164,64 +37737,54 @@ function spawnJobWorker(jobId, cwd) {
37164
37737
  });
37165
37738
  child.unref();
37166
37739
  }
37167
- server.registerTool("chit_converge_run", {
37168
- description: "Start an autonomous converge loop as a BACKGROUND job (a detached worker advances it; you keep chatting). Returns immediately with a job_id and loop_id. Inspect with chit_job_status / chit_status, stop with chit_job_cancel. Use the foreground chit_converge_start/next instead when you want to checkpoint each iteration. v1 starts a NEW loop only: an existing loop_id is refused (use chit_converge_next to continue a foreground loop, or force=true / a new loop_id).",
37169
- inputSchema: {
37170
- task: exports_external.string().describe("The slice to converge on"),
37171
- scope: exports_external.string().describe("Session scope id; both agents keep their thread across iterations"),
37172
- cwd: exports_external.string().optional().describe("Repo to run in (defaults to the server cwd)"),
37173
- manifest_path: exports_external.string().optional().describe("Converge manifest path (absolute or relative to cwd). Default: the built-in."),
37174
- max_iterations: exports_external.number().int().min(1).default(3).describe("Iteration budget. Default 3."),
37175
- loop_id: exports_external.string().optional().describe("Seed a loop id. Default: generated."),
37176
- force: exports_external.boolean().default(false).describe("Overwrite an existing loop log at this loop_id rather than refusing."),
37177
- allow_unenforced_permissions: exports_external.boolean().default(false).describe("Run even when a declared permission cannot be enforced (emits warnings).")
37178
- }
37179
- }, async ({
37180
- task,
37181
- scope,
37182
- cwd,
37183
- manifest_path,
37184
- max_iterations,
37185
- loop_id,
37186
- force,
37187
- allow_unenforced_permissions
37188
- }) => {
37189
- const runCwd = resolve4(cwd ?? process.cwd());
37740
+ function launchConvergeJob(p) {
37190
37741
  let raw;
37191
37742
  let manifestAbs;
37192
- if (manifest_path) {
37193
- manifestAbs = isAbsolute3(manifest_path) ? manifest_path : resolve4(runCwd, manifest_path);
37743
+ if (p.manifestPath) {
37744
+ manifestAbs = isAbsolute3(p.manifestPath) ? p.manifestPath : resolve4(p.cwd, p.manifestPath);
37194
37745
  try {
37195
- raw = JSON.parse(readFileSync11(manifestAbs, "utf-8"));
37746
+ raw = JSON.parse(readFileSync12(manifestAbs, "utf-8"));
37196
37747
  } catch (e) {
37197
- return errorResult(`could not read manifest at ${manifestAbs}: ${e.message}`);
37748
+ return {
37749
+ ok: false,
37750
+ error: `could not read manifest at ${manifestAbs}: ${e.message}`
37751
+ };
37198
37752
  }
37199
37753
  } else {
37200
37754
  raw = DEFAULT_CONVERGE_MANIFEST;
37201
37755
  }
37202
- const prep = prepareConvergeExecute(raw, getRegistry(), scope, runCwd, allow_unenforced_permissions);
37756
+ const prep = prepareConvergeExecute(raw, getRegistry(), p.scope, p.cwd, p.allowUnenforced);
37203
37757
  if (!prep.ok)
37204
- return errorResult(prep.error);
37205
- const loopId = loop_id ?? crypto.randomUUID();
37758
+ return { ok: false, error: prep.error };
37759
+ const loopId = p.loopId ?? crypto.randomUUID();
37206
37760
  try {
37207
- startLoop(runCwd, { scope, task, maxIterations: max_iterations, loopId, force });
37761
+ startLoop(p.cwd, {
37762
+ scope: p.scope,
37763
+ task: p.task,
37764
+ maxIterations: p.maxIterations,
37765
+ loopId,
37766
+ force: p.force
37767
+ });
37208
37768
  } catch (e) {
37209
37769
  if (e instanceof LoopStoreError) {
37210
- return errorResult(`${e.message}. Use chit_converge_next to continue a foreground loop, or start a background job with force=true or a new loop_id.`);
37770
+ return {
37771
+ ok: false,
37772
+ error: `${e.message}. Use chit_converge_next to continue a foreground loop, or start a background job with force=true or a new loop_id.`
37773
+ };
37211
37774
  }
37212
- return errorResult(e.message);
37775
+ return { ok: false, error: e.message };
37213
37776
  }
37214
37777
  const jobId = crypto.randomUUID();
37215
37778
  const job = {
37216
37779
  jobId,
37217
37780
  loopId,
37218
- repoKey: repoKey(runCwd),
37219
- cwd: runCwd,
37220
- scope,
37221
- task,
37781
+ repoKey: repoKey(p.cwd),
37782
+ cwd: p.cwd,
37783
+ scope: p.scope,
37784
+ task: p.task,
37222
37785
  ...manifestAbs !== undefined && { manifestPath: manifestAbs },
37223
- maxIterations: max_iterations,
37224
- allowUnenforced: allow_unenforced_permissions,
37786
+ maxIterations: p.maxIterations,
37787
+ allowUnenforced: p.allowUnenforced,
37225
37788
  state: "queued",
37226
37789
  createdAt: new Date().toISOString(),
37227
37790
  iterationsCompleted: 0,
@@ -37230,11 +37793,11 @@ server.registerTool("chit_converge_run", {
37230
37793
  try {
37231
37794
  jobStore.create(job);
37232
37795
  } catch (e) {
37233
- stopLoop(runCwd, loopId, { status: "blocked", reason: "could not create job record" });
37234
- return errorResult(e.message);
37796
+ stopLoop(p.cwd, loopId, { status: "blocked", reason: "could not create job record" });
37797
+ return { ok: false, error: e.message };
37235
37798
  }
37236
37799
  try {
37237
- spawnJobWorker(jobId, runCwd);
37800
+ spawnJobWorker(jobId, p.cwd);
37238
37801
  } catch (e) {
37239
37802
  jobStore.update(jobId, (c) => ({
37240
37803
  ...c,
@@ -37242,16 +37805,53 @@ server.registerTool("chit_converge_run", {
37242
37805
  failure: `could not spawn worker: ${e.message}`,
37243
37806
  endedAt: new Date().toISOString()
37244
37807
  }));
37245
- stopLoop(runCwd, loopId, { status: "blocked", reason: "worker spawn failed" });
37246
- return errorResult(`could not spawn background worker: ${e.message}`);
37808
+ stopLoop(p.cwd, loopId, { status: "blocked", reason: "worker spawn failed" });
37809
+ return { ok: false, error: `could not spawn background worker: ${e.message}` };
37810
+ }
37811
+ return { ok: true, jobId, loopId, warnings: prep.warnings };
37812
+ }
37813
+ server.registerTool("chit_converge_run", {
37814
+ description: "Start an autonomous converge loop as a BACKGROUND job (a detached worker advances it; you keep chatting). Returns immediately with a job_id and loop_id. Inspect with chit_job_status / chit_status, stop with chit_job_cancel. Use the foreground chit_converge_start/next instead when you want to checkpoint each iteration. v1 starts a NEW loop only: an existing loop_id is refused (use chit_converge_next to continue a foreground loop, or force=true / a new loop_id).",
37815
+ inputSchema: {
37816
+ task: exports_external.string().describe("The slice to converge on"),
37817
+ scope: exports_external.string().describe("Session scope id; both agents keep their thread across iterations"),
37818
+ cwd: exports_external.string().optional().describe("Repo to run in (defaults to the server cwd)"),
37819
+ manifest_path: exports_external.string().optional().describe("Converge manifest path (absolute or relative to cwd). Default: the built-in."),
37820
+ max_iterations: exports_external.number().int().min(1).default(3).describe("Iteration budget. Default 3."),
37821
+ loop_id: exports_external.string().optional().describe("Seed a loop id. Default: generated."),
37822
+ force: exports_external.boolean().default(false).describe("Overwrite an existing loop log at this loop_id rather than refusing."),
37823
+ allow_unenforced_permissions: exports_external.boolean().default(false).describe("Run even when a declared permission cannot be enforced (emits warnings).")
37247
37824
  }
37825
+ }, async ({
37826
+ task,
37827
+ scope,
37828
+ cwd,
37829
+ manifest_path,
37830
+ max_iterations,
37831
+ loop_id,
37832
+ force,
37833
+ allow_unenforced_permissions
37834
+ }) => {
37835
+ const runCwd = resolve4(cwd ?? process.cwd());
37836
+ const r = launchConvergeJob({
37837
+ task,
37838
+ scope,
37839
+ cwd: runCwd,
37840
+ ...manifest_path !== undefined && { manifestPath: manifest_path },
37841
+ maxIterations: max_iterations,
37842
+ ...loop_id !== undefined && { loopId: loop_id },
37843
+ force,
37844
+ allowUnenforced: allow_unenforced_permissions
37845
+ });
37846
+ if (!r.ok)
37847
+ return errorResult(r.error);
37248
37848
  return jsonResult({
37249
- jobId,
37250
- loopId,
37849
+ jobId: r.jobId,
37850
+ loopId: r.loopId,
37251
37851
  repo: repoRoot(runCwd),
37252
37852
  state: "queued",
37253
- nextAction: `running in the background; poll chit_job_status "${jobId}" (or chit_status), cancel with chit_job_cancel "${jobId}"`,
37254
- ...prep.warnings.length > 0 && { warnings: prep.warnings }
37853
+ nextAction: `running in the background; poll chit_job_status "${r.jobId}" (or chit_status), cancel with chit_job_cancel "${r.jobId}"`,
37854
+ ...r.warnings.length > 0 && { warnings: r.warnings }
37255
37855
  });
37256
37856
  });
37257
37857
  function describeJob(job) {
@@ -37309,6 +37909,27 @@ function describeJob(job) {
37309
37909
  nextAction
37310
37910
  };
37311
37911
  }
37912
+ function requestJobCancel(jobId) {
37913
+ const job = jobStore.get(jobId);
37914
+ if (!job)
37915
+ return { status: "missing" };
37916
+ if (job.state !== "queued" && job.state !== "running") {
37917
+ return { status: "terminal", state: job.state };
37918
+ }
37919
+ const updated = jobStore.update(jobId, (c) => ({
37920
+ ...c,
37921
+ cancelRequestedAt: new Date().toISOString(),
37922
+ ...c.state === "running" && { phase: "cancelling" }
37923
+ }));
37924
+ let signaled = false;
37925
+ if (!isStale(updated, Date.now()) && updated.pgid !== undefined && pidAlive(updated.pid)) {
37926
+ try {
37927
+ process.kill(-updated.pgid, "SIGTERM");
37928
+ signaled = true;
37929
+ } catch {}
37930
+ }
37931
+ return { status: "requested", state: updated.state, signaled };
37932
+ }
37312
37933
  server.registerTool("chit_job_status", {
37313
37934
  description: "Show one background job: state (queued/running/completed/cancelled/failed, or derived `stale` when the worker is gone), current phase, timing fields (elapsedMs, lastHeartbeatAgeMs, phaseElapsedMs), loop id, iterations, last verdict, audit refs, and the latest iteration's changed files / workspace warnings / usage. Read-only.",
37314
37935
  inputSchema: { job_id: exports_external.string() }
@@ -37322,37 +37943,152 @@ server.registerTool("chit_job_cancel", {
37322
37943
  description: "Cancel a background job from any turn. Persists the cancel intent FIRST (so it survives a worker restart), then signals the worker's process group. A queued job is cancelled before it starts; a running job stops at the next safe point and records a clean `cancelled` stop. A job that already finished is reported back unchanged.",
37323
37944
  inputSchema: { job_id: exports_external.string() }
37324
37945
  }, async ({ job_id }) => {
37325
- const job = jobStore.get(job_id);
37326
- if (!job)
37946
+ const r = requestJobCancel(job_id);
37947
+ if (r.status === "missing")
37327
37948
  return errorResult(`unknown job_id ${job_id}`);
37328
- if (job.state !== "queued" && job.state !== "running") {
37949
+ if (r.status === "terminal") {
37329
37950
  return jsonResult({
37330
37951
  jobId: job_id,
37331
- state: job.state,
37952
+ state: r.state,
37332
37953
  cancelled: false,
37333
- note: `job already ${job.state}`
37954
+ note: `job already ${r.state}`
37334
37955
  });
37335
37956
  }
37336
- const updated = jobStore.update(job_id, (c) => ({
37337
- ...c,
37338
- cancelRequestedAt: new Date().toISOString(),
37339
- ...c.state === "running" && { phase: "cancelling" }
37340
- }));
37341
- let signaled = false;
37342
- if (!isStale(updated, Date.now()) && updated.pgid !== undefined && pidAlive(updated.pid)) {
37343
- try {
37344
- process.kill(-updated.pgid, "SIGTERM");
37345
- signaled = true;
37346
- } catch {}
37347
- }
37348
37957
  return jsonResult({
37349
37958
  jobId: job_id,
37350
- state: updated.state,
37959
+ state: r.state,
37351
37960
  cancelRequested: true,
37352
- signaled,
37961
+ signaled: r.signaled,
37353
37962
  note: "cancellation requested; the worker stops at the next safe point and records a clean cancelled stop"
37354
37963
  });
37355
37964
  });
37965
+ var campaignDeps = {
37966
+ git: realGit,
37967
+ createWorktree: (repo, cid, tid, sha) => createTaskWorktree(realGit, repo, cid, tid, sha),
37968
+ launchJob: (p) => {
37969
+ const r = launchConvergeJob({
37970
+ task: p.task,
37971
+ scope: p.scope,
37972
+ cwd: p.cwd,
37973
+ ...p.manifestPath !== undefined && { manifestPath: p.manifestPath },
37974
+ maxIterations: p.maxIterations,
37975
+ loopId: p.loopId,
37976
+ allowUnenforced: false
37977
+ });
37978
+ if (!r.ok)
37979
+ throw new Error(r.error);
37980
+ return { jobId: r.jobId, loopId: r.loopId };
37981
+ },
37982
+ getJob: (id) => jobStore.get(id),
37983
+ cancelJob: (id) => {
37984
+ requestJobCancel(id);
37985
+ },
37986
+ isStale: (job) => isStale(job, Date.now()),
37987
+ loopDetail: (worktreePath, loopId) => {
37988
+ try {
37989
+ const iters = readLoop(worktreePath, loopId).filter((r) => r.type === "iteration");
37990
+ const last = iters.at(-1);
37991
+ if (last && last.type === "iteration") {
37992
+ return { changedFiles: last.changedFiles, workspaceWarnings: last.workspaceWarnings ?? [] };
37993
+ }
37994
+ } catch {}
37995
+ return { changedFiles: [], workspaceWarnings: [] };
37996
+ },
37997
+ now: () => Date.now()
37998
+ };
37999
+ var campaignTaskSchema = exports_external.object({
38000
+ id: exports_external.string().describe("Unique task id within the campaign (a safe slug)"),
38001
+ title: exports_external.string().describe("Short task title"),
38002
+ body: exports_external.string().describe("The task brief handed to the converge implementer"),
38003
+ dependencies: exports_external.array(exports_external.string()).optional().describe("Task ids that must reach review_ready before this task runs"),
38004
+ claimedPaths: exports_external.array(exports_external.string()).optional().describe("Paths this task will touch (globs: dir/**, dir/, or a file). Required unless allowPathOverlap; tasks with overlapping claims never run concurrently."),
38005
+ allowPathOverlap: exports_external.boolean().optional().describe("Opt-in to running with no/overlapping claims; the task then runs alone."),
38006
+ manifestPath: exports_external.string().optional().describe("Per-task converge manifest override (absolute or relative to cwd).")
38007
+ });
38008
+ function campaignError(e) {
38009
+ if (e instanceof PlanError || e instanceof WorktreeError || e instanceof CampaignStoreError) {
38010
+ return errorResult(e.message);
38011
+ }
38012
+ return errorResult(e.message);
38013
+ }
38014
+ server.registerTool("chit_campaign_start", {
38015
+ description: "Start a campaign: run several converge tasks in parallel, each in its own git worktree, as background jobs. Plans the task graph, launches the initial runnable wave (no-dependency tasks, up to max_parallel), and returns immediately. Then poll chit_campaign_status and call chit_campaign_advance to launch the next wave as jobs finish. No auto-merge: the output is reviewable worktree branches. Manifest resolution per task: task.manifestPath > campaign manifest_path > the bundled default converge manifest.",
38016
+ inputSchema: {
38017
+ tasks: exports_external.array(campaignTaskSchema).min(1).describe("The task graph (an explicit, reviewed list)"),
38018
+ cwd: exports_external.string().optional().describe("Any path in the target repo (defaults to the server cwd)"),
38019
+ max_parallel: exports_external.number().int().min(1).default(2).describe("Max concurrent tasks. Default 2."),
38020
+ base_branch: exports_external.string().optional().describe("Ref task worktrees branch from. Default: HEAD."),
38021
+ manifest_path: exports_external.string().optional().describe("Campaign-level default converge manifest (absolute or relative to cwd)."),
38022
+ max_iterations: exports_external.number().int().min(1).default(3).describe("Per-task iteration budget. Default 3.")
38023
+ }
38024
+ }, async ({ tasks, cwd, max_parallel, base_branch, manifest_path, max_iterations }) => {
38025
+ const runCwd = resolve4(cwd ?? process.cwd());
38026
+ const campaignManifest = manifest_path !== undefined ? isAbsolute3(manifest_path) ? manifest_path : resolve4(runCwd, manifest_path) : undefined;
38027
+ const planned = tasks.map((t) => ({
38028
+ ...t,
38029
+ ...t.manifestPath !== undefined && {
38030
+ manifestPath: isAbsolute3(t.manifestPath) ? t.manifestPath : resolve4(runCwd, t.manifestPath)
38031
+ }
38032
+ }));
38033
+ const store = new CampaignStore(runCwd);
38034
+ try {
38035
+ const campaign = startCampaign(store, campaignDeps, {
38036
+ id: crypto.randomUUID(),
38037
+ cwd: runCwd,
38038
+ tasks: planned,
38039
+ maxParallel: max_parallel,
38040
+ ...base_branch !== undefined && { baseBranch: base_branch },
38041
+ ...campaignManifest !== undefined && { manifestPath: campaignManifest },
38042
+ maxIterations: max_iterations
38043
+ });
38044
+ return jsonResult(describeCampaign(campaign, campaignDeps));
38045
+ } catch (e) {
38046
+ return campaignError(e);
38047
+ }
38048
+ });
38049
+ server.registerTool("chit_campaign_status", {
38050
+ description: "Read-only campaign overview: each task's status, live job state/phase, branch/worktree, changed files, audit refs, plus how many tasks are runnable now and the next action. Inspection is safe: this NEVER launches jobs, creates worktrees, or mutates state (use chit_campaign_advance to make progress).",
38051
+ inputSchema: {
38052
+ campaign_id: exports_external.string(),
38053
+ cwd: exports_external.string().optional().describe("Any path in the target repo (defaults to the server cwd)")
38054
+ }
38055
+ }, async ({ campaign_id, cwd }) => {
38056
+ const store = new CampaignStore(resolve4(cwd ?? process.cwd()));
38057
+ const campaign = store.get(campaign_id);
38058
+ if (!campaign)
38059
+ return errorResult(`unknown campaign_id ${campaign_id}`);
38060
+ return jsonResult(describeCampaign(campaign, campaignDeps));
38061
+ });
38062
+ server.registerTool("chit_campaign_advance", {
38063
+ description: "Advance a campaign: reconcile finished jobs into task state (converged -> review_ready; blocked/max-iterations/failed/stale -> failed; dependents proceed only past a review_ready task), then launch the next runnable wave. The only progression trigger besides start. Call it when chit_campaign_status reports runnable tasks or a finished job.",
38064
+ inputSchema: {
38065
+ campaign_id: exports_external.string(),
38066
+ cwd: exports_external.string().optional().describe("Any path in the target repo (defaults to the server cwd)")
38067
+ }
38068
+ }, async ({ campaign_id, cwd }) => {
38069
+ const store = new CampaignStore(resolve4(cwd ?? process.cwd()));
38070
+ try {
38071
+ const campaign = advanceCampaign(store, campaignDeps, campaign_id);
38072
+ return jsonResult(describeCampaign(campaign, campaignDeps));
38073
+ } catch (e) {
38074
+ return campaignError(e);
38075
+ }
38076
+ });
38077
+ server.registerTool("chit_campaign_cancel", {
38078
+ description: "Cancel a campaign: request cancellation of every active task job (intent-first, the same safety as chit_job_cancel) and mark pending tasks cancelled. Running jobs settle cleanly in the background. Worktrees are left in place for inspection.",
38079
+ inputSchema: {
38080
+ campaign_id: exports_external.string(),
38081
+ cwd: exports_external.string().optional().describe("Any path in the target repo (defaults to the server cwd)")
38082
+ }
38083
+ }, async ({ campaign_id, cwd }) => {
38084
+ const store = new CampaignStore(resolve4(cwd ?? process.cwd()));
38085
+ try {
38086
+ const campaign = cancelCampaign(store, campaignDeps, campaign_id);
38087
+ return jsonResult(describeCampaign(campaign, campaignDeps));
38088
+ } catch (e) {
38089
+ return campaignError(e);
38090
+ }
38091
+ });
37356
38092
  async function startMcpServer() {
37357
38093
  await server.connect(new StdioServerTransport);
37358
38094
  }
@@ -37580,9 +38316,9 @@ ${AUDIT_HELP}`);
37580
38316
  }
37581
38317
 
37582
38318
  // src/cli/doctor.ts
37583
- import { randomUUID as randomUUID5 } from "crypto";
37584
- import { mkdirSync as mkdirSync6, rmSync as rmSync7, writeFileSync as writeFileSync6 } from "fs";
37585
- import { join as join9 } from "path";
38319
+ import { randomUUID as randomUUID6 } from "crypto";
38320
+ import { mkdirSync as mkdirSync8, rmSync as rmSync8, writeFileSync as writeFileSync7 } from "fs";
38321
+ import { join as join11 } from "path";
37586
38322
  var defaultIO3 = {
37587
38323
  out: (s) => process.stdout.write(s),
37588
38324
  err: (s) => process.stderr.write(s)
@@ -37650,10 +38386,10 @@ function checkGitRepo(deps) {
37650
38386
  }
37651
38387
  function checkAuditDir(deps) {
37652
38388
  try {
37653
- mkdirSync6(deps.auditDir, { recursive: true });
37654
- const probeFile = join9(deps.auditDir, `.doctor-${randomUUID5()}`);
37655
- writeFileSync6(probeFile, "ok");
37656
- rmSync7(probeFile);
38389
+ mkdirSync8(deps.auditDir, { recursive: true });
38390
+ const probeFile = join11(deps.auditDir, `.doctor-${randomUUID6()}`);
38391
+ writeFileSync7(probeFile, "ok");
38392
+ rmSync8(probeFile);
37657
38393
  return { name: "audit dir", status: "pass", detail: `writable (${deps.auditDir})` };
37658
38394
  } catch (e) {
37659
38395
  return {
@@ -38326,7 +39062,7 @@ ${HELP}`);
38326
39062
  }
38327
39063
  let manifestRaw;
38328
39064
  try {
38329
- manifestRaw = JSON.parse(readFileSync16(args.manifestPath, "utf-8"));
39065
+ manifestRaw = JSON.parse(readFileSync17(args.manifestPath, "utf-8"));
38330
39066
  } catch (e) {
38331
39067
  process.stderr.write(`chit: failed to read manifest ${args.manifestPath}: ${e.message}
38332
39068
  `);
@@ -38497,7 +39233,7 @@ Pass --allow-unenforced-permissions to run anyway (emits a warning each run).
38497
39233
  return 1;
38498
39234
  }
38499
39235
  function defaultRuntimePath() {
38500
- return dirname2(dirname2(import.meta.dir));
39236
+ return dirname3(dirname3(import.meta.dir));
38501
39237
  }
38502
39238
  var TRACE_PREVIEW_CHARS = 280;
38503
39239
  function tracePreview(label, text) {
@@ -38537,7 +39273,7 @@ ${HELP}`);
38537
39273
  `);
38538
39274
  return 2;
38539
39275
  }
38540
- const outputDir = args.outputDir ?? join14(homedir8(), ".claude", "skills");
39276
+ const outputDir = args.outputDir ?? join16(homedir10(), ".claude", "skills");
38541
39277
  const runtimePath = args.runtimePath ?? defaultRuntimePath();
38542
39278
  try {
38543
39279
  const result = installClaudeSkill({
@@ -38580,7 +39316,7 @@ ${HELP}`);
38580
39316
  }
38581
39317
  let raw2;
38582
39318
  try {
38583
- raw2 = JSON.parse(readFileSync16(args.manifestPath, "utf-8"));
39319
+ raw2 = JSON.parse(readFileSync17(args.manifestPath, "utf-8"));
38584
39320
  } catch (e) {
38585
39321
  process.stderr.write(`chit: failed to read manifest ${args.manifestPath}: ${e.message}
38586
39322
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chit-run/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Versioned, cross-vendor agent routines with an audit trail. Stop being the glue between your agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",