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