@kody-ade/kody-engine 0.4.211 → 0.4.212-live.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.
package/README.md CHANGED
@@ -121,9 +121,9 @@ kody-engine release-publish --issue <N> [--dry-run]
121
121
  kody-engine release-deploy --issue <N> [--dry-run]
122
122
 
123
123
  # scheduled duties and goals
124
- kody-engine job-scheduler # fan out due .kody/duties/*.md files
125
- kody-engine job-tick --job <slug> [--force] # one agent tick for one duty
126
- kody-engine job-tick-scripted --job <slug> [--force] # one deterministic tickScript duty tick
124
+ kody-engine duty-scheduler # fan out due .kody/duties/*.md files
125
+ kody-engine duty-tick --duty <slug> [--force] # one agent tick for one duty
126
+ kody-engine duty-tick-scripted --duty <slug> [--force] # one deterministic tickScript duty tick
127
127
  kody-engine goal-scheduler # fan out active .kody/goals/* state files
128
128
  kody-engine goal-tick --goal <id> # advance one stacked-PR goal
129
129
 
@@ -141,7 +141,7 @@ kody-engine stats # inspect run/even
141
141
 
142
142
  ### Duties
143
143
 
144
- A **duty** is a markdown file at `.kody/duties/<slug>.md` with frontmatter such as `every:` and `staff:` plus human-owned prose. `job-scheduler` wakes on cron, finds due duties, and dispatches either `job-tick` for an agent tick or `job-tick-scripted` for a deterministic `tickScript:` duty. `kody init` copies built-in starter duties and scaffolds `.kody/staff/kody.md`.
144
+ A **duty** is a markdown file at `.kody/duties/<slug>.md` with frontmatter such as `every:` and `staff:` plus human-owned prose. `duty-scheduler` wakes on cron, finds due duties, and dispatches either `duty-tick` for an agent tick or `duty-tick-scripted` for a deterministic `tickScript:` duty. `kody init` copies built-in starter duties and scaffolds `.kody/staff/kody.md`.
145
145
 
146
146
  Locked-toolbox duties can declare `tools: [...]` to run with only the named high-level MCP intents plus `submit_state`; duties without `tools:` keep the legacy Bash/gh toolbox.
147
147
 
package/dist/bin/kody.js CHANGED
@@ -858,7 +858,7 @@ function dutyToolDefinitions(opts) {
858
858
  };
