@gethmy/agent 1.18.1 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +1539 -1452
  2. package/dist/index.js +1539 -1452
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -730,1457 +730,1584 @@ var init_config_validation = __esm(() => {
730
730
  }
731
731
  };
732
732
  });
733
-
734
- // src/git-pr.ts
735
- var exports_git_pr = {};
736
- __export(exports_git_pr, {
737
- validateGitProviderCli: () => validateGitProviderCli,
738
- upsertReviewedSha: () => upsertReviewedSha,
739
- updateExistingPr: () => updateExistingPr,
740
- resolvePrUrl: () => resolvePrUrl,
741
- renameRemoteBranch: () => renameRemoteBranch,
742
- remoteBranchExists: () => remoteBranchExists,
743
- pushBranch: () => pushBranch,
744
- mergePullRequest: () => mergePullRequest,
745
- getPrStatus: () => getPrStatus,
746
- getHeadSha: () => getHeadSha,
747
- getBranchWebUrl: () => getBranchWebUrl,
748
- findExistingPr: () => findExistingPr,
749
- extractReviewedSha: () => extractReviewedSha,
750
- extractPrUrl: () => extractPrUrl,
751
- detectGitProvider: () => detectGitProvider,
752
- deriveCiStatus: () => deriveCiStatus,
753
- createPullRequest: () => createPullRequest,
754
- checkPrMergeStatus: () => checkPrMergeStatus,
755
- buildPrBody: () => buildPrBody
756
- });
757
- import { execFile, execFileSync } from "node:child_process";
758
- import { promisify } from "node:util";
759
- function detectGitProvider(cwd) {
760
- try {
761
- const url = execFileSync("git", ["remote", "get-url", "origin"], {
762
- cwd,
763
- encoding: "utf-8"
764
- }).trim();
765
- if (url.includes("github.com"))
766
- return "github";
767
- if (url.includes("dev.azure.com") || url.includes("visualstudio.com"))
768
- return "azure";
769
- if (url.includes("gitlab.com") || /\bgitlab\b/.test(url))
770
- return "gitlab";
771
- if (url.includes("bitbucket.org"))
772
- return "bitbucket";
773
- return "unknown";
774
- } catch {
775
- return "unknown";
733
+ // ../harmony-shared/dist/branchRef.js
734
+ function extractBranchRef(description) {
735
+ if (!description)
736
+ return null;
737
+ for (const match of description.matchAll(BRANCH_REF_PATTERN)) {
738
+ const branch = match[1];
739
+ if (SAFE_GIT_REF_PATTERN.test(branch))
740
+ return branch;
776
741
  }
742
+ return null;
777
743
  }
778
- function validateGitProviderCli(provider, cwd) {
779
- switch (provider) {
780
- case "github": {
781
- try {
782
- execFileSync("gh", ["auth", "status"], { cwd, stdio: "pipe" });
783
- } catch {
784
- throw new Error("GitHub CLI (gh) is not authenticated. Run: gh auth login");
785
- }
786
- break;
787
- }
788
- case "azure": {
789
- try {
790
- execFileSync("az", ["--version"], { cwd, stdio: "pipe" });
791
- } catch {
792
- throw new Error("Azure CLI (az) not found. Install it: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli");
793
- }
794
- try {
795
- execFileSync("az", ["account", "show"], { cwd, stdio: "pipe" });
796
- } catch {
797
- throw new Error("Azure CLI is not authenticated. Run: az login");
798
- }
799
- break;
800
- }
801
- case "gitlab": {
802
- try {
803
- execFileSync("glab", ["auth", "status"], { cwd, stdio: "pipe" });
804
- } catch {
805
- throw new Error("GitLab CLI (glab) is not installed or not authenticated. Install: https://gitlab.com/gitlab-org/cli — then run: glab auth login");
806
- }
807
- break;
808
- }
809
- case "bitbucket":
810
- case "unknown":
811
- log.warn(TAG2, `Git provider "${provider}" — PR creation will be skipped (no CLI support)`);
812
- break;
744
+ function hasUnsafeDaemonBranchLine(description) {
745
+ if (!description)
746
+ return false;
747
+ for (const match of description.matchAll(DAEMON_BRANCH_LINE_PATTERN)) {
748
+ if (!SAFE_GIT_REF_PATTERN.test(match[1]))
749
+ return true;
813
750
  }
751
+ return false;
814
752
  }
815
- function isValidPrUrl(url) {
816
- return VALID_PR_URL_RE.test(url);
753
+ var BRANCH_REF_PATTERN, DAEMON_BRANCH_LINE_PATTERN, SAFE_GIT_REF_PATTERN;
754
+ var init_branchRef = __esm(() => {
755
+ BRANCH_REF_PATTERN = /Branch:\s*`([^`]+)`/g;
756
+ DAEMON_BRANCH_LINE_PATTERN = /^[ \t]*Branch:\s*`([^`]+)`/gm;
757
+ SAFE_GIT_REF_PATTERN = /^[a-zA-Z0-9/_.+-]+$/;
758
+ });
759
+
760
+ // ../harmony-shared/dist/cardLinks.js
761
+ var init_cardLinks = () => {};
762
+ // ../harmony-shared/dist/classification.js
763
+ function escalateTier(tier) {
764
+ const i = MODEL_TIERS.indexOf(tier);
765
+ return MODEL_TIERS[Math.min(i + 1, MODEL_TIERS.length - 1)];
817
766
  }
818
- function extractReviewedSha(description) {
819
- if (!description)
820
- return null;
821
- const m = description.match(REVIEWED_SHA_RE);
822
- return m ? m[1] : null;
767
+ function isModelTier(v) {
768
+ return typeof v === "string" && MODEL_TIERS.includes(v);
823
769
  }
824
- function upsertReviewedSha(description, sha) {
825
- const line = `Reviewed-SHA: ${sha}`;
826
- if (REVIEWED_SHA_RE.test(description)) {
827
- return description.replace(REVIEWED_SHA_RE, line);
828
- }
829
- const sep = description ? `
830
- ` : "";
831
- return `${description}${sep}${line}`;
770
+ var MODEL_TIERS;
771
+ var init_classification = __esm(() => {
772
+ MODEL_TIERS = ["simple", "advanced", "research"];
773
+ });
774
+
775
+ // ../harmony-shared/dist/commentSerializer.js
776
+ function sanitizeHeaderField(value) {
777
+ return value.replace(/[\]\r\n|<>]/g, " ").trim() || "—";
832
778
  }
833
- function deriveCiStatus(rollup) {
834
- if (!Array.isArray(rollup) || rollup.length === 0)
835
- return "unknown";
836
- let anyPending = false;
837
- for (const check of rollup) {
838
- if (typeof check !== "object" || check === null)
839
- continue;
840
- const c = check;
841
- if (typeof c.status === "string") {
842
- if (c.status.toUpperCase() !== "COMPLETED") {
843
- anyPending = true;
844
- continue;
845
- }
846
- const conclusion = typeof c.conclusion === "string" ? c.conclusion.toUpperCase() : "";
847
- if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion))
848
- continue;
849
- return "failure";
779
+ function authorLabel(c) {
780
+ if (c.author_type === "agent")
781
+ return "AI agent";
782
+ const raw = c.author?.full_name || "teammate";
783
+ return sanitizeHeaderField(raw);
784
+ }
785
+ function criticalIds(comments) {
786
+ const keep = new Set;
787
+ for (const c of comments) {
788
+ if (c.comment_type === "decision")
789
+ keep.add(c.id);
790
+ if (c.supersedes_id) {
791
+ keep.add(c.id);
792
+ keep.add(c.supersedes_id);
850
793
  }
851
- if (typeof c.state === "string") {
852
- const state = c.state.toUpperCase();
853
- if (state === "SUCCESS")
854
- continue;
855
- if (state === "PENDING") {
856
- anyPending = true;
857
- continue;
858
- }
859
- return "failure";
794
+ if (c.confirms_id) {
795
+ keep.add(c.id);
796
+ keep.add(c.confirms_id);
860
797
  }
861
798
  }
862
- return anyPending ? "pending" : "success";
799
+ return keep;
863
800
  }
864
- async function getPrStatus(prUrl, cwd, provider) {
865
- if (provider !== "github" || !isValidPrUrl(prUrl)) {
866
- return { ciStatus: "unknown", headSha: null };
801
+ function serializeCommentThread(comments, options = {}) {
802
+ const { heading = "Conversation", includeInstructions = true, activity = [], maxComments } = options;
803
+ const visible = comments.filter((c) => !c.deleted_at).slice().sort((a, b) => a.created_at.localeCompare(b.created_at));
804
+ if (visible.length === 0)
805
+ return "";
806
+ const indexById = new Map;
807
+ visible.forEach((c, i) => {
808
+ indexById.set(c.id, i + 1);
809
+ });
810
+ let rendered = visible;
811
+ let elidedCount = 0;
812
+ if (maxComments && visible.length > maxComments) {
813
+ const keep = criticalIds(visible);
814
+ const recentThreshold = visible.length - maxComments;
815
+ rendered = visible.filter((c, i) => i >= recentThreshold || keep.has(c.id));
816
+ elidedCount = visible.length - rendered.length;
867
817
  }
868
- try {
869
- const { stdout } = await execFileAsync("gh", ["pr", "view", prUrl, "--json", "statusCheckRollup,headRefOid"], { cwd, encoding: "utf-8", timeout: 1e4 });
870
- const parsed = JSON.parse(stdout.trim());
871
- const headSha = typeof parsed.headRefOid === "string" ? parsed.headRefOid : null;
872
- return { ciStatus: deriveCiStatus(parsed.statusCheckRollup), headSha };
873
- } catch {
874
- return { ciStatus: "unknown", headSha: null };
818
+ const ref = (id) => {
819
+ const n = indexById.get(id);
820
+ return n ? `#${n}` : `#${id.slice(0, 8)}`;
821
+ };
822
+ const lines = [];
823
+ if (elidedCount > 0) {
824
+ lines.push({
825
+ at: visible[0]?.created_at ?? "",
826
+ text: `(${elidedCount} earlier comment(s) omitted for brevity)`
827
+ });
875
828
  }
876
- }
877
- async function mergePullRequest(prUrl, cwd, provider, strategy, deleteBranch) {
878
- if (provider !== "github") {
879
- throw new Error(`auto-merge unsupported for provider "${provider}"`);
829
+ for (const c of rendered) {
830
+ const tags = [];
831
+ if (c.edited_at)
832
+ tags.push("edited");
833
+ if (c.reply_to_id)
834
+ tags.push(`reply to ${ref(c.reply_to_id)}`);
835
+ if (c.supersedes_id)
836
+ tags.push(`supersedes ${ref(c.supersedes_id)}`);
837
+ if (c.confirms_id)
838
+ tags.push(`confirms ${ref(c.confirms_id)}`);
839
+ if (c.resolved_at)
840
+ tags.push("resolved");
841
+ const tagStr = tags.length ? ` | ${tags.join(" | ")}` : "";
842
+ const header = `[${sanitizeHeaderField(ref(c.id))} | ${sanitizeHeaderField(c.author_type)} | ${authorLabel(c)} | ${sanitizeHeaderField(c.comment_type)} | ${sanitizeHeaderField(c.created_at)}${tagStr}]`;
843
+ const fencedBody = c.body.trim().replaceAll("<", "&lt;").replaceAll(">", "&gt;");
844
+ lines.push({
845
+ at: c.created_at,
846
+ text: `${header}
847
+ <comment-body>
848
+ ${fencedBody}
849
+ </comment-body>`
850
+ });
880
851
  }
881
- const args = ["pr", "merge", prUrl, `--${strategy}`];
882
- if (deleteBranch)
883
- args.push("--delete-branch");
884
- await execFileAsync("gh", args, { cwd, encoding: "utf-8", timeout: 30000 });
885
- }
886
- function getHeadSha(cwd) {
887
- try {
888
- return execFileSync("git", ["rev-parse", "HEAD"], {
889
- cwd,
890
- encoding: "utf-8"
891
- }).trim();
892
- } catch {
893
- return null;
852
+ for (const a of activity) {
853
+ const actor = a.actor ? `${a.actor} ` : "";
854
+ lines.push({ at: a.at, text: `· (system) ${a.at} — ${actor}${a.text}` });
894
855
  }
856
+ lines.sort((a, b) => a.at.localeCompare(b.at));
857
+ const body = lines.map((l) => l.text).join(`
858
+
859
+ `);
860
+ const instruction = includeInstructions ? `
861
+
862
+ ${CONFLICT_INSTRUCTION}` : "";
863
+ return `## ${heading} (oldest → newest)
864
+
865
+ ${body}${instruction}`;
895
866
  }
896
- async function checkPrMergeStatus(prUrl, cwd, provider) {
897
- if (!isValidPrUrl(prUrl))
898
- return "unknown";
899
- try {
900
- switch (provider) {
901
- case "github": {
902
- const { stdout } = await execFileAsync("gh", ["pr", "view", prUrl, "--json", "state", "--jq", ".state"], { cwd, encoding: "utf-8", timeout: 1e4 });
903
- switch (stdout.trim()) {
904
- case "MERGED":
905
- return "merged";
906
- case "OPEN":
907
- return "open";
908
- case "CLOSED":
909
- return "closed";
910
- default:
911
- return "unknown";
867
+ var CONFLICT_INSTRUCTION;
868
+ var init_commentSerializer = __esm(() => {
869
+ CONFLICT_INSTRUCTION = "When two comments conflict, prefer the latest created_at, UNLESS a later " + "comment explicitly confirms or restates the earlier finding. Evaluate " + "substance, not just recency. Cite the comment id(s) you relied on.";
870
+ });
871
+
872
+ // ../harmony-shared/dist/constants.js
873
+ var TIMINGS;
874
+ var init_constants = __esm(() => {
875
+ TIMINGS = {
876
+ SEARCH_DEBOUNCE: 300,
877
+ AUTOSAVE_DEBOUNCE: 1000,
878
+ TOAST_DURATION: 3000,
879
+ QUERY_STALE_TIME: 1000 * 60 * 5,
880
+ QUERY_GC_TIME: 1000 * 60 * 60 * 24
881
+ };
882
+ });
883
+ // ../harmony-shared/dist/gateEvaluate.js
884
+ function isGateKind(value) {
885
+ return typeof value === "string" && GATE_KINDS.includes(value);
886
+ }
887
+ function isGateOperator(value) {
888
+ return typeof value === "string" && GATE_OPERATORS.includes(value);
889
+ }
890
+ function gateEvaluate(gateSpec, evidence) {
891
+ const structured = isPlainObject(evidence) ? evidence.structured ?? {} : {};
892
+ const safeStructured = isPlainObject(structured) ? structured : {};
893
+ if (!isPlainObject(gateSpec)) {
894
+ return {
895
+ passed: false,
896
+ findings: [{ level: "error", message: "Malformed gate: not an object." }],
897
+ structured: safeStructured
898
+ };
899
+ }
900
+ const spec = gateSpec;
901
+ if (!isGateKind(spec.kind)) {
902
+ return {
903
+ passed: false,
904
+ findings: [
905
+ {
906
+ level: "error",
907
+ message: `Malformed gate: unknown kind ${formatValue(spec.kind)}.`
912
908
  }
913
- }
914
- case "gitlab": {
915
- const mrMatch = prUrl.match(/merge_requests\/(\d+)/);
916
- if (!mrMatch)
917
- return "unknown";
918
- const { stdout } = await execFileAsync("glab", ["mr", "view", mrMatch[1], "--output", "json"], { cwd, encoding: "utf-8", timeout: 1e4 });
919
- let parsed;
920
- try {
921
- parsed = JSON.parse(stdout.trim());
922
- } catch {
923
- log.warn(TAG2, `Failed to parse glab JSON output for MR ${mrMatch[1]}`);
924
- return "unknown";
909
+ ],
910
+ structured: safeStructured
911
+ };
912
+ }
913
+ if (spec.pendingEngine === true) {
914
+ return {
915
+ passed: true,
916
+ findings: [
917
+ {
918
+ level: "info",
919
+ message: `Gate "${spec.kind}" is advisory (pending engine); not enforced.`
925
920
  }
926
- if (typeof parsed !== "object" || parsed === null)
927
- return "unknown";
928
- const state = parsed.state;
929
- if (state === "merged")
930
- return "merged";
931
- if (state === "opened")
932
- return "open";
933
- if (state === "closed")
934
- return "closed";
935
- return "unknown";
936
- }
937
- default:
938
- return "unknown";
921
+ ],
922
+ structured: safeStructured
923
+ };
924
+ }
925
+ if (!isPlainObject(evidence)) {
926
+ return {
927
+ passed: false,
928
+ findings: [
929
+ { level: "error", message: "Malformed evidence: not an object." }
930
+ ],
931
+ structured: safeStructured
932
+ };
933
+ }
934
+ const result = evidence.result;
935
+ const resultIsKnown = result === "passed" || result === "failed" || result === "blocked";
936
+ const conditions = Array.isArray(spec.conditions) ? spec.conditions : null;
937
+ if (conditions === null) {
938
+ if (result === "passed") {
939
+ return {
940
+ passed: true,
941
+ findings: [
942
+ {
943
+ level: "info",
944
+ message: `Gate "${spec.kind}" passed on evidence result.`
945
+ }
946
+ ],
947
+ structured: safeStructured
948
+ };
939
949
  }
940
- } catch {
941
- return "unknown";
950
+ return {
951
+ passed: false,
952
+ findings: [
953
+ {
954
+ level: "error",
955
+ message: resultIsKnown ? `Gate "${spec.kind}" not satisfied: evidence result is "${String(result)}".` : `Gate "${spec.kind}" not satisfied: evidence result is missing or invalid.`
956
+ }
957
+ ],
958
+ structured: safeStructured
959
+ };
942
960
  }
943
- }
944
- function extractPrUrl(description) {
945
- if (!description)
946
- return null;
947
- const match = description.match(PR_URL_RE);
948
- if (!match)
949
- return null;
950
- try {
951
- return new URL(match[1]).href;
952
- } catch {
953
- return null;
961
+ const mode = spec.mode === "any" ? "any" : "all";
962
+ const findings = [];
963
+ const outcomes = [];
964
+ for (const raw of conditions) {
965
+ const { ok, finding } = evaluateCondition(raw, safeStructured);
966
+ outcomes.push(ok);
967
+ if (finding)
968
+ findings.push(finding);
954
969
  }
955
- }
956
- function resolvePrUrl(description, branchName, cwd, provider) {
957
- const fromDesc = extractPrUrl(description);
958
- if (fromDesc)
959
- return fromDesc;
960
- if (!branchName)
961
- return null;
962
- return findExistingPr(branchName, cwd, provider) || null;
963
- }
964
- function remoteBranchExists(branchName, cwd) {
965
- try {
966
- execFileSync("git", ["ls-remote", "--exit-code", "origin", `refs/heads/${branchName}`], { cwd, stdio: "pipe" });
967
- return true;
968
- } catch {
969
- return false;
970
+ if (result === "blocked") {
971
+ findings.unshift({
972
+ level: "error",
973
+ message: `Gate "${spec.kind}" cannot pass: evidence result is "blocked".`
974
+ });
975
+ return { passed: false, findings, structured: safeStructured };
970
976
  }
971
- }
972
- function pushBranch(branchName, cwd) {
973
- if (remoteBranchExists(branchName, cwd)) {
974
- log.info(TAG2, `Remote branch ${branchName} exists (rework), force-pushing`);
975
- let expectedSha = null;
976
- try {
977
- execFileSync("git", ["fetch", "origin", branchName], {
978
- cwd,
979
- stdio: "pipe"
977
+ let predicatePassed;
978
+ if (mode === "any") {
979
+ predicatePassed = outcomes.length > 0 && outcomes.some((o) => o);
980
+ if (outcomes.length === 0) {
981
+ findings.push({
982
+ level: "error",
983
+ message: `Gate "${spec.kind}" (mode "any") has no conditions to satisfy.`
980
984
  });
981
- expectedSha = execFileSync("git", ["rev-parse", `refs/remotes/origin/${branchName}`], { cwd, encoding: "utf-8" }).trim();
982
- } catch (err) {
983
- log.warn(TAG2, `could not resolve remote tip for ${branchName}, falling back to weak lease: ${err instanceof Error ? err.message : err}`);
984
985
  }
985
- const lease = expectedSha ? `--force-with-lease=refs/heads/${branchName}:${expectedSha}` : "--force-with-lease";
986
- execFileSync("git", ["push", lease, "-u", "origin", branchName], {
987
- cwd,
988
- stdio: "pipe"
989
- });
990
986
  } else {
991
- execFileSync("git", ["push", "-u", "origin", branchName], {
992
- cwd,
993
- stdio: "pipe"
987
+ predicatePassed = outcomes.every((o) => o);
988
+ }
989
+ if (predicatePassed) {
990
+ findings.push({
991
+ level: "info",
992
+ message: `Gate "${spec.kind}" predicate satisfied (mode "${mode}").`
994
993
  });
995
994
  }
995
+ return { passed: predicatePassed, findings, structured: safeStructured };
996
996
  }
997
- function renameRemoteBranch(oldRef, newRef, cwd) {
998
- if (oldRef === newRef)
999
- return;
1000
- let sha;
1001
- try {
1002
- sha = execFileSync("git", ["rev-parse", "HEAD"], {
1003
- cwd,
1004
- encoding: "utf-8"
1005
- }).trim();
1006
- } catch (err) {
1007
- throw new Error(`renameRemoteBranch: could not resolve HEAD: ${err instanceof Error ? err.message : err}`);
997
+ function evaluateCondition(raw, structured) {
998
+ if (!isPlainObject(raw)) {
999
+ return {
1000
+ ok: false,
1001
+ finding: {
1002
+ level: "error",
1003
+ message: "Malformed condition: not an object."
1004
+ }
1005
+ };
1008
1006
  }
1009
- log.info(TAG2, `Renaming remote ${oldRef} → ${newRef}`);
1010
- execFileSync("git", ["push", "origin", `${sha}:refs/heads/${newRef}`, "--force-with-lease"], { cwd, stdio: "pipe" });
1011
- try {
1012
- execFileSync("git", ["push", "origin", `:refs/heads/${oldRef}`], {
1013
- cwd,
1014
- stdio: "pipe"
1015
- });
1016
- } catch (err) {
1017
- log.warn(TAG2, `renameRemoteBranch: could not delete old ref ${oldRef}: ${err instanceof Error ? err.message : err}`);
1007
+ const cond = raw;
1008
+ if (typeof cond.path !== "string" || cond.path.length === 0) {
1009
+ return {
1010
+ ok: false,
1011
+ finding: {
1012
+ level: "error",
1013
+ message: "Malformed condition: missing string `path`."
1014
+ }
1015
+ };
1018
1016
  }
1019
- try {
1020
- execFileSync("git", ["branch", "-m", oldRef, newRef], {
1021
- cwd,
1022
- stdio: "pipe"
1023
- });
1024
- } catch {}
1025
- }
1026
- function getBranchWebUrl(branchName, cwd) {
1027
- try {
1028
- const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
1029
- cwd,
1030
- encoding: "utf-8"
1031
- }).trim();
1032
- const encoded = branchName.split("/").map(encodeURIComponent).join("/");
1033
- if (/github\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
1034
- const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
1035
- if (m)
1036
- return `https://github.com/${m[1]}/${m[2]}/tree/${encoded}`;
1017
+ if (!isGateOperator(cond.op)) {
1018
+ return {
1019
+ ok: false,
1020
+ finding: {
1021
+ level: "error",
1022
+ message: `Unknown operator ${formatValue(cond.op)} at "${cond.path}"; failing closed.`,
1023
+ path: cond.path
1024
+ }
1025
+ };
1026
+ }
1027
+ const actual = resolvePath(structured, cond.path);
1028
+ const expected = cond.value;
1029
+ const op = cond.op;
1030
+ let ok;
1031
+ switch (op) {
1032
+ case "exists":
1033
+ ok = actual !== undefined;
1034
+ break;
1035
+ case "eq":
1036
+ ok = strictEquals(actual, expected);
1037
+ break;
1038
+ case "neq":
1039
+ ok = !strictEquals(actual, expected);
1040
+ break;
1041
+ case "gte":
1042
+ case "gt":
1043
+ case "lte":
1044
+ case "lt":
1045
+ ok = numericCompare(op, actual, expected);
1046
+ break;
1047
+ case "contains":
1048
+ ok = containsCheck(actual, expected);
1049
+ break;
1050
+ default: {
1051
+ const _never = op;
1052
+ ok = false;
1037
1053
  }
1038
- if (/gitlab\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
1039
- const m = remoteUrl.match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
1040
- if (m)
1041
- return `https://gitlab.com/${m[1]}/-/tree/${encoded}`;
1054
+ }
1055
+ if (ok)
1056
+ return { ok: true };
1057
+ return {
1058
+ ok: false,
1059
+ finding: {
1060
+ level: "error",
1061
+ message: `Condition failed: ${cond.path} ${op} ${formatValue(expected)} (actual: ${formatValue(actual)}).`,
1062
+ path: cond.path
1042
1063
  }
1043
- if (/bitbucket\.org[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
1044
- const m = remoteUrl.match(/bitbucket\.org[:/](.+?)(?:\.git)?$/);
1045
- if (m)
1046
- return `https://bitbucket.org/${m[1]}/branch/${encoded}`;
1064
+ };
1065
+ }
1066
+ function isPlainObject(value) {
1067
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1068
+ }
1069
+ function resolvePath(root, path) {
1070
+ const segments = path.split(".");
1071
+ let current = root;
1072
+ for (const segment of segments) {
1073
+ if (current === null || current === undefined)
1074
+ return;
1075
+ if (Array.isArray(current)) {
1076
+ const index = Number(segment);
1077
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
1078
+ return;
1079
+ }
1080
+ current = current[index];
1081
+ } else if (typeof current === "object") {
1082
+ if (!Object.hasOwn(current, segment)) {
1083
+ return;
1084
+ }
1085
+ current = current[segment];
1086
+ } else {
1087
+ return;
1047
1088
  }
1048
- return null;
1049
- } catch {
1050
- return null;
1051
1089
  }
1090
+ return current;
1052
1091
  }
1053
- function buildPrBody(card, commitLog) {
1054
- return [
1055
- "## Summary",
1056
- "",
1057
- `Automated PR for card **#${card.short_id} ${card.title}**.`,
1058
- "",
1059
- "## Commits",
1060
- "",
1061
- "```",
1062
- commitLog,
1063
- "```",
1064
- "",
1065
- "## Card",
1066
- "",
1067
- card.description?.slice(0, 500) || "No description.",
1068
- "",
1069
- "---",
1070
- "*Created by Harmony Agent Daemon*"
1071
- ].join(`
1072
- `);
1092
+ function strictEquals(a, b) {
1093
+ if (a === null || b === null)
1094
+ return a === b;
1095
+ const t = typeof a;
1096
+ if (t !== "string" && t !== "number" && t !== "boolean")
1097
+ return false;
1098
+ return a === b;
1073
1099
  }
1074
- function createPullRequest(card, branchName, worktreePath, config, provider) {
1075
- let commitLog = "";
1076
- try {
1077
- commitLog = execFileSync("git", ["log", "--oneline", `origin/${config.worktree.baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8" }).trim();
1078
- } catch {
1079
- commitLog = "(unable to retrieve commit log)";
1100
+ function numericCompare(op, actual, expected) {
1101
+ if (typeof actual !== "number" || typeof expected !== "number")
1102
+ return false;
1103
+ if (Number.isNaN(actual) || Number.isNaN(expected))
1104
+ return false;
1105
+ switch (op) {
1106
+ case "gte":
1107
+ return actual >= expected;
1108
+ case "gt":
1109
+ return actual > expected;
1110
+ case "lte":
1111
+ return actual <= expected;
1112
+ case "lt":
1113
+ return actual < expected;
1080
1114
  }
1081
- const title = `#${card.short_id} ${card.title}`;
1082
- const body = buildPrBody(card, commitLog);
1083
- const base = config.worktree.baseBranch;
1084
- const existingUrl = findExistingPr(branchName, worktreePath, provider);
1085
- if (existingUrl) {
1086
- log.info(TAG2, `PR already exists for ${branchName}, updating body...`);
1087
- updateExistingPr(branchName, body, worktreePath, provider);
1088
- return existingUrl;
1115
+ }
1116
+ function containsCheck(actual, expected) {
1117
+ if (typeof actual === "string" && typeof expected === "string") {
1118
+ return actual.includes(expected);
1089
1119
  }
1090
- try {
1091
- let result;
1092
- switch (provider) {
1093
- case "github":
1094
- result = execFileSync("gh", ["pr", "create", "--title", title, "--body", body, "--base", base], { cwd: worktreePath, encoding: "utf-8" }).trim();
1095
- break;
1096
- case "azure": {
1097
- const azOutput = execFileSync("az", [
1098
- "repos",
1099
- "pr",
1100
- "create",
1101
- "--title",
1102
- title,
1103
- "--description",
1104
- body,
1105
- "--source-branch",
1106
- branchName,
1107
- "--target-branch",
1108
- base,
1109
- "--auto-complete",
1110
- "false"
1111
- ], { cwd: worktreePath, encoding: "utf-8" }).trim();
1112
- try {
1113
- const parsed = JSON.parse(azOutput);
1114
- result = parsed.remoteUrl ?? parsed.url ?? azOutput;
1115
- } catch {
1116
- result = azOutput;
1117
- }
1118
- break;
1119
- }
1120
- case "gitlab":
1121
- result = execFileSync("glab", [
1122
- "mr",
1123
- "create",
1124
- "--title",
1125
- title,
1126
- "--description",
1127
- body,
1128
- "--source-branch",
1129
- branchName,
1130
- "--target-branch",
1131
- base,
1132
- "--no-editor"
1133
- ], { cwd: worktreePath, encoding: "utf-8" }).trim();
1134
- break;
1135
- default:
1136
- log.warn(TAG2, `No PR CLI for provider "${provider}" — branch pushed but no PR created`);
1137
- return null;
1138
- }
1139
- log.info(TAG2, `PR created: ${result}`);
1140
- return result;
1141
- } catch (err) {
1142
- log.error(TAG2, `Failed to create PR: ${err instanceof Error ? err.message : err}`);
1143
- return null;
1120
+ if (Array.isArray(actual)) {
1121
+ return actual.some((el) => strictEquals(el, expected));
1144
1122
  }
1123
+ return false;
1145
1124
  }
1146
- function findExistingPr(branchName, worktreePath, provider) {
1147
- try {
1148
- switch (provider) {
1149
- case "github":
1150
- return execFileSync("gh", ["pr", "view", branchName, "--json", "url", "--jq", ".url"], { cwd: worktreePath, encoding: "utf-8" }).trim();
1151
- case "gitlab": {
1152
- const json = execFileSync("glab", ["mr", "view", branchName, "--output", "json"], { cwd: worktreePath, encoding: "utf-8" }).trim();
1153
- const parsed = JSON.parse(json);
1154
- return parsed.web_url || null;
1155
- }
1156
- default:
1157
- return null;
1158
- }
1159
- } catch {
1160
- return null;
1125
+ function formatValue(value) {
1126
+ if (value === undefined)
1127
+ return "undefined";
1128
+ if (value === null)
1129
+ return "null";
1130
+ if (typeof value === "string")
1131
+ return JSON.stringify(value);
1132
+ if (typeof value === "number" || typeof value === "boolean") {
1133
+ return String(value);
1161
1134
  }
1162
- }
1163
- function updateExistingPr(branchName, body, worktreePath, provider) {
1164
1135
  try {
1165
- switch (provider) {
1166
- case "github":
1167
- execFileSync("gh", ["pr", "edit", branchName, "--body", body], {
1168
- cwd: worktreePath,
1169
- stdio: "pipe"
1170
- });
1171
- break;
1172
- case "gitlab":
1173
- execFileSync("glab", ["mr", "update", branchName, "--description", body], { cwd: worktreePath, stdio: "pipe" });
1174
- break;
1175
- }
1176
- log.info(TAG2, `Updated existing PR body for ${branchName}`);
1177
- } catch (err) {
1178
- log.warn(TAG2, `Failed to update PR body: ${err instanceof Error ? err.message : err}`);
1136
+ return JSON.stringify(value);
1137
+ } catch {
1138
+ return "[unserializable]";
1179
1139
  }
1180
1140
  }
1181
- var execFileAsync, TAG2 = "git-pr", VALID_PR_URL_RE, PR_URL_RE, REVIEWED_SHA_RE;
1182
- var init_git_pr = __esm(() => {
1183
- init_log();
1184
- execFileAsync = promisify(execFile);
1185
- VALID_PR_URL_RE = /^https:\/\/(github\.com|gitlab\.com|dev\.azure\.com|bitbucket\.org)\//;
1186
- PR_URL_RE = /PR:\s*(https?:\/\/[^\s)]+)/;
1187
- REVIEWED_SHA_RE = /^Reviewed-SHA:\s*([0-9a-f]{7,40})\s*$/im;
1188
- });
1189
-
1190
- // src/http-server.ts
1191
- import {
1192
- createServer
1193
- } from "node:http";
1194
-
1195
- class HttpServer {
1196
- opts;
1197
- server = null;
1198
- boundPort = null;
1199
- constructor(opts) {
1200
- this.opts = opts;
1201
- }
1202
- get port() {
1203
- return this.boundPort;
1204
- }
1205
- async start() {
1206
- this.server = createServer((req, res) => {
1207
- this.route(req, res).catch((err) => {
1208
- log.error(TAG3, `unhandled: ${err instanceof Error ? err.message : err}`);
1209
- if (!res.headersSent) {
1210
- res.writeHead(500, { "content-type": "application/json" });
1211
- res.end(JSON.stringify({ error: "internal_error" }));
1212
- }
1213
- });
1214
- });
1215
- const attempts = Math.max(1, this.opts.maxPortAttempts ?? 10);
1216
- const startPort = this.opts.port;
1217
- for (let i = 0;i < attempts; i++) {
1218
- const port = startPort + i;
1219
- try {
1220
- await this.listenOnce(port);
1221
- this.boundPort = port;
1222
- if (port !== startPort) {
1223
- log.info(TAG3, `port ${startPort} busy bound to ${port} instead`);
1224
- }
1225
- return port;
1226
- } catch (err) {
1227
- const lastAttempt = i === attempts - 1;
1228
- if (isAddrInUse(err) && !lastAttempt) {
1229
- log.debug(TAG3, `port ${port} in use, trying ${port + 1}`);
1230
- continue;
1231
- }
1232
- throw err;
1233
- }
1234
- }
1235
- throw new Error("HTTP server failed to bind");
1236
- }
1237
- listenOnce(port) {
1238
- return new Promise((resolve, reject) => {
1239
- const server = this.server;
1240
- if (!server) {
1241
- reject(new Error("server not created"));
1242
- return;
1243
- }
1244
- const onError = (err) => {
1245
- server.removeListener("listening", onListening);
1246
- reject(err);
1247
- };
1248
- const onListening = () => {
1249
- server.removeListener("error", onError);
1250
- resolve();
1251
- };
1252
- server.once("error", onError);
1253
- server.once("listening", onListening);
1254
- server.listen(port, this.opts.bindAddr);
1255
- });
1256
- }
1257
- async stop() {
1258
- if (!this.server)
1259
- return;
1260
- await new Promise((resolve) => {
1261
- this.server?.close(() => resolve());
1262
- });
1263
- this.server = null;
1264
- this.boundPort = null;
1265
- }
1266
- async route(req, res) {
1267
- const url = new URL(req.url ?? "/", "http://localhost");
1268
- const method = (req.method ?? "GET").toUpperCase();
1269
- const path = url.pathname;
1270
- if (method === "GET" && path === "/health") {
1271
- return this.respondHealth(res);
1272
- }
1273
- if (method === "GET" && path === "/status") {
1274
- return this.respondStatus(res);
1275
- }
1276
- if (method === "POST") {
1277
- const cmd = parseCommand(path);
1278
- if (cmd) {
1279
- return this.respondCommand(res, cmd.command, cmd.cardId);
1280
- }
1281
- }
1282
- res.writeHead(404, { "content-type": "application/json" });
1283
- res.end(JSON.stringify({ error: "not_found", path }));
1284
- }
1285
- respondHealth(res) {
1286
- const health = this.opts.getHealth();
1287
- res.writeHead(health.healthy ? 200 : 503, {
1288
- "content-type": "application/json"
1289
- });
1290
- res.end(JSON.stringify(health));
1141
+ var GATE_KINDS, GATE_OPERATORS;
1142
+ var init_gateEvaluate = __esm(() => {
1143
+ GATE_KINDS = [
1144
+ "build_green",
1145
+ "review_passed",
1146
+ "checklist",
1147
+ "dod",
1148
+ "artifact",
1149
+ "label",
1150
+ "custom"
1151
+ ];
1152
+ GATE_OPERATORS = [
1153
+ "eq",
1154
+ "neq",
1155
+ "gte",
1156
+ "gt",
1157
+ "lte",
1158
+ "lt",
1159
+ "contains",
1160
+ "exists"
1161
+ ];
1162
+ });
1163
+
1164
+ // ../harmony-shared/dist/gateEvidence.js
1165
+ function toStageGateEvidenceInsert(context, evidence) {
1166
+ return {
1167
+ card_id: context.cardId,
1168
+ workspace_id: context.workspaceId,
1169
+ stage_id: context.stageId,
1170
+ gate_kind: context.gate.kind,
1171
+ result: evidence.result,
1172
+ structured: evidence.structured
1173
+ };
1174
+ }
1175
+
1176
+ // ../harmony-shared/dist/logger.js
1177
+ var init_logger = () => {};
1178
+ // ../harmony-shared/dist/playbookCatalog.js
1179
+ var init_playbookCatalog = () => {};
1180
+
1181
+ // ../harmony-shared/dist/playbookStage.js
1182
+ function normalizeLoopDef(raw) {
1183
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
1184
+ return null;
1185
+ const obj = raw;
1186
+ if (obj.mode !== "converge" && obj.mode !== "fanout")
1187
+ return null;
1188
+ const mode = obj.mode;
1189
+ const rawMax = obj.max_iterations;
1190
+ const maxInt = typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax >= 1 ? Math.floor(rawMax) : DEFAULT_LOOP_MAX_ITERATIONS;
1191
+ const exitGate = obj.exit_gate && typeof obj.exit_gate === "object" && !Array.isArray(obj.exit_gate) ? obj.exit_gate : null;
1192
+ const def = { mode, max_iterations: maxInt };
1193
+ if (exitGate)
1194
+ def.exit_gate = exitGate;
1195
+ if (obj.item_source && typeof obj.item_source === "object" && !Array.isArray(obj.item_source)) {
1196
+ def.item_source = obj.item_source;
1291
1197
  }
1292
- respondStatus(res) {
1293
- const snapshot = this.opts.getStatus();
1294
- res.writeHead(200, { "content-type": "application/json" });
1295
- res.end(JSON.stringify(snapshot));
1198
+ if (typeof obj.concurrency === "number" && obj.concurrency >= 1) {
1199
+ def.concurrency = Math.floor(obj.concurrency);
1296
1200
  }
1297
- async respondCommand(res, command, cardId) {
1298
- try {
1299
- await this.opts.handleCommand(command, cardId);
1300
- res.writeHead(200, { "content-type": "application/json" });
1301
- res.end(JSON.stringify({ ok: true, command, cardId }));
1302
- } catch (err) {
1303
- res.writeHead(500, { "content-type": "application/json" });
1304
- res.end(JSON.stringify({
1305
- error: "command_failed",
1306
- detail: err instanceof Error ? err.message : String(err)
1307
- }));
1308
- }
1201
+ if (obj.on_item_fail === "continue" || obj.on_item_fail === "halt") {
1202
+ def.on_item_fail = obj.on_item_fail;
1309
1203
  }
1204
+ return def;
1310
1205
  }
1311
- function isAddrInUse(err) {
1312
- return typeof err === "object" && err !== null && err.code === "EADDRINUSE";
1206
+ function getStageLoop(stage) {
1207
+ return normalizeLoopDef(stage.loop);
1313
1208
  }
1314
- function parseCommand(path) {
1315
- const match = path.match(/^\/(pause|resume|stop)\/([^/]+)$/);
1316
- if (!match)
1317
- return null;
1318
- return { command: match[1], cardId: decodeURIComponent(match[2]) };
1209
+ function isConvergeLoop(loop) {
1210
+ return loop !== null && loop.mode === "converge";
1319
1211
  }
1320
- var TAG3 = "http";
1321
- var init_http_server = __esm(() => {
1322
- init_log();
1323
- });
1324
-
1325
- // src/auto-merge.ts
1326
- function decideAutoMergeAction(input) {
1327
- const { ciStatus, headSha, reviewedSha, config } = input;
1328
- if (!config.enabled)
1329
- return "wait";
1330
- if (config.requireGreenCi) {
1331
- if (ciStatus === "failure")
1332
- return "stamp-failure";
1333
- if (ciStatus !== "success")
1334
- return "wait";
1335
- }
1336
- if (config.reReviewOnBranchChange && reviewedSha && headSha && reviewedSha !== headSha) {
1337
- return "rereview";
1212
+ function resolveLoopExitGate(stage, loop) {
1213
+ return loop.exit_gate ?? stage.gate ?? null;
1214
+ }
1215
+ function decideLoopContinuation(args) {
1216
+ const { loop, gatePassed, hasExitGate, completedIterations } = args;
1217
+ const max = Math.max(1, Math.floor(loop.max_iterations) || 1);
1218
+ if (!hasExitGate) {
1219
+ return completedIterations >= max ? "exit" : "iterate";
1338
1220
  }
1339
- return "merge";
1221
+ if (gatePassed)
1222
+ return "exit";
1223
+ if (completedIterations >= max)
1224
+ return "exhausted";
1225
+ return "iterate";
1340
1226
  }
1341
- async function stampCiFailure(client, card) {
1342
- const existing = card.description || "";
1343
- if (existing.includes("CI checks failed"))
1344
- return;
1345
- const sep = existing ? `
1346
- ` : "";
1347
- const ts = new Date().toISOString();
1348
- await client.updateCard(card.id, {
1349
- description: `${existing}${sep}CI checks failed at ${ts}`
1350
- });
1227
+ function readStageDefs(def) {
1228
+ if (def.steps_version !== 2)
1229
+ return [];
1230
+ return Array.isArray(def.steps) ? def.steps : [];
1351
1231
  }
1352
- async function removeApprovedLabel(client, card, resolvedLabels, approvedLabel) {
1353
- const name = approvedLabel.toLowerCase();
1354
- const obj = resolvedLabels.find((l) => l.name.toLowerCase() === name);
1355
- if (obj)
1356
- await client.removeLabelFromCard(card.id, obj.id);
1232
+ function resolveStageDef(def, currentStage) {
1233
+ if (def.steps_version !== 2)
1234
+ return { kind: "not_stage_model" };
1235
+ const stages = readStageDefs(def);
1236
+ const index = stages.findIndex((s) => s?.id === currentStage);
1237
+ if (index === -1)
1238
+ return { kind: "stage_not_found" };
1239
+ return { kind: "found", stage: stages[index], index };
1357
1240
  }
1358
- async function attemptAutoMerge(deps) {
1359
- const { client, card, resolvedLabels, prUrl, cwd, provider, config } = deps;
1360
- const autoMerge = config.review.autoMerge;
1361
- if (!autoMerge.enabled || provider !== "github")
1362
- return;
1363
- const { ciStatus, headSha } = await getPrStatus(prUrl, cwd, provider);
1364
- const reviewedSha = extractReviewedSha(card.description ?? null);
1365
- const action = decideAutoMergeAction({
1366
- ciStatus,
1367
- headSha,
1368
- reviewedSha,
1369
- config: autoMerge
1370
- });
1371
- switch (action) {
1372
- case "wait":
1373
- log.debug(TAG4, `#${card.short_id} waiting (ci=${ciStatus})`);
1374
- return;
1375
- case "stamp-failure":
1376
- log.info(TAG4, `#${card.short_id} CI failed — flagging for human`);
1377
- await stampCiFailure(client, card);
1378
- return;
1379
- case "rereview":
1380
- log.info(TAG4, `#${card.short_id} branch changed since review — re-reviewing`);
1381
- await removeApprovedLabel(client, card, resolvedLabels, config.review.approvedLabel);
1382
- return;
1383
- case "merge":
1384
- log.info(TAG4, `#${card.short_id} auto-merging (${autoMerge.strategy})`);
1385
- await mergePullRequest(prUrl, cwd, provider, autoMerge.strategy, autoMerge.deleteBranch);
1386
- return;
1387
- }
1388
- }
1389
- var TAG4 = "auto-merge";
1390
- var init_auto_merge = __esm(() => {
1391
- init_git_pr();
1392
- init_log();
1393
- });
1394
- // ../harmony-shared/dist/branchRef.js
1395
- var BRANCH_REF_PATTERN, SAFE_GIT_REF_PATTERN;
1396
- var init_branchRef = __esm(() => {
1397
- BRANCH_REF_PATTERN = /Branch:\s*`([^`]+)`/;
1398
- SAFE_GIT_REF_PATTERN = /^[a-zA-Z0-9/_.-]+$/;
1399
- });
1400
-
1401
- // ../harmony-shared/dist/cardLinks.js
1402
- var init_cardLinks = () => {};
1403
- // ../harmony-shared/dist/classification.js
1404
- function escalateTier(tier) {
1405
- const i = MODEL_TIERS.indexOf(tier);
1406
- return MODEL_TIERS[Math.min(i + 1, MODEL_TIERS.length - 1)];
1407
- }
1408
- function isModelTier(v) {
1409
- return typeof v === "string" && MODEL_TIERS.includes(v);
1410
- }
1411
- var MODEL_TIERS;
1412
- var init_classification = __esm(() => {
1413
- MODEL_TIERS = ["simple", "advanced", "research"];
1414
- });
1415
-
1416
- // ../harmony-shared/dist/commentSerializer.js
1417
- function sanitizeHeaderField(value) {
1418
- return value.replace(/[\]\r\n|<>]/g, " ").trim() || "—";
1241
+ function isAgentRunnableOwner(owner) {
1242
+ return owner === "agent" || owner === "either";
1419
1243
  }
1420
- function authorLabel(c) {
1421
- if (c.author_type === "agent")
1422
- return "AI agent";
1423
- const raw = c.author?.full_name || "teammate";
1424
- return sanitizeHeaderField(raw);
1244
+ function nextStageAfter(def, index) {
1245
+ const stages = readStageDefs(def);
1246
+ if (index < 0 || index >= stages.length)
1247
+ return { kind: "out_of_range" };
1248
+ const next = stages[index + 1];
1249
+ if (!next)
1250
+ return { kind: "terminal" };
1251
+ return { kind: "next", stage: next, index: index + 1 };
1425
1252
  }
1426
- function criticalIds(comments) {
1427
- const keep = new Set;
1428
- for (const c of comments) {
1429
- if (c.comment_type === "decision")
1430
- keep.add(c.id);
1431
- if (c.supersedes_id) {
1432
- keep.add(c.id);
1433
- keep.add(c.supersedes_id);
1434
- }
1435
- if (c.confirms_id) {
1436
- keep.add(c.id);
1437
- keep.add(c.confirms_id);
1253
+ function entryActionAllowlist(entryAction) {
1254
+ if (!entryAction)
1255
+ return null;
1256
+ const direct = SKILL_TOOL_ALLOWLIST[entryAction];
1257
+ if (direct)
1258
+ return direct;
1259
+ if (HARMONY_TOOL_RE.test(entryAction)) {
1260
+ const qualified = `mcp__harmony__${entryAction}`;
1261
+ if (STAGE_DAEMON_OWNED_TOOLS.includes(qualified)) {
1262
+ return null;
1438
1263
  }
1264
+ return qualified;
1439
1265
  }
1440
- return keep;
1266
+ return null;
1441
1267
  }
1442
- function serializeCommentThread(comments, options = {}) {
1443
- const { heading = "Conversation", includeInstructions = true, activity = [], maxComments } = options;
1444
- const visible = comments.filter((c) => !c.deleted_at).slice().sort((a, b) => a.created_at.localeCompare(b.created_at));
1445
- if (visible.length === 0)
1446
- return "";
1447
- const indexById = new Map;
1448
- visible.forEach((c, i) => {
1449
- indexById.set(c.id, i + 1);
1450
- });
1451
- let rendered = visible;
1452
- let elidedCount = 0;
1453
- if (maxComments && visible.length > maxComments) {
1454
- const keep = criticalIds(visible);
1455
- const recentThreshold = visible.length - maxComments;
1456
- rendered = visible.filter((c, i) => i >= recentThreshold || keep.has(c.id));
1457
- elidedCount = visible.length - rendered.length;
1458
- }
1459
- const ref = (id) => {
1460
- const n = indexById.get(id);
1461
- return n ? `#${n}` : `#${id.slice(0, 8)}`;
1268
+ function stageDisallowedTools() {
1269
+ return STAGE_DAEMON_OWNED_TOOLS.length > 0 ? STAGE_DAEMON_OWNED_TOOLS.join(",") : null;
1270
+ }
1271
+ var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE, STAGE_DAEMON_OWNED_TOOLS;
1272
+ var init_playbookStage = __esm(() => {
1273
+ SKILL_TOOL_ALLOWLIST = {
1274
+ hmy: "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
1275
+ "hmy-new": "Read,Grep,Glob,mcp__harmony__*",
1276
+ "hmy-plan": "Read,Grep,Glob,mcp__harmony__*",
1277
+ "hmy-review": "Read,Grep,Glob,Bash,mcp__harmony__*",
1278
+ "hmy-cleanup": "Read,Grep,Glob,mcp__harmony__*",
1279
+ "hmy-standup": "Read,Grep,Glob,mcp__harmony__*"
1462
1280
  };
1463
- const lines = [];
1464
- if (elidedCount > 0) {
1465
- lines.push({
1466
- at: visible[0]?.created_at ?? "",
1467
- text: `(${elidedCount} earlier comment(s) omitted for brevity)`
1468
- });
1469
- }
1470
- for (const c of rendered) {
1471
- const tags = [];
1472
- if (c.edited_at)
1473
- tags.push("edited");
1474
- if (c.reply_to_id)
1475
- tags.push(`reply to ${ref(c.reply_to_id)}`);
1476
- if (c.supersedes_id)
1477
- tags.push(`supersedes ${ref(c.supersedes_id)}`);
1478
- if (c.confirms_id)
1479
- tags.push(`confirms ${ref(c.confirms_id)}`);
1480
- if (c.resolved_at)
1481
- tags.push("resolved");
1482
- const tagStr = tags.length ? ` | ${tags.join(" | ")}` : "";
1483
- const header = `[${sanitizeHeaderField(ref(c.id))} | ${sanitizeHeaderField(c.author_type)} | ${authorLabel(c)} | ${sanitizeHeaderField(c.comment_type)} | ${sanitizeHeaderField(c.created_at)}${tagStr}]`;
1484
- const fencedBody = c.body.trim().replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1485
- lines.push({
1486
- at: c.created_at,
1487
- text: `${header}
1488
- <comment-body>
1489
- ${fencedBody}
1490
- </comment-body>`
1491
- });
1492
- }
1493
- for (const a of activity) {
1494
- const actor = a.actor ? `${a.actor} ` : "";
1495
- lines.push({ at: a.at, text: `· (system) ${a.at} — ${actor}${a.text}` });
1496
- }
1497
- lines.sort((a, b) => a.at.localeCompare(b.at));
1498
- const body = lines.map((l) => l.text).join(`
1281
+ HARMONY_TOOL_RE = /^harmony_[a-z_]+$/;
1282
+ STAGE_DAEMON_OWNED_TOOLS = [
1283
+ "mcp__harmony__harmony_end_agent_session",
1284
+ "mcp__harmony__harmony_start_agent_session",
1285
+ "mcp__harmony__harmony_move_card"
1286
+ ];
1287
+ });
1499
1288
 
1500
- `);
1501
- const instruction = includeInstructions ? `
1289
+ // ../harmony-shared/dist/projectTemplates.js
1290
+ var init_projectTemplates = () => {};
1502
1291
 
1503
- ${CONFLICT_INSTRUCTION}` : "";
1504
- return `## ${heading} (oldest newest)
1292
+ // ../harmony-shared/dist/reviewMethodology.js
1293
+ var REVIEW_SYSTEM_PROMPT = `You are a senior code reviewer. Follow this two-pass methodology strictly.
1294
+ Report findings; do NOT fix them. This is a read-only review.
1505
1295
 
1506
- ${body}${instruction}`;
1507
- }
1508
- var CONFLICT_INSTRUCTION;
1509
- var init_commentSerializer = __esm(() => {
1510
- CONFLICT_INSTRUCTION = "When two comments conflict, prefer the latest created_at, UNLESS a later " + "comment explicitly confirms or restates the earlier finding. Evaluate " + "substance, not just recency. Cite the comment id(s) you relied on.";
1511
- });
1296
+ Review the diff through five lenses on every pass: functionality, security,
1297
+ performance, code quality, and best practices. For every finding, set
1298
+ \`relatedToDiff\`: true when the change under review introduced or exposed it,
1299
+ false when it is a pre-existing issue you happened to notice. Only diff-caused
1300
+ findings gate the verdict pre-existing ones are reported for context and never
1301
+ block.
1512
1302
 
1513
- // ../harmony-shared/dist/constants.js
1514
- var TIMINGS;
1515
- var init_constants = __esm(() => {
1516
- TIMINGS = {
1517
- SEARCH_DEBOUNCE: 300,
1518
- AUTOSAVE_DEBOUNCE: 1000,
1519
- TOAST_DURATION: 3000,
1520
- QUERY_STALE_TIME: 1000 * 60 * 5,
1521
- QUERY_GC_TIME: 1000 * 60 * 60 * 24
1522
- };
1523
- });
1524
- // ../harmony-shared/dist/gateEvaluate.js
1525
- function isGateKind(value) {
1526
- return typeof value === "string" && GATE_KINDS.includes(value);
1527
- }
1528
- function isGateOperator(value) {
1529
- return typeof value === "string" && GATE_OPERATORS.includes(value);
1530
- }
1531
- function gateEvaluate(gateSpec, evidence) {
1532
- const structured = isPlainObject(evidence) ? evidence.structured ?? {} : {};
1533
- const safeStructured = isPlainObject(structured) ? structured : {};
1534
- if (!isPlainObject(gateSpec)) {
1535
- return {
1536
- passed: false,
1537
- findings: [{ level: "error", message: "Malformed gate: not an object." }],
1538
- structured: safeStructured
1539
- };
1540
- }
1541
- const spec = gateSpec;
1542
- if (!isGateKind(spec.kind)) {
1543
- return {
1544
- passed: false,
1545
- findings: [
1546
- {
1547
- level: "error",
1548
- message: `Malformed gate: unknown kind ${formatValue(spec.kind)}.`
1549
- }
1550
- ],
1551
- structured: safeStructured
1552
- };
1553
- }
1554
- if (spec.pendingEngine === true) {
1555
- return {
1556
- passed: true,
1557
- findings: [
1558
- {
1559
- level: "info",
1560
- message: `Gate "${spec.kind}" is advisory (pending engine); not enforced.`
1561
- }
1562
- ],
1563
- structured: safeStructured
1564
- };
1565
- }
1566
- if (!isPlainObject(evidence)) {
1567
- return {
1568
- passed: false,
1569
- findings: [
1570
- { level: "error", message: "Malformed evidence: not an object." }
1571
- ],
1572
- structured: safeStructured
1573
- };
1574
- }
1575
- const result = evidence.result;
1576
- const resultIsKnown = result === "passed" || result === "failed" || result === "blocked";
1577
- const conditions = Array.isArray(spec.conditions) ? spec.conditions : null;
1578
- if (conditions === null) {
1579
- if (result === "passed") {
1580
- return {
1581
- passed: true,
1582
- findings: [
1583
- {
1584
- level: "info",
1585
- message: `Gate "${spec.kind}" passed on evidence result.`
1586
- }
1587
- ],
1588
- structured: safeStructured
1589
- };
1303
+ ## Two-Pass Review
1304
+
1305
+ ### Pass 1 — CRITICAL (highest severity)
1306
+
1307
+ **SQL & Data Safety**
1308
+ - String interpolation in SQL — use parameterized queries / prepared statements
1309
+ - TOCTOU races: check-then-set patterns that should be atomic WHERE + UPDATE
1310
+
1311
+ **Race Conditions & Concurrency**
1312
+ - Read-check-write without uniqueness constraint or duplicate key handling
1313
+ - Status transitions without atomic WHERE old_status UPDATE SET new_status
1314
+ - Unsafe HTML rendering (dangerouslySetInnerHTML, v-html) on user-controlled data (XSS)
1315
+
1316
+ **Security & Access Control**
1317
+ - Hardcoded secrets, API keys, or credentials committed to source
1318
+ - New endpoints, mutations, or service-role/RLS-exempt queries missing an auth or ownership check
1319
+ - Over-broad CORS, missing input validation on a trust boundary, injection beyond SQL (command, path, template)
1320
+
1321
+ **LLM Output Trust Boundary**
1322
+ - LLM-generated values written to DB without format validation (EMAIL_REGEXP, URI.parse, .trim())
1323
+ - Structured tool output accepted without type/shape checks before database writes
1324
+
1325
+ **Enum & Value Completeness**
1326
+ - When the diff introduces a new enum/status/type value, trace it through every consumer
1327
+ - Check allowlists, filter arrays, and case/if-elsif chains for the new value
1328
+ - Use Grep to find all references to sibling values and Read each match — look OUTSIDE the diff
1329
+
1330
+ ### Pass 2 — INFORMATIONAL (lower severity)
1331
+
1332
+ **Functionality & Edge Cases**
1333
+ - Logic errors, off-by-one, unhandled null/undefined, wrong API or library usage
1334
+ - Conditional side effects: code paths that branch but forget a side effect on one branch (e.g., promoting without attaching URL)
1335
+
1336
+ **Performance**
1337
+ - O(n²) algorithms and O(n*m) lookups (Array.find in a loop instead of a Map/index)
1338
+ - N+1 queries, unbounded fetches missing pagination, repeated work that should be cached/memoized
1339
+ - Unnecessary React re-renders (unstable props/deps, inline object/array literals); leaked subscriptions, timers, or listeners
1340
+ - Inline styles re-parsed every render
1341
+
1342
+ **Code Quality**
1343
+ - Dead code: variables assigned but never read, unreachable branches
1344
+ - Duplication that should be extracted, over-long functions, unclear naming
1345
+ - \`any\` / unchecked casts that defeat the type system
1346
+ - Comments/docstrings describing old behavior after code changed
1347
+
1348
+ **Best Practices & Conventions**
1349
+ - Deviations from established project conventions and framework idioms / anti-patterns
1350
+ - React hook dependency arrays that are wrong, missing, or over-broad
1351
+ - Accessibility gaps on new UI: missing labels, roles, alt text, or keyboard paths
1352
+
1353
+ **Test Gaps**
1354
+ - Missing negative-path tests for new error handling
1355
+ - Security enforcement features without integration tests
1356
+
1357
+ **Completeness Gaps**
1358
+ - Partial enum handling, incomplete error paths, missing edge cases that are straightforward to add
1359
+
1360
+ ## Severity Classification
1361
+
1362
+ - **critical**: SQL safety, race conditions, XSS, secrets/auth/injection holes, LLM trust boundary violations, enum completeness gaps causing runtime errors
1363
+ - **major**: Missing requirements, broken functionality, significant completeness gaps, conditional side effects, performance regressions on a hot path
1364
+ - **minor**: Dead code, stale comments, test gaps, naming/duplication, minor view issues, cosmetic completeness gaps
1365
+
1366
+ ## Suppressions DO NOT flag these
1367
+
1368
+ - Redundancy that aids readability (e.g., present? redundant with length > 20)
1369
+ - "Add a comment explaining why this threshold was chosen" — thresholds change, comments rot
1370
+ - Consistency-only changes (wrapping a value to match how another constant is guarded)
1371
+ - Regex edge cases when input is constrained and the edge case never occurs in practice
1372
+ - Eval threshold changes — these are tuned empirically
1373
+ - Harmless no-ops (e.g., .reject on an element never in the array)
1374
+ - Pre-existing issues unrelated to the diff, beyond a single noted finding (set relatedToDiff:false; never block on them)
1375
+ - ANYTHING already addressed in the diff you are reviewing — read the FULL diff before flagging`, REVIEW_ACCEPTANCE_CHECKS = `## Acceptance Checks
1376
+
1377
+ Before judging code quality, verify the change actually satisfies the card.
1378
+ Derive one acceptance check per concrete requirement in the card description and
1379
+ one per subtask (the stated acceptance criteria). For each, assign a status from
1380
+ hard evidence — cite the file:line you read or the dev-server behaviour you
1381
+ observed that proves it:
1382
+
1383
+ - **pass** — implemented and verified by code you read or behaviour you observed
1384
+ - **partial** — started but incomplete (a missing branch, an edge case, or one of several bundled requirements)
1385
+ - **fail** — required but absent, or implemented incorrectly
1386
+ - **unverifiable** — cannot be confirmed from the diff or a running app (state why)
1387
+
1388
+ Do NOT mark a check "pass" on the implementing agent's say-so or a subtask's
1389
+ checkbox alone — only on evidence you found yourself. Any \`fail\` or \`partial\`
1390
+ check is an unaddressed requirement and forces a rejected verdict.`, QA_VISUAL_CHECKLIST = `## Visual QA Checklist
1391
+
1392
+ For each page affected by the changes:
1393
+
1394
+ 1. **Visual scan** — Screenshot the page. Check for layout breaks, broken images, alignment issues, z-index problems.
1395
+ 2. **Interactive elements** — Click every button, link, and control. Does each do what it says?
1396
+ 3. **Forms** — Fill and submit. Test empty submission, invalid data, edge cases.
1397
+ 4. **Navigation** — Check all paths in/out. Breadcrumbs, back button, deep links.
1398
+ 5. **States** — Check empty state, loading state, error state, overflow state.
1399
+ 6. **Console** — Check for JS exceptions, failed network requests (4xx/5xx), CORS errors after interactions.
1400
+ 7. **Responsiveness** — If the change is visual, check mobile viewport (375px).
1401
+
1402
+ ### SPA-Specific (React/Vite)
1403
+ - Use snapshot for navigation — client-side routes may not appear in link lists.
1404
+ - Check for stale state: navigate away and back — does data refresh correctly?
1405
+ - Test browser back/forward — does the app handle history correctly?
1406
+ - Watch for hydration errors or layout shifts after dynamic content loads.`, REVIEW_VERDICT_SCHEMA = `{
1407
+ "verdict": "approved" | "rejected",
1408
+ "summary": "Brief overall assessment",
1409
+ "scopeCheck": {
1410
+ "status": "clean" | "drift" | "missing",
1411
+ "notes": "Optional explanation of scope issues"
1412
+ },
1413
+ "acceptanceChecks": [
1414
+ {
1415
+ "criterion": "The requirement or subtask being verified",
1416
+ "status": "pass" | "partial" | "fail" | "unverifiable",
1417
+ "evidence": "file:line or observed behaviour that proves the status"
1590
1418
  }
1591
- return {
1592
- passed: false,
1593
- findings: [
1594
- {
1595
- level: "error",
1596
- message: resultIsKnown ? `Gate "${spec.kind}" not satisfied: evidence result is "${String(result)}".` : `Gate "${spec.kind}" not satisfied: evidence result is missing or invalid.`
1597
- }
1598
- ],
1599
- structured: safeStructured
1600
- };
1601
- }
1602
- const mode = spec.mode === "any" ? "any" : "all";
1603
- const findings = [];
1604
- const outcomes = [];
1605
- for (const raw of conditions) {
1606
- const { ok, finding } = evaluateCondition(raw, safeStructured);
1607
- outcomes.push(ok);
1608
- if (finding)
1609
- findings.push(finding);
1610
- }
1611
- if (result === "blocked") {
1612
- findings.unshift({
1613
- level: "error",
1614
- message: `Gate "${spec.kind}" cannot pass: evidence result is "blocked".`
1615
- });
1616
- return { passed: false, findings, structured: safeStructured };
1617
- }
1618
- let predicatePassed;
1619
- if (mode === "any") {
1620
- predicatePassed = outcomes.length > 0 && outcomes.some((o) => o);
1621
- if (outcomes.length === 0) {
1622
- findings.push({
1623
- level: "error",
1624
- message: `Gate "${spec.kind}" (mode "any") has no conditions to satisfy.`
1625
- });
1419
+ ],
1420
+ "findings": [
1421
+ {
1422
+ "severity": "critical" | "major" | "minor",
1423
+ "category": "sql-safety | race-condition | security | llm-trust | enum-completeness | functional | performance | code-quality | best-practices | accessibility | visual | ux | console | scope | other",
1424
+ "title": "Short title",
1425
+ "description": "Detailed description of the issue",
1426
+ "location": "file:line (if applicable)",
1427
+ "relatedToDiff": true
1626
1428
  }
1627
- } else {
1628
- predicatePassed = outcomes.every((o) => o);
1629
- }
1630
- if (predicatePassed) {
1631
- findings.push({
1632
- level: "info",
1633
- message: `Gate "${spec.kind}" predicate satisfied (mode "${mode}").`
1634
- });
1429
+ ]
1430
+ }`, REVIEW_DECISION_RULES = `Counting only findings with \`relatedToDiff: true\`:
1431
+ - **rejected**: Any acceptance check that is \`fail\` or \`partial\`, any \`critical\` finding, unaddressed requirements, or 2+ \`major\` findings.
1432
+ - **approved**: Every acceptance check \`pass\` (or \`unverifiable\` with a stated reason), no critical findings, at most 1 major finding; minor findings OK.`;
1433
+ // ../harmony-shared/dist/stageHandoff.js
1434
+ function buildHandoffCommentBody(input) {
1435
+ const handoff = {
1436
+ version: STAGE_HANDOFF_VERSION,
1437
+ stageId: input.stageId,
1438
+ stageName: input.stageName,
1439
+ artifactType: input.artifactType,
1440
+ produced: input.produced,
1441
+ decisions: input.decisions,
1442
+ nextStageNeeds: input.nextStageNeeds,
1443
+ producedAt: input.producedAt ?? new Date().toISOString()
1444
+ };
1445
+ const decisionLines = handoff.decisions.length > 0 ? handoff.decisions.map((d) => `- ${d}`).join(`
1446
+ `) : "_None._";
1447
+ const prose = [
1448
+ `**Stage handoff — ${handoff.stageName}**`,
1449
+ "",
1450
+ `**Produced:** ${handoff.produced}`,
1451
+ "",
1452
+ "**Decisions (settled — do not re-litigate):**",
1453
+ decisionLines,
1454
+ "",
1455
+ `**What the next stage needs:** ${handoff.nextStageNeeds}`
1456
+ ].join(`
1457
+ `);
1458
+ const payload = [
1459
+ "```json",
1460
+ `// ${HANDOFF_MARKER}`,
1461
+ JSON.stringify(handoff, null, 2),
1462
+ "```"
1463
+ ].join(`
1464
+ `);
1465
+ return `${prose}
1466
+
1467
+ ${payload}`;
1468
+ }
1469
+ function isTypedStageHandoff(value) {
1470
+ if (typeof value !== "object" || value === null)
1471
+ return false;
1472
+ const v = value;
1473
+ return typeof v.stageId === "string" && typeof v.stageName === "string" && typeof v.produced === "string" && typeof v.nextStageNeeds === "string" && typeof v.producedAt === "string" && Array.isArray(v.decisions) && v.decisions.every((d) => typeof d === "string") && (v.artifactType === null || typeof v.artifactType === "string") && v.version === STAGE_HANDOFF_VERSION;
1474
+ }
1475
+ function parseHandoffCommentBody(body) {
1476
+ const match = HANDOFF_BLOCK_RE.exec(body);
1477
+ if (!match)
1478
+ return null;
1479
+ try {
1480
+ const parsed = JSON.parse(match[1]);
1481
+ return isTypedStageHandoff(parsed) ? parsed : null;
1482
+ } catch {
1483
+ return null;
1635
1484
  }
1636
- return { passed: predicatePassed, findings, structured: safeStructured };
1637
1485
  }
1638
- function evaluateCondition(raw, structured) {
1639
- if (!isPlainObject(raw)) {
1640
- return {
1641
- ok: false,
1642
- finding: {
1643
- level: "error",
1644
- message: "Malformed condition: not an object."
1645
- }
1646
- };
1486
+ function extractLatestHandoff(comments, opts = {}) {
1487
+ let best = null;
1488
+ for (const c of comments) {
1489
+ if (c.deleted_at)
1490
+ continue;
1491
+ if (c.author_type !== "agent")
1492
+ continue;
1493
+ const handoff = parseHandoffCommentBody(c.body);
1494
+ if (!handoff)
1495
+ continue;
1496
+ if (opts.excludeStageId && handoff.stageId === opts.excludeStageId)
1497
+ continue;
1498
+ if (!best || c.created_at.localeCompare(best.at) > 0) {
1499
+ best = { handoff, at: c.created_at };
1500
+ }
1647
1501
  }
1648
- const cond = raw;
1649
- if (typeof cond.path !== "string" || cond.path.length === 0) {
1650
- return {
1651
- ok: false,
1652
- finding: {
1653
- level: "error",
1654
- message: "Malformed condition: missing string `path`."
1655
- }
1656
- };
1502
+ return best?.handoff ?? null;
1503
+ }
1504
+ function renderInheritedHandoffSection(handoff) {
1505
+ const decisions = handoff.decisions.length > 0 ? handoff.decisions.map((d) => `- ${d}`).join(`
1506
+ `) : "- (none recorded)";
1507
+ return [
1508
+ "## Inherited handoff (from the previous stage)",
1509
+ "",
1510
+ `This is the only state you inherit. The **${handoff.stageName}** stage produced it; treat its decisions as settled.`,
1511
+ "",
1512
+ `**Produced:** ${handoff.produced}`,
1513
+ "",
1514
+ "**Decisions you must respect:**",
1515
+ decisions,
1516
+ "",
1517
+ `**What you need to do with it:** ${handoff.nextStageNeeds}`
1518
+ ].join(`
1519
+ `);
1520
+ }
1521
+ var STAGE_HANDOFF_VERSION = 1, HANDOFF_MARKER = "harmony:stage-handoff", HANDOFF_BLOCK_RE;
1522
+ var init_stageHandoff = __esm(() => {
1523
+ HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
1524
+ });
1525
+
1526
+ // ../harmony-shared/dist/types.js
1527
+ var init_types2 = () => {};
1528
+
1529
+ // ../harmony-shared/dist/index.js
1530
+ var init_dist = __esm(() => {
1531
+ init_branchRef();
1532
+ init_cardLinks();
1533
+ init_classification();
1534
+ init_commentSerializer();
1535
+ init_constants();
1536
+ init_gateEvaluate();
1537
+ init_logger();
1538
+ init_playbookCatalog();
1539
+ init_playbookStage();
1540
+ init_projectTemplates();
1541
+ init_stageHandoff();
1542
+ init_types2();
1543
+ });
1544
+
1545
+ // src/git-pr.ts
1546
+ var exports_git_pr = {};
1547
+ __export(exports_git_pr, {
1548
+ validateGitProviderCli: () => validateGitProviderCli,
1549
+ upsertReviewedSha: () => upsertReviewedSha,
1550
+ updateExistingPr: () => updateExistingPr,
1551
+ resolvePrUrl: () => resolvePrUrl,
1552
+ resolvePrHeadBranch: () => resolvePrHeadBranch,
1553
+ renameRemoteBranch: () => renameRemoteBranch,
1554
+ remoteBranchExists: () => remoteBranchExists,
1555
+ pushBranch: () => pushBranch,
1556
+ mergePullRequest: () => mergePullRequest,
1557
+ getPrStatus: () => getPrStatus,
1558
+ getHeadSha: () => getHeadSha,
1559
+ getBranchWebUrl: () => getBranchWebUrl,
1560
+ findExistingPr: () => findExistingPr,
1561
+ extractReviewedSha: () => extractReviewedSha,
1562
+ extractPrUrl: () => extractPrUrl,
1563
+ extractAzurePrId: () => extractAzurePrId,
1564
+ detectGitProvider: () => detectGitProvider,
1565
+ deriveCiStatus: () => deriveCiStatus,
1566
+ decidePrBranch: () => decidePrBranch,
1567
+ createPullRequest: () => createPullRequest,
1568
+ checkPrMergeStatus: () => checkPrMergeStatus,
1569
+ buildPrBody: () => buildPrBody
1570
+ });
1571
+ import { execFile, execFileSync } from "node:child_process";
1572
+ import { promisify } from "node:util";
1573
+ function detectGitProvider(cwd) {
1574
+ try {
1575
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
1576
+ cwd,
1577
+ encoding: "utf-8"
1578
+ }).trim();
1579
+ if (url.includes("github.com"))
1580
+ return "github";
1581
+ if (url.includes("dev.azure.com") || url.includes("visualstudio.com"))
1582
+ return "azure";
1583
+ if (url.includes("gitlab.com") || /\bgitlab\b/.test(url))
1584
+ return "gitlab";
1585
+ if (url.includes("bitbucket.org"))
1586
+ return "bitbucket";
1587
+ return "unknown";
1588
+ } catch {
1589
+ return "unknown";
1657
1590
  }
1658
- if (!isGateOperator(cond.op)) {
1659
- return {
1660
- ok: false,
1661
- finding: {
1662
- level: "error",
1663
- message: `Unknown operator ${formatValue(cond.op)} at "${cond.path}"; failing closed.`,
1664
- path: cond.path
1591
+ }
1592
+ function validateGitProviderCli(provider, cwd) {
1593
+ switch (provider) {
1594
+ case "github": {
1595
+ try {
1596
+ execFileSync("gh", ["auth", "status"], { cwd, stdio: "pipe" });
1597
+ } catch {
1598
+ throw new Error("GitHub CLI (gh) is not authenticated. Run: gh auth login");
1665
1599
  }
1666
- };
1667
- }
1668
- const actual = resolvePath(structured, cond.path);
1669
- const expected = cond.value;
1670
- const op = cond.op;
1671
- let ok;
1672
- switch (op) {
1673
- case "exists":
1674
- ok = actual !== undefined;
1675
- break;
1676
- case "eq":
1677
- ok = strictEquals(actual, expected);
1678
- break;
1679
- case "neq":
1680
- ok = !strictEquals(actual, expected);
1681
1600
  break;
1682
- case "gte":
1683
- case "gt":
1684
- case "lte":
1685
- case "lt":
1686
- ok = numericCompare(op, actual, expected);
1601
+ }
1602
+ case "azure": {
1603
+ try {
1604
+ execFileSync("az", ["--version"], { cwd, stdio: "pipe" });
1605
+ } catch {
1606
+ throw new Error("Azure CLI (az) not found. Install it: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli");
1607
+ }
1608
+ try {
1609
+ execFileSync("az", ["account", "show"], { cwd, stdio: "pipe" });
1610
+ } catch {
1611
+ throw new Error("Azure CLI is not authenticated. Run: az login");
1612
+ }
1687
1613
  break;
1688
- case "contains":
1689
- ok = containsCheck(actual, expected);
1614
+ }
1615
+ case "gitlab": {
1616
+ try {
1617
+ execFileSync("glab", ["auth", "status"], { cwd, stdio: "pipe" });
1618
+ } catch {
1619
+ throw new Error("GitLab CLI (glab) is not installed or not authenticated. Install: https://gitlab.com/gitlab-org/cli — then run: glab auth login");
1620
+ }
1690
1621
  break;
1691
- default: {
1692
- const _never = op;
1693
- ok = false;
1694
1622
  }
1623
+ case "bitbucket":
1624
+ case "unknown":
1625
+ log.warn(TAG2, `Git provider "${provider}" — PR creation will be skipped (no CLI support)`);
1626
+ break;
1695
1627
  }
1696
- if (ok)
1697
- return { ok: true };
1698
- return {
1699
- ok: false,
1700
- finding: {
1701
- level: "error",
1702
- message: `Condition failed: ${cond.path} ${op} ${formatValue(expected)} (actual: ${formatValue(actual)}).`,
1703
- path: cond.path
1704
- }
1705
- };
1706
1628
  }
1707
- function isPlainObject(value) {
1708
- return typeof value === "object" && value !== null && !Array.isArray(value);
1629
+ function isValidPrUrl(url) {
1630
+ return VALID_PR_URL_RE.test(url);
1709
1631
  }
1710
- function resolvePath(root, path) {
1711
- const segments = path.split(".");
1712
- let current = root;
1713
- for (const segment of segments) {
1714
- if (current === null || current === undefined)
1715
- return;
1716
- if (Array.isArray(current)) {
1717
- const index = Number(segment);
1718
- if (!Number.isInteger(index) || index < 0 || index >= current.length) {
1719
- return;
1632
+ function extractReviewedSha(description) {
1633
+ if (!description)
1634
+ return null;
1635
+ const m = description.match(REVIEWED_SHA_RE);
1636
+ return m ? m[1] : null;
1637
+ }
1638
+ function upsertReviewedSha(description, sha) {
1639
+ const line = `Reviewed-SHA: ${sha}`;
1640
+ if (REVIEWED_SHA_RE.test(description)) {
1641
+ return description.replace(REVIEWED_SHA_RE, line);
1642
+ }
1643
+ const sep = description ? `
1644
+ ` : "";
1645
+ return `${description}${sep}${line}`;
1646
+ }
1647
+ function deriveCiStatus(rollup) {
1648
+ if (!Array.isArray(rollup) || rollup.length === 0)
1649
+ return "unknown";
1650
+ let anyPending = false;
1651
+ for (const check of rollup) {
1652
+ if (typeof check !== "object" || check === null)
1653
+ continue;
1654
+ const c = check;
1655
+ if (typeof c.status === "string") {
1656
+ if (c.status.toUpperCase() !== "COMPLETED") {
1657
+ anyPending = true;
1658
+ continue;
1720
1659
  }
1721
- current = current[index];
1722
- } else if (typeof current === "object") {
1723
- if (!Object.hasOwn(current, segment)) {
1724
- return;
1660
+ const conclusion = typeof c.conclusion === "string" ? c.conclusion.toUpperCase() : "";
1661
+ if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion))
1662
+ continue;
1663
+ return "failure";
1664
+ }
1665
+ if (typeof c.state === "string") {
1666
+ const state = c.state.toUpperCase();
1667
+ if (state === "SUCCESS")
1668
+ continue;
1669
+ if (state === "PENDING") {
1670
+ anyPending = true;
1671
+ continue;
1725
1672
  }
1726
- current = current[segment];
1727
- } else {
1728
- return;
1673
+ return "failure";
1729
1674
  }
1730
1675
  }
1731
- return current;
1732
- }
1733
- function strictEquals(a, b) {
1734
- if (a === null || b === null)
1735
- return a === b;
1736
- const t = typeof a;
1737
- if (t !== "string" && t !== "number" && t !== "boolean")
1738
- return false;
1739
- return a === b;
1676
+ return anyPending ? "pending" : "success";
1740
1677
  }
1741
- function numericCompare(op, actual, expected) {
1742
- if (typeof actual !== "number" || typeof expected !== "number")
1743
- return false;
1744
- if (Number.isNaN(actual) || Number.isNaN(expected))
1745
- return false;
1746
- switch (op) {
1747
- case "gte":
1748
- return actual >= expected;
1749
- case "gt":
1750
- return actual > expected;
1751
- case "lte":
1752
- return actual <= expected;
1753
- case "lt":
1754
- return actual < expected;
1678
+ async function getPrStatus(prUrl, cwd, provider) {
1679
+ if (provider !== "github" || !isValidPrUrl(prUrl)) {
1680
+ return { ciStatus: "unknown", headSha: null };
1681
+ }
1682
+ try {
1683
+ const { stdout } = await execFileAsync("gh", ["pr", "view", prUrl, "--json", "statusCheckRollup,headRefOid"], { cwd, encoding: "utf-8", timeout: 1e4 });
1684
+ const parsed = JSON.parse(stdout.trim());
1685
+ const headSha = typeof parsed.headRefOid === "string" ? parsed.headRefOid : null;
1686
+ return { ciStatus: deriveCiStatus(parsed.statusCheckRollup), headSha };
1687
+ } catch {
1688
+ return { ciStatus: "unknown", headSha: null };
1755
1689
  }
1756
1690
  }
1757
- function containsCheck(actual, expected) {
1758
- if (typeof actual === "string" && typeof expected === "string") {
1759
- return actual.includes(expected);
1760
- }
1761
- if (Array.isArray(actual)) {
1762
- return actual.some((el) => strictEquals(el, expected));
1691
+ async function mergePullRequest(prUrl, cwd, provider, strategy, deleteBranch) {
1692
+ if (provider !== "github") {
1693
+ throw new Error(`auto-merge unsupported for provider "${provider}"`);
1763
1694
  }
1764
- return false;
1695
+ const args = ["pr", "merge", prUrl, `--${strategy}`];
1696
+ if (deleteBranch)
1697
+ args.push("--delete-branch");
1698
+ await execFileAsync("gh", args, { cwd, encoding: "utf-8", timeout: 30000 });
1765
1699
  }
1766
- function formatValue(value) {
1767
- if (value === undefined)
1768
- return "undefined";
1769
- if (value === null)
1770
- return "null";
1771
- if (typeof value === "string")
1772
- return JSON.stringify(value);
1773
- if (typeof value === "number" || typeof value === "boolean") {
1774
- return String(value);
1700
+ function getHeadSha(cwd) {
1701
+ try {
1702
+ return execFileSync("git", ["rev-parse", "HEAD"], {
1703
+ cwd,
1704
+ encoding: "utf-8"
1705
+ }).trim();
1706
+ } catch {
1707
+ return null;
1775
1708
  }
1709
+ }
1710
+ async function checkPrMergeStatus(prUrl, cwd, provider) {
1711
+ if (!isValidPrUrl(prUrl))
1712
+ return "unknown";
1776
1713
  try {
1777
- return JSON.stringify(value);
1714
+ switch (provider) {
1715
+ case "github": {
1716
+ const { stdout } = await execFileAsync("gh", ["pr", "view", prUrl, "--json", "state", "--jq", ".state"], { cwd, encoding: "utf-8", timeout: 1e4 });
1717
+ switch (stdout.trim()) {
1718
+ case "MERGED":
1719
+ return "merged";
1720
+ case "OPEN":
1721
+ return "open";
1722
+ case "CLOSED":
1723
+ return "closed";
1724
+ default:
1725
+ return "unknown";
1726
+ }
1727
+ }
1728
+ case "gitlab": {
1729
+ const mrMatch = prUrl.match(/merge_requests\/(\d+)/);
1730
+ if (!mrMatch)
1731
+ return "unknown";
1732
+ const { stdout } = await execFileAsync("glab", ["mr", "view", mrMatch[1], "--output", "json"], { cwd, encoding: "utf-8", timeout: 1e4 });
1733
+ let parsed;
1734
+ try {
1735
+ parsed = JSON.parse(stdout.trim());
1736
+ } catch {
1737
+ log.warn(TAG2, `Failed to parse glab JSON output for MR ${mrMatch[1]}`);
1738
+ return "unknown";
1739
+ }
1740
+ if (typeof parsed !== "object" || parsed === null)
1741
+ return "unknown";
1742
+ const state = parsed.state;
1743
+ if (state === "merged")
1744
+ return "merged";
1745
+ if (state === "opened")
1746
+ return "open";
1747
+ if (state === "closed")
1748
+ return "closed";
1749
+ return "unknown";
1750
+ }
1751
+ default:
1752
+ return "unknown";
1753
+ }
1778
1754
  } catch {
1779
- return "[unserializable]";
1755
+ return "unknown";
1780
1756
  }
1781
1757
  }
1782
- var GATE_KINDS, GATE_OPERATORS;
1783
- var init_gateEvaluate = __esm(() => {
1784
- GATE_KINDS = [
1785
- "build_green",
1786
- "review_passed",
1787
- "checklist",
1788
- "dod",
1789
- "artifact",
1790
- "label",
1791
- "custom"
1792
- ];
1793
- GATE_OPERATORS = [
1794
- "eq",
1795
- "neq",
1796
- "gte",
1797
- "gt",
1798
- "lte",
1799
- "lt",
1800
- "contains",
1801
- "exists"
1802
- ];
1803
- });
1804
-
1805
- // ../harmony-shared/dist/gateEvidence.js
1806
- function toStageGateEvidenceInsert(context, evidence) {
1807
- return {
1808
- card_id: context.cardId,
1809
- workspace_id: context.workspaceId,
1810
- stage_id: context.stageId,
1811
- gate_kind: context.gate.kind,
1812
- result: evidence.result,
1813
- structured: evidence.structured
1814
- };
1758
+ function stripHeadsPrefix(ref) {
1759
+ return ref.replace(/^refs\/heads\//, "");
1815
1760
  }
1816
-
1817
- // ../harmony-shared/dist/logger.js
1818
- var init_logger = () => {};
1819
- // ../harmony-shared/dist/playbookCatalog.js
1820
- var init_playbookCatalog = () => {};
1821
-
1822
- // ../harmony-shared/dist/playbookStage.js
1823
- function normalizeLoopDef(raw) {
1824
- if (raw === null || typeof raw !== "object" || Array.isArray(raw))
1825
- return null;
1826
- const obj = raw;
1827
- if (obj.mode !== "converge" && obj.mode !== "fanout")
1828
- return null;
1829
- const mode = obj.mode;
1830
- const rawMax = obj.max_iterations;
1831
- const maxInt = typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax >= 1 ? Math.floor(rawMax) : DEFAULT_LOOP_MAX_ITERATIONS;
1832
- const exitGate = obj.exit_gate && typeof obj.exit_gate === "object" && !Array.isArray(obj.exit_gate) ? obj.exit_gate : null;
1833
- const def = { mode, max_iterations: maxInt };
1834
- if (exitGate)
1835
- def.exit_gate = exitGate;
1836
- if (obj.item_source && typeof obj.item_source === "object" && !Array.isArray(obj.item_source)) {
1837
- def.item_source = obj.item_source;
1761
+ function branchOrSkip(branch) {
1762
+ if (!branch)
1763
+ return { kind: "skip", reason: "PR has no head branch name" };
1764
+ if (!SAFE_GIT_REF_PATTERN.test(branch)) {
1765
+ return { kind: "skip", reason: `unsafe git ref: ${branch}` };
1838
1766
  }
1839
- if (typeof obj.concurrency === "number" && obj.concurrency >= 1) {
1840
- def.concurrency = Math.floor(obj.concurrency);
1767
+ return { kind: "branch", branch };
1768
+ }
1769
+ function decideGithubPrBranch(rawJson) {
1770
+ if (rawJson === null) {
1771
+ return { kind: "skip", reason: "gh pr view failed" };
1841
1772
  }
1842
- if (obj.on_item_fail === "continue" || obj.on_item_fail === "halt") {
1843
- def.on_item_fail = obj.on_item_fail;
1773
+ let parsed;
1774
+ try {
1775
+ parsed = JSON.parse(rawJson.trim());
1776
+ } catch {
1777
+ return { kind: "skip", reason: "unparseable gh pr view output" };
1844
1778
  }
1845
- return def;
1846
- }
1847
- function getStageLoop(stage) {
1848
- return normalizeLoopDef(stage.loop);
1849
- }
1850
- function isConvergeLoop(loop) {
1851
- return loop !== null && loop.mode === "converge";
1852
- }
1853
- function resolveLoopExitGate(stage, loop) {
1854
- return loop.exit_gate ?? stage.gate ?? null;
1855
- }
1856
- function decideLoopContinuation(args) {
1857
- const { loop, gatePassed, hasExitGate, completedIterations } = args;
1858
- const max = Math.max(1, Math.floor(loop.max_iterations) || 1);
1859
- if (!hasExitGate) {
1860
- return completedIterations >= max ? "exit" : "iterate";
1779
+ if (typeof parsed !== "object" || parsed === null) {
1780
+ return { kind: "skip", reason: "unparseable gh pr view output" };
1861
1781
  }
1862
- if (gatePassed)
1863
- return "exit";
1864
- if (completedIterations >= max)
1865
- return "exhausted";
1866
- return "iterate";
1782
+ if (parsed.isCrossRepository === true) {
1783
+ return {
1784
+ kind: "skip",
1785
+ reason: "fork PR (cross-repo head branch not on origin)"
1786
+ };
1787
+ }
1788
+ return branchOrSkip(typeof parsed.headRefName === "string" ? parsed.headRefName : null);
1867
1789
  }
1868
- function readStageDefs(def) {
1869
- if (def.steps_version !== 2)
1870
- return [];
1871
- return Array.isArray(def.steps) ? def.steps : [];
1790
+ function decideAzurePrBranch(rawJson) {
1791
+ if (rawJson === null) {
1792
+ return { kind: "skip", reason: "az repos pr show failed" };
1793
+ }
1794
+ let parsed;
1795
+ try {
1796
+ parsed = JSON.parse(rawJson.trim());
1797
+ } catch {
1798
+ return { kind: "skip", reason: "unparseable az repos pr show output" };
1799
+ }
1800
+ if (typeof parsed !== "object" || parsed === null) {
1801
+ return { kind: "skip", reason: "unparseable az repos pr show output" };
1802
+ }
1803
+ if (parsed.forkSource != null) {
1804
+ return {
1805
+ kind: "skip",
1806
+ reason: "fork PR (cross-repo head branch not on origin)"
1807
+ };
1808
+ }
1809
+ const ref = typeof parsed.sourceRefName === "string" ? parsed.sourceRefName : null;
1810
+ return branchOrSkip(ref ? stripHeadsPrefix(ref) : null);
1872
1811
  }
1873
- function resolveStageDef(def, currentStage) {
1874
- if (def.steps_version !== 2)
1875
- return { kind: "not_stage_model" };
1876
- const stages = readStageDefs(def);
1877
- const index = stages.findIndex((s) => s?.id === currentStage);
1878
- if (index === -1)
1879
- return { kind: "stage_not_found" };
1880
- return { kind: "found", stage: stages[index], index };
1812
+ function decidePrBranch(provider, rawJson) {
1813
+ switch (provider) {
1814
+ case "github":
1815
+ return decideGithubPrBranch(rawJson);
1816
+ case "azure":
1817
+ return decideAzurePrBranch(rawJson);
1818
+ default:
1819
+ return {
1820
+ kind: "skip",
1821
+ reason: `PR-link review not yet supported for provider "${provider}"`
1822
+ };
1823
+ }
1881
1824
  }
1882
- function isAgentRunnableOwner(owner) {
1883
- return owner === "agent" || owner === "either";
1825
+ function extractAzurePrId(prUrl) {
1826
+ const m = prUrl.match(/pullrequest\/(\d+)/i);
1827
+ return m ? m[1] : null;
1884
1828
  }
1885
- function nextStageAfter(def, index) {
1886
- const stages = readStageDefs(def);
1887
- if (index < 0 || index >= stages.length)
1888
- return { kind: "out_of_range" };
1889
- const next = stages[index + 1];
1890
- if (!next)
1891
- return { kind: "terminal" };
1892
- return { kind: "next", stage: next, index: index + 1 };
1829
+ async function resolvePrHeadBranch(prUrl, cwd, provider) {
1830
+ if (provider === "github") {
1831
+ try {
1832
+ const { stdout } = await execFileAsync("gh", ["pr", "view", prUrl, "--json", "headRefName,isCrossRepository"], { cwd, encoding: "utf-8", timeout: 1e4 });
1833
+ return decidePrBranch("github", stdout);
1834
+ } catch (err) {
1835
+ log.warn(TAG2, `gh pr view failed for ${prUrl}: ${err instanceof Error ? err.message : String(err)}`);
1836
+ return decidePrBranch("github", null);
1837
+ }
1838
+ }
1839
+ if (provider === "azure") {
1840
+ const prId = extractAzurePrId(prUrl);
1841
+ if (!prId) {
1842
+ return {
1843
+ kind: "skip",
1844
+ reason: `could not parse Azure PR id from ${prUrl}`
1845
+ };
1846
+ }
1847
+ try {
1848
+ const { stdout } = await execFileAsync("az", ["repos", "pr", "show", "--id", prId, "--output", "json"], { cwd, encoding: "utf-8", timeout: 1e4 });
1849
+ return decidePrBranch("azure", stdout);
1850
+ } catch (err) {
1851
+ log.warn(TAG2, `az repos pr show failed for ${prUrl}: ${err instanceof Error ? err.message : String(err)}`);
1852
+ return decidePrBranch("azure", null);
1853
+ }
1854
+ }
1855
+ return decidePrBranch(provider, null);
1893
1856
  }
1894
- function entryActionAllowlist(entryAction) {
1895
- if (!entryAction)
1857
+ function extractPrUrl(description) {
1858
+ if (!description)
1859
+ return null;
1860
+ const match = description.match(PR_URL_RE);
1861
+ if (!match)
1862
+ return null;
1863
+ try {
1864
+ return new URL(match[1]).href;
1865
+ } catch {
1896
1866
  return null;
1897
- const direct = SKILL_TOOL_ALLOWLIST[entryAction];
1898
- if (direct)
1899
- return direct;
1900
- if (HARMONY_TOOL_RE.test(entryAction)) {
1901
- const qualified = `mcp__harmony__${entryAction}`;
1902
- if (STAGE_DAEMON_OWNED_TOOLS.includes(qualified)) {
1903
- return null;
1904
- }
1905
- return qualified;
1906
1867
  }
1907
- return null;
1908
1868
  }
1909
- function stageDisallowedTools() {
1910
- return STAGE_DAEMON_OWNED_TOOLS.length > 0 ? STAGE_DAEMON_OWNED_TOOLS.join(",") : null;
1869
+ function resolvePrUrl(description, branchName, cwd, provider) {
1870
+ const fromDesc = extractPrUrl(description);
1871
+ if (fromDesc)
1872
+ return fromDesc;
1873
+ if (!branchName)
1874
+ return null;
1875
+ return findExistingPr(branchName, cwd, provider) || null;
1911
1876
  }
1912
- var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE, STAGE_DAEMON_OWNED_TOOLS;
1913
- var init_playbookStage = __esm(() => {
1914
- SKILL_TOOL_ALLOWLIST = {
1915
- hmy: "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
1916
- "hmy-new": "Read,Grep,Glob,mcp__harmony__*",
1917
- "hmy-plan": "Read,Grep,Glob,mcp__harmony__*",
1918
- "hmy-review": "Read,Grep,Glob,Bash,mcp__harmony__*",
1919
- "hmy-cleanup": "Read,Grep,Glob,mcp__harmony__*",
1920
- "hmy-standup": "Read,Grep,Glob,mcp__harmony__*"
1921
- };
1922
- HARMONY_TOOL_RE = /^harmony_[a-z_]+$/;
1923
- STAGE_DAEMON_OWNED_TOOLS = [
1924
- "mcp__harmony__harmony_end_agent_session",
1925
- "mcp__harmony__harmony_start_agent_session",
1926
- "mcp__harmony__harmony_move_card"
1927
- ];
1877
+ function remoteBranchExists(branchName, cwd) {
1878
+ try {
1879
+ execFileSync("git", ["ls-remote", "--exit-code", "origin", `refs/heads/${branchName}`], { cwd, stdio: "pipe" });
1880
+ return true;
1881
+ } catch {
1882
+ return false;
1883
+ }
1884
+ }
1885
+ function pushBranch(branchName, cwd) {
1886
+ if (remoteBranchExists(branchName, cwd)) {
1887
+ log.info(TAG2, `Remote branch ${branchName} exists (rework), force-pushing`);
1888
+ let expectedSha = null;
1889
+ try {
1890
+ execFileSync("git", ["fetch", "origin", branchName], {
1891
+ cwd,
1892
+ stdio: "pipe"
1893
+ });
1894
+ expectedSha = execFileSync("git", ["rev-parse", `refs/remotes/origin/${branchName}`], { cwd, encoding: "utf-8" }).trim();
1895
+ } catch (err) {
1896
+ log.warn(TAG2, `could not resolve remote tip for ${branchName}, falling back to weak lease: ${err instanceof Error ? err.message : err}`);
1897
+ }
1898
+ const lease = expectedSha ? `--force-with-lease=refs/heads/${branchName}:${expectedSha}` : "--force-with-lease";
1899
+ execFileSync("git", ["push", lease, "-u", "origin", branchName], {
1900
+ cwd,
1901
+ stdio: "pipe"
1902
+ });
1903
+ } else {
1904
+ execFileSync("git", ["push", "-u", "origin", branchName], {
1905
+ cwd,
1906
+ stdio: "pipe"
1907
+ });
1908
+ }
1909
+ }
1910
+ function renameRemoteBranch(oldRef, newRef, cwd) {
1911
+ if (oldRef === newRef)
1912
+ return;
1913
+ let sha;
1914
+ try {
1915
+ sha = execFileSync("git", ["rev-parse", "HEAD"], {
1916
+ cwd,
1917
+ encoding: "utf-8"
1918
+ }).trim();
1919
+ } catch (err) {
1920
+ throw new Error(`renameRemoteBranch: could not resolve HEAD: ${err instanceof Error ? err.message : err}`);
1921
+ }
1922
+ log.info(TAG2, `Renaming remote ${oldRef} → ${newRef}`);
1923
+ execFileSync("git", ["push", "origin", `${sha}:refs/heads/${newRef}`, "--force-with-lease"], { cwd, stdio: "pipe" });
1924
+ try {
1925
+ execFileSync("git", ["push", "origin", `:refs/heads/${oldRef}`], {
1926
+ cwd,
1927
+ stdio: "pipe"
1928
+ });
1929
+ } catch (err) {
1930
+ log.warn(TAG2, `renameRemoteBranch: could not delete old ref ${oldRef}: ${err instanceof Error ? err.message : err}`);
1931
+ }
1932
+ try {
1933
+ execFileSync("git", ["branch", "-m", oldRef, newRef], {
1934
+ cwd,
1935
+ stdio: "pipe"
1936
+ });
1937
+ } catch {}
1938
+ }
1939
+ function getBranchWebUrl(branchName, cwd) {
1940
+ try {
1941
+ const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
1942
+ cwd,
1943
+ encoding: "utf-8"
1944
+ }).trim();
1945
+ const encoded = branchName.split("/").map(encodeURIComponent).join("/");
1946
+ if (/github\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
1947
+ const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
1948
+ if (m)
1949
+ return `https://github.com/${m[1]}/${m[2]}/tree/${encoded}`;
1950
+ }
1951
+ if (/gitlab\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
1952
+ const m = remoteUrl.match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
1953
+ if (m)
1954
+ return `https://gitlab.com/${m[1]}/-/tree/${encoded}`;
1955
+ }
1956
+ if (/bitbucket\.org[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
1957
+ const m = remoteUrl.match(/bitbucket\.org[:/](.+?)(?:\.git)?$/);
1958
+ if (m)
1959
+ return `https://bitbucket.org/${m[1]}/branch/${encoded}`;
1960
+ }
1961
+ return null;
1962
+ } catch {
1963
+ return null;
1964
+ }
1965
+ }
1966
+ function buildPrBody(card, commitLog) {
1967
+ return [
1968
+ "## Summary",
1969
+ "",
1970
+ `Automated PR for card **#${card.short_id} — ${card.title}**.`,
1971
+ "",
1972
+ "## Commits",
1973
+ "",
1974
+ "```",
1975
+ commitLog,
1976
+ "```",
1977
+ "",
1978
+ "## Card",
1979
+ "",
1980
+ card.description?.slice(0, 500) || "No description.",
1981
+ "",
1982
+ "---",
1983
+ "*Created by Harmony Agent Daemon*"
1984
+ ].join(`
1985
+ `);
1986
+ }
1987
+ function createPullRequest(card, branchName, worktreePath, config, provider, existingPrUrl) {
1988
+ if (existingPrUrl) {
1989
+ log.info(TAG2, `Reusing existing PR from card description: ${existingPrUrl}`);
1990
+ return existingPrUrl;
1991
+ }
1992
+ let commitLog = "";
1993
+ try {
1994
+ commitLog = execFileSync("git", ["log", "--oneline", `origin/${config.worktree.baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8" }).trim();
1995
+ } catch {
1996
+ commitLog = "(unable to retrieve commit log)";
1997
+ }
1998
+ const title = `#${card.short_id} ${card.title}`;
1999
+ const body = buildPrBody(card, commitLog);
2000
+ const base = config.worktree.baseBranch;
2001
+ const existingUrl = findExistingPr(branchName, worktreePath, provider);
2002
+ if (existingUrl) {
2003
+ log.info(TAG2, `PR already exists for ${branchName}, updating body...`);
2004
+ updateExistingPr(branchName, body, worktreePath, provider);
2005
+ return existingUrl;
2006
+ }
2007
+ try {
2008
+ let result;
2009
+ switch (provider) {
2010
+ case "github":
2011
+ result = execFileSync("gh", ["pr", "create", "--title", title, "--body", body, "--base", base], { cwd: worktreePath, encoding: "utf-8" }).trim();
2012
+ break;
2013
+ case "azure": {
2014
+ const azOutput = execFileSync("az", [
2015
+ "repos",
2016
+ "pr",
2017
+ "create",
2018
+ "--title",
2019
+ title,
2020
+ "--description",
2021
+ body,
2022
+ "--source-branch",
2023
+ branchName,
2024
+ "--target-branch",
2025
+ base,
2026
+ "--auto-complete",
2027
+ "false"
2028
+ ], { cwd: worktreePath, encoding: "utf-8" }).trim();
2029
+ try {
2030
+ const parsed = JSON.parse(azOutput);
2031
+ result = parsed.remoteUrl ?? parsed.url ?? azOutput;
2032
+ } catch {
2033
+ result = azOutput;
2034
+ }
2035
+ break;
2036
+ }
2037
+ case "gitlab":
2038
+ result = execFileSync("glab", [
2039
+ "mr",
2040
+ "create",
2041
+ "--title",
2042
+ title,
2043
+ "--description",
2044
+ body,
2045
+ "--source-branch",
2046
+ branchName,
2047
+ "--target-branch",
2048
+ base,
2049
+ "--no-editor"
2050
+ ], { cwd: worktreePath, encoding: "utf-8" }).trim();
2051
+ break;
2052
+ default:
2053
+ log.warn(TAG2, `No PR CLI for provider "${provider}" — branch pushed but no PR created`);
2054
+ return null;
2055
+ }
2056
+ log.info(TAG2, `PR created: ${result}`);
2057
+ return result;
2058
+ } catch (err) {
2059
+ log.error(TAG2, `Failed to create PR: ${err instanceof Error ? err.message : err}`);
2060
+ return null;
2061
+ }
2062
+ }
2063
+ function findExistingPr(branchName, worktreePath, provider) {
2064
+ try {
2065
+ switch (provider) {
2066
+ case "github":
2067
+ return execFileSync("gh", ["pr", "view", branchName, "--json", "url", "--jq", ".url"], { cwd: worktreePath, encoding: "utf-8" }).trim();
2068
+ case "gitlab": {
2069
+ const json = execFileSync("glab", ["mr", "view", branchName, "--output", "json"], { cwd: worktreePath, encoding: "utf-8" }).trim();
2070
+ const parsed = JSON.parse(json);
2071
+ return parsed.web_url || null;
2072
+ }
2073
+ default:
2074
+ return null;
2075
+ }
2076
+ } catch {
2077
+ return null;
2078
+ }
2079
+ }
2080
+ function updateExistingPr(branchName, body, worktreePath, provider) {
2081
+ try {
2082
+ switch (provider) {
2083
+ case "github":
2084
+ execFileSync("gh", ["pr", "edit", branchName, "--body", body], {
2085
+ cwd: worktreePath,
2086
+ stdio: "pipe"
2087
+ });
2088
+ break;
2089
+ case "gitlab":
2090
+ execFileSync("glab", ["mr", "update", branchName, "--description", body], { cwd: worktreePath, stdio: "pipe" });
2091
+ break;
2092
+ }
2093
+ log.info(TAG2, `Updated existing PR body for ${branchName}`);
2094
+ } catch (err) {
2095
+ log.warn(TAG2, `Failed to update PR body: ${err instanceof Error ? err.message : err}`);
2096
+ }
2097
+ }
2098
+ var execFileAsync, TAG2 = "git-pr", VALID_PR_URL_RE, PR_URL_RE, REVIEWED_SHA_RE;
2099
+ var init_git_pr = __esm(() => {
2100
+ init_dist();
2101
+ init_log();
2102
+ execFileAsync = promisify(execFile);
2103
+ VALID_PR_URL_RE = /^https:\/\/(github\.com|gitlab\.com|dev\.azure\.com|bitbucket\.org)\//;
2104
+ PR_URL_RE = /PR:\s*(https?:\/\/[^\s)]+)/;
2105
+ REVIEWED_SHA_RE = /^Reviewed-SHA:\s*([0-9a-f]{7,40})\s*$/im;
1928
2106
  });
1929
2107
 
1930
- // ../harmony-shared/dist/projectTemplates.js
1931
- var init_projectTemplates = () => {};
1932
-
1933
- // ../harmony-shared/dist/reviewMethodology.js
1934
- var REVIEW_SYSTEM_PROMPT = `You are a senior code reviewer. Follow this two-pass methodology strictly.
1935
- Report findings; do NOT fix them. This is a read-only review.
1936
-
1937
- Review the diff through five lenses on every pass: functionality, security,
1938
- performance, code quality, and best practices. For every finding, set
1939
- \`relatedToDiff\`: true when the change under review introduced or exposed it,
1940
- false when it is a pre-existing issue you happened to notice. Only diff-caused
1941
- findings gate the verdict — pre-existing ones are reported for context and never
1942
- block.
1943
-
1944
- ## Two-Pass Review
1945
-
1946
- ### Pass 1 — CRITICAL (highest severity)
1947
-
1948
- **SQL & Data Safety**
1949
- - String interpolation in SQL — use parameterized queries / prepared statements
1950
- - TOCTOU races: check-then-set patterns that should be atomic WHERE + UPDATE
1951
-
1952
- **Race Conditions & Concurrency**
1953
- - Read-check-write without uniqueness constraint or duplicate key handling
1954
- - Status transitions without atomic WHERE old_status UPDATE SET new_status
1955
- - Unsafe HTML rendering (dangerouslySetInnerHTML, v-html) on user-controlled data (XSS)
1956
-
1957
- **Security & Access Control**
1958
- - Hardcoded secrets, API keys, or credentials committed to source
1959
- - New endpoints, mutations, or service-role/RLS-exempt queries missing an auth or ownership check
1960
- - Over-broad CORS, missing input validation on a trust boundary, injection beyond SQL (command, path, template)
1961
-
1962
- **LLM Output Trust Boundary**
1963
- - LLM-generated values written to DB without format validation (EMAIL_REGEXP, URI.parse, .trim())
1964
- - Structured tool output accepted without type/shape checks before database writes
1965
-
1966
- **Enum & Value Completeness**
1967
- - When the diff introduces a new enum/status/type value, trace it through every consumer
1968
- - Check allowlists, filter arrays, and case/if-elsif chains for the new value
1969
- - Use Grep to find all references to sibling values and Read each match — look OUTSIDE the diff
1970
-
1971
- ### Pass 2 — INFORMATIONAL (lower severity)
1972
-
1973
- **Functionality & Edge Cases**
1974
- - Logic errors, off-by-one, unhandled null/undefined, wrong API or library usage
1975
- - Conditional side effects: code paths that branch but forget a side effect on one branch (e.g., promoting without attaching URL)
1976
-
1977
- **Performance**
1978
- - O(n²) algorithms and O(n*m) lookups (Array.find in a loop instead of a Map/index)
1979
- - N+1 queries, unbounded fetches missing pagination, repeated work that should be cached/memoized
1980
- - Unnecessary React re-renders (unstable props/deps, inline object/array literals); leaked subscriptions, timers, or listeners
1981
- - Inline styles re-parsed every render
1982
-
1983
- **Code Quality**
1984
- - Dead code: variables assigned but never read, unreachable branches
1985
- - Duplication that should be extracted, over-long functions, unclear naming
1986
- - \`any\` / unchecked casts that defeat the type system
1987
- - Comments/docstrings describing old behavior after code changed
1988
-
1989
- **Best Practices & Conventions**
1990
- - Deviations from established project conventions and framework idioms / anti-patterns
1991
- - React hook dependency arrays that are wrong, missing, or over-broad
1992
- - Accessibility gaps on new UI: missing labels, roles, alt text, or keyboard paths
1993
-
1994
- **Test Gaps**
1995
- - Missing negative-path tests for new error handling
1996
- - Security enforcement features without integration tests
1997
-
1998
- **Completeness Gaps**
1999
- - Partial enum handling, incomplete error paths, missing edge cases that are straightforward to add
2000
-
2001
- ## Severity Classification
2002
-
2003
- - **critical**: SQL safety, race conditions, XSS, secrets/auth/injection holes, LLM trust boundary violations, enum completeness gaps causing runtime errors
2004
- - **major**: Missing requirements, broken functionality, significant completeness gaps, conditional side effects, performance regressions on a hot path
2005
- - **minor**: Dead code, stale comments, test gaps, naming/duplication, minor view issues, cosmetic completeness gaps
2006
-
2007
- ## Suppressions — DO NOT flag these
2008
-
2009
- - Redundancy that aids readability (e.g., present? redundant with length > 20)
2010
- - "Add a comment explaining why this threshold was chosen" — thresholds change, comments rot
2011
- - Consistency-only changes (wrapping a value to match how another constant is guarded)
2012
- - Regex edge cases when input is constrained and the edge case never occurs in practice
2013
- - Eval threshold changes — these are tuned empirically
2014
- - Harmless no-ops (e.g., .reject on an element never in the array)
2015
- - Pre-existing issues unrelated to the diff, beyond a single noted finding (set relatedToDiff:false; never block on them)
2016
- - ANYTHING already addressed in the diff you are reviewing — read the FULL diff before flagging`, REVIEW_ACCEPTANCE_CHECKS = `## Acceptance Checks
2017
-
2018
- Before judging code quality, verify the change actually satisfies the card.
2019
- Derive one acceptance check per concrete requirement in the card description and
2020
- one per subtask (the stated acceptance criteria). For each, assign a status from
2021
- hard evidence — cite the file:line you read or the dev-server behaviour you
2022
- observed that proves it:
2023
-
2024
- - **pass** — implemented and verified by code you read or behaviour you observed
2025
- - **partial** — started but incomplete (a missing branch, an edge case, or one of several bundled requirements)
2026
- - **fail** — required but absent, or implemented incorrectly
2027
- - **unverifiable** — cannot be confirmed from the diff or a running app (state why)
2028
-
2029
- Do NOT mark a check "pass" on the implementing agent's say-so or a subtask's
2030
- checkbox alone — only on evidence you found yourself. Any \`fail\` or \`partial\`
2031
- check is an unaddressed requirement and forces a rejected verdict.`, QA_VISUAL_CHECKLIST = `## Visual QA Checklist
2032
-
2033
- For each page affected by the changes:
2034
-
2035
- 1. **Visual scan** — Screenshot the page. Check for layout breaks, broken images, alignment issues, z-index problems.
2036
- 2. **Interactive elements** — Click every button, link, and control. Does each do what it says?
2037
- 3. **Forms** — Fill and submit. Test empty submission, invalid data, edge cases.
2038
- 4. **Navigation** — Check all paths in/out. Breadcrumbs, back button, deep links.
2039
- 5. **States** — Check empty state, loading state, error state, overflow state.
2040
- 6. **Console** — Check for JS exceptions, failed network requests (4xx/5xx), CORS errors after interactions.
2041
- 7. **Responsiveness** — If the change is visual, check mobile viewport (375px).
2108
+ // src/http-server.ts
2109
+ import {
2110
+ createServer
2111
+ } from "node:http";
2042
2112
 
2043
- ### SPA-Specific (React/Vite)
2044
- - Use snapshot for navigation — client-side routes may not appear in link lists.
2045
- - Check for stale state: navigate away and back — does data refresh correctly?
2046
- - Test browser back/forward — does the app handle history correctly?
2047
- - Watch for hydration errors or layout shifts after dynamic content loads.`, REVIEW_VERDICT_SCHEMA = `{
2048
- "verdict": "approved" | "rejected",
2049
- "summary": "Brief overall assessment",
2050
- "scopeCheck": {
2051
- "status": "clean" | "drift" | "missing",
2052
- "notes": "Optional explanation of scope issues"
2053
- },
2054
- "acceptanceChecks": [
2055
- {
2056
- "criterion": "The requirement or subtask being verified",
2057
- "status": "pass" | "partial" | "fail" | "unverifiable",
2058
- "evidence": "file:line or observed behaviour that proves the status"
2113
+ class HttpServer {
2114
+ opts;
2115
+ server = null;
2116
+ boundPort = null;
2117
+ constructor(opts) {
2118
+ this.opts = opts;
2119
+ }
2120
+ get port() {
2121
+ return this.boundPort;
2122
+ }
2123
+ async start() {
2124
+ this.server = createServer((req, res) => {
2125
+ this.route(req, res).catch((err) => {
2126
+ log.error(TAG3, `unhandled: ${err instanceof Error ? err.message : err}`);
2127
+ if (!res.headersSent) {
2128
+ res.writeHead(500, { "content-type": "application/json" });
2129
+ res.end(JSON.stringify({ error: "internal_error" }));
2130
+ }
2131
+ });
2132
+ });
2133
+ const attempts = Math.max(1, this.opts.maxPortAttempts ?? 10);
2134
+ const startPort = this.opts.port;
2135
+ for (let i = 0;i < attempts; i++) {
2136
+ const port = startPort + i;
2137
+ try {
2138
+ await this.listenOnce(port);
2139
+ this.boundPort = port;
2140
+ if (port !== startPort) {
2141
+ log.info(TAG3, `port ${startPort} busy — bound to ${port} instead`);
2142
+ }
2143
+ return port;
2144
+ } catch (err) {
2145
+ const lastAttempt = i === attempts - 1;
2146
+ if (isAddrInUse(err) && !lastAttempt) {
2147
+ log.debug(TAG3, `port ${port} in use, trying ${port + 1}`);
2148
+ continue;
2149
+ }
2150
+ throw err;
2151
+ }
2059
2152
  }
2060
- ],
2061
- "findings": [
2062
- {
2063
- "severity": "critical" | "major" | "minor",
2064
- "category": "sql-safety | race-condition | security | llm-trust | enum-completeness | functional | performance | code-quality | best-practices | accessibility | visual | ux | console | scope | other",
2065
- "title": "Short title",
2066
- "description": "Detailed description of the issue",
2067
- "location": "file:line (if applicable)",
2068
- "relatedToDiff": true
2153
+ throw new Error("HTTP server failed to bind");
2154
+ }
2155
+ listenOnce(port) {
2156
+ return new Promise((resolve, reject) => {
2157
+ const server = this.server;
2158
+ if (!server) {
2159
+ reject(new Error("server not created"));
2160
+ return;
2161
+ }
2162
+ const onError = (err) => {
2163
+ server.removeListener("listening", onListening);
2164
+ reject(err);
2165
+ };
2166
+ const onListening = () => {
2167
+ server.removeListener("error", onError);
2168
+ resolve();
2169
+ };
2170
+ server.once("error", onError);
2171
+ server.once("listening", onListening);
2172
+ server.listen(port, this.opts.bindAddr);
2173
+ });
2174
+ }
2175
+ async stop() {
2176
+ if (!this.server)
2177
+ return;
2178
+ await new Promise((resolve) => {
2179
+ this.server?.close(() => resolve());
2180
+ });
2181
+ this.server = null;
2182
+ this.boundPort = null;
2183
+ }
2184
+ async route(req, res) {
2185
+ const url = new URL(req.url ?? "/", "http://localhost");
2186
+ const method = (req.method ?? "GET").toUpperCase();
2187
+ const path = url.pathname;
2188
+ if (method === "GET" && path === "/health") {
2189
+ return this.respondHealth(res);
2190
+ }
2191
+ if (method === "GET" && path === "/status") {
2192
+ return this.respondStatus(res);
2193
+ }
2194
+ if (method === "POST") {
2195
+ const cmd = parseCommand(path);
2196
+ if (cmd) {
2197
+ return this.respondCommand(res, cmd.command, cmd.cardId);
2198
+ }
2199
+ }
2200
+ res.writeHead(404, { "content-type": "application/json" });
2201
+ res.end(JSON.stringify({ error: "not_found", path }));
2202
+ }
2203
+ respondHealth(res) {
2204
+ const health = this.opts.getHealth();
2205
+ res.writeHead(health.healthy ? 200 : 503, {
2206
+ "content-type": "application/json"
2207
+ });
2208
+ res.end(JSON.stringify(health));
2209
+ }
2210
+ respondStatus(res) {
2211
+ const snapshot = this.opts.getStatus();
2212
+ res.writeHead(200, { "content-type": "application/json" });
2213
+ res.end(JSON.stringify(snapshot));
2214
+ }
2215
+ async respondCommand(res, command, cardId) {
2216
+ try {
2217
+ await this.opts.handleCommand(command, cardId);
2218
+ res.writeHead(200, { "content-type": "application/json" });
2219
+ res.end(JSON.stringify({ ok: true, command, cardId }));
2220
+ } catch (err) {
2221
+ res.writeHead(500, { "content-type": "application/json" });
2222
+ res.end(JSON.stringify({
2223
+ error: "command_failed",
2224
+ detail: err instanceof Error ? err.message : String(err)
2225
+ }));
2069
2226
  }
2070
- ]
2071
- }`, REVIEW_DECISION_RULES = `Counting only findings with \`relatedToDiff: true\`:
2072
- - **rejected**: Any acceptance check that is \`fail\` or \`partial\`, any \`critical\` finding, unaddressed requirements, or 2+ \`major\` findings.
2073
- - **approved**: Every acceptance check \`pass\` (or \`unverifiable\` with a stated reason), no critical findings, at most 1 major finding; minor findings OK.`;
2074
- // ../harmony-shared/dist/stageHandoff.js
2075
- function buildHandoffCommentBody(input) {
2076
- const handoff = {
2077
- version: STAGE_HANDOFF_VERSION,
2078
- stageId: input.stageId,
2079
- stageName: input.stageName,
2080
- artifactType: input.artifactType,
2081
- produced: input.produced,
2082
- decisions: input.decisions,
2083
- nextStageNeeds: input.nextStageNeeds,
2084
- producedAt: input.producedAt ?? new Date().toISOString()
2085
- };
2086
- const decisionLines = handoff.decisions.length > 0 ? handoff.decisions.map((d) => `- ${d}`).join(`
2087
- `) : "_None._";
2088
- const prose = [
2089
- `**Stage handoff — ${handoff.stageName}**`,
2090
- "",
2091
- `**Produced:** ${handoff.produced}`,
2092
- "",
2093
- "**Decisions (settled — do not re-litigate):**",
2094
- decisionLines,
2095
- "",
2096
- `**What the next stage needs:** ${handoff.nextStageNeeds}`
2097
- ].join(`
2098
- `);
2099
- const payload = [
2100
- "```json",
2101
- `// ${HANDOFF_MARKER}`,
2102
- JSON.stringify(handoff, null, 2),
2103
- "```"
2104
- ].join(`
2105
- `);
2106
- return `${prose}
2107
-
2108
- ${payload}`;
2227
+ }
2109
2228
  }
2110
- function isTypedStageHandoff(value) {
2111
- if (typeof value !== "object" || value === null)
2112
- return false;
2113
- const v = value;
2114
- return typeof v.stageId === "string" && typeof v.stageName === "string" && typeof v.produced === "string" && typeof v.nextStageNeeds === "string" && typeof v.producedAt === "string" && Array.isArray(v.decisions) && v.decisions.every((d) => typeof d === "string") && (v.artifactType === null || typeof v.artifactType === "string") && v.version === STAGE_HANDOFF_VERSION;
2229
+ function isAddrInUse(err) {
2230
+ return typeof err === "object" && err !== null && err.code === "EADDRINUSE";
2115
2231
  }
2116
- function parseHandoffCommentBody(body) {
2117
- const match = HANDOFF_BLOCK_RE.exec(body);
2232
+ function parseCommand(path) {
2233
+ const match = path.match(/^\/(pause|resume|stop)\/([^/]+)$/);
2118
2234
  if (!match)
2119
2235
  return null;
2120
- try {
2121
- const parsed = JSON.parse(match[1]);
2122
- return isTypedStageHandoff(parsed) ? parsed : null;
2123
- } catch {
2124
- return null;
2125
- }
2236
+ return { command: match[1], cardId: decodeURIComponent(match[2]) };
2126
2237
  }
2127
- function extractLatestHandoff(comments, opts = {}) {
2128
- let best = null;
2129
- for (const c of comments) {
2130
- if (c.deleted_at)
2131
- continue;
2132
- if (c.author_type !== "agent")
2133
- continue;
2134
- const handoff = parseHandoffCommentBody(c.body);
2135
- if (!handoff)
2136
- continue;
2137
- if (opts.excludeStageId && handoff.stageId === opts.excludeStageId)
2138
- continue;
2139
- if (!best || c.created_at.localeCompare(best.at) > 0) {
2140
- best = { handoff, at: c.created_at };
2141
- }
2238
+ var TAG3 = "http";
2239
+ var init_http_server = __esm(() => {
2240
+ init_log();
2241
+ });
2242
+
2243
+ // src/auto-merge.ts
2244
+ function decideAutoMergeAction(input) {
2245
+ const { ciStatus, headSha, reviewedSha, config } = input;
2246
+ if (!config.enabled)
2247
+ return "wait";
2248
+ if (config.requireGreenCi) {
2249
+ if (ciStatus === "failure")
2250
+ return "stamp-failure";
2251
+ if (ciStatus !== "success")
2252
+ return "wait";
2142
2253
  }
2143
- return best?.handoff ?? null;
2254
+ if (config.reReviewOnBranchChange && reviewedSha && headSha && reviewedSha !== headSha) {
2255
+ return "rereview";
2256
+ }
2257
+ return "merge";
2144
2258
  }
2145
- function renderInheritedHandoffSection(handoff) {
2146
- const decisions = handoff.decisions.length > 0 ? handoff.decisions.map((d) => `- ${d}`).join(`
2147
- `) : "- (none recorded)";
2148
- return [
2149
- "## Inherited handoff (from the previous stage)",
2150
- "",
2151
- `This is the only state you inherit. The **${handoff.stageName}** stage produced it; treat its decisions as settled.`,
2152
- "",
2153
- `**Produced:** ${handoff.produced}`,
2154
- "",
2155
- "**Decisions you must respect:**",
2156
- decisions,
2157
- "",
2158
- `**What you need to do with it:** ${handoff.nextStageNeeds}`
2159
- ].join(`
2160
- `);
2259
+ async function stampCiFailure(client, card) {
2260
+ const existing = card.description || "";
2261
+ if (existing.includes("CI checks failed"))
2262
+ return;
2263
+ const sep = existing ? `
2264
+ ` : "";
2265
+ const ts = new Date().toISOString();
2266
+ await client.updateCard(card.id, {
2267
+ description: `${existing}${sep}CI checks failed at ${ts}`
2268
+ });
2161
2269
  }
2162
- var STAGE_HANDOFF_VERSION = 1, HANDOFF_MARKER = "harmony:stage-handoff", HANDOFF_BLOCK_RE;
2163
- var init_stageHandoff = __esm(() => {
2164
- HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
2165
- });
2166
-
2167
- // ../harmony-shared/dist/types.js
2168
- var init_types2 = () => {};
2169
-
2170
- // ../harmony-shared/dist/index.js
2171
- var init_dist = __esm(() => {
2172
- init_branchRef();
2173
- init_cardLinks();
2174
- init_classification();
2175
- init_commentSerializer();
2176
- init_constants();
2177
- init_gateEvaluate();
2178
- init_logger();
2179
- init_playbookCatalog();
2180
- init_playbookStage();
2181
- init_projectTemplates();
2182
- init_stageHandoff();
2183
- init_types2();
2270
+ async function removeApprovedLabel(client, card, resolvedLabels, approvedLabel) {
2271
+ const name = approvedLabel.toLowerCase();
2272
+ const obj = resolvedLabels.find((l) => l.name.toLowerCase() === name);
2273
+ if (obj)
2274
+ await client.removeLabelFromCard(card.id, obj.id);
2275
+ }
2276
+ async function attemptAutoMerge(deps) {
2277
+ const { client, card, resolvedLabels, prUrl, cwd, provider, config } = deps;
2278
+ const autoMerge = config.review.autoMerge;
2279
+ if (!autoMerge.enabled || provider !== "github")
2280
+ return;
2281
+ const { ciStatus, headSha } = await getPrStatus(prUrl, cwd, provider);
2282
+ const reviewedSha = extractReviewedSha(card.description ?? null);
2283
+ const action = decideAutoMergeAction({
2284
+ ciStatus,
2285
+ headSha,
2286
+ reviewedSha,
2287
+ config: autoMerge
2288
+ });
2289
+ switch (action) {
2290
+ case "wait":
2291
+ log.debug(TAG4, `#${card.short_id} waiting (ci=${ciStatus})`);
2292
+ return;
2293
+ case "stamp-failure":
2294
+ log.info(TAG4, `#${card.short_id} CI failed — flagging for human`);
2295
+ await stampCiFailure(client, card);
2296
+ return;
2297
+ case "rereview":
2298
+ log.info(TAG4, `#${card.short_id} branch changed since review — re-reviewing`);
2299
+ await removeApprovedLabel(client, card, resolvedLabels, config.review.approvedLabel);
2300
+ return;
2301
+ case "merge":
2302
+ log.info(TAG4, `#${card.short_id} auto-merging (${autoMerge.strategy})`);
2303
+ await mergePullRequest(prUrl, cwd, provider, autoMerge.strategy, autoMerge.deleteBranch);
2304
+ return;
2305
+ }
2306
+ }
2307
+ var TAG4 = "auto-merge";
2308
+ var init_auto_merge = __esm(() => {
2309
+ init_git_pr();
2310
+ init_log();
2184
2311
  });
2185
2312
 
2186
2313
  // src/pm.ts
@@ -2497,18 +2624,35 @@ function checkoutExistingBranch(basePath, branchName) {
2497
2624
  return worktreeDir;
2498
2625
  }
2499
2626
  function extractBranchFromDescription(description) {
2500
- if (!description)
2501
- return null;
2502
- const branch = description.match(BRANCH_REF_PATTERN)?.[1] ?? null;
2503
- if (branch && !SAFE_GIT_REF_PATTERN.test(branch)) {
2504
- log.warn(TAG7, `Extracted branch name contains unsafe characters: ${branch}`);
2505
- return null;
2627
+ const branch = extractBranchRef(description);
2628
+ if (!branch && hasUnsafeDaemonBranchLine(description)) {
2629
+ log.warn(TAG7, "Daemon Branch: line contains unsafe characters; ignoring it");
2506
2630
  }
2507
2631
  return branch;
2508
2632
  }
2633
+ function qualifiesForAutoReview(description) {
2634
+ return Boolean(extractBranchFromDescription(description) || extractPrUrl(description ?? null));
2635
+ }
2636
+ async function resolveReviewBranch(description, cwd) {
2637
+ const fromLine = extractBranchFromDescription(description);
2638
+ if (fromLine)
2639
+ return { kind: "branch", branch: fromLine };
2640
+ const prUrl = extractPrUrl(description ?? null);
2641
+ if (!prUrl)
2642
+ return { kind: "none" };
2643
+ const provider = detectGitProvider(cwd);
2644
+ const resolved = await resolvePrHeadBranch(prUrl, cwd, provider);
2645
+ return resolved;
2646
+ }
2647
+ function reviewedFromPrUrl(description) {
2648
+ if (extractBranchFromDescription(description))
2649
+ return null;
2650
+ return extractPrUrl(description ?? null);
2651
+ }
2509
2652
  var TAG7 = "review-worktree";
2510
2653
  var init_review_worktree = __esm(() => {
2511
2654
  init_dist();
2655
+ init_git_pr();
2512
2656
  init_log();
2513
2657
  init_pm();
2514
2658
  init_worktree();
@@ -5396,7 +5540,7 @@ async function postReviewComment(client, card, commentType, body) {
5396
5540
  log.error(TAG20, `Failed to post review comment to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
5397
5541
  }
5398
5542
  }
5399
- async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath, workspaceId, agentSessionId, stateStore) {
5543
+ async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath, workspaceId, agentSessionId, stateStore, resolvedFromPrUrl) {
5400
5544
  let freshDesc;
5401
5545
  try {
5402
5546
  const { card: fresh } = await client.getCard(card.id);
@@ -5459,7 +5603,7 @@ ${runLogTail}
5459
5603
  }
5460
5604
  if (config.review.createPR && approvedBranch) {
5461
5605
  const provider = detectGitProvider(worktreePath);
5462
- prUrl = createPullRequest(card, approvedBranch, worktreePath, config, provider);
5606
+ prUrl = createPullRequest(card, approvedBranch, worktreePath, config, provider, resolvedFromPrUrl ?? null);
5463
5607
  }
5464
5608
  }
5465
5609
  await addLabelByName(client, card, config.review.approvedLabel, config.review.approvedLabelColor);
@@ -6409,40 +6553,30 @@ class ReviewWorker {
6409
6553
  costCents: 0,
6410
6554
  numTurns: 0
6411
6555
  });
6412
- this.branchName = extractBranchFromDescription(card.description);
6413
- const localMode = !this.branchName;
6414
- let localDiff = null;
6415
- if (localMode) {
6416
- log.info(this.tag, `No branch found for #${card.short_id}, attempting local review`);
6417
- this.worktreePath = execFileSync10("git", ["rev-parse", "--show-toplevel"], {
6418
- encoding: "utf-8",
6419
- timeout: 5000
6420
- }).trim();
6421
- const resolved = this.resolveLocalChanges(this.worktreePath, card.short_id);
6422
- if (!resolved) {
6423
- log.info(this.tag, `No local changes found for #${card.short_id} — marking for human review (staying in Review)`);
6424
- await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
6425
- return;
6426
- }
6427
- log.info(this.tag, `Found local changes via ${resolved.source} for #${card.short_id}`);
6428
- localDiff = resolved.diff;
6429
- }
6430
- if (this.branchName) {
6431
- log.info(this.tag, `Review branch: ${this.branchName}`);
6556
+ const repoRoot = execFileSync10("git", ["rev-parse", "--show-toplevel"], {
6557
+ encoding: "utf-8",
6558
+ timeout: 5000
6559
+ }).trim();
6560
+ const resolution = await resolveReviewBranch(card.description, repoRoot);
6561
+ if (resolution.kind !== "branch") {
6562
+ const why = resolution.kind === "skip" ? resolution.reason : "no branch or PR reference";
6563
+ log.info(this.tag, `#${card.short_id} not auto-reviewable (${why}) — marking for human review (staying in Review)`);
6564
+ await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
6565
+ return;
6432
6566
  }
6567
+ this.branchName = resolution.branch;
6568
+ log.info(this.tag, `Review branch: ${this.branchName}`);
6433
6569
  const { session: reviewSession } = await this.client.startAgentSession(card.id, {
6434
6570
  agentIdentifier: agentIdentifier(this.id),
6435
6571
  agentName: `${AGENT_NAME} (Review)`,
6436
6572
  agentId: this.agentId,
6437
6573
  status: "working",
6438
- currentTask: localMode ? "Reviewing local changes" : "Setting up review worktree",
6574
+ currentTask: "Setting up review worktree",
6439
6575
  progressPercent: 5
6440
6576
  });
6441
6577
  this.sessionId = reviewSession && typeof reviewSession === "object" && "id" in reviewSession ? reviewSession.id ?? null : null;
6442
6578
  const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
6443
- if (!localMode) {
6444
- this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
6445
- }
6579
+ this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
6446
6580
  await labelPromise;
6447
6581
  if (this.aborted)
6448
6582
  return;
@@ -6453,47 +6587,41 @@ class ReviewWorker {
6453
6587
  }
6454
6588
  const port = this.reviewPort;
6455
6589
  const cwd = this.worktreePath;
6456
- if (!localMode) {
6457
- log.info(this.tag, `Starting dev server on port ${port}...`);
6458
- const [devCmd, devArgs] = spawnRunArgs("dev", "--port", String(port));
6459
- this.devServerProcess = spawnInGroup(devCmd, devArgs, {
6460
- cwd,
6461
- stdio: ["ignore", "pipe", "pipe"]
6462
- });
6463
- let devServerSpawnError = null;
6464
- this.devServerProcess.once("error", (err) => {
6465
- devServerSpawnError = err;
6466
- });
6467
- await this.client.updateAgentProgress(card.id, {
6468
- agentIdentifier: agentIdentifier(this.id),
6469
- agentName: `${AGENT_NAME} (Review)`,
6470
- status: "waiting",
6471
- currentTask: `Starting dev server on port ${port}…`,
6472
- progressPercent: 10
6473
- });
6474
- if (devServerSpawnError) {
6475
- throw new DevServerReadinessError(`dev server failed to start (${devCmd}): ${devServerSpawnError.message}`);
6476
- }
6477
- await waitForDevServer(this.devServerProcess, 30000);
6478
- await probeDevServer(port);
6479
- log.info(this.tag, `Dev server ready on port ${port}`);
6480
- await this.client.updateAgentProgress(card.id, {
6481
- agentIdentifier: agentIdentifier(this.id),
6482
- agentName: `${AGENT_NAME} (Review)`,
6483
- status: "working",
6484
- currentTask: "Reviewing changes",
6485
- progressPercent: 15
6486
- });
6590
+ log.info(this.tag, `Starting dev server on port ${port}...`);
6591
+ const [devCmd, devArgs] = spawnRunArgs("dev", "--port", String(port));
6592
+ this.devServerProcess = spawnInGroup(devCmd, devArgs, {
6593
+ cwd,
6594
+ stdio: ["ignore", "pipe", "pipe"]
6595
+ });
6596
+ let devServerSpawnError = null;
6597
+ this.devServerProcess.once("error", (err) => {
6598
+ devServerSpawnError = err;
6599
+ });
6600
+ await this.client.updateAgentProgress(card.id, {
6601
+ agentIdentifier: agentIdentifier(this.id),
6602
+ agentName: `${AGENT_NAME} (Review)`,
6603
+ status: "waiting",
6604
+ currentTask: `Starting dev server on port ${port}…`,
6605
+ progressPercent: 10
6606
+ });
6607
+ if (devServerSpawnError) {
6608
+ throw new DevServerReadinessError(`dev server failed to start (${devCmd}): ${devServerSpawnError.message}`);
6487
6609
  }
6610
+ await waitForDevServer(this.devServerProcess, 30000);
6611
+ await probeDevServer(port);
6612
+ log.info(this.tag, `Dev server ready on port ${port}`);
6613
+ await this.client.updateAgentProgress(card.id, {
6614
+ agentIdentifier: agentIdentifier(this.id),
6615
+ agentName: `${AGENT_NAME} (Review)`,
6616
+ status: "working",
6617
+ currentTask: "Reviewing changes",
6618
+ progressPercent: 15
6619
+ });
6488
6620
  if (this.aborted)
6489
6621
  return;
6490
6622
  let diff = "";
6491
6623
  try {
6492
- if (localMode) {
6493
- diff = localDiff ?? "";
6494
- } else {
6495
- diff = execFileSync10("git", ["diff", `origin/${this.config.worktree.baseBranch}..HEAD`], { cwd, encoding: "utf-8", timeout: 30000 });
6496
- }
6624
+ diff = execFileSync10("git", ["diff", `origin/${this.config.worktree.baseBranch}..HEAD`], { cwd, encoding: "utf-8", timeout: 30000 });
6497
6625
  } catch {
6498
6626
  diff = "(unable to retrieve diff)";
6499
6627
  }
@@ -6545,9 +6673,7 @@ class ReviewWorker {
6545
6673
  this.state = "completing";
6546
6674
  await this.recordPhase("completing");
6547
6675
  log.info(this.tag, `Claude review finished for #${card.short_id}`);
6548
- if (!localMode) {
6549
- this.killDevServer();
6550
- }
6676
+ this.killDevServer();
6551
6677
  const result = parseReviewOutput(stdout);
6552
6678
  log.info(this.tag, `Review verdict: ${result.verdict} (${result.findings.length} finding(s))`);
6553
6679
  await this.client.updateAgentProgress(card.id, {
@@ -6557,7 +6683,7 @@ class ReviewWorker {
6557
6683
  currentTask: `Processing ${result.verdict} verdict`,
6558
6684
  progressPercent: 80
6559
6685
  });
6560
- await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId, this.stateStore);
6686
+ await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId, this.stateStore, reviewedFromPrUrl(card.description));
6561
6687
  await this.collectReviewGate(card, result);
6562
6688
  } catch (err) {
6563
6689
  this.state = "error";
@@ -6799,50 +6925,6 @@ class ReviewWorker {
6799
6925
  log.debug(this.tag, "Killed dev server group");
6800
6926
  }
6801
6927
  }
6802
- resolveLocalChanges(repoRoot, shortId) {
6803
- try {
6804
- const localChanges = execFileSync10("git", ["diff", "HEAD"], {
6805
- cwd: repoRoot,
6806
- encoding: "utf-8",
6807
- timeout: 5000
6808
- });
6809
- if (localChanges) {
6810
- return { diff: localChanges, source: "uncommitted changes" };
6811
- }
6812
- } catch {
6813
- log.warn(this.tag, "Failed to check uncommitted changes");
6814
- }
6815
- try {
6816
- const matchingCommits = execFileSync10("git", ["log", "--format=%H", "-20", `--grep=#${shortId}`], { cwd: repoRoot, encoding: "utf-8", timeout: 1e4 }).trim();
6817
- if (matchingCommits) {
6818
- const hashes = matchingCommits.split(`
6819
- `).filter((h) => /^[0-9a-f]{4,40}$/i.test(h));
6820
- if (hashes.length === 0)
6821
- return null;
6822
- log.info(this.tag, `Found ${hashes.length} commit(s) referencing #${shortId}`);
6823
- const diffs = [];
6824
- for (const hash of hashes) {
6825
- try {
6826
- const commitDiff = execFileSync10("git", ["diff", `${hash}~1..${hash}`], { cwd: repoRoot, encoding: "utf-8", timeout: 30000 });
6827
- if (commitDiff)
6828
- diffs.push(commitDiff);
6829
- } catch {
6830
- log.warn(this.tag, `Failed to diff commit ${hash}`);
6831
- }
6832
- }
6833
- if (diffs.length > 0) {
6834
- return {
6835
- diff: diffs.join(`
6836
- `),
6837
- source: `${diffs.length} commit(s) matching #${shortId}`
6838
- };
6839
- }
6840
- }
6841
- } catch {
6842
- log.warn(this.tag, "Failed to search recent commits");
6843
- }
6844
- return null;
6845
- }
6846
6928
  cleanup() {
6847
6929
  if (this.timeoutTimer) {
6848
6930
  clearTimeout(this.timeoutTimer);
@@ -9540,8 +9622,8 @@ class Reconciler {
9540
9622
  log.debug(TAG36, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
9541
9623
  continue;
9542
9624
  }
9543
- if (mode === "review" && !extractBranchFromDescription(card.description)) {
9544
- log.debug(TAG36, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
9625
+ if (mode === "review" && !qualifiesForAutoReview(card.description)) {
9626
+ log.debug(TAG36, `Skipping #${card.short_id} — no branch or PR reference (not qualified for auto-review)`);
9545
9627
  continue;
9546
9628
  }
9547
9629
  log.info(TAG36, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
@@ -10581,8 +10663,12 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
10581
10663
  log.debug(TAG40, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
10582
10664
  return;
10583
10665
  }
10584
- if (mode === "review" && !extractBranchFromDescription(card.description)) {
10585
- log.info(TAG40, `Card #${card.short_id} has no branch reference — skipping auto-review`);
10666
+ if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
10667
+ log.debug(TAG40, `Card #${card.short_id} has "${NEED_REVIEW_LABEL}" label (needs human) — skipping review`);
10668
+ return;
10669
+ }
10670
+ if (mode === "review" && !qualifiesForAutoReview(card.description)) {
10671
+ log.info(TAG40, `Card #${card.short_id} has no branch or PR reference — skipping auto-review`);
10586
10672
  return;
10587
10673
  }
10588
10674
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
@@ -10604,6 +10690,7 @@ var init_src = __esm(() => {
10604
10690
  init_startup_banner();
10605
10691
  init_state_store();
10606
10692
  init_stream_parser_selftest();
10693
+ init_types();
10607
10694
  init_unblock();
10608
10695
  init_watcher();
10609
10696
  init_worktree_gc();