@kody-ade/kody-engine 0.4.174 → 0.4.177

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/kody.js CHANGED
@@ -358,141 +358,6 @@ var init_submitMcp = __esm({
358
358
  }
359
359
  });
360
360
 
361
- // src/repoWorkspace.ts
362
- import { spawn as spawn2, spawnSync } from "child_process";
363
- import * as fs6 from "fs";
364
- import * as path6 from "path";
365
- async function resolveAndClone(reposRoot, repo, repoToken, cloneRepo) {
366
- const name = repo?.trim();
367
- if (!name || !REPO_RE.test(name)) return null;
368
- const root = path6.resolve(reposRoot);
369
- const dir = path6.resolve(root, name);
370
- if (dir !== root && !dir.startsWith(root + path6.sep)) return null;
371
- if (fs6.existsSync(path6.join(dir, ".git"))) return dir;
372
- const inflight = repoClones.get(dir);
373
- if (inflight) {
374
- await inflight;
375
- return dir;
376
- }
377
- const p = cloneRepo(name, repoToken, dir).finally(() => {
378
- if (repoClones.get(dir) === p) repoClones.delete(dir);
379
- });
380
- repoClones.set(dir, p);
381
- await p;
382
- return dir;
383
- }
384
- async function ensureRepoCwd(opts) {
385
- const dir = await resolveAndClone(
386
- opts.reposRoot,
387
- opts.repo,
388
- opts.repoToken,
389
- opts.cloneRepo
390
- );
391
- return dir ?? opts.baseCwd;
392
- }
393
- async function fetchRepo(opts) {
394
- const dir = await resolveAndClone(
395
- opts.reposRoot,
396
- opts.repo,
397
- opts.repoToken,
398
- opts.cloneRepo ?? defaultCloneRepo
399
- );
400
- if (!dir) {
401
- throw new Error(
402
- `invalid repo "${opts.repo}" \u2014 expected "owner/name" with no path escapes`
403
- );
404
- }
405
- return dir;
406
- }
407
- var REPO_RE, repoClones, defaultCloneRepo;
408
- var init_repoWorkspace = __esm({
409
- "src/repoWorkspace.ts"() {
410
- "use strict";
411
- REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
412
- repoClones = /* @__PURE__ */ new Map();
413
- defaultCloneRepo = (repo, token, dir) => {
414
- fs6.mkdirSync(path6.dirname(dir), { recursive: true });
415
- const authUrl = token ? `https://x-access-token:${token}@github.com/${repo}.git` : `https://github.com/${repo}.git`;
416
- return new Promise((resolve6, reject) => {
417
- const child = spawn2("git", ["clone", "--depth=1", authUrl, dir], {
418
- stdio: "inherit"
419
- });
420
- child.on("exit", (code) => {
421
- if (code !== 0) {
422
- reject(new Error(`git clone ${repo} failed (exit ${code})`));
423
- return;
424
- }
425
- try {
426
- const name = process.env.GIT_AUTHOR_NAME ?? "Kody Bot";
427
- const email = process.env.GIT_AUTHOR_EMAIL ?? "kody-bot@users.noreply.github.com";
428
- spawnSync("git", ["-C", dir, "config", "user.name", name]);
429
- spawnSync("git", ["-C", dir, "config", "user.email", email]);
430
- } catch {
431
- }
432
- resolve6();
433
- });
434
- child.on("error", reject);
435
- });
436
- };
437
- }
438
- });
439
-
440
- // src/fetchRepoMcp.ts
441
- var fetchRepoMcp_exports = {};
442
- __export(fetchRepoMcp_exports, {
443
- buildFetchRepoMcpServer: () => buildFetchRepoMcpServer
444
- });
445
- import {
446
- createSdkMcpServer as createSdkMcpServer3,
447
- tool as tool3
448
- } from "@anthropic-ai/claude-agent-sdk";
449
- import { z as z3 } from "zod";
450
- function buildFetchRepoMcpServer(opts) {
451
- const fetchTool = tool3(
452
- "fetch_repo",
453
- 'Clone another GitHub repository into your workspace so you can read and work on it. Pass `repo` as "owner/name" (e.g. "A-Guy-educ/A-Guy"). Returns the absolute path of the clone \u2014 then use your Read/Grep/Glob/Bash tools at that path to inspect it. Already-fetched repos are reused instantly. Use this whenever the user asks about a repository other than your current one \u2014 you are NOT limited to a single repo.',
454
- {
455
- repo: z3.string().describe('GitHub repository as "owner/name", e.g. "A-Guy-educ/A-Guy".')
456
- },
457
- async (args) => {
458
- const repo = String(args.repo ?? "").trim();
459
- try {
460
- const dir = await fetchRepo({
461
- reposRoot: opts.reposRoot,
462
- repo,
463
- repoToken: opts.repoToken
464
- });
465
- return {
466
- content: [
467
- {
468
- type: "text",
469
- text: `Cloned ${repo} \u2192 ${dir}
470
- Use Read/Grep/Glob/Bash at that absolute path to explore it. It now lives in your workspace alongside any other repos you've fetched.`
471
- }
472
- ]
473
- };
474
- } catch (err) {
475
- const msg = err instanceof Error ? err.message : String(err);
476
- return {
477
- content: [{ type: "text", text: `Could not fetch ${repo}: ${msg}` }],
478
- isError: true
479
- };
480
- }
481
- }
482
- );
483
- return createSdkMcpServer3({
484
- name: "kody-fetch-repo",
485
- version: "0.1.0",
486
- tools: [fetchTool]
487
- });
488
- }
489
- var init_fetchRepoMcp = __esm({
490
- "src/fetchRepoMcp.ts"() {
491
- "use strict";
492
- init_repoWorkspace();
493
- }
494
- });
495
-
496
361
  // src/issue.ts
497
362
  import { execFileSync } from "child_process";
498
363
  function ghToken() {
@@ -676,6 +541,362 @@ var init_issue = __esm({
676
541
  }
677
542
  });
678
543
 