859
859
  const checkRunsTool = {
860
860
  name: "read_check_runs",
861
- description: "Read CI for a branch or commit ref (e.g. 'dev'). Returns {sha, state, failing:[{name,conclusion,detailsUrl}], pending:[{name,status}]}. state is RED (\u22651 check has a terminal-failure conclusion: failure/timed_out/startup_failure/action_required), PENDING (none failed but some still running), or GREEN (all completed, none failed). Kody's own job check-runs (run/kody/job-tick/\u2026) are excluded by default. This reads the commit's authoritative check-runs \u2014 use it instead of guessing CI health from a run list.",
861
+ description: "Read CI for a branch or commit ref (e.g. 'dev'). Returns {sha, state, failing:[{name,conclusion,detailsUrl}], pending:[{name,status}]}. state is RED (\u22651 check has a terminal-failure conclusion: failure/timed_out/startup_failure/action_required), PENDING (none failed but some still running), or GREEN (all completed, none failed). Kody's own job check-runs (run/kody/duty-tick/\u2026) are excluded by default. This reads the commit's authoritative check-runs \u2014 use it instead of guessing CI health from a run list.",
862
862
  inputSchema: {
863
863
  ref: z2.string().min(1).describe("Branch name or commit SHA to read CI for (e.g. 'dev')."),
864
864
  ignoreNames: z2.array(z2.string()).optional().describe("Check names to exclude (default: Kody's own job names).")
@@ -980,7 +980,7 @@ var init_dutyMcp = __esm({
980
980
  TRUST_STATE_BRANCH = "kody-state";
981
981
  THREAD_BODY_MAX = 4e3;
982
982
  CHECK_FAIL_CONCLUSIONS = /* @__PURE__ */ new Set(["FAILURE", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED"]);
983
- DEFAULT_IGNORE_CHECKS = ["run", "kody", "job-tick", "goal-tick", "worker-ask", "chat"];
983
+ DEFAULT_IGNORE_CHECKS = ["run", "kody", "duty-tick", "goal-tick", "worker-ask", "chat"];
984
984
  trackMarker = (key) => `<!-- kody-track:${key} -->`;
985
985
  commentMarker = (key) => `<!-- kody-track-comment:${key} -->`;
986
986
  GATE_EXEMPT_EXECUTABLES = /* @__PURE__ */ new Set(["qa-engineer", "ui-review"]);
@@ -1542,7 +1542,7 @@ var init_loadCoverageRules = __esm({
1542
1542
  // package.json
1543
1543
  var package_default = {
1544
1544
  name: "@kody-ade/kody-engine",
1545
- version: "0.4.211",
1545
+ version: "0.4.212-live.0",
1546
1546
  description: "kody \u2014 autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
1547
1547
  license: "MIT",
1548
1548
  type: "module",
@@ -4406,6 +4406,7 @@ import { execFileSync as execFileSync2 } from "child_process";
4406
4406
  var STATE_BEGIN = "<!-- kody:state:v1:begin -->";
4407
4407
  var STATE_END = "<!-- kody:state:v1:end -->";
4408
4408
  var HISTORY_MAX_ENTRIES = 20;
4409
+ var JOB_RUNS_MAX_ENTRIES = 20;
4409
4410
  var API_TIMEOUT_MS2 = 3e4;
4410
4411
  function emptyState() {
4411
4412
  return {
@@ -4419,6 +4420,7 @@ function emptyState() {
4419
4420
  },
4420
4421
  executables: {},
4421
4422
  artifacts: {},
4423
+ jobs: {},
4422
4424
  history: []
4423
4425
  };
4424
4426
  }
@@ -4487,6 +4489,7 @@ function parseStateComment(body) {
4487
4489
  core: { ...emptyState().core, ...parsed.core },
4488
4490
  executables: parsed.executables ?? {},
4489
4491
  artifacts: parsed.artifacts && typeof parsed.artifacts === "object" ? parsed.artifacts : {},
4492
+ jobs: normalizeJobs(parsed.jobs),
4490
4493
  history: Array.isArray(parsed.history) ? parsed.history : [],
4491
4494
  flow: parsed.flow
4492
4495
  };
@@ -4512,6 +4515,7 @@ function reduce(state, executable, action, phase, staff, job) {
4512
4515
  ...job?.runUrl ? { runUrl: job.runUrl } : {}
4513
4516
  };
4514
4517
  const newHistory = [...state.history, entry].slice(-HISTORY_MAX_ENTRIES);
4518
+ const newJobs = reduceJobs(state.jobs ?? {}, executable, action, staff, job);
4515
4519
  return {
4516
4520
  schemaVersion: 1,
4517
4521
  core: {
@@ -4525,10 +4529,47 @@ function reduce(state, executable, action, phase, staff, job) {
4525
4529
  },
4526
4530
  executables: newExecutables,
4527
4531
  artifacts: { ...state.artifacts ?? {} },
4532
+ jobs: newJobs,
4528
4533
  history: newHistory,
4529
4534
  flow: state.flow
4530
4535
  };
4531
4536
  }
4537
+ function reduceJobs(jobs, executable, action, staff, job) {
4538
+ const status = statusFromAction(action);
4539
+ const id = job?.jobKey || job?.jobId || `legacy:${executable}`;
4540
+ const prior = jobs[id];
4541
+ const note = noteFromAction(action);
4542
+ const prUrl = job?.prUrl ?? prUrlFromAction(action);
4543
+ const run = {
4544
+ id: job?.jobId || `${id}:${action.timestamp}`,
4545
+ timestamp: action.timestamp,
4546
+ action: action.type,
4547
+ status,
4548
+ ...note ? { note } : {},
4549
+ ...job?.runUrl ? { runUrl: job.runUrl } : {},
4550
+ ...prUrl ? { prUrl } : {}
4551
+ };
4552
+ const runs = [...prior?.runs ?? [], run].slice(-JOB_RUNS_MAX_ENTRIES);
4553
+ const ranAsStaff = typeof staff === "string" && staff.length > 0 ? staff : job?.persona;
4554
+ const next = {
4555
+ id,
4556
+ executable: job?.executable ?? prior?.executable ?? executable,
4557
+ ...job?.duty ?? prior?.duty ? { duty: job?.duty ?? prior?.duty } : {},
4558
+ ...ranAsStaff ?? prior?.staff ? { staff: ranAsStaff ?? prior?.staff } : {},
4559
+ ...job?.flavor ?? prior?.flavor ? { flavor: job?.flavor ?? prior?.flavor } : {},
4560
+ ...job?.schedule ?? prior?.schedule ? { schedule: job?.schedule ?? prior?.schedule } : {},
4561
+ ...typeof job?.target === "number" ? { target: job.target } : prior?.target !== void 0 ? { target: prior.target } : {},
4562
+ ...job?.why ?? prior?.reason ? { reason: job?.why ?? prior?.reason } : {},
4563
+ status,
4564
+ createdAt: prior?.createdAt ?? action.timestamp,
4565
+ updatedAt: action.timestamp,
4566
+ ...status === "succeeded" ? { completedAt: action.timestamp } : {},
4567
+ ...job?.runUrl ?? prior?.runUrl ? { runUrl: job?.runUrl ?? prior?.runUrl } : {},
4568
+ ...prUrl ?? prior?.prUrl ? { prUrl: prUrl ?? prior?.prUrl } : {},
4569
+ runs
4570
+ };
4571
+ return { ...jobs, [id]: next };
4572
+ }
4532
4573
  function statusFromAction(action) {
4533
4574
  if (/FAILED$|ERROR$|MISSING$|REJECTED$/i.test(action.type)) return "failed";
4534
4575
  if (/COMPLETED$|SHIPPED$|MERGED$|SUCCESS$/i.test(action.type)) return "succeeded";
@@ -4545,6 +4586,46 @@ function noteFromAction(action) {
4545
4586
  if (typeof p?.commitMessage === "string") return p.commitMessage.slice(0, 120);
4546
4587
  return void 0;
4547
4588
  }
4589
+ function prUrlFromAction(action) {
4590
+ const p = action.payload;
4591
+ return typeof p?.prUrl === "string" ? p.prUrl : void 0;
4592
+ }
4593
+ function normalizeJobs(input) {
4594
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
4595
+ const out = {};
4596
+ for (const [key, value] of Object.entries(input)) {
4597
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
4598
+ const raw = value;
4599
+ if (typeof raw.id !== "string" || typeof raw.executable !== "string") continue;
4600
+ if (!isStatus(raw.status)) continue;
4601
+ out[key] = {
4602
+ id: raw.id,
4603
+ executable: raw.executable,
4604
+ ...typeof raw.duty === "string" ? { duty: raw.duty } : {},
4605
+ ...typeof raw.staff === "string" ? { staff: raw.staff } : {},
4606
+ ...raw.flavor === "instant" || raw.flavor === "scheduled" ? { flavor: raw.flavor } : {},
4607
+ ...typeof raw.schedule === "string" ? { schedule: raw.schedule } : {},
4608
+ ...typeof raw.target === "number" ? { target: raw.target } : {},
4609
+ ...typeof raw.reason === "string" ? { reason: raw.reason } : {},
4610
+ status: raw.status,
4611
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
4612
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : "",
4613
+ ...typeof raw.completedAt === "string" ? { completedAt: raw.completedAt } : {},
4614
+ ...typeof raw.runUrl === "string" ? { runUrl: raw.runUrl } : {},
4615
+ ...typeof raw.prUrl === "string" ? { prUrl: raw.prUrl } : {},
4616
+ runs: Array.isArray(raw.runs) ? raw.runs.filter(isTaskJobRun).slice(-JOB_RUNS_MAX_ENTRIES) : []
4617
+ };
4618
+ }
4619
+ return out;
4620
+ }
4621
+ function isTaskJobRun(input) {
4622
+ if (!input || typeof input !== "object" || Array.isArray(input)) return false;
4623
+ const run = input;
4624
+ return typeof run.id === "string" && typeof run.timestamp === "string" && typeof run.action === "string" && isStatus(run.status);
4625
+ }
4626
+ function isStatus(input) {
4627
+ return input === "pending" || input === "running" || input === "succeeded" || input === "failed";
4628
+ }
4548
4629
  function renderStateComment(state) {
4549
4630
  const lines = [];
4550
4631
  lines.push("## \u{1F4CB} kody task state");
@@ -4566,6 +4647,11 @@ function renderStateComment(state) {
4566
4647
  if (attempts) lines.push(`- **Attempts:** ${attempts}`);
4567
4648
  if (state.core.prUrl) lines.push(`- **PR:** ${state.core.prUrl}`);
4568
4649
  if (state.core.runUrl) lines.push(`- **Run:** ${state.core.runUrl}`);
4650
+ const jobEntries = Object.values(state.jobs ?? {});
4651
+ if (jobEntries.length > 0) {
4652
+ const completed = jobEntries.filter((j) => j.status === "succeeded").length;
4653
+ lines.push(`- **Jobs:** ${completed}/${jobEntries.length} complete`);
4654
+ }
4569
4655
  const artifactNames = Object.keys(state.artifacts ?? {});
4570
4656
  if (artifactNames.length > 0) {
4571
4657
  lines.push(`- **Artifacts:** ${artifactNames.map((n) => `\`${n}\``).join(", ")}`);
@@ -4581,6 +4667,14 @@ function renderStateComment(state) {
4581
4667
  }
4582
4668
  lines.push("");
4583
4669
  }
4670
+ if (jobEntries.length > 0) {
4671
+ lines.push("### Jobs");
4672
+ lines.push("");
4673
+ for (const job of jobEntries) {
4674
+ lines.push(`- \`${job.id}\` **${job.executable}** \u2192 \`${job.status}\` (${job.runs.length} runs)`);
4675
+ }
4676
+ lines.push("");
4677
+ }
4584
4678
  lines.push("<details>");
4585
4679
  lines.push("<summary>Raw state (JSON)</summary>");
4586
4680
  lines.push("");
@@ -4593,6 +4687,7 @@ function renderStateComment(state) {
4593
4687
  schemaVersion: state.schemaVersion,
4594
4688
  core: state.core,
4595
4689
  artifacts: state.artifacts ?? {},
4690
+ jobs: state.jobs ?? {},
4596
4691
  executables: state.executables,
4597
4692
  history: state.history,
4598
4693
  ...state.flow ? { flow: state.flow } : {}
@@ -4906,19 +5001,7 @@ function readContainerState(ctx, child, reader) {
4906
5001
  if (cached2 && typeof cached2 === "object") {
4907
5002
  return cached2;
4908
5003
  }
4909
- return {
4910
- schemaVersion: 1,
4911
- core: {
4912
- phase: "idle",
4913
- status: "pending",
4914
- currentExecutable: null,
4915
- lastOutcome: null,
4916
- attempts: {}
4917
- },
4918
- executables: {},
4919
- artifacts: {},
4920
- history: []
4921
- };
5004
+ return emptyState();
4922
5005
  }
4923
5006
  function parsePrNumber2(url) {
4924
5007
  const m = url.match(/\/pull\/(\d+)(?:[/?#]|$)/);
@@ -5560,10 +5643,16 @@ import { execFileSync as execFileSync7 } from "child_process";
5560
5643
  // src/scripts/saveTaskState.ts
5561
5644
  function jobMetaFromData(data) {
5562
5645
  return {
5646
+ jobKey: typeof data.jobKey === "string" ? data.jobKey : void 0,
5563
5647
  jobId: typeof data.jobId === "string" ? data.jobId : void 0,
5564
5648
  flavor: typeof data.jobFlavor === "string" ? data.jobFlavor : void 0,
5565
5649
  schedule: typeof data.jobSchedule === "string" ? data.jobSchedule : void 0,
5566
- runUrl: typeof data.runUrl === "string" ? data.runUrl : void 0
5650
+ runUrl: typeof data.runUrl === "string" ? data.runUrl : void 0,
5651
+ duty: typeof data.jobDuty === "string" ? data.jobDuty : void 0,
5652
+ executable: typeof data.jobExecutable === "string" ? data.jobExecutable : void 0,
5653
+ target: typeof data.jobTarget === "number" ? data.jobTarget : void 0,
5654
+ persona: typeof data.jobPersona === "string" ? data.jobPersona : void 0,
5655
+ why: typeof data.jobWhy === "string" ? data.jobWhy : void 0
5567
5656
  };
5568
5657
  }
5569
5658
  var saveTaskState = async (ctx, profile) => {
@@ -5573,7 +5662,10 @@ var saveTaskState = async (ctx, profile) => {
5573
5662
  if (!target || !number || !state) return;
5574
5663
  const executable = profile.name;
5575
5664
  const action = ctx.data.action ?? synthesizeAction(ctx);
5576
- const next = reduce(state, executable, action, profile.phase, profile.staff, jobMetaFromData(ctx.data));
5665
+ const next = reduce(state, executable, action, profile.phase, profile.staff, {
5666
+ ...jobMetaFromData(ctx.data),
5667
+ ...ctx.output.prUrl ? { prUrl: ctx.output.prUrl } : {}
5668
+ });
5577
5669
  if (ctx.output.prUrl) next.core.prUrl = ctx.output.prUrl;
5578
5670
  if (typeof ctx.data.runUrl === "string") next.core.runUrl = ctx.data.runUrl;
5579
5671
  writeTaskState(target, number, next, ctx.cwd);
@@ -6289,7 +6381,19 @@ var composePrompt = async (ctx, profile) => {
6289
6381
  repoOwner: ctx.config.github.owner,
6290
6382
  repoName: ctx.config.github.repo,
6291
6383
  defaultBranch: ctx.config.git.defaultBranch,
6292
- branch: ctx.data.branch ?? ""
6384
+ branch: ctx.data.branch ?? "",
6385
+ // The `{{dutyReference}}` block is built from ctx.data.* (with legacy
6386
+ // jobSlug/jobTitle/workerSlug/jobSchedule fallbacks) so a duty prompt can
6387
+ // place a labeled summary at the top. The five underlying tokens are
6388
+ // also exposed individually so a template can compose them differently
6389
+ // (e.g. put the executable slug inline in a header).
6390
+ dutyReference: formatDutyReference(ctx.data, profile.name),
6391
+ dutySlug: pickToken(ctx.data, "dutySlug", "jobSlug"),
6392
+ dutyTitle: pickToken(ctx.data, "dutyTitle", "jobTitle"),
6393
+ executableSlug: pickToken(ctx.data, "executableSlug") || profile.name,
6394
+ staffSlug: pickToken(ctx.data, "staffSlug", "workerSlug"),
6395
+ staffTitle: pickToken(ctx.data, "staffTitle", "workerTitle"),
6396
+ dutySchedule: pickToken(ctx.data, "dutySchedule", "jobSchedule")
6293
6397
  };
6294
6398
  ctx.data.prompt = template.replace(MUSTACHE, (_, key) => {
6295
6399
  const value = tokens[key] ?? "";
@@ -6354,6 +6458,39 @@ function formatToolsUsage(profile) {
6354
6458
  }
6355
6459
  return lines.join("\n");
6356
6460
  }
6461
+ function formatDutyReference(data, profileName) {
6462
+ const dutySlug = pickToken(data, "dutySlug", "jobSlug");
6463
+ const dutyTitle = pickToken(data, "dutyTitle", "jobTitle");
6464
+ const executableSlug = pickToken(data, "executableSlug") || profileName;
6465
+ const staffSlug = pickToken(data, "staffSlug", "workerSlug");
6466
+ const staffTitle = pickToken(data, "staffTitle", "workerTitle");
6467
+ const dutySchedule = pickToken(data, "dutySchedule", "jobSchedule");
6468
+ const lines = ["# Duty reference", ""];
6469
+ if (dutySlug) {
6470
+ lines.push(`- Duty: \`${dutySlug}\`${dutyTitle ? ` \u2014 *${dutyTitle}*` : ""}`);
6471
+ }
6472
+ if (executableSlug) {
6473
+ lines.push(`- Executable: \`${executableSlug}\``);
6474
+ }
6475
+ const staffLine = staffSlug ? `\`${staffSlug}\`${staffTitle && staffTitle !== staffSlug ? ` \u2014 *${staffTitle}*` : ""}` : "";
6476
+ if (staffLine) {
6477
+ lines.push(`- Staff: ${staffLine}`);
6478
+ }
6479
+ if (dutySchedule) {
6480
+ lines.push(`- Cadence: \`${dutySchedule}\``);
6481
+ }
6482
+ if (lines.length === 2) {
6483
+ return "";
6484
+ }
6485
+ return lines.join("\n");
6486
+ }
6487
+ function pickToken(data, ...keys) {
6488
+ for (const k of keys) {
6489
+ const v = data[k];
6490
+ if (typeof v === "string" && v.length > 0) return v;
6491
+ }
6492
+ return "";
6493
+ }
6357
6494
 
6358
6495
  // src/scripts/createQaGoal.ts
6359
6496
  init_issue();
@@ -7654,16 +7791,32 @@ ${stateBody}`;
7654
7791
  ctx.output.nextDispatch = { executable: classification, cliArgs };
7655
7792
  };
7656
7793
 
7657
- // src/scripts/dispatchJobFileTicks.ts
7794
+ // src/scripts/dispatchDutyFileTicks.ts
7658
7795
  import * as fs29 from "fs";
7659
7796
  import * as path26 from "path";
7660
7797
 
7661
7798
  // src/job.ts
7662
7799
  var DEFAULT_INSTANT_PERSONA = "kody";
7800
+ var localJobSeq = 0;
7663
7801
  function newJobId(flavor) {
7664
7802
  const runId = process.env.GITHUB_RUN_ID;
7665
7803
  if (runId) return `gh-${runId}-${process.env.GITHUB_RUN_ATTEMPT ?? "1"}`;
7666
- return `${flavor}-${Date.now()}`;
7804
+ localJobSeq += 1;
7805
+ return `${flavor}-${Date.now()}-${localJobSeq}`;
7806
+ }
7807
+ function stableJobKey(job) {
7808
+ const executable = job.executable ?? job.duty ?? "unknown";
7809
+ if (job.flavor === "scheduled" && job.duty) return `scheduled:${job.duty}:${executable}`;
7810
+ const target = typeof job.target === "number" ? job.target : targetFromCliArgs(job.cliArgs);
7811
+ return target === void 0 ? `${job.flavor}:${executable}` : `${job.flavor}:${executable}:${target}`;
7812
+ }
7813
+ function targetFromCliArgs(cliArgs) {
7814
+ if (!cliArgs) return void 0;
7815
+ for (const key of ["issue", "pr", "target", "issue_number"]) {
7816
+ const value = cliArgs[key];
7817
+ if (typeof value === "number" && Number.isFinite(value)) return value;
7818
+ }
7819
+ return void 0;
7667
7820
  }
7668
7821
  var InvalidJobError = class extends Error {
7669
7822
  constructor(message) {
@@ -7705,7 +7858,9 @@ async function runJob(job, base) {
7705
7858
  }
7706
7859
  const preloadedData = {};
7707
7860
  preloadedData.jobId = newJobId(valid.flavor);
7861
+ preloadedData.jobKey = stableJobKey(valid);
7708
7862
  preloadedData.jobFlavor = valid.flavor;
7863
+ if (valid.target !== void 0) preloadedData.jobTarget = valid.target;
7709
7864
  if (valid.duty !== void 0 && valid.duty.length > 0) preloadedData.jobDuty = valid.duty;
7710
7865
  if (valid.executable !== void 0 && valid.executable.length > 0) preloadedData.jobExecutable = valid.executable;
7711
7866
  if (valid.schedule !== void 0 && valid.schedule.length > 0) preloadedData.jobSchedule = valid.schedule;
@@ -8200,16 +8355,16 @@ function resolveBackend(opts) {
8200
8355
  }
8201
8356
  }
8202
8357
 
8203
- // src/scripts/dispatchJobFileTicks.ts
8204
- var dispatchJobFileTicks = async (ctx, _profile, args) => {
8358
+ // src/scripts/dispatchDutyFileTicks.ts
8359
+ var dispatchDutyFileTicks = async (ctx, _profile, args) => {
8205
8360
  ctx.skipAgent = true;
8206
8361
  const targetExecutable = String(args?.targetExecutable ?? "");
8207
8362
  if (!targetExecutable) {
8208
- throw new Error("dispatchJobFileTicks: `with.targetExecutable` is required");
8363
+ throw new Error("dispatchDutyFileTicks: `with.targetExecutable` is required");
8209
8364
  }
8210
8365
  const jobsDir = String(args?.jobsDir ?? ".kody/duties");
8211
- const scriptedExecutable = String(args?.scriptedExecutable ?? "job-tick-scripted");
8212
- const slugArg = String(args?.slugArg ?? "job");
8366
+ const scriptedExecutable = String(args?.scriptedExecutable ?? "duty-tick-scripted");
8367
+ const slugArg = String(args?.slugArg ?? "duty");
8213
8368
  const backend = resolveBackend({ config: ctx.config, cwd: ctx.cwd, jobsDir });
8214
8369
  if (backend.hydrate) {
8215
8370
  await backend.hydrate();
@@ -8422,14 +8577,14 @@ async function stampFired(backend, slug, now) {
8422
8577
  }
8423
8578
  }
8424
8579
 
8425
- // src/scripts/dispatchJobTicks.ts
8580
+ // src/scripts/dispatchDutyTicks.ts
8426
8581
  init_issue();
8427
- var dispatchJobTicks = async (ctx, _profile, args) => {
8582
+ var dispatchDutyTicks = async (ctx, _profile, args) => {
8428
8583
  ctx.skipAgent = true;
8429
8584
  const label = String(args?.label ?? "");
8430
8585
  const targetExecutable = String(args?.targetExecutable ?? "");
8431
- if (!label) throw new Error("dispatchJobTicks: `with.label` is required");
8432
- if (!targetExecutable) throw new Error("dispatchJobTicks: `with.targetExecutable` is required");
8586
+ if (!label) throw new Error("dispatchDutyTicks: `with.label` is required");
8587
+ if (!targetExecutable) throw new Error("dispatchDutyTicks: `with.targetExecutable` is required");
8433
8588
  const issueArg = String(args?.issueArg ?? "issue");
8434
8589
  const issues = listIssuesByLabel(label, ctx.cwd);
8435
8590
  ctx.data.jobIssueCount = issues.length;
@@ -9761,6 +9916,12 @@ var loadDutyState = async (ctx, profile, args) => {
9761
9916
  ctx.data.jobSlug = slug;
9762
9917
  ctx.data.jobState = loaded;
9763
9918
  ctx.data.jobStateJson = JSON.stringify(loaded.state, null, 2);
9919
+ ctx.data.dutySlug = slug;
9920
+ ctx.data.dutyTitle = profile.describe;
9921
+ ctx.data.executableSlug = profile.executable ?? profile.name;
9922
+ ctx.data.staffSlug = profile.staff ?? "";
9923
+ ctx.data.staffTitle = "";
9924
+ ctx.data.dutySchedule = profile.every ?? profile.schedule ?? "";
9764
9925
  const mentions = (profile.mentions ?? []).map((l) => `@${l}`).join(" ");
9765
9926
  ctx.data.mentions = mentions;
9766
9927
  const declaredTools = profile.dutyTools ?? [];
@@ -9931,6 +10092,12 @@ var loadJobFromFile = async (ctx, profile, args) => {
9931
10092
  ctx.data.workerTitle = workerTitle;
9932
10093
  ctx.data.workerPersona = workerPersona;
9933
10094
  ctx.data.mentions = mentions;
10095
+ ctx.data.dutySlug = slug;
10096
+ ctx.data.dutyTitle = title;
10097
+ ctx.data.staffSlug = workerSlug;
10098
+ ctx.data.staffTitle = workerTitle;
10099
+ ctx.data.executableSlug = profile.name;
10100
+ ctx.data.dutySchedule = "";
9934
10101
  const declaredTools = frontmatter.tools ?? [];
9935
10102
  if (declaredTools.length > 0) {
9936
10103
  const unknown = declaredTools.filter((name) => !DUTY_TOOL_PALETTE2.has(name));
@@ -10697,6 +10864,10 @@ var parseIssueStateFromAgentResult = async (ctx, _profile, agentResult, args) =>
10697
10864
  };
10698
10865
 
10699
10866
  // src/scripts/parseJobStateFromAgentResult.ts
10867
+ var DUTY_NEXT_STATE_FENCE_ALIASES = {
10868
+ "kody-job-next-state": "kody-duty-next-state",
10869
+ "kody-duty-next-state": "kody-job-next-state"
10870
+ };
10700
10871
  var parseJobStateFromAgentResult = async (ctx, _profile, agentResult, args) => {
10701
10872
  const fenceLabel = String(args?.fenceLabel ?? "");
10702
10873
  if (!fenceLabel) {
@@ -10719,7 +10890,16 @@ var parseJobStateFromAgentResult = async (ctx, _profile, agentResult, args) => {
10719
10890
  };
10720
10891
  return;
10721
10892
  }
10722
- const result = extractNextStateFromText(agentResult.finalText, fenceLabel, prevRev);
10893
+ let result = extractNextStateFromText(agentResult.finalText, fenceLabel, prevRev);
10894
+ if (result.error?.startsWith("missing `")) {
10895
+ const alias = DUTY_NEXT_STATE_FENCE_ALIASES[fenceLabel];
10896
+ if (alias) {
10897
+ const aliasResult = extractNextStateFromText(agentResult.finalText, alias, prevRev);
10898
+ if (!aliasResult.error?.startsWith("missing `")) {
10899
+ result = aliasResult;
10900
+ }
10901
+ }
10902
+ }
10723
10903
  if (result.error) {
10724
10904
  const cleanFinishNoBlock = result.error.startsWith("missing `") && agentResult.outcome === "completed" && loaded != null;
10725
10905
  if (cleanFinishNoBlock) {
@@ -12266,7 +12446,7 @@ var runTickScript = async (ctx, _profile, args) => {
12266
12446
  const tickScript = frontmatter.tickScript;
12267
12447
  if (!tickScript) {
12268
12448
  ctx.output.exitCode = 99;
12269
- ctx.output.reason = `runTickScript: job ${slug} has no \`tickScript:\` frontmatter \u2014 route via job-tick instead`;
12449
+ ctx.output.reason = `runTickScript: duty ${slug} has no \`tickScript:\` frontmatter \u2014 route via duty-tick instead`;
12270
12450
  return;
12271
12451
  }
12272
12452
  const scriptPath = path34.isAbsolute(tickScript) ? tickScript : path34.join(ctx.cwd, tickScript);
@@ -13249,8 +13429,8 @@ var preflightScripts = {
13249
13429
  classifyByLabel,
13250
13430
  diagMcp,
13251
13431
  warmupMcp,
13252
- dispatchJobTicks,
13253
- dispatchJobFileTicks,
13432
+ dispatchDutyTicks,
13433
+ dispatchDutyFileTicks,
13254
13434
  runTickScript,
13255
13435
  runPreviewBuild,
13256
13436
  loadGoalState,
@@ -1,7 +1,7 @@
1
1
  {
2
- "name": "job-scheduler",
2
+ "name": "duty-scheduler",
3
3
  "role": "watch",
4
- "describe": "Scheduled: for every duty file under .kody/duties/, invoke job-tick once. No agent on the scheduler itself.",
4
+ "describe": "Scheduled: for every duty file under .kody/duties/, invoke duty-tick once. No agent on the scheduler itself.",
5
5
  "kind": "scheduled",
6
6
  "schedule": "*/5 * * * *",
7
7
  "inputs": [],
@@ -36,11 +36,11 @@
36
36
  "scripts": {
37
37
  "preflight": [
38
38
  {
39
- "script": "dispatchJobFileTicks",
39
+ "script": "dispatchDutyFileTicks",
40
40
  "with": {
41
41
  "jobsDir": ".kody/duties",
42
- "targetExecutable": "job-tick",
43
- "slugArg": "job"
42
+ "targetExecutable": "duty-tick",
43
+ "slugArg": "duty"
44
44
  }
45
45
  }
46
46
  ],
@@ -1,12 +1,12 @@
1
1
  {
2
- "name": "job-tick",
2
+ "name": "duty-tick",
3
3
  "role": "primitive",
4
4
  "describe": "One classifier tick for one duty file: read intent + state, decide and execute via gh, emit next state.",
5
5
  "kind": "oneshot",
6
6
  "inputs": [
7
7
  {
8
- "name": "job",
9
- "flag": "--job",
8
+ "name": "duty",
9
+ "flag": "--duty",
10
10
  "type": "string",
11
11
  "required": true,
12
12
  "describe": "Duty slug — basename (without .md) of the file under .kody/duties/."
@@ -15,7 +15,7 @@
15
15
  "name": "force",
16
16
  "flag": "--force",
17
17
  "type": "bool",
18
- "describe": "When true, the agent ignores the job body's cadence guard and executes the work this tick. All other body rules (allowed commands, restrictions, state schema) still apply. Used for manual triggers from the dashboard's 'Run now' button."
18
+ "describe": "When true, the agent ignores the duty body's cadence guard and executes the work this tick. All other body rules (allowed commands, restrictions, state schema) still apply. Used for manual triggers from the dashboard's 'Run now' button."
19
19
  }
20
20
  ],
21
21
  "claudeCode": {
@@ -53,7 +53,7 @@
53
53
  "script": "loadJobFromFile",
54
54
  "with": {
55
55
  "jobsDir": ".kody/duties",
56
- "slugArg": "job"
56
+ "slugArg": "duty"
57
57
  }
58
58
  },
59
59
  {
@@ -0,0 +1,77 @@
1
+ {{dutyReference}}
2
+
3
+ You are **{{staffTitle}}** (staff `{{staffSlug}}`), running through **kody duty-tick** — the coordinator for one file-based duty. You do **not** touch code, do **not** commit, and do **not** edit files. You coordinate by inspecting GitHub state and issuing Kody commands as PR comments.
4
+
5
+ ## Who you are — staff persona (authoritative identity)
6
+
7
+ The duty below assigns you, staff **`{{staffSlug}}`**, as its executor. This persona defines *who* runs the duty: your authority, doctrine, voice, and hard limits. Where the persona's restrictions are stricter than the duty body, **the persona wins** — a duty can never grant you authority your staff persona withholds.
8
+
9
+ {{workerPersona}}
10
+
11
+ ## The duty
12
+
13
+ Slug **`{{dutySlug}}`** — *{{dutyTitle}}*, assigned to staff **`{{staffSlug}}`**, running on executable **`{{executableSlug}}`**. The duty body below is authoritative for *what* to do, *when* (cadence), allowed commands, and state schema. It is human-edited — re-read it every tick. Execute it **as** the persona above.
14
+
15
+ **Addressing the operator.** When the duty body tells you to @-mention the operator (e.g. the first line of an inbox recommendation), the exact handle(s) to use are: {{mentions}}. Copy that string **verbatim** — never invent, abbreviate, guess, or retype a GitHub username. A wrong handle silently fails to route to the operator's inbox, so the recommendation is lost. If the line above is blank, the duty declared no operator; post without a mention.
16
+
17
+ ### Duty body
18
+
19
+ {{jobIntent}}
20
+
21
+ ## Current state
22
+
23
+ This is the state you wrote at the end of the previous tick (or `null` if this is the first tick):
24
+
25
+ ```json
26
+ {{jobStateJson}}
27
+ ```
28
+
29
+ `cursor` is *your* enum — pick whatever labels map cleanly to your duty's phases. `data` is where you stash anything you need on the next tick (per-PR attempt counters, last-seen SHAs, etc). `done: true` is how you signal that the duty is permanently over — for evergreen duties this should always remain `false`.
30
+
31
+ ## What to do on this tick
32
+
33
+ `forceRun = {{args.force}}` — set to `true` when an operator clicked "Run now" on the dashboard. When `forceRun` is `true`, ignore the duty body's `**Cadence guard.**` paragraph (or any equivalent "skip if last run was within X" rule) and execute the work as if the guard had passed. All other body rules — allowed commands, restrictions, state schema — still apply. Force only overrides cadence.
34
+
35
+ 1. **Check `done`.** If the prior state has `done: true`, emit the same state back unchanged and exit without any action.
36
+ 2. **Re-read the duty body.** It may have changed since the last tick.
37
+ 3. **Execute exactly the work the body's `## Duty` section describes**, subject to its `## Allowed Commands` and `## Restrictions`. Use the `## State` section to interpret and update `data`.
38
+ 4. **Optionally post a short narration** wherever the duty tells you to (typically a PR comment alongside the action). Keep it terse.
39
+ 5. **Submit the new state** by calling the `submit_state` tool (see contract below). Do not include `version` or `rev` — the postflight script manages those.
40
+
41
+ ## Output contract (MANDATORY, exactly once, at the end)
42
+
43
+ Call the **`submit_state`** tool exactly once, as the final step, with your next state:
44
+
45
+ - `cursor` — your next cursor (string, e.g. `"idle"`).
46
+ - `data` — your next `data` object. Carry forward prior `data` and mutate only what you acted on this tick.
47
+ - `done` — `true` only if the duty is permanently finished; evergreen duties stay `false`.
48
+
49
+ This is the ONLY way your decision is saved. If you don't call it, the tick fails and the state is NOT updated — on the next wake you'll see the same prior state and can retry.
50
+
51
+ > Backstop (legacy): if the `submit_state` tool is unavailable, end your reply with the same JSON in a single fenced block tagged `kody-job-next-state` (or the new `kody-duty-next-state` alias) instead:
52
+ >
53
+ > ````
54
+ > ```kody-job-next-state
55
+ > { "cursor": "<next>", "data": { ... }, "done": <true|false> }
56
+ > ```
57
+ > ````
58
+
59
+ ## Rules
60
+
61
+ - Never edit, create, or delete files in the working tree.
62
+ - Never commit or push via `git`. The only permitted commit path is `gh api -X PUT` against the report file (see exception below).
63
+ - Only shell calls allowed: `gh`. Everything must go through it.
64
+ - Keep each tick focused: do one action per candidate per wake. The cron will call you again.
65
+ - If state says you're waiting on something, just check and re-emit — don't spawn a duplicate.
66
+ - Honour the duty body's `## Restrictions` over any inferred shortcut.
67
+
68
+ ### Single permitted write: the duty's report file
69
+
70
+ A duty MAY (optionally — only if its body asks for it) write a single
71
+ markdown report file at the canonical path:
72
+
73
+ ```
74
+ .kody/reports/{{dutySlug}}.md
75
+ ```
76
+
77
+ Only that exact path. Only via `gh api -X PUT /repos/<owner>/<repo>/contents/.kody/reports/{{dutySlug}}.md` (with base64 content + `sha` of the existing file when updating). All other writes — code files, other report paths, other slugs — remain forbidden. The dashboard's `/reports` page surfaces these files automatically; this is the canonical channel for a duty's diagnostic output when an issue comment isn't expressive enough.
@@ -1,4 +1,6 @@
1
- You are **{{workerTitle}}** (staff `{{workerSlug}}`), running duty **`{{jobSlug}}`** — *{{jobTitle}}* — in **locked-toolbox mode**.
1
+ {{dutyReference}}
2
+
3
+ You are **{{staffTitle}}** (staff `{{staffSlug}}`), running duty **`{{dutySlug}}`** — *{{dutyTitle}}* — in **locked-toolbox mode**.
2
4
 
3
5
  You have NO shell. You cannot run `gh`, edit files, or post raw comments. The only actions you can take this tick are the typed tools listed below, plus `submit_state` at the end. The duty body tells you *when* to use each tool; the tools themselves do the work.
4
6
 
@@ -15,7 +17,7 @@ Anything not in that list does not exist for this tick. If the duty body asks fo
15
17
 
16
18
  ## The duty
17
19
 
18
- Slug **`{{jobSlug}}`** — assigned to staff **`{{workerSlug}}`**. The body is authoritative for *what* and *when*; re-read it every tick.
20
+ Slug **`{{dutySlug}}`** — assigned to staff **`{{staffSlug}}`**, running on executable **`{{executableSlug}}`**. The body is authoritative for *what* and *when*; re-read it every tick.
19
21
 
20
22
  **Operator handle.** Where the duty refers to "the operator," the `recommend_to_operator` tool already prepends this string: `{{mentions}}`. Never type it yourself.
21
23
 
@@ -1,12 +1,12 @@
1
1
  {
2
- "name": "job-tick-scripted",
2
+ "name": "duty-tick-scripted",
3
3
  "role": "utility",
4
- "describe": "Deterministic job tick: runs the slug's `tickScript:` (declared in job frontmatter), parses next-state from its stdout, persists. No agent.",
4
+ "describe": "Deterministic duty tick: runs the slug's `tickScript:` (declared in duty frontmatter), parses next-state from its stdout, persists. No agent.",
5
5
  "kind": "oneshot",
6
6
  "inputs": [
7
7
  {
8
- "name": "job",
9
- "flag": "--job",
8
+ "name": "duty",
9
+ "flag": "--duty",
10
10
  "type": "string",
11
11
  "required": true,
12
12
  "describe": "Duty slug — basename (without .md) of the file under .kody/duties/."
@@ -15,7 +15,7 @@
15
15
  "name": "force",
16
16
  "flag": "--force",
17
17
  "type": "bool",
18
- "describe": "Accepted for parity with `job-tick`. Scripted ticks have no agent cadence guard to bypass — the dispatcher already gated on frontmatter `every:`. Forwarded to the script via env if it cares."
18
+ "describe": "Accepted for parity with `duty-tick`. Scripted ticks have no agent cadence guard to bypass — the dispatcher already gated on frontmatter `every:`. Forwarded to the script via env if it cares."
19
19
  }
20
20
  ],
21
21
  "claudeCode": {
@@ -52,7 +52,7 @@
52
52
  "script": "runTickScript",
53
53
  "with": {
54
54
  "jobsDir": ".kody/duties",
55
- "slugArg": "job",
55
+ "slugArg": "duty",
56
56
  "fenceLabel": "kody-job-next-state"
57
57
  }
58
58
  }
@@ -287,7 +287,7 @@ export interface ClaudeCodeSpec {
287
287
  /**
288
288
  * Opt-in: expose an in-process `submit_state` tool the agent calls to
289
289
  * persist its next state, instead of relying on a trailing fenced
290
- * `kody-job-next-state` block it must remember to emit. Used by job-tick.
290
+ * `kody-job-next-state` block it must remember to emit. Used by duty-tick.
291
291
  * The fenced block stays supported as a fallback. Default false.
292
292
  */
293
293
  enableSubmitTool?: boolean
@@ -439,18 +439,20 @@ export type PostflightScript = (
439
439
  export type AnyScript = PreflightScript | PostflightScript
440
440
 
441
441
  // ────────────────────────────────────────────────────────────────────────────
442
- // Job — the unified execution unit (Phase 1 of the job-model migration).
442
+ // Job — the unified work request (task-state jobs collect run attempts).
443
443
  //
444
- // A Job is the single thing the engine executes, regardless of how it was
445
- // triggered. It REFERENCES the reusable nouns — `executable` (how), `persona`
446
- // (who), `duty` (why, by slug) — and OWNS its `schedule` (when). Two flavors:
444
+ // A Job is the required work the engine tries to execute, regardless of how it
445
+ // was triggered. It REFERENCES the reusable nouns — `executable` (how),
446
+ // `persona` (who), `duty` (why, by slug) — and OWNS its `schedule` (when).
447
+ // Task state stores this durable job separately from individual run attempts.
448
+ // Two flavors:
447
449
  // - "instant" — run once now (an `@kody <verb>` comment or a manual dispatch)
448
450
  // - "scheduled" — fired on `schedule` (cron) by the tick path
449
451
  //
450
452
  // Fields are optional-heavy on purpose: a comment-minted instant job carries
451
453
  // `executable` + inline `why`; a cron-minted scheduled job carries `duty` +
452
454
  // `schedule` + `persona`. `runJob` (src/job.ts) lowers a Job onto the existing
453
- // executor; nothing mints Jobs yet this is an additive seam.
455
+ // executor and seeds both stable job metadata and per-run metadata.
454
456
  // ────────────────────────────────────────────────────────────────────────────
455
457
 
456
458
  export type JobFlavor = "instant" | "scheduled"
@@ -10,7 +10,7 @@ staff: kody
10
10
  > dashboard's `/reports` page).
11
11
  >
12
12
  > Cadence is enforced by the engine via the `every: 7d` frontmatter — this
13
- > file only fires once per 7 days regardless of how often `job-scheduler`
13
+ > file only fires once per 7 days regardless of how often `duty-scheduler`
14
14
  > wakes. No prose cadence guard needed.
15
15
 
16
16
  ## Job
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kody-ade/kody-engine",
3
- "version": "0.4.211",
3
+ "version": "0.4.212-live.0",
4
4
  "description": "kody — autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,4 +1,4 @@
1
- # Goal Manager
1
+ # Goal Manager .
2
2
 
3
3
  Autonomous manager for **manager-driven goals**. One worker, every goal:
4
4
  each tick it picks up every goal flagged `managed`, breaks it into tasks,
@@ -1,75 +0,0 @@
1
- You are **{{workerTitle}}** (worker `{{workerSlug}}`), operating through **kody job-tick** — the coordinator for one file-based job. You do **not** touch code, do **not** commit, and do **not** edit files. You coordinate by inspecting GitHub state and issuing Kody commands as PR comments.
2
-
3
- ## Who you are — worker persona (authoritative identity)
4
-
5
- The job below assigns you, worker **`{{workerSlug}}`**, as its executor. This persona defines *who* runs the job: your authority, doctrine, voice, and hard limits. Where the persona's restrictions are stricter than the job body, **the persona wins** — a job can never grant you authority your worker persona withholds.
6
-
7
- {{workerPersona}}
8
-
9
- ## The job
10
-
11
- Slug **`{{jobSlug}}`** — *{{jobTitle}}*, assigned to worker **`{{workerSlug}}`**. The job body below is authoritative for *what* to do, *when* (cadence), allowed commands, and state schema. It is human-edited — re-read it every tick. Execute it **as** the persona above.
12
-
13
- **Addressing the operator.** When the job body tells you to @-mention the operator (e.g. the first line of an inbox recommendation), the exact handle(s) to use are: {{mentions}}. Copy that string **verbatim** — never invent, abbreviate, guess, or retype a GitHub username. A wrong handle silently fails to route to the operator's inbox, so the recommendation is lost. If the line above is blank, the duty declared no operator; post without a mention.
14
-
15
- ### Job body
16
-
17
- {{jobIntent}}
18
-
19
- ## Current state
20
-
21
- This is the state you wrote at the end of the previous tick (or `null` if this is the first tick):
22
-
23
- ```json
24
- {{jobStateJson}}
25
- ```
26
-
27
- `cursor` is *your* enum — pick whatever labels map cleanly to your job's phases. `data` is where you stash anything you need on the next tick (per-PR attempt counters, last-seen SHAs, etc). `done: true` is how you signal that the job is permanently over — for evergreen jobs this should always remain `false`.
28
-
29
- ## What to do on this tick
30
-
31
- `forceRun = {{args.force}}` — set to `true` when an operator clicked "Run now" on the dashboard. When `forceRun` is `true`, ignore the job body's `**Cadence guard.**` paragraph (or any equivalent "skip if last run was within X" rule) and execute the work as if the guard had passed. All other body rules — allowed commands, restrictions, state schema — still apply. Force only overrides cadence.
32
-
33
- 1. **Check `done`.** If the prior state has `done: true`, emit the same state back unchanged and exit without any action.
34
- 2. **Re-read the job body.** It may have changed since the last tick.
35
- 3. **Execute exactly the work the body's `## Job` section describes**, subject to its `## Allowed Commands` and `## Restrictions`. Use the `## State` section to interpret and update `data`.
36
- 4. **Optionally post a short narration** wherever the job tells you to (typically a PR comment alongside the action). Keep it terse.
37
- 5. **Submit the new state** by calling the `submit_state` tool (see contract below). Do not include `version` or `rev` — the postflight script manages those.
38
-
39
- ## Output contract (MANDATORY, exactly once, at the end)
40
-
41
- Call the **`submit_state`** tool exactly once, as the final step, with your next state:
42
-
43
- - `cursor` — your next cursor (string, e.g. `"idle"`).
44
- - `data` — your next `data` object. Carry forward prior `data` and mutate only what you acted on this tick.
45
- - `done` — `true` only if the duty is permanently finished; evergreen duties stay `false`.
46
-
47
- This is the ONLY way your decision is saved. If you don't call it, the tick fails and the state is NOT updated — on the next wake you'll see the same prior state and can retry.
48
-
49
- > Backstop (legacy): if the `submit_state` tool is unavailable, end your reply with the same JSON in a single fenced block tagged `kody-job-next-state` instead:
50
- >
51
- > ````
52
- > ```kody-job-next-state
53
- > { "cursor": "<next>", "data": { ... }, "done": <true|false> }
54
- > ```
55
- > ````
56
-
57
- ## Rules
58
-
59
- - Never edit, create, or delete files in the working tree.
60
- - Never commit or push via `git`. The only permitted commit path is `gh api -X PUT` against the report file (see exception below).
61
- - Only shell calls allowed: `gh`. Everything must go through it.
62
- - Keep each tick focused: do one action per candidate per wake. The cron will call you again.
63
- - If state says you're waiting on something, just check and re-emit — don't spawn a duplicate.
64
- - Honour the job body's `## Restrictions` over any inferred shortcut.
65
-
66
- ### Single permitted write: the job's report file
67
-
68
- A job MAY (optionally — only if its body asks for it) write a single
69
- markdown report file at the canonical path:
70
-
71
- ```
72
- .kody/reports/{{jobSlug}}.md
73
- ```
74
-
75
- Only that exact path. Only via `gh api -X PUT /repos/<owner>/<repo>/contents/.kody/reports/{{jobSlug}}.md` (with base64 content + `sha` of the existing file when updating). All other writes — code files, other report paths, other slugs — remain forbidden. The dashboard's `/reports` page surfaces these files automatically; this is the canonical channel for a job's diagnostic output when an issue comment isn't expressive enough.