544
+ // src/dutyMcp.ts
545
+ var dutyMcp_exports = {};
546
+ __export(dutyMcp_exports, {
547
+ DUTY_MCP_TOOL_NAMES: () => DUTY_MCP_TOOL_NAMES,
548
+ buildDutyMcpServer: () => buildDutyMcpServer
549
+ });
550
+ import { createSdkMcpServer as createSdkMcpServer3, tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
551
+ import { z as z3 } from "zod";
552
+ function summarizeCiStatus(rollup) {
553
+ if (!Array.isArray(rollup) || rollup.length === 0) return "UNKNOWN";
554
+ let hasRunning = false;
555
+ for (const check of rollup) {
556
+ const status = String(check.status ?? "").toUpperCase();
557
+ const conclusion = String(check.conclusion ?? "").toUpperCase();
558
+ if (FAIL_CONCLUSIONS.has(conclusion)) return "FAILING";
559
+ if (!conclusion && RUNNING_STATUSES.has(status)) hasRunning = true;
560
+ }
561
+ return hasRunning ? "RUNNING" : "PASSING";
562
+ }
563
+ function computeBehindBy(repoSlug, base, head) {
564
+ try {
565
+ const raw = gh(["api", `repos/${repoSlug}/compare/${base}...${head}`, "--jq", ".behind_by"]);
566
+ const n = Number(raw.trim());
567
+ return Number.isFinite(n) ? n : -1;
568
+ } catch {
569
+ return -1;
570
+ }
571
+ }
572
+ function listRepairCandidates(repoSlug) {
573
+ const raw = gh([
574
+ "pr",
575
+ "list",
576
+ "--state",
577
+ "open",
578
+ "--limit",
579
+ "100",
580
+ "--json",
581
+ "number,title,headRefName,headRefOid,baseRefName,isDraft,mergeable,statusCheckRollup,updatedAt"
582
+ ]);
583
+ const prs = JSON.parse(raw);
584
+ return prs.filter((p) => !p.isDraft).map((p) => {
585
+ const ciStatus = summarizeCiStatus(p.statusCheckRollup);
586
+ const mergeable = String(p.mergeable ?? "UNKNOWN").toUpperCase();
587
+ const behindBy = mergeable === "CONFLICTING" || ciStatus === "FAILING" ? 0 : computeBehindBy(repoSlug, p.baseRefName, p.headRefName);
588
+ return {
589
+ number: p.number,
590
+ title: p.title,
591
+ headSha: p.headRefOid,
592
+ baseRef: p.baseRefName,
593
+ isDraft: false,
594
+ mergeable,
595
+ ciStatus,
596
+ behindBy,
597
+ updatedAt: p.updatedAt
598
+ };
599
+ });
600
+ }
601
+ function dispatchVerb(workflowFile, executable, prNumber) {
602
+ try {
603
+ gh([
604
+ "workflow",
605
+ "run",
606
+ workflowFile,
607
+ "-f",
608
+ `executable=${executable}`,
609
+ "-f",
610
+ `issue_number=${prNumber}`
611
+ ]);
612
+ return { ok: true };
613
+ } catch (err) {
614
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
615
+ }
616
+ }
617
+ function postRecommendation(prNumber, mention, message) {
618
+ const body = mention ? `${mention} ${message}` : message;
619
+ try {
620
+ gh(["pr", "comment", String(prNumber), "--body", body]);
621
+ return { ok: true };
622
+ } catch (err) {
623
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
624
+ }
625
+ }
626
+ function readLedger(label) {
627
+ const startTag = `<!-- ${label}:start -->`;
628
+ const endTag = `<!-- ${label}:end -->`;
629
+ try {
630
+ const raw = gh([
631
+ "issue",
632
+ "list",
633
+ "--state",
634
+ "open",
635
+ "--label",
636
+ label,
637
+ "--limit",
638
+ "5",
639
+ "--json",
640
+ "number,body"
641
+ ]);
642
+ const issues = JSON.parse(raw);
643
+ if (issues.length === 0) return { found: false, payload: null };
644
+ const issue = issues.sort((a, b) => a.number - b.number)[0];
645
+ const body = issue?.body ?? "";
646
+ const startIdx = body.indexOf(startTag);
647
+ const endIdx = body.indexOf(endTag);
648
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
649
+ return { found: true, issueNumber: issue?.number, payload: null };
650
+ }
651
+ const between = body.slice(startIdx + startTag.length, endIdx);
652
+ const fenceMatch = between.match(/```json\s*([\s\S]*?)```/);
653
+ if (!fenceMatch) return { found: true, issueNumber: issue?.number, payload: null };
654
+ try {
655
+ return { found: true, issueNumber: issue?.number, payload: JSON.parse(fenceMatch[1]) };
656
+ } catch {
657
+ return { found: true, issueNumber: issue?.number, payload: null };
658
+ }
659
+ } catch (err) {
660
+ return { found: false, payload: { error: err instanceof Error ? err.message : String(err) } };
661
+ }
662
+ }
663
+ function buildDutyMcpServer(opts) {
664
+ const workflowFile = opts.workflowFile ?? "kody.yml";
665
+ const listTool = tool3(
666
+ "list_prs_to_repair",
667
+ "Return open non-draft PRs with the signals you need to pick a repair: number, title, headSha, baseRef, mergeable (CONFLICTING/MERGEABLE/UNKNOWN), ciStatus (PASSING/FAILING/RUNNING/UNKNOWN), behindBy (commits behind base; 0 for PRs that already match conflicts or CI-failure rules), updatedAt. Drafts are excluded. One call returns everything \u2014 do not iterate or paginate.",
668
+ {},
669
+ async () => {
670
+ const candidates = listRepairCandidates(opts.repoSlug);
671
+ return {
672
+ content: [
673
+ {
674
+ type: "text",
675
+ text: JSON.stringify({ prs: candidates }, null, 2)
676
+ }
677
+ ]
678
+ };
679
+ }
680
+ );
681
+ const makeDispatch = (verb, describe) => tool3(
682
+ `${verb.replace("-", "_")}_pr`,
683
+ describe,
684
+ {
685
+ pr: z3.number().int().positive().describe("PR number to repair.")
686
+ },
687
+ async (args) => {
688
+ const result = dispatchVerb(workflowFile, verb, args.pr);
689
+ const text = result.ok ? `Dispatched \`${verb}\` on PR #${args.pr}. The repair runs in its own workflow_dispatch \u2014 wait for the next tick to see the new headSha.` : `Dispatch failed for \`${verb}\` on PR #${args.pr}: ${result.error}`;
690
+ return {
691
+ content: [{ type: "text", text }]
692
+ };
693
+ }
694
+ );
695
+ const syncTool = makeDispatch(
696
+ "sync",
697
+ "Bring a stale PR up to date with its base branch (merges base \u2192 head + pushes). Use when behindBy > 10 AND mergeable !== CONFLICTING AND ciStatus !== FAILING. Returns immediately \u2014 the actual merge runs in a separate workflow."
698
+ );
699
+ const fixCiTool = makeDispatch(
700
+ "fix-ci",
701
+ "Repair a PR whose CI is failing. Use when ciStatus === FAILING. The repair runs in a separate workflow."
702
+ );
703
+ const resolveTool = makeDispatch(
704
+ "resolve",
705
+ "Resolve merge conflicts on a PR. Use when mergeable === CONFLICTING. The repair runs in a separate workflow."
706
+ );
707
+ const recommendTool = tool3(
708
+ "recommend_to_operator",
709
+ "Post ONE comment on a PR with the operator @-mention prepended. Use this when a verb is NOT graduated in the trust ledger and you want the operator to confirm via the dashboard inbox. The mention handle is substituted from kody.config.json `github.operators` \u2014 do not type it yourself.",
710
+ {
711
+ pr: z3.number().int().positive().describe("PR number to comment on."),
712
+ body: z3.string().min(1).describe("Comment body (markdown). Do not include the operator mention \u2014 the engine prepends it.")
713
+ },
714
+ async (args) => {
715
+ const result = postRecommendation(args.pr, opts.operatorMention, args.body);
716
+ const text = result.ok ? `Recommendation posted on PR #${args.pr}.` : `Recommendation failed on PR #${args.pr}: ${result.error}`;
717
+ return {
718
+ content: [{ type: "text", text }]
719
+ };
720
+ }
721
+ );
722
+ const ledgerTool = tool3(
723
+ "read_ledger",
724
+ "Read the trust ledger (or any sentinel-fenced JSON manifest stored on a labeled issue). Returns `{found, issueNumber, payload}` where payload is the parsed JSON between `<!-- <label>:start -->` and `<!-- <label>:end -->` sentinels. Use `read_ledger({label: 'kody:cto-decisions'})` to look up per-verb graduation modes for the trust gate.",
725
+ {
726
+ label: z3.string().min(1).describe("GitHub issue label that identifies the manifest issue (e.g. 'kody:cto-decisions').")
727
+ },
728
+ async (args) => {
729
+ const result = readLedger(args.label);
730
+ return {
731
+ content: [
732
+ {
733
+ type: "text",
734
+ text: JSON.stringify(result, null, 2)
735
+ }
736
+ ]
737
+ };
738
+ }
739
+ );
740
+ const server = createSdkMcpServer3({
741
+ name: "kody-duty",
742
+ version: "0.1.0",
743
+ tools: [listTool, syncTool, fixCiTool, resolveTool, recommendTool, ledgerTool]
744
+ });
745
+ return { server };
746
+ }
747
+ var FAIL_CONCLUSIONS, RUNNING_STATUSES, DUTY_MCP_TOOL_NAMES;
748
+ var init_dutyMcp = __esm({
749
+ "src/dutyMcp.ts"() {
750
+ "use strict";
751
+ init_issue();
752
+ FAIL_CONCLUSIONS = /* @__PURE__ */ new Set(["FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "CANCELLED"]);
753
+ RUNNING_STATUSES = /* @__PURE__ */ new Set(["IN_PROGRESS", "QUEUED", "PENDING", "WAITING", "REQUESTED"]);
754
+ DUTY_MCP_TOOL_NAMES = [
755
+ "list_prs_to_repair",
756
+ "sync_pr",
757
+ "fix_ci_pr",
758
+ "resolve_pr",
759
+ "recommend_to_operator",
760
+ "read_ledger"
761
+ ];
762
+ }
763
+ });
764
+
765
+ // src/repoWorkspace.ts
766
+ import { spawn as spawn2, spawnSync } from "child_process";
767
+ import * as fs6 from "fs";
768
+ import * as path6 from "path";
769
+ async function resolveAndClone(reposRoot, repo, repoToken, cloneRepo) {
770
+ const name = repo?.trim();
771
+ if (!name || !REPO_RE.test(name)) return null;
772
+ const root = path6.resolve(reposRoot);
773
+ const dir = path6.resolve(root, name);
774
+ if (dir !== root && !dir.startsWith(root + path6.sep)) return null;
775
+ if (fs6.existsSync(path6.join(dir, ".git"))) return dir;
776
+ const inflight = repoClones.get(dir);
777
+ if (inflight) {
778
+ await inflight;
779
+ return dir;
780
+ }
781
+ const p = cloneRepo(name, repoToken, dir).finally(() => {
782
+ if (repoClones.get(dir) === p) repoClones.delete(dir);
783
+ });
784
+ repoClones.set(dir, p);
785
+ await p;
786
+ return dir;
787
+ }
788
+ async function ensureRepoCwd(opts) {
789
+ const dir = await resolveAndClone(
790
+ opts.reposRoot,
791
+ opts.repo,
792
+ opts.repoToken,
793
+ opts.cloneRepo
794
+ );
795
+ return dir ?? opts.baseCwd;
796
+ }
797
+ async function fetchRepo(opts) {
798
+ const dir = await resolveAndClone(
799
+ opts.reposRoot,
800
+ opts.repo,
801
+ opts.repoToken,
802
+ opts.cloneRepo ?? defaultCloneRepo
803
+ );
804
+ if (!dir) {
805
+ throw new Error(
806
+ `invalid repo "${opts.repo}" \u2014 expected "owner/name" with no path escapes`
807
+ );
808
+ }
809
+ return dir;
810
+ }
811
+ var REPO_RE, repoClones, defaultCloneRepo;
812
+ var init_repoWorkspace = __esm({
813
+ "src/repoWorkspace.ts"() {
814
+ "use strict";
815
+ REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
816
+ repoClones = /* @__PURE__ */ new Map();
817
+ defaultCloneRepo = (repo, token, dir) => {
818
+ fs6.mkdirSync(path6.dirname(dir), { recursive: true });
819
+ const authUrl = token ? `https://x-access-token:${token}@github.com/${repo}.git` : `https://github.com/${repo}.git`;
820
+ return new Promise((resolve6, reject) => {
821
+ const child = spawn2("git", ["clone", "--depth=1", authUrl, dir], {
822
+ stdio: "inherit"
823
+ });
824
+ child.on("exit", (code) => {
825
+ if (code !== 0) {
826
+ reject(new Error(`git clone ${repo} failed (exit ${code})`));
827
+ return;
828
+ }
829
+ try {
830
+ const name = process.env.GIT_AUTHOR_NAME ?? "Kody Bot";
831
+ const email = process.env.GIT_AUTHOR_EMAIL ?? "kody-bot@users.noreply.github.com";
832
+ spawnSync("git", ["-C", dir, "config", "user.name", name]);
833
+ spawnSync("git", ["-C", dir, "config", "user.email", email]);
834
+ } catch {
835
+ }
836
+ resolve6();
837
+ });
838
+ child.on("error", reject);
839
+ });
840
+ };
841
+ }
842
+ });
843
+
844
+ // src/fetchRepoMcp.ts
845
+ var fetchRepoMcp_exports = {};
846
+ __export(fetchRepoMcp_exports, {
847
+ buildFetchRepoMcpServer: () => buildFetchRepoMcpServer
848
+ });
849
+ import {
850
+ createSdkMcpServer as createSdkMcpServer4,
851
+ tool as tool4
852
+ } from "@anthropic-ai/claude-agent-sdk";
853
+ import { z as z4 } from "zod";
854
+ function buildFetchRepoMcpServer(opts) {
855
+ const fetchTool = tool4(
856
+ "fetch_repo",
857
+ 'Clone another GitHub repository into your workspace so you can read and work on it. Pass `repo` as "owner/name" (e.g. "A-Guy-educ/A-Guy"). Returns the absolute path of the clone \u2014 then use your Read/Grep/Glob/Bash tools at that path to inspect it. Already-fetched repos are reused instantly. Use this whenever the user asks about a repository other than your current one \u2014 you are NOT limited to a single repo.',
858
+ {
859
+ repo: z4.string().describe('GitHub repository as "owner/name", e.g. "A-Guy-educ/A-Guy".')
860
+ },
861
+ async (args) => {
862
+ const repo = String(args.repo ?? "").trim();
863
+ try {
864
+ const dir = await fetchRepo({
865
+ reposRoot: opts.reposRoot,
866
+ repo,
867
+ repoToken: opts.repoToken
868
+ });
869
+ return {
870
+ content: [
871
+ {
872
+ type: "text",
873
+ text: `Cloned ${repo} \u2192 ${dir}
874
+ Use Read/Grep/Glob/Bash at that absolute path to explore it. It now lives in your workspace alongside any other repos you've fetched.`
875
+ }
876
+ ]
877
+ };
878
+ } catch (err) {
879
+ const msg = err instanceof Error ? err.message : String(err);
880
+ return {
881
+ content: [{ type: "text", text: `Could not fetch ${repo}: ${msg}` }],
882
+ isError: true
883
+ };
884
+ }
885
+ }
886
+ );
887
+ return createSdkMcpServer4({
888
+ name: "kody-fetch-repo",
889
+ version: "0.1.0",
890
+ tools: [fetchTool]
891
+ });
892
+ }
893
+ var init_fetchRepoMcp = __esm({
894
+ "src/fetchRepoMcp.ts"() {
895
+ "use strict";
896
+ init_repoWorkspace();
897
+ }
898
+ });
899
+
679
900
  // src/prompt.ts
680
901
  import * as fs21 from "fs";
681
902
  import * as path20 from "path";
@@ -1088,7 +1309,7 @@ var init_loadPriorArt = __esm({
1088
1309
  // package.json
1089
1310
  var package_default = {
1090
1311
  name: "@kody-ade/kody-engine",
1091
- version: "0.4.174",
1312
+ version: "0.4.177",
1092
1313
  description: "kody \u2014 autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
1093
1314
  license: "MIT",
1094
1315
  type: "module",
@@ -1146,7 +1367,7 @@ var package_default = {
1146
1367
  // src/chat-cli.ts
1147
1368
  import { execFileSync as execFileSync29 } from "child_process";
1148
1369
  import * as fs43 from "fs";
1149
- import * as path39 from "path";
1370
+ import * as path40 from "path";
1150
1371
 
1151
1372
  // src/chat/events.ts
1152
1373
  import * as fs from "fs";
@@ -1782,6 +2003,19 @@ async function runAgent(opts) {
1782
2003
  getSubmitted = submitHandle.getSubmitted;
1783
2004
  mcpEntries.push(["kody-submit", submitHandle.server]);
1784
2005
  }
2006
+ if (opts.enableDutyTool) {
2007
+ const { buildDutyMcpServer: buildDutyMcpServer2 } = await Promise.resolve().then(() => (init_dutyMcp(), dutyMcp_exports));
2008
+ if (!opts.dutyRepoSlug) {
2009
+ throw new Error(
2010
+ "enableDutyTool requires dutyRepoSlug (owner/name) \u2014 set kody.config.json github.{owner,repo} or GITHUB_REPOSITORY env var"
2011
+ );
2012
+ }
2013
+ const dutyHandle = buildDutyMcpServer2({
2014
+ repoSlug: opts.dutyRepoSlug,
2015
+ operatorMention: opts.dutyOperatorMention ?? ""
2016
+ });
2017
+ mcpEntries.push(["kody-duty", dutyHandle.server]);
2018
+ }
1785
2019
  if (opts.enableFetchRepoTool && opts.reposRoot) {
1786
2020
  const { buildFetchRepoMcpServer: buildFetchRepoMcpServer2 } = await Promise.resolve().then(() => (init_fetchRepoMcp(), fetchRepoMcp_exports));
1787
2021
  const fetchServer = buildFetchRepoMcpServer2({
@@ -2847,7 +3081,7 @@ async function emit2(sink, type, sessionId, suffix, payload) {
2847
3081
  // src/kody-cli.ts
2848
3082
  import { execFileSync as execFileSync28 } from "child_process";
2849
3083
  import * as fs42 from "fs";
2850
- import * as path38 from "path";
3084
+ import * as path39 from "path";
2851
3085
 
2852
3086
  // src/app-auth.ts
2853
3087
  import { createSign } from "crypto";
@@ -2997,6 +3231,12 @@ var POLITE_WORDS = /* @__PURE__ */ new Set([
2997
3231
  "pls",
2998
3232
  "yo"
2999
3233
  ]);
3234
+ function primaryNumericInputName(executable) {
3235
+ const inputs = getProfileInputs(executable);
3236
+ if (!inputs) return null;
3237
+ const intInput = inputs.find((i) => i.type === "int" && i.required);
3238
+ return intInput?.name ?? null;
3239
+ }
3000
3240
  function autoDispatch(opts) {
3001
3241
  const explicit = opts?.explicit;
3002
3242
  if (explicit?.issueNumber && explicit.issueNumber > 0) {
@@ -3016,7 +3256,8 @@ function autoDispatch(opts) {
3016
3256
  if (!Number.isNaN(n) && n > 0) {
3017
3257
  const exe = String(event.inputs?.executable ?? "").trim() || "run";
3018
3258
  const base = String(event.inputs?.base ?? "").trim();
3019
- const cliArgs = { issue: n };
3259
+ const targetKey = primaryNumericInputName(exe) ?? "issue";
3260
+ const cliArgs = { [targetKey]: n };
3020
3261
  if (base) cliArgs.base = base;
3021
3262
  return { executable: exe, cliArgs, target: n };
3022
3263
  }
@@ -3257,9 +3498,9 @@ function coerceBare(spec, value) {
3257
3498
  }
3258
3499
 
3259
3500
  // src/executor.ts
3260
- import { execFileSync as execFileSync27, spawn as spawn9 } from "child_process";
3501
+ import { execFileSync as execFileSync27, spawn as spawn10 } from "child_process";
3261
3502
  import * as fs41 from "fs";
3262
- import * as path37 from "path";
3503
+ import * as path38 from "path";
3263
3504
 
3264
3505
  // src/discipline.ts
3265
3506
  var DISCIPLINE = `# Working discipline (applies to this entire task)
@@ -5494,10 +5735,10 @@ import * as fs23 from "fs";
5494
5735
  import * as path22 from "path";
5495
5736
  var VALID_STATES = /* @__PURE__ */ new Set(["active", "abandoned", "closed", "awaiting-merge", "done"]);
5496
5737
  var GoalStateError = class extends Error {
5497
- constructor(path40, message) {
5498
- super(`Invalid goal state at ${path40}:
5738
+ constructor(path41, message) {
5739
+ super(`Invalid goal state at ${path41}:
5499
5740
  ${message}`);
5500
- this.path = path40;
5741
+ this.path = path41;
5501
5742
  this.name = "GoalStateError";
5502
5743
  }
5503
5744
  path;
@@ -7116,6 +7357,9 @@ function parseFlatYaml(text) {
7116
7357
  } else if (key === "mentions") {
7117
7358
  const logins = value.split(",").map((s) => s.trim().replace(/^@/, "")).filter(Boolean);
7118
7359
  if (logins.length > 0) out.mentions = logins;
7360
+ } else if (key === "tools") {
7361
+ const names = value.split(",").map((s) => s.trim()).filter(Boolean);
7362
+ if (names.length > 0) out.tools = names;
7119
7363
  }
7120
7364
  }
7121
7365
  return out;
@@ -9076,9 +9320,11 @@ var loadIssueStateComment = async (ctx, _profile, args) => {
9076
9320
  };
9077
9321
 
9078
9322
  // src/scripts/loadJobFromFile.ts
9323
+ init_dutyMcp();
9079
9324
  import * as fs32 from "fs";
9080
9325
  import * as path30 from "path";
9081
- var loadJobFromFile = async (ctx, _profile, args) => {
9326
+ var DUTY_TOOL_PALETTE = new Set(DUTY_MCP_TOOL_NAMES);
9327
+ var loadJobFromFile = async (ctx, profile, args) => {
9082
9328
  const jobsDir = String(args?.jobsDir ?? ".kody/duties");
9083
9329
  const workersDir = String(args?.workersDir ?? ".kody/staff");
9084
9330
  const slugArg = String(args?.slugArg ?? "job");
@@ -9120,6 +9366,21 @@ var loadJobFromFile = async (ctx, _profile, args) => {
9120
9366
  ctx.data.workerTitle = workerTitle;
9121
9367
  ctx.data.workerPersona = workerPersona;
9122
9368
  ctx.data.mentions = mentions;
9369
+ const declaredTools = frontmatter.tools ?? [];
9370
+ if (declaredTools.length > 0) {
9371
+ const unknown = declaredTools.filter((name) => !DUTY_TOOL_PALETTE.has(name));
9372
+ if (unknown.length > 0) {
9373
+ throw new Error(
9374
+ `loadJobFromFile: duty '${slug}' declared tools not in the kody-duty palette: ${unknown.join(", ")}. Available: ${[...DUTY_MCP_TOOL_NAMES].join(", ")}`
9375
+ );
9376
+ }
9377
+ const mcpToolNames = declaredTools.map((name) => `mcp__kody-duty__${name}`);
9378
+ profile.claudeCode.tools = [...mcpToolNames, "mcp__kody-submit__submit_state"];
9379
+ ctx.data.dutyTools = declaredTools;
9380
+ ctx.data.dutyOperatorMention = mentions;
9381
+ ctx.data.promptTemplate = "prompts/locked.md";
9382
+ ctx.data.dutyToolsList = declaredTools.map((name) => `- \`${name}\``).join("\n");
9383
+ }
9123
9384
  };
9124
9385
  function parseJobFile(raw, slug) {
9125
9386
  let stripped = raw;
@@ -10053,8 +10314,8 @@ var FlyClient = class {
10053
10314
  get fetch() {
10054
10315
  return this.opts.fetchImpl ?? fetch;
10055
10316
  }
10056
- async call(path40, init = {}) {
10057
- const res = await this.fetch(`${FLY_API_BASE}${path40}`, {
10317
+ async call(path41, init = {}) {
10318
+ const res = await this.fetch(`${FLY_API_BASE}${path41}`, {
10058
10319
  method: init.method ?? "GET",
10059
10320
  headers: {
10060
10321
  Authorization: `Bearer ${this.opts.token}`,
@@ -10065,7 +10326,7 @@ var FlyClient = class {
10065
10326
  if (res.status === 404 && init.allow404) return null;
10066
10327
  if (!res.ok) {
10067
10328
  const text = await res.text().catch(() => "");
10068
- throw new Error(`Fly API ${res.status} on ${path40}: ${text.slice(0, 200) || res.statusText}`);
10329
+ throw new Error(`Fly API ${res.status} on ${path41}: ${text.slice(0, 200) || res.statusText}`);
10069
10330
  }
10070
10331
  if (res.status === 204) return null;
10071
10332
  const raw = await res.text();
@@ -11962,10 +12223,471 @@ var runnerServe = async (ctx) => {
11962
12223
  });
11963
12224
  };
11964
12225
 
12226
+ // src/scripts/runPreviewBuild.ts
12227
+ import { copyFile, writeFile } from "fs/promises";
12228
+ import * as path36 from "path";
12229
+ import { fileURLToPath } from "url";
12230
+
12231
+ // src/scripts/previewBuildHelpers.ts
12232
+ import { createDecipheriv as createDecipheriv2, createHash } from "crypto";
12233
+ var NEVER_PASS_TO_BUILD = /* @__PURE__ */ new Set([
12234
+ "FLY_API_TOKEN",
12235
+ "FLY_ORG_SLUG",
12236
+ "FLY_DEFAULT_REGION",
12237
+ "KODY_MASTER_KEY",
12238
+ // Preview-config knob; consumed by the dispatcher before spawn.
12239
+ "KODY_PREVIEW_BUILD_MODE"
12240
+ ]);
12241
+ function shortHash(s) {
12242
+ return createHash("sha256").update(s).digest("hex").slice(0, 6);
12243
+ }
12244
+ function previewAppName(repo, pr) {
12245
+ const [owner, name] = repo.split("/");
12246
+ if (!owner || !name) {
12247
+ throw new Error(`invalid repo "${repo}", expected "owner/name"`);
12248
+ }
12249
+ return `kp-${shortHash(owner)}-${shortHash(name)}-pr-${pr}`;
12250
+ }
12251
+ function basePreviewAppName(repo) {
12252
+ const [owner, name] = repo.split("/");
12253
+ if (!owner || !name) {
12254
+ throw new Error(`invalid repo "${repo}", expected "owner/name"`);
12255
+ }
12256
+ return `kp-${shortHash(owner)}-${shortHash(name)}-base`;
12257
+ }
12258
+ function decryptVaultPayload(payload, keyRaw) {
12259
+ const parts = payload.split(":");
12260
+ if (parts.length !== 4 || parts[0] !== "v1") {
12261
+ throw new Error("invalid vault payload format");
12262
+ }
12263
+ const [, ivB64, ctB64, tagB64] = parts;
12264
+ const key = /^[0-9a-fA-F]{64}$/.test(keyRaw) ? Buffer.from(keyRaw, "hex") : Buffer.from(keyRaw, "base64");
12265
+ if (key.length !== 32) {
12266
+ throw new Error("KODY_MASTER_KEY must decode to 32 bytes");
12267
+ }
12268
+ const iv = Buffer.from(ivB64, "base64");
12269
+ const ct = Buffer.from(ctB64, "base64");
12270
+ const tag = Buffer.from(tagB64, "base64");
12271
+ const decipher = createDecipheriv2("aes-256-gcm", key, iv);
12272
+ decipher.setAuthTag(tag);
12273
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
12274
+ }
12275
+ function buildEnvFromVault(doc) {
12276
+ const buildEnv = {};
12277
+ for (const [name, entry] of Object.entries(doc.secrets ?? {})) {
12278
+ if (!entry?.value) continue;
12279
+ if (NEVER_PASS_TO_BUILD.has(name)) continue;
12280
+ buildEnv[name] = entry.value;
12281
+ }
12282
+ const raw = doc.secrets?.KODY_PREVIEW_BUILD_MODE?.value;
12283
+ const buildMode = raw?.toLowerCase().trim() === "dev" ? "dev" : "prod";
12284
+ return { buildEnv, buildMode };
12285
+ }
12286
+ function formatPreviewComment(args) {
12287
+ const url = `https://${args.appName}.fly.dev`;
12288
+ return [
12289
+ "<!-- kody-fly-preview -->",
12290
+ `\u2705 **Preview ready:** ${url}`,
12291
+ "",
12292
+ `<sub>App: \`${args.appName}\` \xB7 Commit: \`${args.ref.slice(0, 7)}\` \xB7 Updated: ${args.nowIso}</sub>`
12293
+ ].join("\n");
12294
+ }
12295
+ function defaultImageTag(repo, ref) {
12296
+ return createHash("sha256").update(`${repo}@${ref}`).digest("hex").slice(0, 12);
12297
+ }
12298
+
12299
+ // src/scripts/previewBuildRun.ts
12300
+ import { spawn as spawn6 } from "child_process";
12301
+ async function runCmd(cmd, args, opts = {}) {
12302
+ await new Promise((resolve6, reject) => {
12303
+ const child = spawn6(cmd, args, {
12304
+ cwd: opts.cwd,
12305
+ env: { ...process.env, ...opts.env ?? {} },
12306
+ stdio: opts.input ? ["pipe", "inherit", "inherit"] : "inherit"
12307
+ });
12308
+ if (opts.input && child.stdin) {
12309
+ child.stdin.write(opts.input);
12310
+ child.stdin.end();
12311
+ }
12312
+ child.on("error", reject);
12313
+ child.on("close", (code) => {
12314
+ if (code === 0) resolve6();
12315
+ else reject(new Error(`${cmd} ${args.join(" ")} exited ${code}`));
12316
+ });
12317
+ });
12318
+ }
12319
+
12320
+ // src/scripts/runPreviewBuild.ts
12321
+ var FLY_MACHINES = "https://api.machines.dev/v1";
12322
+ var FLY_GRAPHQL = "https://api.fly.io/graphql";
12323
+ var REQ_TIMEOUT_MS = 3e4;
12324
+ function bundledDockerfilePath(mode) {
12325
+ const here = path36.dirname(fileURLToPath(import.meta.url));
12326
+ const file = mode === "dev" ? "default-Dockerfile.preview.dev" : "default-Dockerfile.preview.prod";
12327
+ return path36.join(here, "preview-build-templates", file);
12328
+ }
12329
+ function required(name) {
12330
+ const v = (process.env[name] ?? "").trim();
12331
+ if (!v) throw new Error(`${name} is required`);
12332
+ return v;
12333
+ }
12334
+ function flyHeaders(token) {
12335
+ return {
12336
+ Authorization: `Bearer ${token}`,
12337
+ "Content-Type": "application/json"
12338
+ };
12339
+ }
12340
+ async function ghJSON(url, token) {
12341
+ const res = await fetch(url, {
12342
+ headers: {
12343
+ Authorization: `Bearer ${token}`,
12344
+ Accept: "application/vnd.github+json",
12345
+ "X-GitHub-Api-Version": "2022-11-28"
12346
+ },
12347
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12348
+ });
12349
+ if (!res.ok) {
12350
+ throw new Error(`GitHub ${url}: ${res.status} ${res.statusText}`);
12351
+ }
12352
+ return await res.json();
12353
+ }
12354
+ async function fetchVaultDoc(repo, ghToken4, masterKey) {
12355
+ const meta = await ghJSON(
12356
+ `https://api.github.com/repos/${repo}/contents/.kody/secrets.enc`,
12357
+ ghToken4
12358
+ );
12359
+ const payload = Buffer.from(meta.content, "base64").toString("utf8");
12360
+ const plaintext = decryptVaultPayload(payload, masterKey);
12361
+ return JSON.parse(plaintext);
12362
+ }
12363
+ async function flyAppExists(name, token) {
12364
+ const res = await fetch(`${FLY_MACHINES}/apps/${encodeURIComponent(name)}`, {
12365
+ headers: flyHeaders(token),
12366
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12367
+ });
12368
+ if (res.status === 404) return false;
12369
+ if (!res.ok) {
12370
+ throw new Error(`appExists ${name}: ${res.status} ${res.statusText}`);
12371
+ }
12372
+ return true;
12373
+ }
12374
+ async function flyCreateApp(name, orgSlug, token) {
12375
+ const res = await fetch(`${FLY_MACHINES}/apps`, {
12376
+ method: "POST",
12377
+ headers: flyHeaders(token),
12378
+ body: JSON.stringify({ app_name: name, org_slug: orgSlug }),
12379
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12380
+ });
12381
+ if (res.status === 422) return;
12382
+ if (!res.ok) {
12383
+ const text = await res.text().catch(() => "");
12384
+ throw new Error(`createApp ${name}: ${res.status} ${text.slice(0, 200)}`);
12385
+ }
12386
+ }
12387
+ async function flyAllocateSharedIps(appName, token) {
12388
+ const mutation = `
12389
+ mutation AllocateIps($appId: ID!) {
12390
+ v4: allocateIpAddress(input: { appId: $appId, type: shared_v4 }) { ipAddress { address } }
12391
+ v6: allocateIpAddress(input: { appId: $appId, type: v6 }) { ipAddress { address } }
12392
+ }
12393
+ `;
12394
+ const res = await fetch(FLY_GRAPHQL, {
12395
+ method: "POST",
12396
+ headers: flyHeaders(token),
12397
+ body: JSON.stringify({ query: mutation, variables: { appId: appName } }),
12398
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12399
+ });
12400
+ if (!res.ok) {
12401
+ throw new Error(`allocateSharedIps ${appName}: ${res.status}`);
12402
+ }
12403
+ const data = await res.json();
12404
+ if (data.errors?.length) {
12405
+ const msgs = data.errors.map((e) => e.message).join("; ");
12406
+ if (!/already|exists/i.test(msgs)) {
12407
+ throw new Error(`allocateSharedIps: ${msgs}`);
12408
+ }
12409
+ }
12410
+ }
12411
+ async function flyListMachines(appName, token) {
12412
+ const res = await fetch(
12413
+ `${FLY_MACHINES}/apps/${encodeURIComponent(appName)}/machines`,
12414
+ {
12415
+ headers: flyHeaders(token),
12416
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12417
+ }
12418
+ );
12419
+ if (res.status === 404) return [];
12420
+ if (!res.ok) {
12421
+ throw new Error(`listMachines ${appName}: ${res.status}`);
12422
+ }
12423
+ const data = await res.json();
12424
+ return data.map((m) => ({ id: m.id, state: m.state }));
12425
+ }
12426
+ async function flyDestroyMachine(appName, machineId, token) {
12427
+ await fetch(
12428
+ `${FLY_MACHINES}/apps/${encodeURIComponent(appName)}/machines/${encodeURIComponent(machineId)}/stop`,
12429
+ {
12430
+ method: "POST",
12431
+ headers: flyHeaders(token),
12432
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12433
+ }
12434
+ ).catch(() => void 0);
12435
+ const res = await fetch(
12436
+ `${FLY_MACHINES}/apps/${encodeURIComponent(appName)}/machines/${encodeURIComponent(machineId)}?force=true`,
12437
+ {
12438
+ method: "DELETE",
12439
+ headers: flyHeaders(token),
12440
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12441
+ }
12442
+ );
12443
+ if (res.status === 404) return;
12444
+ if (!res.ok) {
12445
+ throw new Error(`destroyMachine ${machineId}: ${res.status}`);
12446
+ }
12447
+ }
12448
+ async function flyCreatePreviewMachine(args, token) {
12449
+ const body = {
12450
+ region: args.region,
12451
+ config: {
12452
+ image: args.image,
12453
+ env: args.env,
12454
+ auto_destroy: false,
12455
+ restart: { policy: "always" },
12456
+ // 4 GB / 2 CPU is the floor that compiles A-Guy-class pages
12457
+ // without OOM when something forces a runtime recompile.
12458
+ guest: { cpu_kind: "shared", cpus: 2, memory_mb: 4096 },
12459
+ services: [
12460
+ {
12461
+ ports: [
12462
+ { port: 443, handlers: ["tls", "http"], force_https: false },
12463
+ { port: 80, handlers: ["http"] }
12464
+ ],
12465
+ protocol: "tcp",
12466
+ internal_port: 8080,
12467
+ auto_stop_machines: "suspend",
12468
+ auto_start_machines: true,
12469
+ min_machines_running: 0
12470
+ }
12471
+ ],
12472
+ checks: {
12473
+ httpget: {
12474
+ type: "http",
12475
+ port: 8080,
12476
+ method: "GET",
12477
+ path: "/",
12478
+ interval: "15s",
12479
+ timeout: "10s",
12480
+ grace_period: "30s"
12481
+ }
12482
+ }
12483
+ }
12484
+ };
12485
+ let lastErr = null;
12486
+ for (let attempt = 0; attempt < 6; attempt++) {
12487
+ const res = await fetch(
12488
+ `${FLY_MACHINES}/apps/${encodeURIComponent(args.appName)}/machines`,
12489
+ {
12490
+ method: "POST",
12491
+ headers: flyHeaders(token),
12492
+ body: JSON.stringify(body),
12493
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12494
+ }
12495
+ );
12496
+ if (res.ok) {
12497
+ const { id } = await res.json();
12498
+ return id;
12499
+ }
12500
+ const text = await res.text().catch(() => "");
12501
+ lastErr = new Error(
12502
+ `createPreviewMachine ${res.status}: ${text.slice(0, 300)}`
12503
+ );
12504
+ if (!/MANIFEST_UNKNOWN|manifest unknown/i.test(text)) break;
12505
+ await new Promise((r) => setTimeout(r, 2e3 * (attempt + 1)));
12506
+ }
12507
+ throw lastErr ?? new Error("createPreviewMachine failed (unknown)");
12508
+ }
12509
+ async function postOrUpdatePreviewComment(args) {
12510
+ const MARKER = "<!-- kody-fly-preview -->";
12511
+ const base = `https://api.github.com/repos/${args.repo}/issues/${args.pr}/comments`;
12512
+ const headers = {
12513
+ Authorization: `Bearer ${args.token}`,
12514
+ Accept: "application/vnd.github+json",
12515
+ "X-GitHub-Api-Version": "2022-11-28",
12516
+ "Content-Type": "application/json"
12517
+ };
12518
+ const listRes = await fetch(`${base}?per_page=100`, {
12519
+ headers,
12520
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12521
+ }).catch(() => null);
12522
+ let existingId = null;
12523
+ if (listRes && listRes.ok) {
12524
+ const comments = await listRes.json().catch(() => []);
12525
+ const hit = comments.find((c) => (c.body ?? "").includes(MARKER));
12526
+ if (hit) existingId = hit.id;
12527
+ }
12528
+ if (existingId) {
12529
+ await fetch(
12530
+ `https://api.github.com/repos/${args.repo}/issues/comments/${existingId}`,
12531
+ {
12532
+ method: "PATCH",
12533
+ headers,
12534
+ body: JSON.stringify({ body: args.body }),
12535
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12536
+ }
12537
+ );
12538
+ return;
12539
+ }
12540
+ await fetch(base, {
12541
+ method: "POST",
12542
+ headers,
12543
+ body: JSON.stringify({ body: args.body }),
12544
+ signal: AbortSignal.timeout(REQ_TIMEOUT_MS)
12545
+ });
12546
+ }
12547
+ var runPreviewBuild = async (ctx, _profile, _args) => {
12548
+ ctx.skipAgent = true;
12549
+ const pr = Number(ctx.args.pr);
12550
+ if (!Number.isFinite(pr) || pr <= 0) {
12551
+ ctx.output.exitCode = 99;
12552
+ ctx.output.reason = `runPreviewBuild: invalid pr arg "${ctx.args.pr}"`;
12553
+ return;
12554
+ }
12555
+ let repo;
12556
+ let ref;
12557
+ let masterKey;
12558
+ let ghToken4;
12559
+ try {
12560
+ repo = required("GITHUB_REPOSITORY");
12561
+ ref = required("GITHUB_SHA");
12562
+ masterKey = required("KODY_MASTER_KEY");
12563
+ ghToken4 = required("GITHUB_TOKEN");
12564
+ } catch (err) {
12565
+ ctx.output.exitCode = 99;
12566
+ ctx.output.reason = `runPreviewBuild: ${err instanceof Error ? err.message : String(err)}`;
12567
+ return;
12568
+ }
12569
+ const ghcrOwner = process.env.KODY_PREVIEW_GHCR_OWNER?.trim() || "";
12570
+ const appName = previewAppName(repo, pr);
12571
+ const tag = defaultImageTag(repo, ref);
12572
+ try {
12573
+ const doc = await fetchVaultDoc(repo, ghToken4, masterKey);
12574
+ const { buildEnv, buildMode } = buildEnvFromVault(doc);
12575
+ const flyToken = doc.secrets?.FLY_API_TOKEN?.value?.trim();
12576
+ if (!flyToken) {
12577
+ ctx.output.exitCode = 99;
12578
+ ctx.output.reason = "runPreviewBuild: vault has no FLY_API_TOKEN \u2014 add it via the dashboard's /secrets page";
12579
+ return;
12580
+ }
12581
+ const orgSlug = doc.secrets?.FLY_ORG_SLUG?.value?.trim() || (process.env.FLY_ORG_SLUG ?? "personal").trim();
12582
+ const region = doc.secrets?.FLY_DEFAULT_REGION?.value?.trim() || (process.env.FLY_REGION ?? "fra").trim();
12583
+ console.log(
12584
+ `[preview-build] vault: ${Object.keys(buildEnv).length} secrets, mode=${buildMode}`
12585
+ );
12586
+ if (Object.keys(buildEnv).length > 0) {
12587
+ const lines = Object.entries(buildEnv).map(
12588
+ ([k, v]) => `${k}=${JSON.stringify(v)}`
12589
+ );
12590
+ await writeFile(
12591
+ path36.join(ctx.cwd, ".env.production.local"),
12592
+ lines.join("\n") + "\n",
12593
+ "utf8"
12594
+ );
12595
+ }
12596
+ const consumerDockerfile = path36.join(ctx.cwd, "Dockerfile.preview");
12597
+ try {
12598
+ await copyFile(bundledDockerfilePath(buildMode), consumerDockerfile);
12599
+ console.log(`[preview-build] using bundled Dockerfile.preview.${buildMode}`);
12600
+ } catch (err) {
12601
+ console.warn(
12602
+ `[preview-build] failed to drop bundled Dockerfile: ${err instanceof Error ? err.message : err}`
12603
+ );
12604
+ }
12605
+ let baseImage = null;
12606
+ if (ghcrOwner) {
12607
+ const baseRef = `${ghcrOwner.toLowerCase()}/${basePreviewAppName(repo)}`;
12608
+ const tok = await fetch(
12609
+ `https://ghcr.io/token?scope=repository:${baseRef}:pull&service=ghcr.io`,
12610
+ { signal: AbortSignal.timeout(15e3) }
12611
+ ).catch(() => null);
12612
+ if (tok?.ok) {
12613
+ const { token: bearer } = await tok.json();
12614
+ const probe = await fetch(`https://ghcr.io/v2/${baseRef}/manifests/latest`, {
12615
+ method: "HEAD",
12616
+ headers: {
12617
+ Authorization: `Bearer ${bearer}`,
12618
+ Accept: "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"
12619
+ },
12620
+ signal: AbortSignal.timeout(15e3)
12621
+ }).catch(() => null);
12622
+ if (probe?.status === 200) {
12623
+ baseImage = `ghcr.io/${baseRef}:latest`;
12624
+ console.log(`[preview-build] inheriting from base ${baseImage}`);
12625
+ }
12626
+ }
12627
+ }
12628
+ if (!await flyAppExists(appName, flyToken)) {
12629
+ await flyCreateApp(appName, orgSlug, flyToken);
12630
+ }
12631
+ await flyAllocateSharedIps(appName, flyToken);
12632
+ await runCmd(
12633
+ "docker",
12634
+ ["login", "registry.fly.io", "-u", "x", "--password-stdin"],
12635
+ { input: flyToken, cwd: ctx.cwd }
12636
+ );
12637
+ const buildArgs = [
12638
+ "build",
12639
+ "-f",
12640
+ "Dockerfile.preview",
12641
+ "-t",
12642
+ `registry.fly.io/${appName}:${tag}`
12643
+ ];
12644
+ if (baseImage) buildArgs.push("--build-arg", `BASE_IMAGE=${baseImage}`);
12645
+ buildArgs.push(".");
12646
+ await runCmd("docker", buildArgs, {
12647
+ cwd: ctx.cwd,
12648
+ env: { DOCKER_BUILDKIT: "1" }
12649
+ });
12650
+ await runCmd("docker", ["push", `registry.fly.io/${appName}:${tag}`], {
12651
+ cwd: ctx.cwd
12652
+ });
12653
+ const stale = await flyListMachines(appName, flyToken);
12654
+ for (const m of stale) {
12655
+ await flyDestroyMachine(appName, m.id, flyToken).catch(() => void 0);
12656
+ }
12657
+ const machineId = await flyCreatePreviewMachine(
12658
+ {
12659
+ appName,
12660
+ region,
12661
+ image: `registry.fly.io/${appName}:${tag}`,
12662
+ env: buildEnv
12663
+ },
12664
+ flyToken
12665
+ );
12666
+ console.log(
12667
+ `[preview-build] done \u2014 machine ${machineId} at https://${appName}.fly.dev`
12668
+ );
12669
+ await postOrUpdatePreviewComment({
12670
+ repo,
12671
+ pr,
12672
+ body: formatPreviewComment({
12673
+ appName,
12674
+ ref,
12675
+ nowIso: (/* @__PURE__ */ new Date()).toISOString()
12676
+ }),
12677
+ token: ghToken4
12678
+ });
12679
+ } catch (err) {
12680
+ ctx.output.exitCode = 1;
12681
+ ctx.output.reason = `runPreviewBuild: ${err instanceof Error ? err.message : String(err)}`;
12682
+ console.error("[preview-build] failed:", err);
12683
+ return;
12684
+ }
12685
+ };
12686
+
11965
12687
  // src/scripts/runTickScript.ts
11966
12688
  import { spawnSync as spawnSync2 } from "child_process";
11967
12689
  import * as fs39 from "fs";
11968
- import * as path36 from "path";
12690
+ import * as path37 from "path";
11969
12691
  var runTickScript = async (ctx, _profile, args) => {
11970
12692
  ctx.skipAgent = true;
11971
12693
  const jobsDir = String(args?.jobsDir ?? ".kody/duties");
@@ -11977,7 +12699,7 @@ var runTickScript = async (ctx, _profile, args) => {
11977
12699
  ctx.output.reason = `runTickScript: ctx.args.${slugArg} must be a non-empty slug`;
11978
12700
  return;
11979
12701
  }
11980
- const jobPath = path36.join(ctx.cwd, jobsDir, `${slug}.md`);
12702
+ const jobPath = path37.join(ctx.cwd, jobsDir, `${slug}.md`);
11981
12703
  if (!fs39.existsSync(jobPath)) {
11982
12704
  ctx.output.exitCode = 99;
11983
12705
  ctx.output.reason = `runTickScript: job file not found: ${jobPath}`;
@@ -11991,7 +12713,7 @@ var runTickScript = async (ctx, _profile, args) => {
11991
12713
  ctx.output.reason = `runTickScript: job ${slug} has no \`tickScript:\` frontmatter \u2014 route via job-tick instead`;
11992
12714
  return;
11993
12715
  }
11994
- const scriptPath = path36.isAbsolute(tickScript) ? tickScript : path36.join(ctx.cwd, tickScript);
12716
+ const scriptPath = path37.isAbsolute(tickScript) ? tickScript : path37.join(ctx.cwd, tickScript);
11995
12717
  if (!fs39.existsSync(scriptPath)) {
11996
12718
  ctx.output.exitCode = 99;
11997
12719
  ctx.output.reason = `runTickScript: tickScript not found: ${scriptPath}`;
@@ -12143,7 +12865,7 @@ function synthesizeAction(ctx) {
12143
12865
  }
12144
12866
 
12145
12867
  // src/scripts/serveFlow.ts
12146
- import { spawn as spawn6 } from "child_process";
12868
+ import { spawn as spawn7 } from "child_process";
12147
12869
  function parseTarget(positional) {
12148
12870
  if (!Array.isArray(positional) || positional.length === 0) return "none";
12149
12871
  const first = String(positional[0]).toLowerCase();
@@ -12192,7 +12914,7 @@ var serveFlow = async (ctx) => {
12192
12914
  if (usesProxy) process.stdout.write(` ANTHROPIC_BASE_URL=${url}
12193
12915
  `);
12194
12916
  const args = ["--dangerously-skip-permissions", "--model", model.model];
12195
- const child = spawn6("claude", args, { stdio: "inherit", env: editorEnv, cwd: ctx.cwd });
12917
+ const child = spawn7("claude", args, { stdio: "inherit", env: editorEnv, cwd: ctx.cwd });
12196
12918
  const exitCode = await new Promise((resolve6) => {
12197
12919
  child.on("exit", (code) => resolve6(code ?? 0));
12198
12920
  child.on("error", (err) => {
@@ -12213,7 +12935,7 @@ var serveFlow = async (ctx) => {
12213
12935
  if (usesProxy) process.stdout.write(` ANTHROPIC_BASE_URL=${url}
12214
12936
  `);
12215
12937
  try {
12216
- const code = spawn6("code", [ctx.cwd], { stdio: "inherit", env: editorEnv, detached: true });
12938
+ const code = spawn7("code", [ctx.cwd], { stdio: "inherit", env: editorEnv, detached: true });
12217
12939
  code.on("error", (err) => {
12218
12940
  process.stderr.write(`[kody serve] failed to launch VS Code: ${err.message}
12219
12941
  `);
@@ -12468,7 +13190,7 @@ var verify = async (ctx) => {
12468
13190
  };
12469
13191
 
12470
13192
  // src/scripts/verifyReproFails.ts
12471
- import { spawn as spawn7 } from "child_process";
13193
+ import { spawn as spawn8 } from "child_process";
12472
13194
  var TEST_TIMEOUT_MS = 10 * 60 * 1e3;
12473
13195
  var TAIL_CHARS2 = 8e3;
12474
13196
  var ANSI_RE2 = /\x1B\[[0-?]*[ -/]*[@-~]/g;
@@ -12537,7 +13259,7 @@ function stripAnsi2(s) {
12537
13259
  }
12538
13260
  function runCommand2(command, cwd) {
12539
13261
  return new Promise((resolve6) => {
12540
- const child = spawn7(command, {
13262
+ const child = spawn8(command, {
12541
13263
  cwd,
12542
13264
  shell: true,
12543
13265
  env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1", CI: process.env.CI ?? "1" },
@@ -12868,7 +13590,7 @@ var appendCompanyActivity = async (ctx, _profile, agentResult) => {
12868
13590
  };
12869
13591
 
12870
13592
  // src/scripts/warmupMcp.ts
12871
- import { spawn as spawn8 } from "child_process";
13593
+ import { spawn as spawn9 } from "child_process";
12872
13594
  var PER_SERVER_TIMEOUT_MS = 6e4;
12873
13595
  var PER_REQUEST_TIMEOUT_MS = 2e4;
12874
13596
  var warmupMcp = async (_ctx, profile) => {
@@ -12890,7 +13612,7 @@ var warmupMcp = async (_ctx, profile) => {
12890
13612
  }
12891
13613
  };
12892
13614
  async function warmupOne(command, args, env) {
12893
- const child = spawn8(command, args, {
13615
+ const child = spawn9(command, args, {
12894
13616
  stdio: ["pipe", "pipe", "pipe"],
12895
13617
  env: env ? { ...process.env, ...env } : process.env
12896
13618
  });
@@ -13170,6 +13892,7 @@ var preflightScripts = {
13170
13892
  dispatchJobTicks,
13171
13893
  dispatchJobFileTicks,
13172
13894
  runTickScript,
13895
+ runPreviewBuild,
13173
13896
  serveFlow,
13174
13897
  brainServe,
13175
13898
  runnerServe,
@@ -13245,24 +13968,24 @@ function firstRequiredFailure(results, tools) {
13245
13968
  }
13246
13969
  return null;
13247
13970
  }
13248
- function verifyOne(tool4, cwd) {
13249
- const result = { name: tool4.name, present: false, verified: false };
13250
- const checkRes = runShell(tool4.install.checkCommand, cwd);
13971
+ function verifyOne(tool5, cwd) {
13972
+ const result = { name: tool5.name, present: false, verified: false };
13973
+ const checkRes = runShell(tool5.install.checkCommand, cwd);
13251
13974
  let present = checkRes.ok;
13252
- if (!present && tool4.install.installCommand) {
13253
- runShell(tool4.install.installCommand, cwd, 12e4);
13254
- present = runShell(tool4.install.checkCommand, cwd).ok;
13975
+ if (!present && tool5.install.installCommand) {
13976
+ runShell(tool5.install.installCommand, cwd, 12e4);
13977
+ present = runShell(tool5.install.checkCommand, cwd).ok;
13255
13978
  }
13256
13979
  result.present = present;
13257
13980
  if (!present) {
13258
- result.error = `tool "${tool4.name}" not on PATH (check: ${tool4.install.checkCommand})`;
13981
+ result.error = `tool "${tool5.name}" not on PATH (check: ${tool5.install.checkCommand})`;
13259
13982
  return result;
13260
13983
  }
13261
- const verifyRes = runShell(tool4.verify, cwd);
13984
+ const verifyRes = runShell(tool5.verify, cwd);
13262
13985
  result.verified = verifyRes.ok;
13263
13986
  if (!verifyRes.ok) {
13264
13987
  const tail = formatStderrTail(verifyRes.stderr, verifyRes.stdout);
13265
- result.error = `tool "${tool4.name}" failed verify: ${tool4.verify}${tail ? ` \u2014 ${tail}` : ""}`;
13988
+ result.error = `tool "${tool5.name}" failed verify: ${tool5.verify}${tail ? ` \u2014 ${tail}` : ""}`;
13266
13989
  }
13267
13990
  return result;
13268
13991
  }
@@ -13394,9 +14117,9 @@ async function runExecutable(profileName, input) {
13394
14117
  })
13395
14118
  };
13396
14119
  })() : null;
13397
- const ndjsonDir = path37.join(input.cwd, ".kody");
14120
+ const ndjsonDir = path38.join(input.cwd, ".kody");
13398
14121
  const invokeAgent = async (prompt) => {
13399
- const externalPlugins = (profile.claudeCode.plugins ?? []).map((p) => path37.isAbsolute(p) ? p : path37.resolve(profile.dir, p)).filter((p) => p.length > 0);
14122
+ const externalPlugins = (profile.claudeCode.plugins ?? []).map((p) => path38.isAbsolute(p) ? p : path38.resolve(profile.dir, p)).filter((p) => p.length > 0);
13400
14123
  const syntheticPath = ctx.data.syntheticPluginPath;
13401
14124
  const pluginPaths = [...externalPlugins, ...syntheticPath ? [syntheticPath] : []];
13402
14125
  const agents = loadSubagents(profile);
@@ -13426,6 +14149,16 @@ async function runExecutable(profileName, input) {
13426
14149
  cacheable: profile.claudeCode.cacheable,
13427
14150
  enableVerifyTool: profile.claudeCode.enableVerifyTool,
13428
14151
  enableSubmitTool: profile.claudeCode.enableSubmitTool,
14152
+ // Locked-toolbox duty mode: `loadJobFromFile` flips `ctx.data.dutyTools`
14153
+ // when a duty declares `tools:` frontmatter. The executor doesn't need
14154
+ // to know the palette — it just forwards the flag so agent.ts can spin
14155
+ // up the in-process `kody-duty` MCP server with the right context.
14156
+ enableDutyTool: Array.isArray(ctx.data.dutyTools) && ctx.data.dutyTools.length > 0,
14157
+ dutyOperatorMention: typeof ctx.data.dutyOperatorMention === "string" ? ctx.data.dutyOperatorMention : void 0,
14158
+ // owner/repo from kody.config.json; envelope falls back to GITHUB_REPOSITORY
14159
+ // for tester repos that don't set config.github (the file isn't always
14160
+ // checked in). Either way, dutyMcp needs "owner/name" to hit the compare API.
14161
+ dutyRepoSlug: config.github?.owner && config.github?.repo ? `${config.github.owner}/${config.github.repo}` : process.env.GITHUB_REPOSITORY?.trim() || void 0,
13429
14162
  verifyToolMaxAttempts: profile.claudeCode.verifyAttempts ?? null,
13430
14163
  verifyConfig: profile.claudeCode.enableVerifyTool ? config : void 0,
13431
14164
  executableName: profileName,
@@ -13638,13 +14371,13 @@ function getProfileInputsForChild(profileName, _cwd) {
13638
14371
  function resolveProfilePath(profileName) {
13639
14372
  const found = resolveExecutable(profileName);
13640
14373
  if (found) return found;
13641
- const here = path37.dirname(new URL(import.meta.url).pathname);
14374
+ const here = path38.dirname(new URL(import.meta.url).pathname);
13642
14375
  const candidates = [
13643
- path37.join(here, "executables", profileName, "profile.json"),
14376
+ path38.join(here, "executables", profileName, "profile.json"),
13644
14377
  // same-dir sibling (dev)
13645
- path37.join(here, "..", "executables", profileName, "profile.json"),
14378
+ path38.join(here, "..", "executables", profileName, "profile.json"),
13646
14379
  // up one (prod: dist/bin → dist/executables)
13647
- path37.join(here, "..", "src", "executables", profileName, "profile.json")
14380
+ path38.join(here, "..", "src", "executables", profileName, "profile.json")
13648
14381
  // fallback
13649
14382
  ];
13650
14383
  for (const c of candidates) {
@@ -13748,7 +14481,7 @@ function resolveShellTimeoutMs(entry) {
13748
14481
  var SIGKILL_GRACE_MS = 5e3;
13749
14482
  async function runShellEntry(entry, ctx, profile) {
13750
14483
  const shellName = entry.shell;
13751
- const shellPath = path37.join(profile.dir, shellName);
14484
+ const shellPath = path38.join(profile.dir, shellName);
13752
14485
  if (!fs41.existsSync(shellPath)) {
13753
14486
  ctx.skipAgent = true;
13754
14487
  ctx.output.exitCode = 99;
@@ -13765,7 +14498,7 @@ async function runShellEntry(entry, ctx, profile) {
13765
14498
  env[`KODY_CFG_${k}`] = v;
13766
14499
  }
13767
14500
  const timeoutMs = resolveShellTimeoutMs(entry);
13768
- const child = spawn9("bash", [shellPath, ...positional], {
14501
+ const child = spawn10("bash", [shellPath, ...positional], {
13769
14502
  cwd: ctx.cwd,
13770
14503
  env,
13771
14504
  stdio: ["pipe", "pipe", "pipe"],
@@ -14255,9 +14988,9 @@ async function resolveAuthToken(env = process.env) {
14255
14988
  return void 0;
14256
14989
  }
14257
14990
  function detectPackageManager2(cwd) {
14258
- if (fs42.existsSync(path38.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
14259
- if (fs42.existsSync(path38.join(cwd, "yarn.lock"))) return "yarn";
14260
- if (fs42.existsSync(path38.join(cwd, "bun.lockb"))) return "bun";
14991
+ if (fs42.existsSync(path39.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
14992
+ if (fs42.existsSync(path39.join(cwd, "yarn.lock"))) return "yarn";
14993
+ if (fs42.existsSync(path39.join(cwd, "bun.lockb"))) return "bun";
14261
14994
  return "npm";
14262
14995
  }
14263
14996
  function shellOut(cmd, args, cwd, stream = true) {
@@ -14344,7 +15077,7 @@ function configureGitIdentity(cwd) {
14344
15077
  }
14345
15078
  function postFailureTail(issueNumber, cwd, reason) {
14346
15079
  if (!issueNumber) return;
14347
- const logPath = path38.join(cwd, ".kody", "last-run.jsonl");
15080
+ const logPath = path39.join(cwd, ".kody", "last-run.jsonl");
14348
15081
  let tail = "";
14349
15082
  try {
14350
15083
  if (fs42.existsSync(logPath)) {
@@ -14373,7 +15106,7 @@ async function runCi(argv) {
14373
15106
  return 0;
14374
15107
  }
14375
15108
  const args = parseCiArgs(argv);
14376
- const cwd = args.cwd ? path38.resolve(args.cwd) : process.cwd();
15109
+ const cwd = args.cwd ? path39.resolve(args.cwd) : process.cwd();
14377
15110
  let earlyConfig;
14378
15111
  try {
14379
15112
  earlyConfig = loadConfig(cwd);
@@ -14646,12 +15379,12 @@ function parseChatArgs(argv, env = process.env) {
14646
15379
  return result;
14647
15380
  }
14648
15381
  function commitChatFiles(cwd, sessionId, verbose) {
14649
- const sessionFile = path39.relative(cwd, sessionFilePath(cwd, sessionId));
14650
- const eventsFile = path39.relative(cwd, eventsFilePath(cwd, sessionId));
15382
+ const sessionFile = path40.relative(cwd, sessionFilePath(cwd, sessionId));
15383
+ const eventsFile = path40.relative(cwd, eventsFilePath(cwd, sessionId));
14651
15384
  const safeSession = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
14652
- const tasksDir = path39.join(".kody", "tasks", safeSession);
15385
+ const tasksDir = path40.join(".kody", "tasks", safeSession);
14653
15386
  const candidatePaths = [sessionFile, eventsFile, tasksDir];
14654
- const paths = candidatePaths.filter((p) => fs43.existsSync(path39.join(cwd, p)));
15387
+ const paths = candidatePaths.filter((p) => fs43.existsSync(path40.join(cwd, p)));
14655
15388
  if (paths.length === 0) return;
14656
15389
  const opts = { cwd, stdio: verbose ? "inherit" : "pipe" };
14657
15390
  try {
@@ -14695,7 +15428,7 @@ async function runChat(argv) {
14695
15428
  ${CHAT_HELP}`);
14696
15429
  return 64;
14697
15430
  }
14698
- const cwd = args.cwd ? path39.resolve(args.cwd) : process.cwd();
15431
+ const cwd = args.cwd ? path40.resolve(args.cwd) : process.cwd();
14699
15432
  const sessionId = args.sessionId;
14700
15433
  const unpackedSecrets = unpackAllSecrets();
14701
15434
  if (unpackedSecrets > 0) {