@kitman-sh/software-engineering 0.10.58 → 0.10.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bundle.js CHANGED
@@ -13996,7 +13996,7 @@ var StepOutputSubmissionResult = Union2([
13996
13996
  }),
13997
13997
  Struct({ status: Literal2("failed"), message: String5 })
13998
13998
  ]);
13999
- var HarnessHoldReasons = ["out_of_quota", "failed_to_start"];
13999
+ var HarnessHoldReasons = ["out_of_quota", "rate_limited", "login_refused", "failed_to_start"];
14000
14000
  var SkippedChoiceReasons = ["not_on_runner", ...HarnessHoldReasons];
14001
14001
  var SkippedChoice = Struct({
14002
14002
  harness: optionalKey2(String5),
@@ -19011,12 +19011,15 @@ var ask = (name, question) => takeStep(name, "ask", "ask", gen2(function* () {
19011
19011
  const fallback = question.default === undefined ? recommendedDefault(recommended) : question.default ?? undefined;
19012
19012
  if (fallback !== undefined && options !== undefined && !options.includes(fallback))
19013
19013
  return yield* new QuestionRefused({ step: name, answer: fallback, options });
19014
+ if (question.auto !== undefined && options !== undefined && !options.includes(question.auto))
19015
+ return yield* new QuestionRefused({ step: name, answer: question.auto, options });
19014
19016
  yield* launch(deferred, (host, token2) => host.putQuestion({
19015
19017
  step: name,
19016
19018
  prompt: question.prompt,
19017
19019
  ...options === undefined ? undefined : { options },
19018
19020
  ...advice,
19019
19021
  ...fallback === undefined ? undefined : { default: fallback },
19022
+ ...question.auto === undefined ? undefined : { auto: question.auto },
19020
19023
  ...question.timeout === undefined ? undefined : { timeoutMillis: toMillis(fromInputUnsafe2(question.timeout)) },
19021
19024
  token: token2
19022
19025
  }));
@@ -20257,7 +20260,7 @@ var scriptedHost = (script, engine, agentLaunches, childLaunches, waits) => {
20257
20260
  ...launch.mergeTarget === undefined ? undefined : { mergeTarget: launch.mergeTarget }
20258
20261
  })), resolve(agentDeferredNamed(launch.deferredName), launch.token, scriptedExit(script.outputs?.[launch.step], (reason) => new StepFailed({ step: launch.step, reason })))),
20259
20262
  putQuestion: (launch) => {
20260
- const answer = script.answers?.[launch.step] ?? launch.default;
20263
+ const answer = script.answers?.[launch.step] ?? (script.fullAuto === true ? launch.auto : undefined) ?? launch.default;
20261
20264
  return resolve(answerDeferred(launch.step), launch.token, answer === undefined ? fail5(new StepFailed({ step: launch.step, reason: "no answer was scripted" })) : succeed4(answer));
20262
20265
  },
20263
20266
  admitChild: (launch) => andThen2(sync2(() => childLaunches.push({ step: launch.step, loop: launch.loop, input: launch.input })), resolve(childDeferred(launch.step), launch.token, scriptedExit(script.children?.[launch.step], (reason) => new ChildFailed({ step: launch.step, loop: launch.loop, reason })))),
@@ -20763,18 +20766,27 @@ var review_default = defineLoop({
20763
20766
  }).pipe(map8((output) => [seat, output])), { concurrency: "unbounded" });
20764
20767
  const earlier = input.earlier ?? [];
20765
20768
  const earlierIds = new Set(earlier.map((finding) => finding.id));
20766
- const followUps = followUpsOf(earlier, outputs.flatMap(([, output]) => output.followUps ?? []));
20767
- const standing = new Set(followUps.flatMap((entry) => entry.status === "fixed" ? [] : [entry.id]));
20768
- const carried = earlier.filter((finding) => standing.has(finding.id));
20769
+ const answered = followUpsOf(earlier, outputs.flatMap(([, output]) => output.followUps ?? []));
20770
+ const standing = new Set(answered.flatMap((entry) => entry.status === "fixed" ? [] : [entry.id]));
20771
+ const verdicts = outputs.flatMap(([, output]) => output.verdicts ?? []);
20772
+ const rebutted = new Set((input.rebuttals ?? []).map((rebuttal) => rebuttal.findingId));
20773
+ const withdrawn = (id) => {
20774
+ const on = verdicts.filter((verdict) => verdict.findingId === id);
20775
+ return rebutted.has(id) && on.length > 0 && on.every((verdict) => verdict.accepted);
20776
+ };
20777
+ const carried = earlier.filter((finding) => standing.has(finding.id) && !withdrawn(finding.id));
20778
+ const followUps = answered.filter((entry) => !withdrawn(entry.id));
20769
20779
  const fresh = mergeFindings(Object.fromEntries(outputs.map(([seat, output]) => [
20770
20780
  seat,
20771
20781
  output.findings.filter((finding) => !earlierIds.has(`${finding.file}:${finding.line}`))
20772
20782
  ])));
20773
20783
  const findings = [...carried, ...fresh];
20774
- const summary = findings.length === 0 ? "No findings: nothing stands in the way of merging." : outputs.map(([, output]) => output.summary).join(" ");
20784
+ const blockers = findings.filter(isBlocking);
20785
+ const scope = input.files === undefined ? outputs.find(([seat]) => seat === scopeSeat)?.[1].scope : undefined;
20786
+ const failing = input.failing ?? [];
20787
+ const summary = blockers.length > 0 ? `Blocking: fix ${blockers.map((finding) => finding.id).join(", ")} before merging.` : scope?.met === false ? "Not ready: the change does not yet do what was asked." : failing.length > 0 ? `Not ready: ${failing.join(", ")} fails on this head.` : findings.length === 0 ? "No findings: nothing stands in the way of merging." : ["Can merge: nothing blocks it.", ...outputs.map(([, output]) => output.summary)].join(" ");
20775
20788
  const freshIds = new Set(fresh.map((finding) => finding.id));
20776
20789
  const text = reviewText(summary, findings, followUps);
20777
- const scope = input.files === undefined ? outputs.find(([seat]) => seat === scopeSeat)?.[1].scope : undefined;
20778
20790
  const stored = yield* artifact("findings", {
20779
20791
  kind: "review-findings",
20780
20792
  title: "Review findings",
@@ -20785,7 +20797,7 @@ var review_default = defineLoop({
20785
20797
  summary,
20786
20798
  findings,
20787
20799
  followUps,
20788
- verdicts: outputs.flatMap(([, output]) => output.verdicts ?? []),
20800
+ verdicts,
20789
20801
  blocking: findings.some(isBlocking),
20790
20802
  artifactId: stored.artifactId,
20791
20803
  ...scope === undefined ? undefined : { scope }
@@ -20793,7 +20805,7 @@ var review_default = defineLoop({
20793
20805
  const changeRequestId = input.changeRequestId;
20794
20806
  if (changeRequestId === undefined || head === undefined)
20795
20807
  return reviewed;
20796
- const approval = reviewed.blocking || scope?.met === false || (input.failing?.length ?? 0) > 0 ? undefined : yield* approveChangeRequest("approve", { changeRequestId, head, summary }).pipe(map8((result) => result.approved ? { head, approved: true } : { head, approved: false, reason: result.reason }));
20808
+ const approval = reviewed.blocking || scope?.met === false || failing.length > 0 ? undefined : yield* approveChangeRequest("approve", { changeRequestId, head, summary }).pipe(map8((result) => result.approved ? { head, approved: true } : { head, approved: false, reason: result.reason }));
20797
20809
  const answer = input.next ?? (yield* ask("next", {
20798
20810
  prompt: `${summary}
20799
20811
 
@@ -20836,2011 +20848,2088 @@ What next?`,
20836
20848
  })
20837
20849
  });
20838
20850
 
20839
- // src/shepherd-output.ts
20840
- var ShepherdOutput = Struct({
20841
- status: Literals(["merged", "ready", "needs_you"]),
20842
- reason: String5,
20843
- fixRounds: Int,
20844
- replies: Int
20845
- });
20846
-
20847
- // src/shepherd.ts
20848
- var reviewLoop = () => ({ id: "review", input: Input, output: Output });
20849
- var stepBudget = "45 minutes";
20851
+ // src/release.ts
20850
20852
  var Input2 = Struct({
20851
- changeRequestId: String5,
20852
- taskId: optional2(String5),
20853
- merge: optional2(Boolean3),
20854
- branch: optional2(String5),
20855
- continuing: optional2(Continuing),
20856
- maxFixRounds: optional2(Int),
20857
- maxCycles: optional2(Int)
20853
+ tag: optional2(String5),
20854
+ revision: optional2(String5),
20855
+ changeRequestId: optional2(String5),
20856
+ deployTarget: optional2(String5),
20857
+ deployEvent: optional2(String5),
20858
+ deployTimeoutMinutes: optional2(Int)
20858
20859
  });
20859
- var Output2 = ShepherdOutput;
20860
- var CiFix = Struct({
20861
- verdict: Literals(["fixed", "pre_existing", "flaky"]),
20862
- summary: String5
20860
+ var Drafted = Struct({
20861
+ tag: String5,
20862
+ previousTag: optional2(String5),
20863
+ title: String5,
20864
+ notes: String5,
20865
+ changeRequests: ArraySchema(String5)
20863
20866
  });
20864
- var Replies = Struct({
20865
- replies: ArraySchema(Struct({
20866
- threadId: String5,
20867
- reply: String5,
20868
- changed: Boolean3
20869
- }))
20867
+ var Deployed = Struct({
20868
+ status: Literals(["deployed", "failed", "rolled_back", "rollback_failed", "none"]),
20869
+ detail: String5
20870
20870
  });
20871
- var failedJobs = (pipeline) => pipeline.jobs.flatMap((job) => job.status === "failed" ? [`### ${job.name}${job.url === undefined ? "" : ` (${job.url})`}
20872
-
20873
- ${job.logTail ?? "No log was served."}`] : []);
20874
- var ciFixPrompt = (pipeline, target) => [
20875
- `The pipeline at ${pipeline.head} failed. These jobs failed, with the end of each log:`,
20876
- ...failedJobs(pipeline),
20877
- "Run the failing job once more here to rule out a flake; if it passes, change nothing and answer flaky.",
20878
- `Then run it on the target branch (a worktree of \`origin/${target}\`, which Kitman fetched just before this Step; do not fetch). If it fails there too, the branch did not cause it: change nothing and answer pre_existing.`,
20879
- "Otherwise it is this branch's own failure: write or fix a test that shows it first, then fix it, and answer fixed. Kitman pushes what you leave."
20871
+ var deployPrompt = (tag, previousTag) => [
20872
+ `Deploy the release ${tag}, which Kitman has just tagged and pushed.`,
20873
+ `If the repository has no executable \`.kitman/deploy\` at ${tag}, change nothing and answer status none.`,
20874
+ `Otherwise run \`.kitman/deploy ${tag}${previousTag === undefined ? "" : ` ${previousTag}`}\` from the checkout of ${tag} (\`git fetch --tags\` first), and do nothing else: it backs up, publishes, deploys, verifies and rolls back by itself, and may take an hour. Run it in the background with its output in a log file, and wait for it to exit, checking every few minutes; never start it twice.`,
20875
+ "Answer with the status its last line `kitman-deploy: <status> <detail>` names, and that detail. If it exited without that line, answer failed with its exit code and the end of its log.",
20876
+ "Never print, copy or summarise the variables or secrets it pulls."
20880
20877
  ].join(`
20881
20878
 
20882
20879
  `);
20883
- var threadLine = (thread) => [
20884
- `### Thread ${thread.id}${thread.path === undefined ? "" : ` on ${thread.path}${thread.line === undefined ? "" : `:${thread.line}`}`}`,
20885
- ...thread.comments.map((entry) => `${entry.author ?? "someone"}: ${entry.body}`)
20886
- ].join(`
20887
- `);
20888
- var ReviewFix = Struct({ summary: String5 });
20889
- var reviewFixPrompt = (findings) => [
20890
- "A review of the files the catch-up merge changed found these. Fix each blocker and major finding, with a test that shows it where one can. Kitman pushes what you leave.",
20891
- ...findings.map((finding) => `- ${finding.id} (${finding.severity}): ${finding.detail}`)
20880
+ var Output2 = Struct({
20881
+ status: Literals([
20882
+ "gate_failed",
20883
+ "not_mergeable",
20884
+ "rejected",
20885
+ "deployed",
20886
+ "rolled_back",
20887
+ "deploy_failed",
20888
+ "rollback_failed",
20889
+ "timed_out"
20890
+ ]),
20891
+ reason: String5,
20892
+ tag: optional2(String5),
20893
+ notesArtifactId: optional2(String5),
20894
+ revision: optional2(String5),
20895
+ url: optional2(String5)
20896
+ });
20897
+ var draftPrompt = (tag, changeRequestId) => [
20898
+ "Draft the release notes for the ChangeRequests merged since the last release tag on the base branch, including those with no linked Task.",
20899
+ ...changeRequestId === undefined ? [] : [`The release ChangeRequest ${changeRequestId} is part of it.`],
20900
+ "Group them by what a user of the software notices, and name each ChangeRequest you cover.",
20901
+ tag === undefined ? "Propose the next version tag, following the scheme earlier tags use. With no earlier tag, propose v0.1.0 and say so in the notes." : `The release is tagged ${tag}.`,
20902
+ "Give the latest earlier release tag as previousTag, or leave it out when there is none."
20892
20903
  ].join(`
20904
+
20893
20905
  `);
20894
- var catchUpVerdict = (catchUp) => {
20895
- if (catchUp.truncated)
20896
- return { kind: "changed", why: "it changed more files than the Runner compares" };
20897
- const files = [...new Set([...catchUp.conflicted, ...catchUp.rewritten])];
20898
- const elsewhere = catchUp.changed.filter((file) => !files.includes(file));
20899
- if (elsewhere.length > 0)
20900
- return { kind: "changed", why: `the branch's own diff changed in ${elsewhere.join(", ")}` };
20901
- return files.length === 0 ? { kind: "carry" } : { kind: "review", files };
20902
- };
20903
- var repliesPrompt = (threads) => [
20904
- "Reviewers left these comments on the ChangeRequest. For each thread, apply what you agree with and give the reply to post on it: what you changed, or why you did not. A comment that asks a question gets an answer and no change.",
20905
- "Never mark a thread resolved: whether a disputed point is settled is the reviewer's call.",
20906
- ...threads.map(threadLine)
20906
+ var redraftPrompt = (drafted, edit) => [
20907
+ "Redraft these release notes as asked, keeping the tag and everything not mentioned.",
20908
+ `Asked: ${edit}`,
20909
+ "```json",
20910
+ JSON.stringify(drafted, null, 2),
20911
+ "```"
20907
20912
  ].join(`
20908
20913
 
20909
20914
  `);
20910
- var threadKey = (thread) => `${thread.id}:${thread.comments.length}`;
20911
- var shepherd_default = defineLoop({
20912
- id: "shepherd",
20913
- version: "1.1.0",
20914
- title: "Shepherd",
20915
+ var readyToMerge = (changeRequestId, round = 1) => gen2(function* () {
20916
+ for (let pass = 1;; pass++) {
20917
+ const at = (name) => {
20918
+ const named = pass === 1 ? name : `${name}:moved:${pass}`;
20919
+ return round === 1 ? named : `${named}:${round}`;
20920
+ };
20921
+ let read = yield* pipelineStatus(at("gate"), { changeRequestId });
20922
+ for (let look = 1;look <= 10 && (read.status === "running" || read.status === "none"); look++) {
20923
+ const again = (name) => at(look === 1 ? name : `${name}:${look}`);
20924
+ yield* pipelineMoved(again("gate:pipeline"), changeRequestId, read);
20925
+ read = yield* pipelineStatus(again("gate:after"), { changeRequestId });
20926
+ }
20927
+ if (read.status !== "success") {
20928
+ const failing = read.jobs.filter((job) => job.status === "failed").map((job) => job.name);
20929
+ return {
20930
+ status: "gate_failed",
20931
+ reason: `The pipeline at ${read.head} is ${read.status}${failing.length === 0 ? "" : `: ${failing.join(", ")}`}`,
20932
+ revision: read.head
20933
+ };
20934
+ }
20935
+ const approved = yield* waitFor(at("approved"), {
20936
+ event: "change_request.approval",
20937
+ filter: { changeRequestId, approved: true, head: read.head },
20938
+ timeout: "7 days",
20939
+ or: read.readAt === undefined ? [] : [{ event: "change_request.pipeline", filter: { changeRequestId }, since: read.readAt }]
20940
+ });
20941
+ if (approved.type !== "change_request.approval") {
20942
+ if (pass >= 10)
20943
+ return {
20944
+ status: "not_mergeable",
20945
+ reason: `ChangeRequest ${changeRequestId} kept moving while it awaited approval`
20946
+ };
20947
+ continue;
20948
+ }
20949
+ const mergeable = yield* waitFor(at("mergeable"), {
20950
+ event: "change_request.mergeable",
20951
+ filter: { changeRequestId },
20952
+ timeout: "1 hour"
20953
+ });
20954
+ if (mergeable.payload.mergeable === false)
20955
+ return {
20956
+ status: "not_mergeable",
20957
+ reason: `ChangeRequest ${changeRequestId} does not merge cleanly; bring it up to date first`
20958
+ };
20959
+ return { passed: read.head };
20960
+ }
20961
+ }).pipe(catchTags2({
20962
+ WaitTimedOut: (timedOut) => succeed7({
20963
+ status: "not_mergeable",
20964
+ reason: `Nothing happened on ChangeRequest ${changeRequestId} while waiting for ${timedOut.event}`
20965
+ }),
20966
+ ChangeRequestClosed: (closed) => succeed7({
20967
+ status: "not_mergeable",
20968
+ reason: `ChangeRequest ${changeRequestId} was ${closed.merged ? "merged" : "closed"} before Kitman released it`
20969
+ })
20970
+ }));
20971
+ var recovery = (previous) => previous === undefined ? ["Retry the deploy", "Leave it"] : ["Retry the deploy", `Roll back to ${previous}`, "Leave it"];
20972
+ var release_default = defineLoop({
20973
+ id: "release",
20974
+ version: "2.0.0",
20975
+ title: "Release",
20915
20976
  input: Input2,
20916
20977
  output: Output2,
20917
- agents: { implementer: "implementer" },
20918
- checkout: "write",
20978
+ agents: { releaser: "releaser", deployer: "deployer" },
20979
+ checkout: "read",
20919
20980
  capabilities: [
20920
20981
  "agent_work",
20921
- "read_change_request",
20922
- "post_comment",
20923
- "merge_change_request",
20924
- "approve_change_request",
20925
- "call",
20926
- "write_back",
20927
20982
  "ask",
20928
- "verify"
20983
+ "verify",
20984
+ "claim",
20985
+ "publish_release",
20986
+ "read_change_request",
20987
+ "merge_change_request"
20929
20988
  ],
20930
- calls: () => [review_default],
20931
- run: (input) => {
20989
+ run: (input) => gen2(function* () {
20932
20990
  const changeRequestId = input.changeRequestId;
20933
- const target = input.continuing?.targetBranch ?? input.branch ?? "HEAD";
20934
- const answered = new Set;
20935
- const reranHeads = new Set;
20936
- const ranOnRunner = new Set;
20937
- let fixRounds = 0;
20938
- let replies = 0;
20939
- let stalledAt;
20940
- let stalls = 0;
20941
- let unreviewed;
20942
- let notCarried;
20943
- const done = (status, reason) => ({ status, reason, fixRounds, replies });
20944
- const runOnRunner = (step, cycle, head, checks) => gen2(function* () {
20945
- const verification = `pipeline-${step}:${cycle}`;
20946
- const failed = yield* verify(verification, { revision: head, checks }).pipe(as2(undefined), catchTag2("StepFailed", (error) => succeed7(error.reason)));
20947
- if (failed === undefined)
20948
- yield* reportPipeline(`pipeline-${step === "run" ? "report" : "rereport"}:${cycle}`, {
20949
- changeRequestId,
20950
- verification
20951
- });
20952
- return failed;
20991
+ let gated;
20992
+ if (changeRequestId === undefined) {
20993
+ const gate = yield* verify("gate", input.revision === undefined ? {} : { revision: input.revision });
20994
+ if (!gate.passed) {
20995
+ const failing = gate.checks.filter((check) => check.status !== "passed").map((check) => check.name);
20996
+ return {
20997
+ status: "gate_failed",
20998
+ reason: `The checks fail at ${gate.revision}: ${failing.join(", ")}`,
20999
+ revision: gate.revision
21000
+ };
21001
+ }
21002
+ gated = gate.revision;
21003
+ } else {
21004
+ const ready = yield* readyToMerge(changeRequestId);
21005
+ if (!("passed" in ready))
21006
+ return ready;
21007
+ gated = ready.passed;
21008
+ }
21009
+ let drafted = yield* agentWork("draft-notes", {
21010
+ agent: "releaser",
21011
+ prompt: draftPrompt(input.tag, changeRequestId),
21012
+ output: Drafted
20953
21013
  });
20954
- return gen2(function* () {
20955
- for (let cycle = 1;; cycle++) {
20956
- if (cycle > (input.maxCycles ?? 20)) {
20957
- const goOn = yield* ask(`continue:${cycle}`, {
20958
- prompt: `Shepherd has looked at this ChangeRequest ${cycle - 1} times without landing it. Keep going?`,
20959
- options: ["Keep going", "Stop"],
20960
- recommended: "Stop",
20961
- recommendationReason: `${cycle - 1} looks have not landed it, so something needs a person rather than another look.`,
20962
- default: "Stop",
20963
- timeout: "1 hour"
20964
- });
20965
- if (goOn === "Stop")
20966
- return done("needs_you", `Stopped after ${cycle - 1} looks without landing it`);
20967
- }
20968
- const pipeline = yield* pipelineStatus(`pipeline:${cycle}`, { changeRequestId });
20969
- if (pipeline.status === "none" && pipeline.runnerChecks !== undefined) {
20970
- if (ranOnRunner.has(pipeline.head))
20971
- return done("needs_you", `The pipeline ran on the Runner at ${pipeline.head}, but no result was recorded`);
20972
- ranOnRunner.add(pipeline.head);
20973
- const failed = yield* runOnRunner("run", cycle, pipeline.head, pipeline.runnerChecks);
20974
- if (failed !== undefined)
20975
- return done("needs_you", `The pipeline could not run on the Runner: ${failed}`);
20976
- continue;
20977
- }
20978
- if (pipeline.status === "none" || pipeline.status === "running") {
20979
- yield* pipelineMoved(`wait:${cycle}`, changeRequestId, pipeline);
20980
- continue;
20981
- }
20982
- if (pipeline.status === "failed" || pipeline.status === "canceled") {
20983
- if (stalledAt === pipeline.head)
20984
- stalls++;
20985
- else
20986
- stalls = 0;
20987
- if (stalls >= 2)
20988
- return done("needs_you", `Two fixes pushed nothing new; the pipeline at ${pipeline.head} still fails`);
20989
- if (fixRounds >= (input.maxFixRounds ?? 3))
20990
- return done("needs_you", `The pipeline still fails after ${fixRounds} fix rounds`);
20991
- fixRounds++;
20992
- stalledAt = pipeline.head;
20993
- const fixed = yield* agentWork(`ci-fix:${cycle}`, {
20994
- agent: "implementer",
20995
- prompt: ciFixPrompt(pipeline, target),
20996
- output: CiFix,
20997
- timeout: stepBudget
20998
- });
20999
- if (fixed.verdict === "pre_existing")
21000
- return done("needs_you", `The failure is pre-existing on the target branch: ${fixed.summary}`);
21001
- if (fixed.verdict === "flaky") {
21002
- if (pipeline.source === "reported") {
21003
- if (pipeline.runnerChecks === undefined)
21004
- return done("needs_you", `The failing job passed when run again, outside the forge: ${fixed.summary}`);
21005
- if (reranHeads.has(pipeline.head))
21006
- return done("needs_you", `The failing job failed again when run again on the Runner: ${fixed.summary}`);
21007
- reranHeads.add(pipeline.head);
21008
- const failed = yield* runOnRunner("rerun", cycle, pipeline.head, pipeline.runnerChecks);
21009
- if (failed !== undefined)
21010
- return done("needs_you", `The pipeline could not run on the Runner: ${failed}`);
21011
- continue;
21012
- }
21013
- if (reranHeads.has(pipeline.head))
21014
- return done("needs_you", `The failing job failed again when run again on the forge: ${fixed.summary}`);
21015
- reranHeads.add(pipeline.head);
21016
- yield* rerunPipeline(`rerun:${cycle}`, { changeRequestId });
21017
- yield* pipelineMoved(`rerun-wait:${cycle}`, changeRequestId, pipeline).pipe(catchTag2("WaitTimedOut", () => void_3));
21018
- }
21019
- continue;
21020
- }
21021
- const threads = (yield* reviewThreads(`threads:${cycle}`, { changeRequestId })).threads.filter((thread) => !answered.has(threadKey(thread)));
21022
- if (threads.length > 0) {
21023
- const answers = yield* agentWork(`comments:${cycle}`, {
21024
- agent: "implementer",
21025
- prompt: repliesPrompt(threads),
21026
- output: Replies,
21027
- timeout: stepBudget
21028
- });
21029
- for (const answer of answers.replies) {
21030
- yield* comment(`reply:${cycle}:${answer.threadId}`, {
21031
- changeRequestId,
21032
- threadId: answer.threadId,
21033
- body: answer.reply
21034
- });
21035
- replies++;
21036
- }
21037
- for (const thread of threads)
21038
- answered.add(`${thread.id}:${thread.comments.length + 1}`);
21039
- if (answers.replies.some((answer) => answer.changed))
21040
- continue;
21041
- }
21042
- const commented = {
21043
- event: "change_request.comment",
21044
- filter: { changeRequestId },
21045
- ...pipeline.readAt === undefined ? undefined : { since: pipeline.readAt }
21046
- };
21047
- const mergeable = yield* waitFor(`mergeable:${cycle}`, {
21048
- event: "change_request.mergeable",
21049
- filter: { changeRequestId },
21050
- timeout: "1 hour",
21051
- or: [commented]
21052
- });
21053
- if (mergeable.type === commented.event)
21054
- continue;
21055
- if (mergeable.payload.mergeable === false) {
21056
- const synced = yield* syncBase(`sync:${cycle}`, { agent: "implementer", target });
21057
- if (synced.status === "conflict")
21058
- return done("needs_you", `Merging ${target} in conflicts on ${synced.files.join(", ")}: ${synced.reason}`);
21059
- if (synced.status === "merged") {
21060
- const caught = synced.catchUp;
21061
- const verdict = caught === undefined ? { kind: "changed", why: "its Runner did not report what it did to the branch" } : catchUpVerdict(caught);
21062
- if (verdict.kind === "changed") {
21063
- unreviewed = undefined;
21064
- notCarried = `merging ${target} in changed it: ${verdict.why}`;
21065
- } else if (caught !== undefined && unreviewed !== undefined) {
21066
- const files = [...new Set([...unreviewed.files, ...verdict.kind === "review" ? verdict.files : []])];
21067
- unreviewed = caught.from === unreviewed.synced ? { ...unreviewed, files, synced: caught.head } : { ...unreviewed, files };
21068
- } else if (caught !== undefined && verdict.kind === "review") {
21069
- notCarried = undefined;
21070
- unreviewed = { files: verdict.files, synced: caught.head, sync: `sync:${cycle}`, earlier: [] };
21071
- } else if (caught !== undefined) {
21072
- notCarried = undefined;
21073
- const carried = yield* approveChangeRequest(`carry:${cycle}`, {
21074
- changeRequestId,
21075
- head: caught.head,
21076
- summary: `Merging ${target} in left the branch's own diff as it was approved.`,
21077
- carries: `sync:${cycle}`
21078
- }).pipe(catchTag2("StepFailed", (error) => succeed7({ approved: false, reason: error.reason })));
21079
- if (!carried.approved)
21080
- notCarried = `the approval was not carried: ${carried.reason}`;
21081
- }
21082
- }
21083
- continue;
21084
- }
21085
- let reviewedHere = false;
21086
- if (unreviewed !== undefined) {
21087
- const files = unreviewed;
21088
- const head = pipeline.head;
21089
- const reviewed = yield* call(`review:${cycle}`, reviewLoop(), {
21090
- base: `origin/${target}`,
21091
- head,
21092
- files: files.files,
21093
- ...head === files.synced ? undefined : { since: files.synced },
21094
- ...files.earlier.length === 0 ? undefined : { earlier: files.earlier }
21095
- });
21096
- if (reviewed.blocking) {
21097
- const blocking = reviewed.findings.filter(isBlocking);
21098
- if (fixRounds >= (input.maxFixRounds ?? 3))
21099
- return done("needs_you", `The review of the catch-up merge's files still finds ${blocking.map((finding) => finding.id).join(", ")} after ${fixRounds} fix rounds`);
21100
- fixRounds++;
21101
- yield* agentWork(`review-fix:${cycle}`, {
21102
- agent: "implementer",
21103
- prompt: reviewFixPrompt(blocking),
21104
- output: ReviewFix,
21105
- timeout: stepBudget
21106
- });
21107
- unreviewed = { ...files, earlier: reviewed.findings };
21108
- continue;
21109
- }
21110
- const approval = yield* approveChangeRequest(`approve:${cycle}`, {
21111
- changeRequestId,
21112
- head,
21113
- summary: `A review of the files merging ${target} in changed (${files.files.join(", ")}) passed with nothing blocking.`,
21114
- review: `review:${cycle}`,
21115
- carries: files.sync
21116
- }).pipe(catchTag2("StepFailed", (error) => succeed7({ approved: false, reason: error.reason })));
21117
- unreviewed = undefined;
21118
- if (approval.approved)
21119
- reviewedHere = true;
21120
- else
21121
- notCarried = `merging ${target} in, whose files' review passed but did not approve it: ${approval.reason}`;
21122
- }
21123
- const moved = pipeline.readAt === undefined ? [] : [
21124
- { event: "change_request.pipeline", filter: { changeRequestId }, since: pipeline.readAt },
21125
- ...pipeline.source === "reported" ? [
21126
- {
21127
- event: "change_request.approval",
21128
- filter: { changeRequestId, approved: true },
21129
- since: pipeline.readAt
21130
- }
21131
- ] : []
21132
- ];
21133
- const approved = reviewedHere ? undefined : yield* waitFor(`approved:${cycle}`, {
21134
- event: "change_request.approval",
21135
- filter: { changeRequestId, approved: true, head: pipeline.head },
21136
- timeout: "7 days",
21137
- or: [commented, ...moved]
21014
+ const tag = input.tag ?? drafted.tag;
21015
+ for (let round = 1;; round++) {
21016
+ const answer = yield* ask(round === 1 ? "approve" : `approve:${round}`, {
21017
+ prompt: `Release ${tag} with these notes?
21018
+
21019
+ ${drafted.notes}`,
21020
+ options: [
21021
+ { value: "Approve", description: "Tags, publishes and deploys it." },
21022
+ { value: "Edit notes", description: "Say what to change; the notes are redrafted." },
21023
+ { value: "Reject", description: "Keeps the draft and releases nothing." }
21024
+ ],
21025
+ recommended: "Approve",
21026
+ recommendationReason: "The release is proved: its checks pass, or its ChangeRequest is green, approved and mergeable.",
21027
+ default: null,
21028
+ auto: "Approve"
21029
+ });
21030
+ if (answer === "Approve")
21031
+ break;
21032
+ if (answer === "Reject") {
21033
+ const why = yield* ask("reject-why", {
21034
+ prompt: `Why not release ${tag}? The draft is kept.`,
21035
+ freeText: true,
21036
+ informationOnly: true,
21037
+ recommendationReason: "Only you know why you rejected it.",
21038
+ default: "No reason given",
21039
+ timeout: "1 day"
21138
21040
  });
21139
- if (approved !== undefined && approved.type !== "change_request.approval")
21140
- continue;
21141
- if (pipeline.source === "reported" && approved !== undefined && approved.payload.head !== pipeline.head)
21142
- continue;
21143
- const merge = input.merge === true ? "Merge" : yield* ask(`merge:${cycle}`, {
21144
- prompt: "The ChangeRequest is approved, green and mergeable. Merge now?",
21145
- options: [
21146
- { value: "Merge", description: "Merges the head whose pipeline passed." },
21147
- { value: "Wait", description: "Leaves the merge to a person." }
21148
- ],
21149
- recommended: "Merge",
21150
- recommendationReason: "It is approved, green and mergeable, which is everything a merge waits for.",
21151
- default: "Wait",
21152
- timeout: "1 hour"
21041
+ const kept = yield* artifact("notes", {
21042
+ kind: "release_notes",
21043
+ title: drafted.title,
21044
+ text: drafted.notes,
21045
+ data: { tag, changeRequests: drafted.changeRequests }
21153
21046
  });
21154
- if (merge === "Wait")
21155
- return done("ready", "Approved, green and mergeable; left for a person to merge");
21156
- const merged = yield* mergeChangeRequest(`merge-change-request:${cycle}`, {
21047
+ return { status: "rejected", reason: why, tag, notesArtifactId: kept.artifactId };
21048
+ }
21049
+ const edit = yield* ask(`edit:${round}`, {
21050
+ prompt: "What should change in the notes?",
21051
+ freeText: true,
21052
+ informationOnly: true,
21053
+ recommendationReason: "You chose to edit them, so only you know what should change.",
21054
+ default: "",
21055
+ timeout: "1 day"
21056
+ });
21057
+ if (edit.trim() === "")
21058
+ continue;
21059
+ drafted = yield* agentWork(`redraft:${round}`, {
21060
+ agent: "releaser",
21061
+ prompt: redraftPrompt(drafted, edit),
21062
+ output: Drafted
21063
+ });
21064
+ }
21065
+ const notes = yield* artifact("notes", {
21066
+ kind: "release_notes",
21067
+ title: drafted.title,
21068
+ text: drafted.notes,
21069
+ data: { tag, changeRequests: drafted.changeRequests }
21070
+ });
21071
+ let revision = gated;
21072
+ if (changeRequestId !== undefined) {
21073
+ for (let round = 1;; round++) {
21074
+ const merged = yield* mergeChangeRequest(round === 1 ? "merge" : `merge:${round}`, {
21157
21075
  changeRequestId,
21158
- head: pipeline.head
21076
+ head: gated
21159
21077
  });
21160
- if (merged.status !== "merged")
21161
- continue;
21162
- yield* writeBack("write-back", { milestone: "merged" });
21163
- return done("merged", `Merged at ${merged.revision}`);
21164
- }
21165
- }).pipe(catchIf2((error) => error._tag === "StepFailed" && error.reason.includes("time budget"), (error) => succeed7(done("needs_you", `Step "${error.step}" ran past its ${stepBudget} budget`))), catchTags2({
21166
- WaitTimedOut: (timedOut) => succeed7(done("needs_you", timedOut.event === "change_request.approval" && notCarried !== undefined ? `Nobody approved the head after ${notCarried}` : `Nothing happened on the ChangeRequest while waiting for ${timedOut.event}`)),
21167
- ChangeRequestClosed: (ended) => succeed7(ended.merged ? done("merged", "Someone else merged the ChangeRequest") : done("needs_you", "The ChangeRequest was closed"))
21168
- }));
21169
- }
21170
- });
21171
-
21172
- // src/build.ts
21173
- var BuildInput = Struct({
21174
- words: optional2(String5),
21175
- taskId: optional2(String5),
21176
- repoBindingId: optional2(String5),
21177
- branch: optional2(String5),
21178
- revision: optional2(String5),
21179
- outcome: optional2(LoopOutcome),
21180
- maxRounds: optional2(Int),
21181
- continuing: optional2(Continuing),
21182
- land: optional2(Boolean3),
21183
- changeRequestId: optional2(String5)
21184
- });
21185
- var CriterionAnswer = Struct({
21186
- criterion: String5,
21187
- status: Literals(["met", "after_merge", "not_met"]),
21188
- detail: String5
21078
+ if (merged.status === "conflict")
21079
+ return {
21080
+ status: "not_mergeable",
21081
+ reason: `ChangeRequest ${changeRequestId} no longer merges cleanly`,
21082
+ tag,
21083
+ notesArtifactId: notes.artifactId
21084
+ };
21085
+ if (merged.status === "merged") {
21086
+ revision = merged.revision;
21087
+ break;
21088
+ }
21089
+ if (round >= 3)
21090
+ return {
21091
+ status: "not_mergeable",
21092
+ reason: `ChangeRequest ${changeRequestId} kept moving while it was gated; it is at ${merged.head}`,
21093
+ tag,
21094
+ notesArtifactId: notes.artifactId
21095
+ };
21096
+ const ready = yield* readyToMerge(changeRequestId, round + 1);
21097
+ if (!("passed" in ready))
21098
+ return { ...ready, tag, notesArtifactId: notes.artifactId };
21099
+ gated = ready.passed;
21100
+ }
21101
+ }
21102
+ const deployEvent = input.deployEvent ?? "deploy";
21103
+ yield* claim("deploy-target", { resource: `deploy:${input.deployTarget ?? deployEvent}` });
21104
+ const released = yield* release2("tag", {
21105
+ tag,
21106
+ revision,
21107
+ title: drafted.title,
21108
+ notes: drafted.notes
21109
+ });
21110
+ const deployed = (step, deployTag) => waitFor(step, {
21111
+ event: deployEvent,
21112
+ filter: { tag: deployTag },
21113
+ timeout: `${input.deployTimeoutMinutes ?? 60} minutes`
21114
+ }).pipe(map8((deploy) => deploy.payload.status === "failed" ? "deploy_failed" : "deployed"), catchTag2("WaitTimedOut", () => succeed7("timed_out")));
21115
+ const settled = { tag, notesArtifactId: notes.artifactId, revision: released.revision, url: released.url };
21116
+ const ran = yield* agentWork("run-deploy", {
21117
+ agent: "deployer",
21118
+ prompt: deployPrompt(tag, drafted.previousTag),
21119
+ output: Deployed,
21120
+ timeout: "3 hours"
21121
+ });
21122
+ if (ran.status !== "none") {
21123
+ const status = ran.status === "failed" ? "deploy_failed" : ran.status;
21124
+ const outcome = {
21125
+ deployed: "deployed",
21126
+ rolled_back: "rolled back",
21127
+ deploy_failed: "failed to deploy",
21128
+ rollback_failed: "failed to deploy, and the rollback failed too"
21129
+ }[status];
21130
+ yield* artifact("report", {
21131
+ kind: "release_report",
21132
+ title: `${tag} ${outcome}`,
21133
+ text: `${tag} at ${released.revision}: ${ran.detail}. ${released.url}`,
21134
+ data: { tag, status, revision: released.revision, url: released.url, detail: ran.detail }
21135
+ });
21136
+ return { status, reason: ran.detail, ...settled };
21137
+ }
21138
+ let status = yield* deployed("deploy", tag);
21139
+ for (let round = 1;status !== "deployed"; round++) {
21140
+ const options = recovery(drafted.previousTag);
21141
+ const answer = yield* ask(`recover:${round}`, {
21142
+ prompt: `${tag} ${status === "timed_out" ? "did not report a deploy in time" : "failed to deploy"}. What now?`,
21143
+ options,
21144
+ recommended: "Retry the deploy",
21145
+ recommendationReason: "A failed or late deploy is often passing, and a retry runs only the failed jobs of the same tag.",
21146
+ default: "Leave it",
21147
+ timeout: "1 hour"
21148
+ });
21149
+ if (answer === "Leave it")
21150
+ return { status, reason: `${tag} at ${released.revision} was left undeployed`, ...settled };
21151
+ if (answer === "Retry the deploy") {
21152
+ yield* rerunPipeline(`deploy:rerun-${round}`, { ref: tag });
21153
+ status = yield* deployed(`deploy:retry-${round}`, tag);
21154
+ continue;
21155
+ }
21156
+ const previous = drafted.previousTag ?? tag;
21157
+ yield* rerunPipeline(`deploy:redeploy-${round}`, { ref: previous, all: true });
21158
+ const back = yield* deployed(`deploy:rollback-${round}`, previous);
21159
+ if (back === "deployed")
21160
+ return { status: "rolled_back", reason: `Rolled back to ${previous}`, ...settled };
21161
+ status = back;
21162
+ }
21163
+ yield* artifact("report", {
21164
+ kind: "release_report",
21165
+ title: `${tag} deployed`,
21166
+ text: `${tag} at ${released.revision} deployed. ${released.url}`,
21167
+ data: { tag, status, revision: released.revision, url: released.url }
21168
+ });
21169
+ return { status: "deployed", reason: `${tag} deployed`, ...settled };
21170
+ })
21189
21171
  });
21190
- var Criteria = Struct({ criteria: ArraySchema(CriterionAnswer) });
21191
- var SuggestedCheck = Struct({
21192
- name: CheckName,
21193
- command: String5,
21194
- workingDirectory: optionalKey2(String5)
21172
+
21173
+ // src/shepherd-output.ts
21174
+ var ShepherdOutput = Struct({
21175
+ status: Literals(["merged", "ready", "needs_you"]),
21176
+ reason: String5,
21177
+ fixRounds: Int,
21178
+ replies: Int,
21179
+ release: optional2(Struct({ runStatus: String5, reason: String5, tag: optional2(String5) }))
21195
21180
  });
21196
- var Implemented = Struct({
21197
- title: String5,
21198
- summary: String5,
21199
- claims: ArraySchema(String5),
21200
- documented: optional2(ArraySchema(String5)),
21201
- rollback: optional2(String5),
21202
- criteria: optional2(ArraySchema(CriterionAnswer)),
21203
- checks: optional2(ArraySchema(SuggestedCheck))
21181
+
21182
+ // src/shepherd.ts
21183
+ var reviewLoop = () => ({ id: "review", input: Input, output: Output });
21184
+ var stepBudget = "45 minutes";
21185
+ var Input3 = Struct({
21186
+ changeRequestId: String5,
21187
+ taskId: optional2(String5),
21188
+ merge: optional2(Boolean3),
21189
+ branch: optional2(String5),
21190
+ continuing: optional2(Continuing),
21191
+ maxFixRounds: optional2(Int),
21192
+ maxCycles: optional2(Int)
21204
21193
  });
21205
- var Reproduced = Struct({
21206
- check: String5,
21194
+ var Output3 = ShepherdOutput;
21195
+ var CiFix = Struct({
21196
+ verdict: Literals(["fixed", "pre_existing", "flaky"]),
21207
21197
  summary: String5
21208
21198
  });
21209
- var ReproducedOnBranch = Struct({
21210
- ...Reproduced.fields,
21211
- revision: optional2(String5)
21212
- });
21213
- var Fixed = Struct({
21214
- summary: String5,
21215
- claims: ArraySchema(String5),
21216
- rebuttals: ArraySchema(Rebuttal)
21217
- });
21218
- var BuildOutput = Struct({
21219
- changeRequest: ChangeRequestResult,
21220
- revision: String5,
21221
- tree: String5,
21222
- settled: Boolean3,
21223
- unsettled: ArraySchema(String5),
21224
- rounds: Int,
21225
- stopped: Literals(["settled", "dispute_stands", "findings_returned", "round_cap"]),
21226
- criteria: ArraySchema(CriterionAnswer),
21227
- approval: optional2(ApproveChangeRequestResult),
21228
- landed: optional2(ShepherdOutput),
21229
- next: Struct({ changeRequestId: String5 })
21199
+ var Replies = Struct({
21200
+ replies: ArraySchema(Struct({
21201
+ threadId: String5,
21202
+ reply: String5,
21203
+ changed: Boolean3
21204
+ }))
21230
21205
  });
21206
+ var failedJobs = (pipeline) => pipeline.jobs.flatMap((job) => job.status === "failed" ? [`### ${job.name}${job.url === undefined ? "" : ` (${job.url})`}
21231
21207
 
21232
- class BuildTargetMissing extends TaggedError3()("BuildTargetMissing", {
21233
- message: String5
21234
- }) {
21235
- }
21236
- var outcomeGuidance = {
21237
- feature: "Deliver the whole scope; review checks it was met.",
21238
- bug: "Your failing test is already in place: make it pass.",
21239
- refactor: "Keep behaviour exactly as it is; the approved verifications must pass before and after.",
21240
- investigation: "Investigate and report what you found, with its sources.",
21241
- docs: "Name, in `documented`, the approved checks that run the commands you documented.",
21242
- migration: "Name, in `rollback`, the approved check that rolls the migration back.",
21243
- performance: "Make it faster without changing behaviour."
21244
- };
21245
-
21246
- class BuildOutcomeRefused extends TaggedError3()("BuildOutcomeRefused", {
21247
- message: String5
21248
- }) {
21249
- }
21250
- var criteriaGuidance = "If your brief has a Task, answer each of its acceptance criteria in `criteria`: met, with the test or file that shows it; after_merge, with why it can only be seen once merged; or not_met, with why. Give an empty `criteria` when the Task has none.";
21251
- var checksGuidance = "If your brief lists no approved verifications for this repository, name in `checks` the commands from its package scripts or CI config that test, lint and typecheck it, each with a `name` of letters, digits, `.`, `_`, `:` and `-` only, such as `unit-tests`; Kitman runs them and labels them as suggested by you.";
21252
- var implementAdaptation = "You run the implement skill as a Kitman Step. The work is in this prompt and your brief, so fetch no ticket. Leave your changes uncommitted: the Run owns the branch. Skip its final code-review: a Review run or a person reviews the change.";
21253
- var implementPrompt = (task, outcome, continuing) => [
21254
- ...continuing === undefined ? [] : [continuingBrief(continuing), "Fix forward from there."],
21255
- `Build ${task}`,
21256
- implementAdaptation,
21257
- ...outcome === undefined ? [] : [outcomeGuidance[outcome]],
21258
- 'Commit nothing yourself: Kitman pushes what you leave in the workspace. Give the ChangeRequest a title and a summary, and list as claims what you believe but Kitman has not checked, such as "the tests pass".',
21259
- criteriaGuidance,
21260
- checksGuidance
21208
+ ${job.logTail ?? "No log was served."}`] : []);
21209
+ var ciFixPrompt = (pipeline, target) => [
21210
+ `The pipeline at ${pipeline.head} failed. These jobs failed, with the end of each log:`,
21211
+ ...failedJobs(pipeline),
21212
+ "Run the failing job once more here to rule out a flake; if it passes, change nothing and answer flaky.",
21213
+ `Then run it on the target branch (a worktree of \`origin/${target}\`, which Kitman fetched just before this Step; do not fetch). If it fails there too, the branch did not cause it: change nothing and answer pre_existing.`,
21214
+ "Otherwise it is this branch's own failure: write or fix a test that shows it first, then fix it, and answer fixed. Kitman pushes what you leave."
21261
21215
  ].join(`
21262
21216
 
21263
21217
  `);
21264
- var criteriaPrompt = (unanswered, unmet) => [
21265
- unanswered ? "You did not answer the Task's acceptance criteria. Answer each one before anyone reviews the change." : "These acceptance criteria are not met yet. Meet them, or say why they cannot be met here:",
21266
- ...unmet.map((answer) => `- ${answer.criterion}: ${answer.detail}`),
21267
- criteriaGuidance
21218
+ var threadLine = (thread) => [
21219
+ `### Thread ${thread.id}${thread.path === undefined ? "" : ` on ${thread.path}${thread.line === undefined ? "" : `:${thread.line}`}`}`,
21220
+ ...thread.comments.map((entry) => `${entry.author ?? "someone"}: ${entry.body}`)
21268
21221
  ].join(`
21269
-
21270
21222
  `);
21271
- var answerLine = (answer) => `${answer.criterion}: ${answer.status.replaceAll("_", " ")}, ${answer.detail}`;
21272
- var reproducePrompt = (task) => [
21273
- `Reproduce the bug in ${task} before anyone fixes it.`,
21274
- "Add a test that fails because of the bug, and change nothing else. Name the approved check that runs it; it must fail now and pass once the bug is fixed."
21223
+ var ReviewFix = Struct({ summary: String5 });
21224
+ var reviewFixPrompt = (findings) => [
21225
+ "A review of the files the catch-up merge changed found these. Fix each blocker and major finding, with a test that shows it where one can. Kitman pushes what you leave.",
21226
+ ...findings.map((finding) => `- ${finding.id} (${finding.severity}): ${finding.detail}`)
21275
21227
  ].join(`
21276
-
21277
21228
  `);
21278
- var reproducedOnBranchPrompt = (task) => [
21279
- `An earlier Run already worked on fixing the bug in ${task}, on this branch.`,
21280
- "Find the commit on this branch where the test reproducing the bug was in place and the fix was not yet, and give it as `revision`; name the approved check that runs that test. Change nothing.",
21281
- "If no commit on the branch has the reproducing test without the fix, add a test that fails on the branch head now, change nothing else, and give no `revision`."
21229
+ var catchUpVerdict = (catchUp) => {
21230
+ if (catchUp.truncated)
21231
+ return { kind: "changed", why: "it changed more files than the Runner compares" };
21232
+ const files = [...new Set([...catchUp.conflicted, ...catchUp.rewritten])];
21233
+ const elsewhere = catchUp.changed.filter((file) => !files.includes(file));
21234
+ if (elsewhere.length > 0)
21235
+ return { kind: "changed", why: `the branch's own diff changed in ${elsewhere.join(", ")}` };
21236
+ return files.length === 0 ? { kind: "carry" } : { kind: "review", files };
21237
+ };
21238
+ var repliesPrompt = (threads) => [
21239
+ "Reviewers left these comments on the ChangeRequest. For each thread, apply what you agree with and give the reply to post on it: what you changed, or why you did not. A comment that asks a question gets an answer and no change.",
21240
+ "Never mark a thread resolved: whether a disputed point is settled is the reviewer's call.",
21241
+ ...threads.map(threadLine)
21282
21242
  ].join(`
21283
21243
 
21284
21244
  `);
21285
- var findingLine = (finding) => `- **${finding.severity}** \`${finding.id}\` ${finding.detail}`;
21286
- var disputeLine = ({ finding, rebuttal }) => `${findingLine(finding)}
21287
- You rebutted: ${rebuttal.reason}`;
21288
- var fixPrompt = (settled) => {
21289
- const checks = settled.open.filter((finding) => isCheckFinding(finding.id));
21290
- const findings = settled.open.filter((finding) => !isCheckFinding(finding.id));
21291
- return [
21292
- ...checks.length === 0 ? [] : [
21293
- "These checks fail on your head and pass on the base. Only the check passing closes one. If you are sure the change did not cause it, such as a flaky test, rebut it with your reason: the ChangeRequest reports that as your claim and the check stays open. A check does not run again on an unchanged tree, so if you change nothing and only these are open, the rally ends.",
21294
- ...checks.map(findingLine)
21295
- ],
21296
- ...findings.length === 0 ? [] : [
21297
- "The review found these. Fix each, or rebut it with your reason if it is wrong; reviewers judge the rebuttals next round.",
21298
- ...findings.map(findingLine)
21299
- ],
21300
- ...settled.standing.length === 0 ? [] : [
21301
- "The reviewers rejected your rebuttal of these. Fix each, or rebut it again with a better reason; one you leave stays a dispute for a person to settle.",
21302
- ...settled.standing.map(disputeLine)
21303
- ]
21304
- ].join(`
21305
- `);
21306
- };
21307
- var blockingIds = (settled) => settled.open.filter(isBlocking).map((finding) => finding.id);
21308
- var onlyChecksBlock = (settled) => blockingIds(settled).every(isCheckFinding) && !settled.standing.some((entry) => isBlocking(entry.finding));
21309
- var rally = (input) => gen2(function* () {
21310
- const claims = [...input.claims];
21311
- const conflicts = [];
21312
- let rebuttals = [];
21313
- let earlier = [];
21314
- let previous;
21315
- let last;
21316
- const shown = new Set;
21317
- for (let round = 1;; round++) {
21318
- const synced = yield* syncBase(`sync:${round}`, {
21319
- agent: "implementer",
21320
- ...input.target === undefined ? undefined : { target: input.target }
21321
- });
21322
- if (synced.status === "conflict")
21323
- conflicts.push(`Could not merge the target branch in round ${round}: ${synced.files.join(", ")} (${synced.reason})`);
21324
- const head = yield* verify(`verify:${round}`, { suggested: input.suggested });
21325
- if (last !== undefined && last.head.tree === head.tree && rebuttals.length === 0 && onlyChecksBlock(last.settled))
21326
- return {
21327
- ...last,
21328
- round: round - 1,
21329
- head,
21330
- decision: { stop: true, reason: "findings_returned", unsettled: blockingIds(last.settled) },
21331
- claims,
21332
- conflicts
21333
- };
21334
- const failing = brokenChecks(head).map((check) => check.name);
21335
- const reviewed = yield* call(`review:${round}`, review_default, {
21336
- base: input.base,
21337
- head: head.revision,
21338
- ...rebuttals.length === 0 ? undefined : { rebuttals },
21339
- ...earlier.length === 0 ? undefined : { earlier },
21340
- ...input.outcome === undefined ? undefined : { outcome: input.outcome },
21341
- ...failing.length === 0 ? undefined : { failing },
21342
- claims: [...claims]
21343
- });
21344
- const checks = checkFindings(head);
21345
- const settled = settleRound({
21346
- findings: [...reviewed.findings, ...scopeFindings(reviewed.scope), ...checks],
21347
- rebuttals,
21348
- verdicts: reviewed.verdicts
21349
- });
21350
- earlier = [...settled.open, ...settled.standing.map((entry) => entry.finding)].filter((finding) => !finding.id.startsWith("scope: ") && !isCheckFinding(finding.id));
21351
- const unseen = checks.some((finding) => !shown.has(finding.detail));
21352
- const decision = rallyStop({
21353
- round,
21354
- maxRounds: input.maxRounds,
21355
- settled,
21356
- ...previous === undefined || unseen ? undefined : { previous }
21357
- });
21358
- if (decision.stop && (!unseen || round >= input.maxRounds))
21359
- return { round, head, settled, decision, claims, scope: reviewed.scope, conflicts };
21360
- for (const finding of checks)
21361
- shown.add(finding.detail);
21362
- const fixed = yield* agentWork(`fix:${round}`, {
21363
- agent: "implementer",
21364
- prompt: fixPrompt(settled),
21365
- output: Fixed
21366
- });
21367
- claims.push(...fixed.claims, ...fixed.rebuttals.flatMap((rebuttal) => isCheckFinding(rebuttal.findingId) ? [`Rebutted ${rebuttal.findingId}: ${rebuttal.reason}`] : []));
21368
- const rebutted = fixed.rebuttals.filter((rebuttal) => !isCheckFinding(rebuttal.findingId));
21369
- rebuttals = [
21370
- ...rebutted,
21371
- ...settled.standing.filter((entry) => isBlocking(entry.finding)).map((entry) => entry.rebuttal).filter((rebuttal) => !rebutted.some((entry) => entry.findingId === rebuttal.findingId))
21372
- ];
21373
- previous = blockingIds(settled);
21374
- last = { head, settled, scope: reviewed.scope };
21375
- }
21376
- });
21377
- var build_default = defineLoop({
21378
- id: "build",
21379
- version: "2.2.0",
21380
- title: "Build",
21381
- input: BuildInput,
21382
- output: BuildOutput,
21245
+ var threadKey = (thread) => `${thread.id}:${thread.comments.length}`;
21246
+ var shepherd_default = defineLoop({
21247
+ id: "shepherd",
21248
+ version: "1.1.0",
21249
+ title: "Shepherd",
21250
+ input: Input3,
21251
+ output: Output3,
21383
21252
  agents: { implementer: "implementer" },
21384
21253
  checkout: "write",
21385
- offers: [
21386
- { id: "shepherd", title: "Shepherd it to merge", loop: "shepherd", from: "next" },
21387
- { id: "review", title: "Review again", loop: "review", from: "next" },
21388
- { id: "tidy", title: "Tidy", loop: "tidy" },
21389
- { id: "retry", title: "Retry with a steer", loop: "build" }
21254
+ capabilities: [
21255
+ "agent_work",
21256
+ "read_change_request",
21257
+ "post_comment",
21258
+ "merge_change_request",
21259
+ "approve_change_request",
21260
+ "call",
21261
+ "write_back",
21262
+ "ask",
21263
+ "verify"
21390
21264
  ],
21391
- capabilities: ["agent_work", "verify", "change_request", "write_back", "post_comment", "approve_change_request"],
21392
- calls: () => [review_default, shepherd_default],
21393
- run: (input) => gen2(function* () {
21394
- if (input.words === undefined && input.taskId === undefined)
21395
- return yield* new BuildTargetMissing({ message: "Give the words to build, or a Task" });
21396
- if (input.outcome === "performance")
21397
- return yield* new BuildOutcomeRefused({
21398
- message: "Build cannot prove a performance gain; start Hillclimb, which commits a benchmark and keeps only the changes Kitman measures faster"
21399
- });
21400
- yield* writeBack("write-back:started", { milestone: "started" });
21401
- const task = input.words === undefined ? "the Task your brief describes." : `this: ${input.words}`;
21402
- const outcome = input.outcome;
21403
- const target = input.branch ?? input.continuing?.targetBranch;
21404
- if (input.changeRequestId !== undefined && input.continuing?.changeRequestId !== input.changeRequestId)
21405
- return yield* new BuildTargetMissing({
21406
- message: `Kitman did not put this Run on ChangeRequest ${input.changeRequestId}'s branch`
21407
- });
21408
- const reproduced = outcome === "bug" ? yield* agentWork("reproduce", {
21409
- agent: "implementer",
21410
- prompt: input.continuing === undefined ? reproducePrompt(task) : reproducedOnBranchPrompt(task),
21411
- output: ReproducedOnBranch
21412
- }) : undefined;
21413
- const base = reproduced !== undefined ? yield* verify("verify:reproduction", reproduced.revision === undefined ? {} : { revision: reproduced.revision }) : outcome === "refactor" ? yield* verify("verify:base", {}) : undefined;
21414
- const implemented = yield* agentWorkOnce("implement", {
21415
- agent: "implementer",
21416
- skill: "implement",
21417
- prompt: implementPrompt(task, outcome, input.continuing),
21418
- output: Implemented
21419
- });
21420
- const unanswered = input.taskId !== undefined && implemented.criteria === undefined;
21421
- const unmet = (implemented.criteria ?? []).filter((answer) => answer.status === "not_met");
21422
- const criteria = unanswered || unmet.length > 0 ? (yield* agentWorkOnce("criteria", {
21423
- agent: "implementer",
21424
- prompt: criteriaPrompt(unanswered, unmet),
21425
- output: Criteria
21426
- })).criteria : implemented.criteria ?? [];
21427
- const last = yield* rally({
21428
- maxRounds: input.maxRounds ?? 3,
21429
- base: `origin/${target ?? "HEAD"}`,
21430
- target,
21431
- outcome,
21432
- claims: [...implemented.claims, ...criteria.map((answer) => `Criterion ${answerLine(answer)}`)],
21433
- suggested: implemented.checks ?? []
21434
- });
21435
- const verdict = evidenceGate({
21436
- ...outcome === undefined ? undefined : { outcome },
21437
- head: last.head,
21438
- ...base === undefined ? undefined : { base },
21439
- ...last.scope === undefined ? undefined : { review: last.scope },
21440
- ...reproduced === undefined ? undefined : { reproducing: reproduced.check },
21441
- ...implemented.documented === undefined ? undefined : { documented: implemented.documented },
21442
- ...implemented.rollback === undefined ? undefined : { rollback: implemented.rollback }
21443
- });
21444
- const unsettled = [
21445
- ...new Set([
21446
- ...verdict.settled ? [] : verdict.reasons,
21447
- ...brokenChecks(last.head).map((check) => failsOn("head", check.name, last.head.tree)),
21448
- ...unsettledItems(last.settled, last.decision),
21449
- ...last.conflicts,
21450
- ...criteria.flatMap((answer) => answer.status === "not_met" ? [`Criterion not met: ${answer.criterion} (${answer.detail})`] : [])
21451
- ])
21452
- ];
21453
- const body = changeRequestBody({
21454
- summary: implemented.summary,
21455
- head: last.head,
21456
- claims: last.claims,
21457
- unsettled,
21458
- criteria: criteria.map(answerLine),
21459
- later: last.settled.open.filter((finding) => !isBlocking(finding)).map((finding) => `${finding.id}: ${finding.detail}`),
21460
- rounds: last.round,
21461
- stopped: last.decision.reason
21265
+ calls: () => [review_default, release_default],
21266
+ run: (input) => {
21267
+ const changeRequestId = input.changeRequestId;
21268
+ const target = input.continuing?.targetBranch ?? input.branch ?? "HEAD";
21269
+ const answered = new Set;
21270
+ const reranHeads = new Set;
21271
+ const ranOnRunner = new Set;
21272
+ let fixRounds = 0;
21273
+ let replies = 0;
21274
+ let stalledAt;
21275
+ let stalls = 0;
21276
+ let unreviewed;
21277
+ let notCarried;
21278
+ const done = (status, reason) => ({ status, reason, fixRounds, replies });
21279
+ const runOnRunner = (step, cycle, head, checks) => gen2(function* () {
21280
+ const verification = `pipeline-${step}:${cycle}`;
21281
+ const failed = yield* verify(verification, { revision: head, checks }).pipe(as2(undefined), catchTag2("StepFailed", (error) => succeed7(error.reason)));
21282
+ if (failed === undefined)
21283
+ yield* reportPipeline(`pipeline-${step === "run" ? "report" : "rereport"}:${cycle}`, {
21284
+ changeRequestId,
21285
+ verification
21286
+ });
21287
+ return failed;
21462
21288
  });
21463
- const named = input.changeRequestId === undefined ? undefined : input.continuing;
21464
- const opened = named === undefined ? yield* changeRequest("change-request", { title: implemented.title, body }) : yield* comment("report", { changeRequestId: named.changeRequestId, body, summary: "kitman-build" }).pipe(as2({ id: named.changeRequestId, number: named.number, url: named.url }));
21465
- if (named === undefined)
21466
- yield* writeBack("write-back", { milestone: "change_request_opened", changeRequestUrl: opened.url });
21467
- const approval = unsettled.length === 0 ? yield* approveChangeRequest("approve", {
21468
- changeRequestId: opened.id,
21469
- head: last.head.revision,
21470
- summary: `Review round ${last.round} passed with nothing blocking.`,
21471
- review: `review:${last.round}`
21472
- }).pipe(catchTag2("StepFailed", (error) => succeed7({ approved: false, reason: error.reason }))) : undefined;
21473
- const landed = input.land !== false ? yield* call("land", shepherd_default, {
21474
- changeRequestId: opened.id,
21475
- ...input.taskId === undefined ? undefined : { taskId: input.taskId },
21476
- ...input.branch === undefined ? undefined : { branch: input.branch }
21477
- }) : undefined;
21478
- return {
21479
- changeRequest: opened,
21480
- revision: last.head.revision,
21481
- tree: last.head.tree,
21482
- settled: unsettled.length === 0,
21483
- unsettled,
21484
- rounds: last.round,
21485
- stopped: last.decision.reason,
21486
- criteria,
21487
- ...approval === undefined ? undefined : { approval },
21488
- ...landed === undefined ? undefined : { landed },
21489
- next: { changeRequestId: opened.id }
21490
- };
21491
- }).pipe(tapError2(() => ignore2(writeBack("write-back:failed", { milestone: "failed" }))))
21289
+ return gen2(function* () {
21290
+ for (let cycle = 1;; cycle++) {
21291
+ if (cycle > (input.maxCycles ?? 20)) {
21292
+ const goOn = yield* ask(`continue:${cycle}`, {
21293
+ prompt: `Shepherd has looked at this ChangeRequest ${cycle - 1} times without landing it. Keep going?`,
21294
+ options: ["Keep going", "Stop"],
21295
+ recommended: "Stop",
21296
+ recommendationReason: `${cycle - 1} looks have not landed it, so something needs a person rather than another look.`,
21297
+ default: "Stop",
21298
+ timeout: "1 hour"
21299
+ });
21300
+ if (goOn === "Stop")
21301
+ return done("needs_you", `Stopped after ${cycle - 1} looks without landing it`);
21302
+ }
21303
+ const pipeline = yield* pipelineStatus(`pipeline:${cycle}`, { changeRequestId });
21304
+ if (pipeline.status === "none" && pipeline.runnerChecks !== undefined) {
21305
+ if (ranOnRunner.has(pipeline.head))
21306
+ return done("needs_you", `The pipeline ran on the Runner at ${pipeline.head}, but no result was recorded`);
21307
+ ranOnRunner.add(pipeline.head);
21308
+ const failed = yield* runOnRunner("run", cycle, pipeline.head, pipeline.runnerChecks);
21309
+ if (failed !== undefined)
21310
+ return done("needs_you", `The pipeline could not run on the Runner: ${failed}`);
21311
+ continue;
21312
+ }
21313
+ if (pipeline.status === "none" || pipeline.status === "running") {
21314
+ yield* pipelineMoved(`wait:${cycle}`, changeRequestId, pipeline);
21315
+ continue;
21316
+ }
21317
+ if (pipeline.status === "failed" || pipeline.status === "canceled") {
21318
+ if (stalledAt === pipeline.head)
21319
+ stalls++;
21320
+ else
21321
+ stalls = 0;
21322
+ if (stalls >= 2)
21323
+ return done("needs_you", `Two fixes pushed nothing new; the pipeline at ${pipeline.head} still fails`);
21324
+ if (fixRounds >= (input.maxFixRounds ?? 3))
21325
+ return done("needs_you", `The pipeline still fails after ${fixRounds} fix rounds`);
21326
+ fixRounds++;
21327
+ stalledAt = pipeline.head;
21328
+ const fixed = yield* agentWork(`ci-fix:${cycle}`, {
21329
+ agent: "implementer",
21330
+ prompt: ciFixPrompt(pipeline, target),
21331
+ output: CiFix,
21332
+ timeout: stepBudget
21333
+ });
21334
+ if (fixed.verdict === "pre_existing")
21335
+ return done("needs_you", `The failure is pre-existing on the target branch: ${fixed.summary}`);
21336
+ if (fixed.verdict === "flaky") {
21337
+ if (pipeline.source === "reported") {
21338
+ if (pipeline.runnerChecks === undefined)
21339
+ return done("needs_you", `The failing job passed when run again, outside the forge: ${fixed.summary}`);
21340
+ if (reranHeads.has(pipeline.head))
21341
+ return done("needs_you", `The failing job failed again when run again on the Runner: ${fixed.summary}`);
21342
+ reranHeads.add(pipeline.head);
21343
+ const failed = yield* runOnRunner("rerun", cycle, pipeline.head, pipeline.runnerChecks);
21344
+ if (failed !== undefined)
21345
+ return done("needs_you", `The pipeline could not run on the Runner: ${failed}`);
21346
+ continue;
21347
+ }
21348
+ if (reranHeads.has(pipeline.head))
21349
+ return done("needs_you", `The failing job failed again when run again on the forge: ${fixed.summary}`);
21350
+ reranHeads.add(pipeline.head);
21351
+ yield* rerunPipeline(`rerun:${cycle}`, { changeRequestId });
21352
+ yield* pipelineMoved(`rerun-wait:${cycle}`, changeRequestId, pipeline).pipe(catchTag2("WaitTimedOut", () => void_3));
21353
+ }
21354
+ continue;
21355
+ }
21356
+ const threads = (yield* reviewThreads(`threads:${cycle}`, { changeRequestId })).threads.filter((thread) => !answered.has(threadKey(thread)));
21357
+ if (threads.length > 0) {
21358
+ const answers = yield* agentWork(`comments:${cycle}`, {
21359
+ agent: "implementer",
21360
+ prompt: repliesPrompt(threads),
21361
+ output: Replies,
21362
+ timeout: stepBudget
21363
+ });
21364
+ for (const answer of answers.replies) {
21365
+ yield* comment(`reply:${cycle}:${answer.threadId}`, {
21366
+ changeRequestId,
21367
+ threadId: answer.threadId,
21368
+ body: answer.reply
21369
+ });
21370
+ replies++;
21371
+ }
21372
+ for (const thread of threads)
21373
+ answered.add(`${thread.id}:${thread.comments.length + 1}`);
21374
+ if (answers.replies.some((answer) => answer.changed))
21375
+ continue;
21376
+ }
21377
+ const commented = {
21378
+ event: "change_request.comment",
21379
+ filter: { changeRequestId },
21380
+ ...pipeline.readAt === undefined ? undefined : { since: pipeline.readAt }
21381
+ };
21382
+ const mergeable = yield* waitFor(`mergeable:${cycle}`, {
21383
+ event: "change_request.mergeable",
21384
+ filter: { changeRequestId },
21385
+ timeout: "1 hour",
21386
+ or: [commented]
21387
+ });
21388
+ if (mergeable.type === commented.event)
21389
+ continue;
21390
+ if (mergeable.payload.mergeable === false) {
21391
+ const synced = yield* syncBase(`sync:${cycle}`, { agent: "implementer", target });
21392
+ if (synced.status === "conflict")
21393
+ return done("needs_you", `Merging ${target} in conflicts on ${synced.files.join(", ")}: ${synced.reason}`);
21394
+ if (synced.status === "merged") {
21395
+ const caught = synced.catchUp;
21396
+ const verdict = caught === undefined ? { kind: "changed", why: "its Runner did not report what it did to the branch" } : catchUpVerdict(caught);
21397
+ if (verdict.kind === "changed") {
21398
+ unreviewed = undefined;
21399
+ notCarried = `merging ${target} in changed it: ${verdict.why}`;
21400
+ } else if (caught !== undefined && unreviewed !== undefined) {
21401
+ const files = [...new Set([...unreviewed.files, ...verdict.kind === "review" ? verdict.files : []])];
21402
+ unreviewed = caught.from === unreviewed.synced ? { ...unreviewed, files, synced: caught.head } : { ...unreviewed, files };
21403
+ } else if (caught !== undefined && verdict.kind === "review") {
21404
+ notCarried = undefined;
21405
+ unreviewed = { files: verdict.files, synced: caught.head, sync: `sync:${cycle}`, earlier: [] };
21406
+ } else if (caught !== undefined) {
21407
+ notCarried = undefined;
21408
+ const carried = yield* approveChangeRequest(`carry:${cycle}`, {
21409
+ changeRequestId,
21410
+ head: caught.head,
21411
+ summary: `Merging ${target} in left the branch's own diff as it was approved.`,
21412
+ carries: `sync:${cycle}`
21413
+ }).pipe(catchTag2("StepFailed", (error) => succeed7({ approved: false, reason: error.reason })));
21414
+ if (!carried.approved)
21415
+ notCarried = `the approval was not carried: ${carried.reason}`;
21416
+ }
21417
+ }
21418
+ continue;
21419
+ }
21420
+ let reviewedHere = false;
21421
+ if (unreviewed !== undefined) {
21422
+ const files = unreviewed;
21423
+ const head = pipeline.head;
21424
+ const reviewed = yield* call(`review:${cycle}`, reviewLoop(), {
21425
+ base: `origin/${target}`,
21426
+ head,
21427
+ files: files.files,
21428
+ ...head === files.synced ? undefined : { since: files.synced },
21429
+ ...files.earlier.length === 0 ? undefined : { earlier: files.earlier }
21430
+ });
21431
+ if (reviewed.blocking) {
21432
+ const blocking = reviewed.findings.filter(isBlocking);
21433
+ if (fixRounds >= (input.maxFixRounds ?? 3))
21434
+ return done("needs_you", `The review of the catch-up merge's files still finds ${blocking.map((finding) => finding.id).join(", ")} after ${fixRounds} fix rounds`);
21435
+ fixRounds++;
21436
+ yield* agentWork(`review-fix:${cycle}`, {
21437
+ agent: "implementer",
21438
+ prompt: reviewFixPrompt(blocking),
21439
+ output: ReviewFix,
21440
+ timeout: stepBudget
21441
+ });
21442
+ unreviewed = { ...files, earlier: reviewed.findings };
21443
+ continue;
21444
+ }
21445
+ const approval = yield* approveChangeRequest(`approve:${cycle}`, {
21446
+ changeRequestId,
21447
+ head,
21448
+ summary: `A review of the files merging ${target} in changed (${files.files.join(", ")}) passed with nothing blocking.`,
21449
+ review: `review:${cycle}`,
21450
+ carries: files.sync
21451
+ }).pipe(catchTag2("StepFailed", (error) => succeed7({ approved: false, reason: error.reason })));
21452
+ unreviewed = undefined;
21453
+ if (approval.approved)
21454
+ reviewedHere = true;
21455
+ else
21456
+ notCarried = `merging ${target} in, whose files' review passed but did not approve it: ${approval.reason}`;
21457
+ }
21458
+ const moved = pipeline.readAt === undefined ? [] : [
21459
+ { event: "change_request.pipeline", filter: { changeRequestId }, since: pipeline.readAt },
21460
+ ...pipeline.source === "reported" ? [
21461
+ {
21462
+ event: "change_request.approval",
21463
+ filter: { changeRequestId, approved: true },
21464
+ since: pipeline.readAt
21465
+ }
21466
+ ] : []
21467
+ ];
21468
+ const approved = reviewedHere ? undefined : yield* waitFor(`approved:${cycle}`, {
21469
+ event: "change_request.approval",
21470
+ filter: { changeRequestId, approved: true, head: pipeline.head },
21471
+ timeout: "7 days",
21472
+ or: [commented, ...moved]
21473
+ });
21474
+ if (approved !== undefined && approved.type !== "change_request.approval")
21475
+ continue;
21476
+ if (pipeline.source === "reported" && approved !== undefined && approved.payload.head !== pipeline.head)
21477
+ continue;
21478
+ const merge = input.merge === true ? "Merge" : yield* ask(`merge:${cycle}`, {
21479
+ prompt: "The ChangeRequest is approved, green and mergeable. Merge now?",
21480
+ options: [
21481
+ { value: "Merge", description: "Merges the head whose pipeline passed." },
21482
+ { value: "Wait", description: "Leaves the merge to a person." }
21483
+ ],
21484
+ recommended: "Merge",
21485
+ recommendationReason: "It is approved, green and mergeable, which is everything a merge waits for.",
21486
+ default: "Wait",
21487
+ auto: "Merge",
21488
+ timeout: "1 hour"
21489
+ });
21490
+ if (merge === "Wait")
21491
+ return done("ready", "Approved, green and mergeable; left for a person to merge");
21492
+ const merged = yield* mergeChangeRequest(`merge-change-request:${cycle}`, {
21493
+ changeRequestId,
21494
+ head: pipeline.head
21495
+ });
21496
+ if (merged.status !== "merged")
21497
+ continue;
21498
+ yield* writeBack("write-back", { milestone: "merged" });
21499
+ const ship = yield* ask("ship", {
21500
+ prompt: `Merged at ${merged.revision}. Release it now?`,
21501
+ options: [
21502
+ { value: "Release", description: "Starts Release on the merge: it gates, tags, publishes and deploys it." },
21503
+ { value: "Not now", description: "Leaves the release to a person or a later Release." }
21504
+ ],
21505
+ recommended: "Release",
21506
+ recommendationReason: "The merge is approved and green, so it is ready to ship.",
21507
+ default: "Not now",
21508
+ auto: "Release",
21509
+ timeout: "1 hour"
21510
+ });
21511
+ if (ship === "Not now")
21512
+ return done("merged", `Merged at ${merged.revision}`);
21513
+ const released = yield* call("release", release_default, { revision: merged.revision });
21514
+ if (released.status !== "deployed" && released.status !== "rejected")
21515
+ yield* ask("release-failed", {
21516
+ prompt: `Merged at ${merged.revision}, but its Release ended ${released.status}: ${released.reason}`,
21517
+ options: [{ value: "Taken over", description: "A person looks at the release and production from here." }],
21518
+ recommended: "Taken over",
21519
+ recommendationReason: "The merge is in, but production may not run it, or may be broken.",
21520
+ default: null
21521
+ });
21522
+ return {
21523
+ ...done("merged", `Merged at ${merged.revision}; Release: ${released.reason}`),
21524
+ release: {
21525
+ runStatus: released.status,
21526
+ reason: released.reason,
21527
+ ...released.tag === undefined ? undefined : { tag: released.tag }
21528
+ }
21529
+ };
21530
+ }
21531
+ }).pipe(catchIf2((error) => error._tag === "StepFailed" && error.reason.includes("time budget"), (error) => succeed7(done("needs_you", `Step "${error.step}" ran past its ${stepBudget} budget`))), catchTags2({
21532
+ WaitTimedOut: (timedOut) => succeed7(done("needs_you", timedOut.event === "change_request.approval" && notCarried !== undefined ? `Nobody approved the head after ${notCarried}` : `Nothing happened on the ChangeRequest while waiting for ${timedOut.event}`)),
21533
+ ChangeRequestClosed: (ended) => succeed7(ended.merged ? done("merged", "Someone else merged the ChangeRequest") : done("needs_you", "The ChangeRequest was closed"))
21534
+ }));
21535
+ }
21492
21536
  });
21493
21537
 
21494
- // src/errand.ts
21495
- var Input3 = Struct({
21496
- words: String5,
21538
+ // src/build.ts
21539
+ var BuildInput = Struct({
21540
+ words: optional2(String5),
21497
21541
  taskId: optional2(String5),
21498
21542
  repoBindingId: optional2(String5),
21499
- branch: optional2(String5)
21500
- });
21501
- var Done3 = Struct({
21502
- status: Literals(["done", "blocked", "too_big"]),
21503
- summary: String5,
21504
- changed: Boolean3,
21505
- title: optional2(String5),
21506
- checks: optional2(ArraySchema(SuggestedCheck))
21507
- });
21508
- var budget = "1 hour";
21509
- var Output3 = Struct({
21510
- status: Literals(["done", "blocked", "too_big", "opened", "handed_to_build", "discarded"]),
21511
- summary: String5,
21512
- changeRequest: optional2(ChangeRequestResult)
21513
- });
21514
- var prompt2 = (words) => [
21515
- `Just do this: ${words}`,
21516
- implementAdaptation,
21517
- "It is an errand: a rename, a bump, regenerated fixtures, an answer to a question. Do it directly, run the tests of what you touch, and commit nothing you were not asked for.",
21518
- "If it turns out to be a feature (several parts, design choices, or more than an hour of work), change nothing and answer too_big with why: a Build will do it with a full review. If something stops you, answer blocked with what.",
21519
- "Answer with a summary for the person who asked, whether you changed files, a ChangeRequest title if you did, and checks to run if the repository has none approved."
21520
- ].join(`
21521
-
21522
- `);
21523
- var errand_default = defineLoop({
21524
- id: "errand",
21525
- version: "1.0.0",
21526
- title: "Errand",
21527
- input: Input3,
21528
- output: Output3,
21529
- agents: { implementer: "implementer" },
21530
- checkout: "write",
21531
- offers: [{ id: "build", title: "Build it with a full review", loop: "build" }],
21532
- capabilities: ["agent_work", "verify", "change_request", "write_back", "ask"],
21533
- calls: () => [build_default],
21534
- run: (input) => gen2(function* () {
21535
- const done = yield* agentWorkOnce("errand", {
21536
- agent: "implementer",
21537
- skill: "implement",
21538
- prompt: prompt2(input.words),
21539
- output: Done3,
21540
- timeout: budget
21541
- }).pipe(catchIf2((error) => error._tag === "StepFailed" && error.reason.includes("time budget"), () => succeed7({
21542
- status: "too_big",
21543
- summary: `It took longer than an errand's ${budget}, so it is a feature: Build can do it with a full review.`,
21544
- changed: false
21545
- })));
21546
- if (done.status !== "done" || !done.changed)
21547
- return { status: done.status, summary: done.summary };
21548
- const head = yield* verify("verify", { suggested: done.checks ?? [] });
21549
- const answer = yield* ask("next", {
21550
- prompt: `${done.summary}
21551
-
21552
- The checks ${head.passed ? "pass" : "fail"}. What next?`,
21553
- options: [
21554
- { value: "Open a ChangeRequest", description: "Deliver it as it is, unreviewed." },
21555
- { value: "Hand to Build for a full review", description: "Build reviews and verifies it before delivering." },
21556
- { value: "Discard", description: "Drop the change." }
21557
- ],
21558
- recommended: head.passed ? "Open a ChangeRequest" : "Hand to Build for a full review",
21559
- recommendationReason: head.passed ? "The checks pass, and an errand is small enough to review on the ChangeRequest." : "The checks fail, so it needs the fixes and review a Build gives before anyone merges it.",
21560
- default: "Open a ChangeRequest",
21561
- timeout: "1 hour"
21562
- });
21563
- if (answer === "Discard")
21564
- return { status: "discarded", summary: done.summary };
21565
- if (answer === "Hand to Build for a full review") {
21566
- const built = yield* call("build", build_default, {
21567
- words: input.words,
21568
- revision: head.revision,
21569
- ...input.taskId === undefined ? undefined : { taskId: input.taskId },
21570
- ...input.branch === undefined ? undefined : { branch: input.branch }
21571
- });
21572
- return { status: "handed_to_build", summary: done.summary, changeRequest: built.changeRequest };
21573
- }
21574
- const opened = yield* changeRequest("change-request", {
21575
- title: done.title ?? input.words.slice(0, 120),
21576
- body: [
21577
- done.summary,
21578
- "> **Unreviewed errand.** One agent did this with no review; ask for a Build or a Review if it needs one.",
21579
- "## Evidence",
21580
- evidenceTable(head)
21581
- ].join(`
21582
-
21583
- `)
21584
- });
21585
- yield* writeBack("write-back", { milestone: "change_request_opened", changeRequestUrl: opened.url });
21586
- return { status: "opened", summary: done.summary, changeRequest: opened };
21587
- })
21588
- });
21589
-
21590
- // src/file-tasks.ts
21591
- var FileTasksInput = Struct({
21592
- tickets: PlanTickets,
21593
- projectId: optional2(String5)
21594
- });
21595
- var Output4 = Struct({
21596
- tasks: ArraySchema(Struct({ key: String5, taskId: String5 }))
21543
+ branch: optional2(String5),
21544
+ revision: optional2(String5),
21545
+ outcome: optional2(LoopOutcome),
21546
+ maxRounds: optional2(Int),
21547
+ continuing: optional2(Continuing),
21548
+ land: optional2(Boolean3),
21549
+ changeRequestId: optional2(String5)
21597
21550
  });
21598
- var file_tasks_default = defineLoop({
21599
- id: "file-tasks",
21600
- version: "1.0.0",
21601
- title: "File Tasks",
21602
- input: FileTasksInput,
21603
- output: Output4,
21604
- agents: {},
21605
- checkout: "none",
21606
- capabilities: ["create_tasks"],
21607
- run: (input) => createTasks("tasks", {
21608
- tickets: input.tickets,
21609
- ...input.projectId === undefined ? undefined : { projectId: input.projectId }
21610
- }).pipe(map8(({ tasks }) => ({ tasks })))
21551
+ var CriterionAnswer = Struct({
21552
+ criterion: String5,
21553
+ status: Literals(["met", "after_merge", "not_met"]),
21554
+ detail: String5
21611
21555
  });
21612
-
21613
- // src/hillclimb.ts
21614
- var Input4 = Struct({
21615
- focus: optional2(String5),
21616
- taskId: optional2(String5),
21617
- repoBindingId: optional2(String5),
21618
- branch: optional2(String5),
21619
- cycles: optional2(Int),
21620
- threshold: optional2(Finite),
21621
- tolerance: optional2(Finite)
21556
+ var Criteria = Struct({ criteria: ArraySchema(CriterionAnswer) });
21557
+ var SuggestedCheck = Struct({
21558
+ name: CheckName,
21559
+ command: String5,
21560
+ workingDirectory: optionalKey2(String5)
21622
21561
  });
21623
- var Opportunity = Struct({
21562
+ var Implemented = Struct({
21624
21563
  title: String5,
21625
- detail: String5,
21626
- evidence: Literals(["measured", "telemetry", "hypothesis"]),
21627
- external: optional2(Boolean3)
21564
+ summary: String5,
21565
+ claims: ArraySchema(String5),
21566
+ documented: optional2(ArraySchema(String5)),
21567
+ rollback: optional2(String5),
21568
+ criteria: optional2(ArraySchema(CriterionAnswer)),
21569
+ checks: optional2(ArraySchema(SuggestedCheck))
21628
21570
  });
21629
- var Discovery = Struct({
21630
- stack: String5,
21631
- journeys: ArraySchema(Struct({ name: String5, objective: String5 })),
21632
- telemetry: ArraySchema(Struct({ source: String5, status: Literals(["used", "unavailable"]), why: String5 })),
21633
- opportunities: ArraySchema(Opportunity)
21571
+ var Reproduced = Struct({
21572
+ check: String5,
21573
+ summary: String5
21634
21574
  });
21635
- var Harness = Struct({
21636
- summary: String5,
21637
- plan: MeasurePlan
21575
+ var ReproducedOnBranch = Struct({
21576
+ ...Reproduced.fields,
21577
+ revision: optional2(String5)
21638
21578
  });
21639
- var Change = Struct({
21640
- status: Literals(["changed", "exhausted", "unsafe"]),
21641
- summary: String5
21579
+ var Fixed = Struct({
21580
+ summary: String5,
21581
+ claims: ArraySchema(String5),
21582
+ rebuttals: ArraySchema(Rebuttal)
21642
21583
  });
21643
- var Kept = Struct({ summary: String5, verdict: String5, reason: String5 });
21644
- var Output5 = Struct({
21645
- status: Literals(["improved", "no_gain", "gain_gone", "checks_failed", "too_noisy"]),
21646
- reason: String5,
21647
- kept: ArraySchema(Kept),
21648
- cycles: Int,
21649
- reportArtifactId: String5,
21650
- changeRequest: optional2(ChangeRequestResult),
21651
- external: ArraySchema(String5),
21652
- unsettled: optional2(ArraySchema(String5))
21584
+ var BuildOutput = Struct({
21585
+ changeRequest: ChangeRequestResult,
21586
+ revision: String5,
21587
+ tree: String5,
21588
+ settled: Boolean3,
21589
+ unsettled: ArraySchema(String5),
21590
+ rounds: Int,
21591
+ stopped: Literals(["settled", "dispute_stands", "findings_returned", "round_cap"]),
21592
+ criteria: ArraySchema(CriterionAnswer),
21593
+ approval: optional2(ApproveChangeRequestResult),
21594
+ landed: optional2(ShepherdOutput),
21595
+ next: Struct({ changeRequestId: String5 })
21653
21596
  });
21654
- var discoverPrompt = (focus) => [
21655
- `Find where this repository is slow${focus === undefined ? ", for the journeys that matter most" : `: ${focus}`}. Change nothing.`,
21656
- "Read the stack, the user journeys and the objective each has, the docs and earlier performance work. Read whatever telemetry you can reach, read-only and with bounded queries; list each source as used or unavailable, with why.",
21657
- "Rank the opportunities, most promising first, each marked measured, telemetry or hypothesis. Mark one that only another repository can make as external."
21658
- ].join(`
21659
21597
 
21660
- `);
21661
- var harnessPrompt = (discovery) => [
21662
- "Commit a local benchmark for these journeys, and change no product code:",
21663
- ...discovery.journeys.map((journey) => `- ${journey.name}: ${journey.objective}`),
21664
- "It runs on this Runner alone: never call a service, database or URL outside it. Each run prints one JSON line of numbers by metric name as its last line; wall time is measured for you as wall_ms. Keep it under a minute a run.",
21665
- "Give the measurement plan: the command, any prepare command, and the metrics with one primary and the rest guardrails. Add a low-variance counter (instructions, allocations, queries) where you can, marked lowVariance."
21666
- ].join(`
21598
+ class BuildTargetMissing extends TaggedError3()("BuildTargetMissing", {
21599
+ message: String5
21600
+ }) {
21601
+ }
21602
+ var outcomeGuidance = {
21603
+ feature: "Deliver the whole scope; review checks it was met.",
21604
+ bug: "Your failing test is already in place: make it pass.",
21605
+ refactor: "Keep behaviour exactly as it is; the approved verifications must pass before and after.",
21606
+ investigation: "Investigate and report what you found, with its sources.",
21607
+ docs: "Name, in `documented`, the approved checks that run the commands you documented.",
21608
+ migration: "Name, in `rollback`, the approved check that rolls the migration back.",
21609
+ performance: "Make it faster without changing behaviour."
21610
+ };
21667
21611
 
21668
- `);
21669
- var changePrompt = (discovery, plan, tried, cycle) => [
21670
- `Cycle ${cycle}: make one bounded change that should improve ${plan.metrics.find((metric) => metric.role === "primary")?.name ?? "the primary metric"} without making a guardrail worse, and commit it. Kitman measures it; do not report numbers of your own.`,
21671
- "Opportunities, most promising first:",
21672
- ...discovery.opportunities.flatMap((entry) => entry.external === true ? [] : [`- ${entry.title} (${entry.evidence}): ${entry.detail}`]),
21673
- ...tried.length === 0 ? [] : ["Already tried:", ...tried.map((entry) => `- ${entry}`)],
21674
- "Keep behaviour exactly as it is and run the tests you touch. Answer exhausted when nothing is left worth trying, or unsafe when the gain would cost correctness; either way change nothing."
21612
+ class BuildOutcomeRefused extends TaggedError3()("BuildOutcomeRefused", {
21613
+ message: String5
21614
+ }) {
21615
+ }
21616
+ var criteriaGuidance = "If your brief has a Task, answer each of its acceptance criteria in `criteria`: met, with the test or file that shows it; after_merge, with why it can only be seen once merged; or not_met, with why. Give an empty `criteria` when the Task has none.";
21617
+ var checksGuidance = "If your brief lists no approved verifications for this repository, name in `checks` the commands from its package scripts or CI config that test, lint and typecheck it, each with a `name` of letters, digits, `.`, `_`, `:` and `-` only, such as `unit-tests`; Kitman runs them and labels them as suggested by you.";
21618
+ var implementAdaptation = "You run the implement skill as a Kitman Step. The work is in this prompt and your brief, so fetch no ticket. Leave your changes uncommitted: the Run owns the branch. Skip its final code-review: a Review run or a person reviews the change.";
21619
+ var implementPrompt = (task, outcome, continuing) => [
21620
+ ...continuing === undefined ? [] : [continuingBrief(continuing), "Fix forward from there."],
21621
+ `Build ${task}`,
21622
+ implementAdaptation,
21623
+ ...outcome === undefined ? [] : [outcomeGuidance[outcome]],
21624
+ 'Commit nothing yourself: Kitman pushes what you leave in the workspace. Give the ChangeRequest a title and a summary, and list as claims what you believe but Kitman has not checked, such as "the tests pass".',
21625
+ criteriaGuidance,
21626
+ checksGuidance
21675
21627
  ].join(`
21676
21628
 
21677
21629
  `);
21678
- var revertPrompt = (start, cycle) => `Cycle ${cycle}'s change did not prove faster. Revert its commits, and only those: run \`git revert --no-edit ${start}..HEAD\`. Touch no commit at or before ${start}, never reset or force-push, and change nothing else.`;
21679
- var fixPrompt2 = (findings) => [
21680
- "The review of the climb found these. Fix each, keep behaviour exactly as it is and keep the gain, and commit. Kitman verifies and measures again; a fix that costs the gain ends the climb.",
21681
- ...findings.map((finding) => `- ${finding.id}: ${finding.detail}`)
21630
+ var criteriaPrompt = (unanswered, unmet) => [
21631
+ unanswered ? "You did not answer the Task's acceptance criteria. Answer each one before anyone reviews the change." : "These acceptance criteria are not met yet. Meet them, or say why they cannot be met here:",
21632
+ ...unmet.map((answer) => `- ${answer.criterion}: ${answer.detail}`),
21633
+ criteriaGuidance
21682
21634
  ].join(`
21683
21635
 
21684
21636
  `);
21685
- var percent = (fraction) => `${(fraction * 100).toFixed(1)}%`;
21686
- var numbers = (measured) => [
21687
- "| Metric | Role | Base median | Head median | Change | Noise |",
21688
- "|---|---|---|---|---|---|",
21689
- ...measured.metrics.map((metric) => `| ${metric.name} | ${metric.role} | ${metric.base.median} | ${metric.head.median} | ${percent(metric.change)} | ${percent(metric.noise)} |`)
21637
+ var answerLine = (answer) => `${answer.criterion}: ${answer.status.replaceAll("_", " ")}, ${answer.detail}`;
21638
+ var reproducePrompt = (task) => [
21639
+ `Reproduce the bug in ${task} before anyone fixes it.`,
21640
+ "Add a test that fails because of the bug, and change nothing else. Name the approved check that runs it; it must fail now and pass once the bug is fixed."
21690
21641
  ].join(`
21691
- `);
21692
- var report = (input) => [
21693
- `# Hillclimb findings
21694
-
21695
- ${input.reason}`,
21696
- `## Stack
21697
-
21698
- ${input.discovery.stack}`,
21699
- `## Telemetry
21700
-
21701
- ${input.discovery.telemetry.map((entry) => `- ${entry.source}: ${entry.status}, ${entry.why}`).join(`
21702
- `) || "- None reached"}`,
21703
- `## Benchmark
21704
-
21705
- ${input.harness.summary}`,
21706
- `## Tried
21707
-
21708
- ${input.tried.map((entry) => `- ${entry}`).join(`
21709
- `) || "- Nothing"}`,
21710
- ...input.kept.length === 0 ? [] : [`## Kept
21711
-
21712
- ${input.kept.map((entry) => `- ${entry.summary}: ${entry.reason}`).join(`
21713
- `)}`],
21714
- ...input.final === undefined ? [] : [`## Final measurement
21715
-
21716
- ${input.final.reason}
21717
21642
 
21718
- ${numbers(input.final)}`],
21719
- `## Opportunities
21720
-
21721
- ${input.discovery.opportunities.map((entry) => `- ${entry.title} (${entry.evidence}${entry.external === true ? ", external" : ""}): ${entry.detail}`).join(`
21722
- `)}`
21643
+ `);
21644
+ var reproducedOnBranchPrompt = (task) => [
21645
+ `An earlier Run already worked on fixing the bug in ${task}, on this branch.`,
21646
+ "Find the commit on this branch where the test reproducing the bug was in place and the fix was not yet, and give it as `revision`; name the approved check that runs that test. Change nothing.",
21647
+ "If no commit on the branch has the reproducing test without the fix, add a test that fails on the branch head now, change nothing else, and give no `revision`."
21723
21648
  ].join(`
21724
21649
 
21725
21650
  `);
21726
- var steadier = (plan) => {
21727
- const counter = plan.metrics.find((metric) => metric.lowVariance === true && metric.role !== "primary");
21728
- return counter === undefined ? undefined : {
21729
- ...plan,
21730
- metrics: plan.metrics.map((metric) => ({
21731
- ...metric,
21732
- role: metric.name === counter.name ? "primary" : "guardrail"
21733
- }))
21734
- };
21651
+ var findingLine = (finding) => `- **${finding.severity}** \`${finding.id}\` ${finding.detail}`;
21652
+ var disputeLine = ({ finding, rebuttal }) => `${findingLine(finding)}
21653
+ You rebutted: ${rebuttal.reason}`;
21654
+ var fixPrompt = (settled) => {
21655
+ const checks = settled.open.filter((finding) => isCheckFinding(finding.id));
21656
+ const findings = settled.open.filter((finding) => !isCheckFinding(finding.id));
21657
+ return [
21658
+ ...checks.length === 0 ? [] : [
21659
+ "These checks fail on your head and pass on the base. Only the check passing closes one. If you are sure the change did not cause it, such as a flaky test, rebut it with your reason: the ChangeRequest reports that as your claim and the check stays open. A check does not run again on an unchanged tree, so if you change nothing and only these are open, the rally ends.",
21660
+ ...checks.map(findingLine)
21661
+ ],
21662
+ ...findings.length === 0 ? [] : [
21663
+ "The review found these. Fix each, or rebut it with your reason if it is wrong; reviewers judge the rebuttals next round.",
21664
+ ...findings.map(findingLine)
21665
+ ],
21666
+ ...settled.standing.length === 0 ? [] : [
21667
+ "The reviewers rejected your rebuttal of these. Fix each, or rebut it again with a better reason; one you leave stays a dispute for a person to settle.",
21668
+ ...settled.standing.map(disputeLine)
21669
+ ]
21670
+ ].join(`
21671
+ `);
21735
21672
  };
21736
- var hillclimb_default = defineLoop({
21737
- id: "hillclimb",
21738
- version: "1.3.0",
21739
- title: "Hillclimb",
21740
- outcome: "performance",
21741
- input: Input4,
21742
- output: Output5,
21743
- agents: { investigator: "climber", implementer: "implementer" },
21673
+ var blockingIds = (settled) => settled.open.filter(isBlocking).map((finding) => finding.id);
21674
+ var onlyChecksBlock = (settled) => blockingIds(settled).every(isCheckFinding) && !settled.standing.some((entry) => isBlocking(entry.finding));
21675
+ var rally = (input) => gen2(function* () {
21676
+ const claims = [...input.claims];
21677
+ const conflicts = [];
21678
+ let rebuttals = [];
21679
+ let earlier = [];
21680
+ let previous;
21681
+ let last;
21682
+ const shown = new Set;
21683
+ for (let round = 1;; round++) {
21684
+ const synced = yield* syncBase(`sync:${round}`, {
21685
+ agent: "implementer",
21686
+ ...input.target === undefined ? undefined : { target: input.target }
21687
+ });
21688
+ if (synced.status === "conflict")
21689
+ conflicts.push(`Could not merge the target branch in round ${round}: ${synced.files.join(", ")} (${synced.reason})`);
21690
+ const head = yield* verify(`verify:${round}`, { suggested: input.suggested });
21691
+ if (last !== undefined && last.head.tree === head.tree && rebuttals.length === 0 && onlyChecksBlock(last.settled))
21692
+ return {
21693
+ ...last,
21694
+ round: round - 1,
21695
+ head,
21696
+ decision: { stop: true, reason: "findings_returned", unsettled: blockingIds(last.settled) },
21697
+ claims,
21698
+ conflicts
21699
+ };
21700
+ const failing = brokenChecks(head).map((check) => check.name);
21701
+ const reviewed = yield* call(`review:${round}`, review_default, {
21702
+ base: input.base,
21703
+ head: head.revision,
21704
+ ...rebuttals.length === 0 ? undefined : { rebuttals },
21705
+ ...earlier.length === 0 ? undefined : { earlier },
21706
+ ...input.outcome === undefined ? undefined : { outcome: input.outcome },
21707
+ ...failing.length === 0 ? undefined : { failing },
21708
+ claims: [...claims]
21709
+ });
21710
+ const checks = checkFindings(head);
21711
+ const settled = settleRound({
21712
+ findings: [...reviewed.findings, ...scopeFindings(reviewed.scope), ...checks],
21713
+ rebuttals,
21714
+ verdicts: reviewed.verdicts
21715
+ });
21716
+ earlier = [...settled.open, ...settled.standing.map((entry) => entry.finding)].filter((finding) => !finding.id.startsWith("scope: ") && !isCheckFinding(finding.id));
21717
+ const unseen = checks.some((finding) => !shown.has(finding.detail));
21718
+ const decision = rallyStop({
21719
+ round,
21720
+ maxRounds: input.maxRounds,
21721
+ settled,
21722
+ ...previous === undefined || unseen ? undefined : { previous }
21723
+ });
21724
+ if (decision.stop && (!unseen || round >= input.maxRounds))
21725
+ return { round, head, settled, decision, claims, scope: reviewed.scope, conflicts };
21726
+ for (const finding of checks)
21727
+ shown.add(finding.detail);
21728
+ const fixed = yield* agentWork(`fix:${round}`, {
21729
+ agent: "implementer",
21730
+ prompt: fixPrompt(settled),
21731
+ output: Fixed
21732
+ });
21733
+ claims.push(...fixed.claims, ...fixed.rebuttals.flatMap((rebuttal) => isCheckFinding(rebuttal.findingId) ? [`Rebutted ${rebuttal.findingId}: ${rebuttal.reason}`] : []));
21734
+ const rebutted = fixed.rebuttals.filter((rebuttal) => !isCheckFinding(rebuttal.findingId));
21735
+ rebuttals = [
21736
+ ...rebutted,
21737
+ ...settled.standing.filter((entry) => isBlocking(entry.finding)).map((entry) => entry.rebuttal).filter((rebuttal) => !rebutted.some((entry) => entry.findingId === rebuttal.findingId))
21738
+ ];
21739
+ previous = blockingIds(settled);
21740
+ last = { head, settled, scope: reviewed.scope };
21741
+ }
21742
+ });
21743
+ var build_default = defineLoop({
21744
+ id: "build",
21745
+ version: "2.2.0",
21746
+ title: "Build",
21747
+ input: BuildInput,
21748
+ output: BuildOutput,
21749
+ agents: { implementer: "implementer" },
21744
21750
  checkout: "write",
21745
- capabilities: ["agent_work", "verify", "measure", "change_request", "write_back", "ask", "create_tasks"],
21746
- calls: () => [review_default],
21747
- offers: [{ id: "retry", title: "Climb again", loop: "hillclimb" }],
21751
+ offers: [
21752
+ { id: "shepherd", title: "Shepherd it to merge", loop: "shepherd", from: "next" },
21753
+ { id: "review", title: "Review again", loop: "review", from: "next" },
21754
+ { id: "tidy", title: "Tidy", loop: "tidy" },
21755
+ { id: "retry", title: "Retry with a steer", loop: "build" }
21756
+ ],
21757
+ capabilities: ["agent_work", "verify", "change_request", "write_back", "post_comment", "approve_change_request"],
21758
+ calls: () => [review_default, shepherd_default],
21748
21759
  run: (input) => gen2(function* () {
21749
- const limits = {
21750
- ...input.threshold === undefined ? undefined : { threshold: input.threshold },
21751
- ...input.tolerance === undefined ? undefined : { tolerance: input.tolerance }
21752
- };
21753
- const discovery = yield* agentWork("discover", {
21754
- agent: "investigator",
21755
- prompt: discoverPrompt(input.focus),
21756
- output: Discovery
21757
- });
21758
- const external = discovery.opportunities.flatMap((entry) => entry.external === true ? [entry.title] : []);
21759
- yield* syncBase("sync:start", {
21760
+ if (input.words === undefined && input.taskId === undefined)
21761
+ return yield* new BuildTargetMissing({ message: "Give the words to build, or a Task" });
21762
+ if (input.outcome === "performance")
21763
+ return yield* new BuildOutcomeRefused({
21764
+ message: "Build cannot prove a performance gain; start Hillclimb, which commits a benchmark and keeps only the changes Kitman measures faster"
21765
+ });
21766
+ yield* writeBack("write-back:started", { milestone: "started" });
21767
+ const task = input.words === undefined ? "the Task your brief describes." : `this: ${input.words}`;
21768
+ const outcome = input.outcome;
21769
+ const target = input.branch ?? input.continuing?.targetBranch;
21770
+ if (input.changeRequestId !== undefined && input.continuing?.changeRequestId !== input.changeRequestId)
21771
+ return yield* new BuildTargetMissing({
21772
+ message: `Kitman did not put this Run on ChangeRequest ${input.changeRequestId}'s branch`
21773
+ });
21774
+ const reproduced = outcome === "bug" ? yield* agentWork("reproduce", {
21760
21775
  agent: "implementer",
21761
- ...input.branch === undefined ? undefined : { target: input.branch }
21762
- });
21763
- const harness = yield* agentWorkOnce("harness", {
21776
+ prompt: input.continuing === undefined ? reproducePrompt(task) : reproducedOnBranchPrompt(task),
21777
+ output: ReproducedOnBranch
21778
+ }) : undefined;
21779
+ const base = reproduced !== undefined ? yield* verify("verify:reproduction", reproduced.revision === undefined ? {} : { revision: reproduced.revision }) : outcome === "refactor" ? yield* verify("verify:base", {}) : undefined;
21780
+ const implemented = yield* agentWorkOnce("implement", {
21764
21781
  agent: "implementer",
21765
- prompt: harnessPrompt(discovery),
21766
- output: Harness
21767
- });
21768
- const baseline = (yield* verify("verify:harness", {})).revision;
21769
- let plan = harness.plan;
21770
- const calibrated = yield* measure("calibrate", { plan, base: baseline, head: baseline, ...limits });
21771
- const kept = [];
21772
- const tried = [];
21773
- let cycles = 0;
21774
- const ending = (status, reason, final) => gen2(function* () {
21775
- const stored = yield* artifact("report", {
21776
- kind: "hillclimb-report",
21777
- title: "Hillclimb findings",
21778
- text: report({ discovery, harness, reason, kept, tried, final })
21779
- });
21780
- return { status, reason, kept, cycles, reportArtifactId: stored.artifactId, external };
21782
+ skill: "implement",
21783
+ prompt: implementPrompt(task, outcome, input.continuing),
21784
+ output: Implemented
21781
21785
  });
21782
- if (calibrated.verdict === "too_noisy") {
21783
- const switched = steadier(plan);
21784
- if (switched === undefined)
21785
- return yield* ending("too_noisy", `${calibrated.reason}. ${calibrated.suggestion ?? ""}`.trim());
21786
- plan = switched;
21787
- }
21788
- let start = baseline;
21789
- let unkept = 0;
21790
- for (let cycle = 1;cycle <= (input.cycles ?? 5) && unkept < 2; cycle++) {
21791
- const change = yield* agentWorkOnce(`change:${cycle}`, {
21792
- agent: "implementer",
21793
- prompt: changePrompt(discovery, plan, tried, cycle),
21794
- output: Change
21795
- });
21796
- if (change.status !== "changed") {
21797
- tried.push(`Cycle ${cycle}: ${change.status}, ${change.summary}`);
21798
- break;
21799
- }
21800
- cycles = cycle;
21801
- const head = yield* verify(`verify:${cycle}`, {});
21802
- const measured = head.passed ? yield* measure(`measure:${cycle}`, { plan, base: start, head: head.revision, ...limits }) : undefined;
21803
- if (measured?.verdict === "gain") {
21804
- kept.push({ summary: change.summary, verdict: measured.verdict, reason: measured.reason });
21805
- tried.push(`Cycle ${cycle}, kept: ${change.summary} (${measured.reason})`);
21806
- start = head.revision;
21807
- unkept = 0;
21808
- continue;
21809
- }
21810
- tried.push(`Cycle ${cycle}, reverted: ${change.summary} (${measured === undefined ? "its checks failed" : measured.reason})`);
21811
- yield* agentWork(`revert:${cycle}`, {
21812
- agent: "implementer",
21813
- prompt: revertPrompt(start, cycle),
21814
- output: Struct({ reverted: Boolean3 })
21815
- });
21816
- unkept++;
21817
- }
21818
- if (external.length > 0) {
21819
- const file = yield* ask("external", {
21820
- prompt: `Only other repositories can make these improvements: ${external.join("; ")}. File them as Tasks?`,
21821
- options: ["File as Tasks", "Leave them"],
21822
- recommended: "File as Tasks",
21823
- recommendationReason: "Filed as Tasks, the improvements wait in the repositories that can make them instead of being lost.",
21824
- default: "Leave them",
21825
- timeout: "1 hour"
21826
- });
21827
- if (file === "File as Tasks")
21828
- yield* createTasks("external-tasks", {
21829
- tickets: discovery.opportunities.flatMap((entry, index) => entry.external === true ? [
21830
- {
21831
- key: `external-${index + 1}`,
21832
- title: entry.title,
21833
- body: entry.detail,
21834
- acceptanceCriteria: [],
21835
- blockedBy: [],
21836
- outcome: "performance"
21837
- }
21838
- ] : [])
21839
- });
21840
- }
21841
- if (kept.length === 0)
21842
- return yield* ending("no_gain", "No change proved faster than the noise allows");
21843
- const synced = yield* syncBase("sync:final", {
21786
+ const unanswered = input.taskId !== undefined && implemented.criteria === undefined;
21787
+ const unmet = (implemented.criteria ?? []).filter((answer) => answer.status === "not_met");
21788
+ const criteria = unanswered || unmet.length > 0 ? (yield* agentWorkOnce("criteria", {
21844
21789
  agent: "implementer",
21845
- ...input.branch === undefined ? undefined : { target: input.branch }
21790
+ prompt: criteriaPrompt(unanswered, unmet),
21791
+ output: Criteria
21792
+ })).criteria : implemented.criteria ?? [];
21793
+ const last = yield* rally({
21794
+ maxRounds: input.maxRounds ?? 3,
21795
+ base: `origin/${target ?? "HEAD"}`,
21796
+ target,
21797
+ outcome,
21798
+ claims: [...implemented.claims, ...criteria.map((answer) => `Criterion ${answerLine(answer)}`)],
21799
+ suggested: implemented.checks ?? []
21846
21800
  });
21847
- const checked = yield* verify("verify:final", {});
21848
- if (!checked.passed) {
21849
- const failing = checked.checks.filter((check) => check.status === "failed").map((check) => check.name);
21850
- return yield* ending("checks_failed", `The final tree fails ${failing.join(", ") || "its checks"}`);
21851
- }
21852
- let final = yield* measure("measure:final", { plan, base: baseline, head: checked.revision, ...limits });
21853
- if (final.verdict !== "gain")
21854
- return yield* ending("gain_gone", `The final tree no longer proves the gain: ${final.reason}`, final);
21855
- const claims = [
21856
- "Behaviour is preserved: every kept change leaves what the code does exactly as it was.",
21857
- `The measurement is valid: ${harness.summary}`,
21858
- ...kept.map((entry) => entry.summary)
21859
- ];
21860
- let reviewed = yield* call("review", review_default, {
21861
- base: baseline,
21862
- head: checked.revision,
21863
- outcome: "performance",
21864
- claims
21801
+ const verdict = evidenceGate({
21802
+ ...outcome === undefined ? undefined : { outcome },
21803
+ head: last.head,
21804
+ ...base === undefined ? undefined : { base },
21805
+ ...last.scope === undefined ? undefined : { review: last.scope },
21806
+ ...reproduced === undefined ? undefined : { reproducing: reproduced.check },
21807
+ ...implemented.documented === undefined ? undefined : { documented: implemented.documented },
21808
+ ...implemented.rollback === undefined ? undefined : { rollback: implemented.rollback }
21865
21809
  });
21866
- for (let round = 1;round <= 2 && reviewed.findings.some(isBlocking); round++) {
21867
- const blocking = reviewed.findings.filter(isBlocking);
21868
- yield* agentWork(`fix:${round}`, {
21869
- agent: "implementer",
21870
- prompt: fixPrompt2(blocking),
21871
- output: Struct({ summary: String5 })
21872
- });
21873
- const fixed = yield* verify(`verify:fix:${round}`, {});
21874
- if (!fixed.passed) {
21875
- const failing = fixed.checks.filter((check) => check.status === "failed").map((check) => check.name);
21876
- return yield* ending("checks_failed", `The review's fixes fail ${failing.join(", ") || "the checks"}`);
21877
- }
21878
- final = yield* measure(`measure:fix:${round}`, { plan, base: baseline, head: fixed.revision, ...limits });
21879
- if (final.verdict !== "gain")
21880
- return yield* ending("gain_gone", `The review's fixes cost the gain: ${final.reason}`, final);
21881
- reviewed = yield* call(`review:${round + 1}`, review_default, {
21882
- base: baseline,
21883
- head: fixed.revision,
21884
- outcome: "performance",
21885
- claims,
21886
- earlier: blocking
21887
- });
21888
- }
21889
21810
  const unsettled = [
21890
- ...[...reviewed.findings, ...scopeFindings(reviewed.scope)].flatMap((finding) => isBlocking(finding) ? [`${finding.id}: ${finding.detail}`] : []),
21891
- ...synced.status === "conflict" ? [`Merging the base in conflicts on ${synced.files.join(", ")}`] : []
21811
+ ...new Set([
21812
+ ...verdict.settled ? [] : verdict.reasons,
21813
+ ...brokenChecks(last.head).map((check) => failsOn("head", check.name, last.head.tree)),
21814
+ ...unsettledItems(last.settled, last.decision),
21815
+ ...last.conflicts,
21816
+ ...criteria.flatMap((answer) => answer.status === "not_met" ? [`Criterion not met: ${answer.criterion} (${answer.detail})`] : [])
21817
+ ])
21892
21818
  ];
21893
- const caveats = synced.status === "merged" ? ["The base moved during the climb; the final measurement includes its changes too."] : [];
21894
- const opened = yield* changeRequest("change-request", {
21895
- title: `Faster: ${kept.map((entry) => entry.summary).join("; ")}`.slice(0, 240),
21896
- body: [
21897
- `${final.reason}, measured by Kitman against the benchmark on the base.`,
21898
- numbers(final),
21899
- `## Kept
21900
-
21901
- ${kept.map((entry) => `- ${entry.summary}: ${entry.reason}`).join(`
21902
- `)}`,
21903
- `## Benchmark
21904
-
21905
- ${harness.summary}
21906
-
21907
- \`${plan.command}\``,
21908
- ...unsettled.length + caveats.length === 0 ? [] : [`## Unsettled
21909
-
21910
- ${[...unsettled, ...caveats].map((entry) => `- ${entry}`).join(`
21911
- `)}`]
21912
- ].join(`
21913
-
21914
- `)
21819
+ const body = changeRequestBody({
21820
+ summary: implemented.summary,
21821
+ head: last.head,
21822
+ claims: last.claims,
21823
+ unsettled,
21824
+ criteria: criteria.map(answerLine),
21825
+ later: last.settled.open.filter((finding) => !isBlocking(finding)).map((finding) => `${finding.id}: ${finding.detail}`),
21826
+ rounds: last.round,
21827
+ stopped: last.decision.reason
21915
21828
  });
21916
- yield* writeBack("write-back", { milestone: "change_request_opened", changeRequestUrl: opened.url });
21917
- return { ...yield* ending("improved", final.reason, final), changeRequest: opened, unsettled };
21918
- })
21829
+ const named = input.changeRequestId === undefined ? undefined : input.continuing;
21830
+ const opened = named === undefined ? yield* changeRequest("change-request", { title: implemented.title, body }) : yield* comment("report", { changeRequestId: named.changeRequestId, body, summary: "kitman-build" }).pipe(as2({ id: named.changeRequestId, number: named.number, url: named.url }));
21831
+ if (named === undefined)
21832
+ yield* writeBack("write-back", { milestone: "change_request_opened", changeRequestUrl: opened.url });
21833
+ const approval = unsettled.length === 0 ? yield* approveChangeRequest("approve", {
21834
+ changeRequestId: opened.id,
21835
+ head: last.head.revision,
21836
+ summary: `Review round ${last.round} passed with nothing blocking.`,
21837
+ review: `review:${last.round}`
21838
+ }).pipe(catchTag2("StepFailed", (error) => succeed7({ approved: false, reason: error.reason }))) : undefined;
21839
+ const landed = input.land !== false && unsettled.length === 0 ? yield* call("land", shepherd_default, {
21840
+ changeRequestId: opened.id,
21841
+ ...input.taskId === undefined ? undefined : { taskId: input.taskId },
21842
+ ...input.branch === undefined ? undefined : { branch: input.branch }
21843
+ }) : undefined;
21844
+ if (input.land !== false && unsettled.length > 0)
21845
+ yield* ask("unsettled", {
21846
+ prompt: `The ChangeRequest ${opened.url} is open, but these still stand: ${unsettled.join("; ")}. Build will not land it.`,
21847
+ options: [{ value: "Taken over", description: "A person fixes, rebuts or steers it from here." }],
21848
+ recommended: "Taken over",
21849
+ recommendationReason: "Build stopped with these unsettled, so the next step is a person\u2019s.",
21850
+ default: null
21851
+ });
21852
+ return {
21853
+ changeRequest: opened,
21854
+ revision: last.head.revision,
21855
+ tree: last.head.tree,
21856
+ settled: unsettled.length === 0,
21857
+ unsettled,
21858
+ rounds: last.round,
21859
+ stopped: last.decision.reason,
21860
+ criteria,
21861
+ ...approval === undefined ? undefined : { approval },
21862
+ ...landed === undefined ? undefined : { landed },
21863
+ next: { changeRequestId: opened.id }
21864
+ };
21865
+ }).pipe(tapError2(() => ignore2(writeBack("write-back:failed", { milestone: "failed" }))))
21919
21866
  });
21920
21867
 
21921
- // src/firewatch.ts
21922
- var Input5 = Struct({
21923
- alert: Record(String5, Json2),
21868
+ // src/errand.ts
21869
+ var Input4 = Struct({
21870
+ words: String5,
21871
+ taskId: optional2(String5),
21924
21872
  repoBindingId: optional2(String5),
21925
- windowHours: optional2(Int.check(isGreaterThanOrEqualTo2(0), isLessThanOrEqualTo3(720))),
21926
- autoFix: optional2(Boolean3)
21873
+ branch: optional2(String5)
21927
21874
  });
21928
- var Conclusion = Struct({
21875
+ var Done3 = Struct({
21876
+ status: Literals(["done", "blocked", "too_big"]),
21929
21877
  summary: String5,
21930
- cause: String5,
21931
- confidence: Literals(["high", "medium", "low"]),
21932
- sources: NonEmptyArray(String5),
21933
- telemetry: ArraySchema(Struct({ source: String5, reached: Boolean3, tried: optional2(String5) })),
21934
- inCode: Boolean3,
21935
- fix: optional2(String5)
21878
+ changed: Boolean3,
21879
+ title: optional2(String5),
21880
+ checks: optional2(ArraySchema(SuggestedCheck))
21936
21881
  });
21937
- var FixRequest = Struct({ words: String5, outcome: Literal2("bug") });
21938
- var Output6 = Struct({
21939
- status: Literals(["folded", "resolved", "concluded"]),
21940
- taskId: optional2(String5),
21941
- joinedRunId: optional2(String5),
21942
- conclusionArtifactId: optional2(String5),
21943
- resolvedDuring: Boolean3,
21944
- fix: Literals(["none", "declined", "started", "failed"]),
21945
- changeRequestUrl: optional2(String5),
21946
- fixRequest: optional2(FixRequest)
21882
+ var budget = "1 hour";
21883
+ var Output4 = Struct({
21884
+ status: Literals(["done", "blocked", "too_big", "opened", "handed_to_build", "discarded"]),
21885
+ summary: String5,
21886
+ changeRequest: optional2(ChangeRequestResult)
21947
21887
  });
21948
- var firstText = (alert, ...keys) => keys.flatMap((key) => {
21949
- const value = alert[key];
21950
- return isString(value) && value.trim() !== "" ? [value] : [];
21951
- })[0];
21952
- var alertTitle = (alert) => firstText(alert, "title", "name", "summary") ?? "Alert";
21953
- var fingerprintOf = (alert) => firstText(alert, "fingerprint", "groupKey") ?? alertTitle(alert);
21954
- var isResolved = (alert) => firstText(alert, "status", "state")?.toLowerCase() === "resolved";
21955
- var priorities = new Map(Object.entries({
21956
- critical: "urgent",
21957
- p1: "urgent",
21958
- urgent: "urgent",
21959
- high: "high",
21960
- error: "high",
21961
- p2: "high",
21962
- warning: "medium",
21963
- medium: "medium",
21964
- p3: "medium",
21965
- low: "low",
21966
- info: "low",
21967
- p4: "low"
21968
- }));
21969
- var priorityOf = (alert) => {
21970
- const severity = firstText(alert, "severity", "priority", "level")?.toLowerCase();
21971
- return (severity === undefined ? undefined : priorities.get(severity)) ?? "high";
21972
- };
21973
- var investigatePrompt = (alert) => [
21974
- "Investigate this alert and conclude what happened and why.",
21975
- "```json",
21976
- JSON.stringify(alert, null, 2),
21977
- "```",
21978
- "Cite as sources the logs, metrics, commits or code that support your conclusion. List every telemetry source you tried and whether you could read it, with what you tried for one you could not; say how confident you are, lower when a source was out of reach.",
21979
- "Say whether the cause is in code Kitman can change, and propose a fix only then."
21888
+ var prompt2 = (words) => [
21889
+ `Just do this: ${words}`,
21890
+ implementAdaptation,
21891
+ "It is an errand: a rename, a bump, regenerated fixtures, an answer to a question. Do it directly, run the tests of what you touch, and commit nothing you were not asked for.",
21892
+ "If it turns out to be a feature (several parts, design choices, or more than an hour of work), change nothing and answer too_big with why: a Build will do it with a full review. If something stops you, answer blocked with what.",
21893
+ "Answer with a summary for the person who asked, whether you changed files, a ChangeRequest title if you did, and checks to run if the repository has none approved."
21980
21894
  ].join(`
21981
21895
 
21982
21896
  `);
21983
- var conclusionText = (conclusion, resolvedDuring) => [
21984
- conclusion.summary,
21985
- ...resolvedDuring ? ["It resolved on its own while it was being investigated."] : [],
21986
- `Cause: ${conclusion.cause} (confidence ${conclusion.confidence})`,
21987
- "Sources:",
21988
- ...conclusion.sources.map((source) => `- ${source}`),
21989
- ...conclusion.telemetry.some((source) => !source.reached) ? [
21990
- "Out of reach:",
21991
- ...conclusion.telemetry.filter((source) => !source.reached).map((source) => `- ${source.source}${source.tried === undefined ? "" : `: ${source.tried}`}`)
21992
- ] : [],
21993
- ...conclusion.fix === undefined ? [] : [`Fix: ${conclusion.fix}`]
21994
- ].join(`
21995
- `);
21996
- var firewatch_default = defineLoop({
21997
- id: "firewatch",
21897
+ var errand_default = defineLoop({
21898
+ id: "errand",
21998
21899
  version: "1.0.0",
21999
- title: "Firewatch",
22000
- input: Input5,
22001
- output: Output6,
22002
- outcome: "investigation",
22003
- agents: { investigator: "investigator" },
22004
- checkout: "read",
22005
- when: [{ event: "monitoring.alert", input: { alert: "alert" } }],
22006
- offers: [{ id: "build-fix", title: "Build the fix", loop: "build", from: "fixRequest" }],
22007
- capabilities: ["agent_work", "ask", "create_tasks"],
21900
+ title: "Errand",
21901
+ input: Input4,
21902
+ output: Output4,
21903
+ agents: { implementer: "implementer" },
21904
+ checkout: "write",
21905
+ offers: [{ id: "build", title: "Build it with a full review", loop: "build" }],
21906
+ capabilities: ["agent_work", "verify", "change_request", "write_back", "ask"],
22008
21907
  calls: () => [build_default],
22009
21908
  run: (input) => gen2(function* () {
22010
- const fingerprint = fingerprintOf(input.alert);
22011
- const resolved = isResolved(input.alert);
22012
- const windowHours = input.windowHours ?? 6;
22013
- const request = { fingerprint, title: alertTitle(input.alert), windowHours, priority: priorityOf(input.alert) };
22014
- const placed = yield* alertTask("task", { ...request, ...resolved ? { resolved } : undefined });
22015
- const taskId = placed.taskId === undefined ? undefined : { taskId: placed.taskId };
22016
- if (placed.joined !== undefined)
22017
- return {
22018
- status: "folded",
22019
- ...taskId,
22020
- joinedRunId: placed.joined.runId,
22021
- resolvedDuring: false,
22022
- fix: "none"
22023
- };
22024
- if (resolved)
22025
- return { status: "resolved", ...taskId, resolvedDuring: false, fix: "none" };
22026
- const conclusion = yield* agentWork("investigate", {
22027
- agent: "investigator",
22028
- prompt: investigatePrompt(input.alert),
22029
- output: Conclusion
22030
- });
22031
- const resolvedDuring = (yield* alertTask("task:check", request)).resolved === true;
22032
- const stored = yield* artifact("conclusion", {
22033
- kind: "investigation_conclusion",
22034
- title: request.title,
22035
- text: conclusionText(conclusion, resolvedDuring),
22036
- data: { alert: input.alert, sources: conclusion.sources, confidence: conclusion.confidence }
22037
- });
22038
- yield* note("summary", { text: conclusion.summary, artifactId: stored.artifactId });
22039
- const concluded = {
22040
- status: "concluded",
22041
- ...taskId,
22042
- conclusionArtifactId: stored.artifactId,
22043
- resolvedDuring
22044
- };
22045
- if (conclusion.fix === undefined || !conclusion.inCode)
22046
- return { ...concluded, fix: "none" };
22047
- const fixRequest = {
22048
- words: `${conclusion.fix}
22049
-
22050
- ${conclusionText(conclusion, resolvedDuring)}`,
22051
- outcome: "bug"
22052
- };
22053
- const confident = conclusion.confidence === "high" && !resolvedDuring;
22054
- const answer = yield* ask("start-fix", {
22055
- prompt: `${conclusion.summary}
21909
+ const done = yield* agentWorkOnce("errand", {
21910
+ agent: "implementer",
21911
+ skill: "implement",
21912
+ prompt: prompt2(input.words),
21913
+ output: Done3,
21914
+ timeout: budget
21915
+ }).pipe(catchIf2((error) => error._tag === "StepFailed" && error.reason.includes("time budget"), () => succeed7({
21916
+ status: "too_big",
21917
+ summary: `It took longer than an errand's ${budget}, so it is a feature: Build can do it with a full review.`,
21918
+ changed: false
21919
+ })));
21920
+ if (done.status !== "done" || !done.changed)
21921
+ return { status: done.status, summary: done.summary };
21922
+ const head = yield* verify("verify", { suggested: done.checks ?? [] });
21923
+ const answer = yield* ask("next", {
21924
+ prompt: `${done.summary}
22056
21925
 
22057
- Start a Build that fixes it as a bug: ${conclusion.fix}?`,
21926
+ The checks ${head.passed ? "pass" : "fail"}. What next?`,
22058
21927
  options: [
22059
- { value: "Start a fix", description: "A Build fixes it as a bug and opens a ChangeRequest." },
22060
- { value: "Not now", description: "Keeps the finding on the Task." }
21928
+ { value: "Open a ChangeRequest", description: "Deliver it as it is, unreviewed." },
21929
+ { value: "Hand to Build for a full review", description: "Build reviews and verifies it before delivering." },
21930
+ { value: "Discard", description: "Drop the change." }
22061
21931
  ],
22062
- recommended: confident ? "Start a fix" : "Not now",
22063
- recommendationReason: confident ? "Firewatch is confident of the cause and the alert is still firing." : resolvedDuring ? "The alert resolved while Firewatch looked, so a fix can wait for a person to judge." : "Firewatch is not confident of the cause, so a fix could aim at the wrong thing.",
22064
- default: input.autoFix === true && confident ? "Start a fix" : "Not now",
21932
+ recommended: head.passed ? "Open a ChangeRequest" : "Hand to Build for a full review",
21933
+ recommendationReason: head.passed ? "The checks pass, and an errand is small enough to review on the ChangeRequest." : "The checks fail, so it needs the fixes and review a Build gives before anyone merges it.",
21934
+ default: "Open a ChangeRequest",
22065
21935
  timeout: "1 hour"
22066
21936
  });
22067
- if (answer === "Not now")
22068
- return { ...concluded, fix: "declined", fixRequest };
22069
- return yield* call("fix", build_default, {
22070
- ...fixRequest,
22071
- ...taskId,
22072
- ...input.repoBindingId === undefined ? undefined : { repoBindingId: input.repoBindingId }
22073
- }).pipe(map8((fixed) => ({ ...concluded, fix: "started", changeRequestUrl: fixed.changeRequest.url })), catchTag2("ChildFailed", () => succeed7({ ...concluded, fix: "failed", fixRequest })));
21937
+ if (answer === "Discard")
21938
+ return { status: "discarded", summary: done.summary };
21939
+ if (answer === "Hand to Build for a full review") {
21940
+ const built = yield* call("build", build_default, {
21941
+ words: input.words,
21942
+ revision: head.revision,
21943
+ ...input.taskId === undefined ? undefined : { taskId: input.taskId },
21944
+ ...input.branch === undefined ? undefined : { branch: input.branch }
21945
+ });
21946
+ return { status: "handed_to_build", summary: done.summary, changeRequest: built.changeRequest };
21947
+ }
21948
+ const opened = yield* changeRequest("change-request", {
21949
+ title: done.title ?? input.words.slice(0, 120),
21950
+ body: [
21951
+ done.summary,
21952
+ "> **Unreviewed errand.** One agent did this with no review; ask for a Build or a Review if it needs one.",
21953
+ "## Evidence",
21954
+ evidenceTable(head)
21955
+ ].join(`
21956
+
21957
+ `)
21958
+ });
21959
+ yield* writeBack("write-back", { milestone: "change_request_opened", changeRequestUrl: opened.url });
21960
+ return { status: "opened", summary: done.summary, changeRequest: opened };
22074
21961
  })
22075
21962
  });
22076
21963
 
22077
- // src/plan.ts
22078
- var Input6 = Struct({
22079
- words: String5,
21964
+ // src/file-tasks.ts
21965
+ var FileTasksInput = Struct({
21966
+ tickets: PlanTickets,
21967
+ projectId: optional2(String5)
21968
+ });
21969
+ var Output5 = Struct({
21970
+ tasks: ArraySchema(Struct({ key: String5, taskId: String5 }))
21971
+ });
21972
+ var file_tasks_default = defineLoop({
21973
+ id: "file-tasks",
21974
+ version: "1.0.0",
21975
+ title: "File Tasks",
21976
+ input: FileTasksInput,
21977
+ output: Output5,
21978
+ agents: {},
21979
+ checkout: "none",
21980
+ capabilities: ["create_tasks"],
21981
+ run: (input) => createTasks("tasks", {
21982
+ tickets: input.tickets,
21983
+ ...input.projectId === undefined ? undefined : { projectId: input.projectId }
21984
+ }).pipe(map8(({ tasks }) => ({ tasks })))
21985
+ });
21986
+
21987
+ // src/hillclimb.ts
21988
+ var Input5 = Struct({
21989
+ focus: optional2(String5),
21990
+ taskId: optional2(String5),
22080
21991
  repoBindingId: optional2(String5),
22081
21992
  branch: optional2(String5),
22082
- projectId: optional2(String5),
22083
- implement: optional2(Boolean3)
21993
+ cycles: optional2(Int),
21994
+ threshold: optional2(Finite),
21995
+ tolerance: optional2(Finite)
22084
21996
  });
22085
- var Plan = Struct({
21997
+ var Opportunity = Struct({
22086
21998
  title: String5,
22087
- spec: String5,
22088
- tickets: PlanTickets,
22089
- outOfScope: ArraySchema(String5),
22090
- afterMerge: ArraySchema(String5),
22091
- docs: optional2(Struct({ title: String5, summary: String5 }))
21999
+ detail: String5,
22000
+ evidence: Literals(["measured", "telemetry", "hypothesis"]),
22001
+ external: optional2(Boolean3)
22002
+ });
22003
+ var Discovery = Struct({
22004
+ stack: String5,
22005
+ journeys: ArraySchema(Struct({ name: String5, objective: String5 })),
22006
+ telemetry: ArraySchema(Struct({ source: String5, status: Literals(["used", "unavailable"]), why: String5 })),
22007
+ opportunities: ArraySchema(Opportunity)
22092
22008
  });
22093
- var CriteriaCheck = Struct({
22094
- alreadyMet: ArraySchema(Struct({ key: String5, criterion: String5 }))
22009
+ var Harness = Struct({
22010
+ summary: String5,
22011
+ plan: MeasurePlan
22095
22012
  });
22096
- var Rejected = Struct({ key: String5, reason: String5 });
22097
- var Opinion = Struct({ summary: String5, concerns: ArraySchema(String5) });
22098
- var TicketResult = Struct({
22099
- key: String5,
22100
- title: String5,
22101
- status: Literals(["merged", "failed", "skipped"]),
22102
- repoBindingId: optional2(String5),
22103
- changeRequest: optional2(ChangeRequestResult),
22104
- reason: optional2(String5)
22013
+ var Change = Struct({
22014
+ status: Literals(["changed", "exhausted", "unsafe"]),
22015
+ summary: String5
22105
22016
  });
22106
- var Output7 = Struct({
22107
- title: String5,
22108
- outOfScope: ArraySchema(String5),
22109
- afterMerge: ArraySchema(String5),
22110
- rejected: ArraySchema(Rejected),
22111
- stopped: optional2(String5),
22112
- implemented: Boolean3,
22113
- tickets: ArraySchema(TicketResult),
22114
- deliveries: ArraySchema(ChangeRequestResult),
22115
- tasks: optional2(CreateTasksResult.fields.tasks),
22116
- docs: optional2(ChangeRequestResult)
22017
+ var Kept = Struct({ summary: String5, verdict: String5, reason: String5 });
22018
+ var Output6 = Struct({
22019
+ status: Literals(["improved", "no_gain", "gain_gone", "checks_failed", "too_noisy"]),
22020
+ reason: String5,
22021
+ kept: ArraySchema(Kept),
22022
+ cycles: Int,
22023
+ reportArtifactId: String5,
22024
+ changeRequest: optional2(ChangeRequestResult),
22025
+ external: ArraySchema(String5),
22026
+ unsettled: optional2(ArraySchema(String5))
22117
22027
  });
22118
- var menu = ["Implement now", "File as Tasks", "Second opinion", "Refine", "Finish"];
22119
- var maxRounds = 6;
22120
- var maxOpinionsPerPlan = 2;
22121
- var menuDescriptions = {
22122
- "Implement now": "Builds the tickets in dependency order and delivers them.",
22123
- "File as Tasks": "Makes a Task of each ticket, to build later.",
22124
- "Second opinion": "Another Agent reviews the plan.",
22125
- Refine: "You say what to change and the planner rewrites it.",
22126
- Finish: "Ends the Run with the plan as it is."
22127
- };
22128
- var onFailure = ["Retry with a steer", "Skip it and its dependents", "Stop here"];
22129
- var ticketRules = [
22130
- "Each ticket is one Build: a vertical slice an agent can implement, review and deliver as one ChangeRequest. Give it a short unique key, and list in blockedBy the keys of the tickets whose code it needs; tickets never block each other in a cycle. Set repoBindingId only when it changes another repository than the plan, keep one repository per ticket, and no cycle across repositories. Set outcome to the kind of change: feature, bug, refactor, docs, migration, investigation or performance.",
22131
- "Each acceptance criterion is something a Build can show before its ChangeRequest: a behaviour a test or a check proves. A text search is never a criterion, nor is something the code already does. Outcomes only visible after merge go under afterMerge, not under a ticket's criteria."
22028
+ var discoverPrompt = (focus) => [
22029
+ `Find where this repository is slow${focus === undefined ? ", for the journeys that matter most" : `: ${focus}`}. Change nothing.`,
22030
+ "Read the stack, the user journeys and the objective each has, the docs and earlier performance work. Read whatever telemetry you can reach, read-only and with bounded queries; list each source as used or unavailable, with why.",
22031
+ "Rank the opportunities, most promising first, each marked measured, telemetry or hypothesis. Mark one that only another repository can make as external."
22132
22032
  ].join(`
22133
22033
 
22134
22034
  `);
22135
- var planPrompt = (words) => [
22136
- `Plan this: ${words}`,
22137
- "You run the grill-with-docs skill as a Kitman Step. Interview the person with the Kitman MCP ask tool, one question at a time, and ask only what changes what gets built: a clear goal needs no interview. Write the ADR and glossary changes the grilling settles in the checkout, and no other files.",
22138
- "Then write the spec and the tickets from the goal, the code and the answers.",
22139
- ticketRules,
22140
- "List in outOfScope what the plan deliberately leaves out, one item each; each holds the Builds of its tickets.",
22141
- "If you left ADR or glossary changes, give docs a ChangeRequest title and a summary of the decisions; Kitman opens it. Your Output is the plan, and Kitman stores it."
22035
+ var harnessPrompt = (discovery) => [
22036
+ "Commit a local benchmark for these journeys, and change no product code:",
22037
+ ...discovery.journeys.map((journey) => `- ${journey.name}: ${journey.objective}`),
22038
+ "It runs on this Runner alone: never call a service, database or URL outside it. Each run prints one JSON line of numbers by metric name as its last line; wall time is measured for you as wall_ms. Keep it under a minute a run.",
22039
+ "Give the measurement plan: the command, any prepare command, and the metrics with one primary and the rest guardrails. Add a low-variance counter (instructions, allocations, queries) where you can, marked lowVariance."
22142
22040
  ].join(`
22143
22041
 
22144
22042
  `);
22145
- var planText = (plan) => [
22146
- `# ${plan.title}`,
22147
- plan.spec,
22148
- ...plan.outOfScope.length === 0 ? [] : ["## Out of scope", plan.outOfScope.map((item) => `- ${item}`).join(`
22149
- `)],
22150
- ...plan.afterMerge.length === 0 ? [] : ["## After merge", plan.afterMerge.map((item) => `- ${item}`).join(`
22151
- `)],
22152
- "## Tickets",
22153
- ...plan.tickets.map(ticketText)
22043
+ var changePrompt = (discovery, plan, tried, cycle) => [
22044
+ `Cycle ${cycle}: make one bounded change that should improve ${plan.metrics.find((metric) => metric.role === "primary")?.name ?? "the primary metric"} without making a guardrail worse, and commit it. Kitman measures it; do not report numbers of your own.`,
22045
+ "Opportunities, most promising first:",
22046
+ ...discovery.opportunities.flatMap((entry) => entry.external === true ? [] : [`- ${entry.title} (${entry.evidence}): ${entry.detail}`]),
22047
+ ...tried.length === 0 ? [] : ["Already tried:", ...tried.map((entry) => `- ${entry}`)],
22048
+ "Keep behaviour exactly as it is and run the tests you touch. Answer exhausted when nothing is left worth trying, or unsafe when the gain would cost correctness; either way change nothing."
22154
22049
  ].join(`
22155
22050
 
22156
22051
  `);
22157
- var ticketText = (ticket) => [
22158
- `### ${ticket.key}: ${ticket.title}`,
22159
- ticket.body,
22160
- ...ticket.acceptanceCriteria.map((criterion) => `- [ ] ${criterion}`),
22161
- ...ticket.blockedBy.length === 0 ? [] : [`Blocked by: ${ticket.blockedBy.join(", ")}`]
22052
+ var revertPrompt = (start, cycle) => `Cycle ${cycle}'s change did not prove faster. Revert its commits, and only those: run \`git revert --no-edit ${start}..HEAD\`. Touch no commit at or before ${start}, never reset or force-push, and change nothing else.`;
22053
+ var fixPrompt2 = (findings) => [
22054
+ "The review of the climb found these. Fix each, keep behaviour exactly as it is and keep the gain, and commit. Kitman verifies and measures again; a fix that costs the gain ends the climb.",
22055
+ ...findings.map((finding) => `- ${finding.id}: ${finding.detail}`)
22162
22056
  ].join(`
22163
22057
 
22164
22058
  `);
22165
- var opinionPrompt = (plan) => [
22166
- "Review this plan as a second opinion. Look for missing scope, tickets too big to review, wrong blockers and risks. Change nothing.",
22167
- planText(plan)
22059
+ var percent = (fraction) => `${(fraction * 100).toFixed(1)}%`;
22060
+ var numbers = (measured) => [
22061
+ "| Metric | Role | Base median | Head median | Change | Noise |",
22062
+ "|---|---|---|---|---|---|",
22063
+ ...measured.metrics.map((metric) => `| ${metric.name} | ${metric.role} | ${metric.base.median} | ${metric.head.median} | ${percent(metric.change)} | ${percent(metric.noise)} |`)
22168
22064
  ].join(`
22169
-
22170
22065
  `);
22171
- var refinePrompt = (plan, words) => [`Change the plan as the user asks: ${words}`, ticketRules, planText(plan)].join(`
22066
+ var report = (input) => [
22067
+ `# Hillclimb findings
22172
22068
 
22173
- `);
22174
- var criteriaPrompt2 = (plan) => [
22175
- "Check each ticket's acceptance criteria against the base branch as it is now. List under alreadyMet each criterion the code already meets, with its ticket's key and the criterion word for word. Change nothing.",
22176
- planText(plan)
22177
- ].join(`
22069
+ ${input.reason}`,
22070
+ `## Stack
22178
22071
 
22179
- `);
22180
- var guard2 = " (guard: the base already does this)";
22181
- var guarded = (plan, check) => {
22182
- const met = (ticket, criterion) => check.alreadyMet.some((entry) => entry.key === ticket.key && entry.criterion === criterion);
22183
- const rejected = plan.tickets.flatMap((ticket) => ticket.acceptanceCriteria.length > 0 && ticket.acceptanceCriteria.every((criterion) => met(ticket, criterion)) ? [{ key: ticket.key, reason: "The base branch already meets every one of its criteria" }] : []);
22184
- const gone = new Set(rejected.map((entry) => entry.key));
22185
- const tickets = plan.tickets.flatMap((ticket) => gone.has(ticket.key) ? [] : [
22186
- {
22187
- ...ticket,
22188
- acceptanceCriteria: ticket.acceptanceCriteria.map((criterion) => met(ticket, criterion) ? `${criterion}${guard2}` : criterion),
22189
- blockedBy: ticket.blockedBy.filter((key) => !gone.has(key))
22190
- }
22191
- ]);
22192
- return { plan: { ...plan, tickets }, rejected };
22193
- };
22194
- var checked = (plan, revision) => map8(agentWork(`criteria-check:${revision}`, { agent: "reviewer", prompt: criteriaPrompt2(plan), output: CriteriaCheck }), (check) => guarded(plan, check));
22195
- var storePlan = (plan, suffix) => gen2(function* () {
22196
- yield* artifact(`spec${suffix}`, {
22197
- kind: "plan_spec",
22198
- title: plan.title,
22199
- text: plan.spec,
22200
- data: { outOfScope: [...plan.outOfScope] }
22201
- });
22202
- yield* artifact(`tickets${suffix}`, {
22203
- kind: "plan_tickets",
22204
- title: `Tickets for ${plan.title}`,
22205
- text: plan.tickets.map(ticketText).join(`
22072
+ ${input.discovery.stack}`,
22073
+ `## Telemetry
22206
22074
 
22207
- `),
22208
- data: plan.tickets.map(({ repoBindingId, outcome, ...ticket }) => ({
22209
- ...ticket,
22210
- acceptanceCriteria: [...ticket.acceptanceCriteria],
22211
- blockedBy: [...ticket.blockedBy],
22212
- ...repoBindingId === undefined ? undefined : { repoBindingId },
22213
- ...outcome === undefined ? undefined : { outcome }
22214
- }))
22215
- });
22216
- });
22217
- var changelog = (before, after, words) => {
22218
- const was = new Map(before.tickets.map((ticket) => [ticket.key, ticketText(ticket)]));
22219
- const now = new Map(after.tickets.map((ticket) => [ticket.key, ticketText(ticket)]));
22220
- const lines = [
22221
- ...after.tickets.filter((ticket) => !was.has(ticket.key)).map((ticket) => `- Added ${ticket.key}: ${ticket.title}`),
22222
- ...before.tickets.filter((ticket) => !now.has(ticket.key)).map((ticket) => `- Removed ${ticket.key}: ${ticket.title}`),
22223
- ...after.tickets.filter((ticket) => was.has(ticket.key) && was.get(ticket.key) !== now.get(ticket.key)).map((ticket) => `- Changed ${ticket.key}: ${ticket.title}`),
22224
- ...before.spec === after.spec ? [] : ["- Changed the spec"],
22225
- ...before.title === after.title ? [] : [`- Renamed the plan from "${before.title}"`]
22226
- ];
22227
- return [`Asked: ${words}`, ...lines.length === 0 ? ["Nothing changed."] : lines].join(`
22228
- `);
22229
- };
22230
- var buildWords = (ticket) => [
22231
- ticket.title,
22232
- ticket.body,
22233
- ...ticket.acceptanceCriteria.length === 0 ? [] : ["Acceptance criteria:", ...ticket.acceptanceCriteria.map((criterion) => `- ${criterion}`)]
22234
- ].join(`
22075
+ ${input.discovery.telemetry.map((entry) => `- ${entry.source}: ${entry.status}, ${entry.why}`).join(`
22076
+ `) || "- None reached"}`,
22077
+ `## Benchmark
22235
22078
 
22236
- `);
22237
- var settled = (ticket, result) => ({
22238
- key: ticket.key,
22239
- title: ticket.title,
22240
- ...result
22241
- });
22242
- var buildTicket = (ticket, start, taskId, retry) => gen2(function* () {
22243
- let head = start;
22244
- const name = retry === undefined ? ticket.key : `${ticket.key}:${retry.label}`;
22245
- for (let attempt = 1;; attempt++) {
22246
- const suffix = attempt === 1 ? "" : `:${attempt}`;
22247
- const words = retry === undefined ? taskId === undefined ? { words: buildWords(ticket) } : undefined : { words: `${buildWords(ticket)}
22079
+ ${input.harness.summary}`,
22080
+ `## Tried
22248
22081
 
22249
- An earlier Build of this failed. ${retry.steer}` };
22250
- const built = yield* call(`build:${name}${suffix}`, build_default, {
22251
- ...words,
22252
- ...taskId === undefined ? undefined : { taskId },
22253
- repoBindingId: head.repoBindingId,
22254
- branch: head.branch,
22255
- revision: head.revision,
22256
- land: false
22257
- }).pipe(catchTag2("ChildFailed", (failure) => succeed7(failure)));
22258
- if (built instanceof ChildFailed)
22259
- return settled(ticket, { status: "failed", repoBindingId: head.repoBindingId, reason: built.reason });
22260
- const merged = yield* mergeChangeRequest(`merge:${name}${suffix}`, {
22261
- changeRequestId: built.changeRequest.id,
22262
- head: built.revision
22263
- });
22264
- const delivered = { repoBindingId: head.repoBindingId, changeRequest: built.changeRequest };
22265
- if (merged.status === "merged")
22266
- return settled(ticket, { status: "merged", ...delivered });
22267
- const choice = yield* ask(`conflict:${name}${suffix}`, {
22268
- prompt: `The ChangeRequest for ${ticket.key} (${built.changeRequest.url}) ${merged.status === "moved" ? "changed after it was built" : `conflicts with ${head.branch}`}. Rebuild ${ticket.key} on the integration branch as it is now, or skip it and the tickets it blocks?`,
22269
- options: [
22270
- {
22271
- value: "Rebuild on the integration branch",
22272
- description: "Builds the ticket again from the branch as it is now."
22273
- },
22274
- { value: "Skip", description: "Leaves it and the tickets it blocks undelivered." }
22275
- ],
22276
- recommended: "Rebuild on the integration branch",
22277
- recommendationReason: "A rebuild on the current branch usually resolves a conflict, and skipping also drops every ticket it blocks.",
22278
- default: "Skip",
22279
- timeout: "1 hour"
22280
- });
22281
- if (choice === "Skip")
22282
- return settled(ticket, { status: "failed", ...delivered, reason: `It conflicted with ${head.branch}` });
22283
- head = yield* integrationBranch(`rebuild-from:${name}:${attempt + 1}`, {
22284
- repoBindingId: head.repoBindingId
22285
- });
22286
- }
22287
- });
22288
- var deliveryBody = (plan, results) => {
22289
- const line = (result) => result.status === "merged" && result.changeRequest !== undefined ? `- **${result.key}** [${result.title}](${result.changeRequest.url}); its evidence is on that ChangeRequest` : `- **${result.key}** ${result.title}: ${result.reason ?? result.status}`;
22290
- const section = (title, status) => {
22291
- const lines = results.flatMap((result) => result.status === status ? [line(result)] : []);
22292
- return lines.length === 0 ? [] : [`## ${title}`, lines.join(`
22293
- `)];
22294
- };
22295
- return [
22296
- `Delivers the plan "${plan.title}".`,
22297
- ...section("Merged", "merged"),
22298
- ...section("Failed", "failed"),
22299
- ...section("Skipped", "skipped")
22300
- ].join(`
22082
+ ${input.tried.map((entry) => `- ${entry}`).join(`
22083
+ `) || "- Nothing"}`,
22084
+ ...input.kept.length === 0 ? [] : [`## Kept
22085
+
22086
+ ${input.kept.map((entry) => `- ${entry.summary}: ${entry.reason}`).join(`
22087
+ `)}`],
22088
+ ...input.final === undefined ? [] : [`## Final measurement
22089
+
22090
+ ${input.final.reason}
22091
+
22092
+ ${numbers(input.final)}`],
22093
+ `## Opportunities
22094
+
22095
+ ${input.discovery.opportunities.map((entry) => `- ${entry.title} (${entry.evidence}${entry.external === true ? ", external" : ""}): ${entry.detail}`).join(`
22096
+ `)}`
22097
+ ].join(`
22301
22098
 
22302
22099
  `);
22100
+ var steadier = (plan) => {
22101
+ const counter = plan.metrics.find((metric) => metric.lowVariance === true && metric.role !== "primary");
22102
+ return counter === undefined ? undefined : {
22103
+ ...plan,
22104
+ metrics: plan.metrics.map((metric) => ({
22105
+ ...metric,
22106
+ role: metric.name === counter.name ? "primary" : "guardrail"
22107
+ }))
22108
+ };
22303
22109
  };
22304
- var implement = (input, plan, revision, filed) => gen2(function* () {
22305
- const tasks = filed?.revision === revision ? filed.tasks : (yield* createTasks("tasks", {
22306
- tickets: plan.tickets,
22307
- ...input.projectId === undefined ? undefined : { projectId: input.projectId }
22308
- })).tasks;
22309
- const taskOf = new Map(tasks.map((task) => [task.key, task.taskId]));
22310
- const results = new Map;
22311
- const waves = yield* planWaves(plan.tickets);
22312
- let stopped;
22313
- for (const [index, wave] of waves.entries()) {
22314
- const ready = [];
22315
- for (const ticket of wave) {
22316
- const unmerged = ticket.blockedBy.find((key) => results.get(key)?.status !== "merged");
22317
- if (stopped !== undefined)
22318
- results.set(ticket.key, settled(ticket, { status: "skipped", reason: stopped }));
22319
- else if (unmerged === undefined)
22320
- ready.push(ticket);
22321
- else
22322
- results.set(ticket.key, settled(ticket, { status: "skipped", reason: `blocked by ${unmerged}, which was not merged` }));
22110
+ var hillclimb_default = defineLoop({
22111
+ id: "hillclimb",
22112
+ version: "1.3.0",
22113
+ title: "Hillclimb",
22114
+ outcome: "performance",
22115
+ input: Input5,
22116
+ output: Output6,
22117
+ agents: { investigator: "climber", implementer: "implementer" },
22118
+ checkout: "write",
22119
+ capabilities: ["agent_work", "verify", "measure", "change_request", "write_back", "ask", "create_tasks"],
22120
+ calls: () => [review_default],
22121
+ offers: [{ id: "retry", title: "Climb again", loop: "hillclimb" }],
22122
+ run: (input) => gen2(function* () {
22123
+ const limits = {
22124
+ ...input.threshold === undefined ? undefined : { threshold: input.threshold },
22125
+ ...input.tolerance === undefined ? undefined : { tolerance: input.tolerance }
22126
+ };
22127
+ const discovery = yield* agentWork("discover", {
22128
+ agent: "investigator",
22129
+ prompt: discoverPrompt(input.focus),
22130
+ output: Discovery
22131
+ });
22132
+ const external = discovery.opportunities.flatMap((entry) => entry.external === true ? [entry.title] : []);
22133
+ yield* syncBase("sync:start", {
22134
+ agent: "implementer",
22135
+ ...input.branch === undefined ? undefined : { target: input.branch }
22136
+ });
22137
+ const harness = yield* agentWorkOnce("harness", {
22138
+ agent: "implementer",
22139
+ prompt: harnessPrompt(discovery),
22140
+ output: Harness
22141
+ });
22142
+ const baseline = (yield* verify("verify:harness", {})).revision;
22143
+ let plan = harness.plan;
22144
+ const calibrated = yield* measure("calibrate", { plan, base: baseline, head: baseline, ...limits });
22145
+ const kept = [];
22146
+ const tried = [];
22147
+ let cycles = 0;
22148
+ const ending = (status, reason, final) => gen2(function* () {
22149
+ const stored = yield* artifact("report", {
22150
+ kind: "hillclimb-report",
22151
+ title: "Hillclimb findings",
22152
+ text: report({ discovery, harness, reason, kept, tried, final })
22153
+ });
22154
+ return { status, reason, kept, cycles, reportArtifactId: stored.artifactId, external };
22155
+ });
22156
+ if (calibrated.verdict === "too_noisy") {
22157
+ const switched = steadier(plan);
22158
+ if (switched === undefined)
22159
+ return yield* ending("too_noisy", `${calibrated.reason}. ${calibrated.suggestion ?? ""}`.trim());
22160
+ plan = switched;
22323
22161
  }
22324
- const repoOf = (ticket) => ticket.repoBindingId ?? input.repoBindingId;
22325
- const heads = new Map;
22326
- for (const repo of new Set(ready.map(repoOf)))
22327
- heads.set(repo, yield* integrationBranch(`integration:wave-${index + 1}:${repo ?? "default"}`, repo === undefined ? {} : { repoBindingId: repo }));
22328
- const built = yield* forEach2(ready.flatMap((ticket) => {
22329
- const head = heads.get(repoOf(ticket));
22330
- return head === undefined ? [] : [buildTicket(ticket, head, taskOf.get(ticket.key))];
22331
- }), (building) => building, { concurrency: "unbounded" });
22332
- for (const first of built) {
22333
- const ticket = ready.find((entry) => entry.key === first.key);
22334
- let result = first;
22335
- for (let retry = 1;ticket !== undefined && result.status === "failed" && stopped === undefined; retry++) {
22336
- const choice = yield* ask(`failed:${ticket.key}:${retry}`, {
22337
- prompt: `The Build of ${ticket.key} failed: ${result.reason ?? "no reason given"}. Retry it with a steer, skip it and the tickets it blocks, or stop here?`,
22338
- options: onFailure,
22339
- recommended: "Retry with a steer",
22340
- recommendationReason: "The reason it failed is above, so a steer can aim the retry at it; skipping also drops the tickets it blocks.",
22341
- default: "Skip it and its dependents",
22342
- timeout: "1 hour"
22343
- });
22344
- if (choice === "Skip it and its dependents")
22345
- break;
22346
- if (choice === "Stop here") {
22347
- stopped = `Stopped after ${ticket.key} failed`;
22348
- break;
22349
- }
22350
- const steer = yield* ask(`steer:${ticket.key}:${retry}`, {
22351
- prompt: `What should the retry of ${ticket.key} do differently?`,
22352
- freeText: true,
22353
- recommended: `Address the failure: ${result.reason ?? "find out why it failed first"}`,
22354
- recommendationReason: "Pointing the retry at the reason it failed is the least a steer should do.",
22355
- default: null
22356
- });
22357
- const head = yield* integrationBranch(`integration:${ticket.key}:retry-${retry}`, {
22358
- ...repoOf(ticket) === undefined ? undefined : { repoBindingId: repoOf(ticket) }
22162
+ let start = baseline;
22163
+ let unkept = 0;
22164
+ for (let cycle = 1;cycle <= (input.cycles ?? 5) && unkept < 2; cycle++) {
22165
+ const change = yield* agentWorkOnce(`change:${cycle}`, {
22166
+ agent: "implementer",
22167
+ prompt: changePrompt(discovery, plan, tried, cycle),
22168
+ output: Change
22169
+ });
22170
+ if (change.status !== "changed") {
22171
+ tried.push(`Cycle ${cycle}: ${change.status}, ${change.summary}`);
22172
+ break;
22173
+ }
22174
+ cycles = cycle;
22175
+ const head = yield* verify(`verify:${cycle}`, {});
22176
+ const measured = head.passed ? yield* measure(`measure:${cycle}`, { plan, base: start, head: head.revision, ...limits }) : undefined;
22177
+ if (measured?.verdict === "gain") {
22178
+ kept.push({ summary: change.summary, verdict: measured.verdict, reason: measured.reason });
22179
+ tried.push(`Cycle ${cycle}, kept: ${change.summary} (${measured.reason})`);
22180
+ start = head.revision;
22181
+ unkept = 0;
22182
+ continue;
22183
+ }
22184
+ tried.push(`Cycle ${cycle}, reverted: ${change.summary} (${measured === undefined ? "its checks failed" : measured.reason})`);
22185
+ yield* agentWork(`revert:${cycle}`, {
22186
+ agent: "implementer",
22187
+ prompt: revertPrompt(start, cycle),
22188
+ output: Struct({ reverted: Boolean3 })
22189
+ });
22190
+ unkept++;
22191
+ }
22192
+ if (external.length > 0) {
22193
+ const file = yield* ask("external", {
22194
+ prompt: `Only other repositories can make these improvements: ${external.join("; ")}. File them as Tasks?`,
22195
+ options: ["File as Tasks", "Leave them"],
22196
+ recommended: "File as Tasks",
22197
+ recommendationReason: "Filed as Tasks, the improvements wait in the repositories that can make them instead of being lost.",
22198
+ default: "Leave them",
22199
+ timeout: "1 hour"
22200
+ });
22201
+ if (file === "File as Tasks")
22202
+ yield* createTasks("external-tasks", {
22203
+ tickets: discovery.opportunities.flatMap((entry, index) => entry.external === true ? [
22204
+ {
22205
+ key: `external-${index + 1}`,
22206
+ title: entry.title,
22207
+ body: entry.detail,
22208
+ acceptanceCriteria: [],
22209
+ blockedBy: [],
22210
+ outcome: "performance"
22211
+ }
22212
+ ] : [])
22359
22213
  });
22360
- result = yield* buildTicket(ticket, head, taskOf.get(ticket.key), { label: `retry-${retry}`, steer });
22214
+ }
22215
+ if (kept.length === 0)
22216
+ return yield* ending("no_gain", "No change proved faster than the noise allows");
22217
+ const synced = yield* syncBase("sync:final", {
22218
+ agent: "implementer",
22219
+ ...input.branch === undefined ? undefined : { target: input.branch }
22220
+ });
22221
+ const checked = yield* verify("verify:final", {});
22222
+ if (!checked.passed) {
22223
+ const failing = checked.checks.filter((check) => check.status === "failed").map((check) => check.name);
22224
+ return yield* ending("checks_failed", `The final tree fails ${failing.join(", ") || "its checks"}`);
22225
+ }
22226
+ let final = yield* measure("measure:final", { plan, base: baseline, head: checked.revision, ...limits });
22227
+ if (final.verdict !== "gain")
22228
+ return yield* ending("gain_gone", `The final tree no longer proves the gain: ${final.reason}`, final);
22229
+ const claims = [
22230
+ "Behaviour is preserved: every kept change leaves what the code does exactly as it was.",
22231
+ `The measurement is valid: ${harness.summary}`,
22232
+ ...kept.map((entry) => entry.summary)
22233
+ ];
22234
+ let reviewed = yield* call("review", review_default, {
22235
+ base: baseline,
22236
+ head: checked.revision,
22237
+ outcome: "performance",
22238
+ claims
22239
+ });
22240
+ for (let round = 1;round <= 2 && reviewed.findings.some(isBlocking); round++) {
22241
+ const blocking = reviewed.findings.filter(isBlocking);
22242
+ yield* agentWork(`fix:${round}`, {
22243
+ agent: "implementer",
22244
+ prompt: fixPrompt2(blocking),
22245
+ output: Struct({ summary: String5 })
22246
+ });
22247
+ const fixed = yield* verify(`verify:fix:${round}`, {});
22248
+ if (!fixed.passed) {
22249
+ const failing = fixed.checks.filter((check) => check.status === "failed").map((check) => check.name);
22250
+ return yield* ending("checks_failed", `The review's fixes fail ${failing.join(", ") || "the checks"}`);
22361
22251
  }
22362
- results.set(result.key, result);
22252
+ final = yield* measure(`measure:fix:${round}`, { plan, base: baseline, head: fixed.revision, ...limits });
22253
+ if (final.verdict !== "gain")
22254
+ return yield* ending("gain_gone", `The review's fixes cost the gain: ${final.reason}`, final);
22255
+ reviewed = yield* call(`review:${round + 1}`, review_default, {
22256
+ base: baseline,
22257
+ head: fixed.revision,
22258
+ outcome: "performance",
22259
+ claims,
22260
+ earlier: blocking
22261
+ });
22363
22262
  }
22364
- }
22365
- const ordered = plan.tickets.flatMap((ticket) => results.get(ticket.key) ?? []);
22366
- const repos = [
22367
- ...new Set(ordered.flatMap((result) => result.status === "merged" ? result.repoBindingId ?? [] : []))
22368
- ];
22369
- const deliveries = yield* forEach2(repos, (repo) => changeRequest(`deliver:${repo}`, {
22370
- title: plan.title,
22371
- body: deliveryBody(plan, ordered.filter((result) => result.repoBindingId === repo || result.repoBindingId === undefined)),
22372
- integration: repo
22373
- }));
22374
- return { tickets: ordered, deliveries, tasks, ...stopped === undefined ? undefined : { stopped } };
22263
+ const unsettled = [
22264
+ ...[...reviewed.findings, ...scopeFindings(reviewed.scope)].flatMap((finding) => isBlocking(finding) ? [`${finding.id}: ${finding.detail}`] : []),
22265
+ ...synced.status === "conflict" ? [`Merging the base in conflicts on ${synced.files.join(", ")}`] : []
22266
+ ];
22267
+ const caveats = synced.status === "merged" ? ["The base moved during the climb; the final measurement includes its changes too."] : [];
22268
+ const opened = yield* changeRequest("change-request", {
22269
+ title: `Faster: ${kept.map((entry) => entry.summary).join("; ")}`.slice(0, 240),
22270
+ body: [
22271
+ `${final.reason}, measured by Kitman against the benchmark on the base.`,
22272
+ numbers(final),
22273
+ `## Kept
22274
+
22275
+ ${kept.map((entry) => `- ${entry.summary}: ${entry.reason}`).join(`
22276
+ `)}`,
22277
+ `## Benchmark
22278
+
22279
+ ${harness.summary}
22280
+
22281
+ \`${plan.command}\``,
22282
+ ...unsettled.length + caveats.length === 0 ? [] : [`## Unsettled
22283
+
22284
+ ${[...unsettled, ...caveats].map((entry) => `- ${entry}`).join(`
22285
+ `)}`]
22286
+ ].join(`
22287
+
22288
+ `)
22289
+ });
22290
+ yield* writeBack("write-back", { milestone: "change_request_opened", changeRequestUrl: opened.url });
22291
+ return { ...yield* ending("improved", final.reason, final), changeRequest: opened, unsettled };
22292
+ })
22375
22293
  });
22376
- var plan_default = defineLoop({
22377
- id: "plan",
22378
- version: "2.0.0",
22379
- title: "Plan",
22294
+
22295
+ // src/firewatch.ts
22296
+ var Input6 = Struct({
22297
+ alert: Record(String5, Json2),
22298
+ repoBindingId: optional2(String5),
22299
+ windowHours: optional2(Int.check(isGreaterThanOrEqualTo2(0), isLessThanOrEqualTo3(720))),
22300
+ autoFix: optional2(Boolean3)
22301
+ });
22302
+ var Conclusion = Struct({
22303
+ summary: String5,
22304
+ cause: String5,
22305
+ confidence: Literals(["high", "medium", "low"]),
22306
+ sources: NonEmptyArray(String5),
22307
+ telemetry: ArraySchema(Struct({ source: String5, reached: Boolean3, tried: optional2(String5) })),
22308
+ inCode: Boolean3,
22309
+ fix: optional2(String5)
22310
+ });
22311
+ var FixRequest = Struct({ words: String5, outcome: Literal2("bug") });
22312
+ var Output7 = Struct({
22313
+ status: Literals(["folded", "resolved", "concluded"]),
22314
+ taskId: optional2(String5),
22315
+ joinedRunId: optional2(String5),
22316
+ conclusionArtifactId: optional2(String5),
22317
+ resolvedDuring: Boolean3,
22318
+ fix: Literals(["none", "declined", "started", "failed"]),
22319
+ changeRequestUrl: optional2(String5),
22320
+ fixRequest: optional2(FixRequest)
22321
+ });
22322
+ var firstText = (alert, ...keys) => keys.flatMap((key) => {
22323
+ const value = alert[key];
22324
+ return isString(value) && value.trim() !== "" ? [value] : [];
22325
+ })[0];
22326
+ var alertTitle = (alert) => firstText(alert, "title", "name", "summary") ?? "Alert";
22327
+ var fingerprintOf = (alert) => firstText(alert, "fingerprint", "groupKey") ?? alertTitle(alert);
22328
+ var isResolved = (alert) => firstText(alert, "status", "state")?.toLowerCase() === "resolved";
22329
+ var priorities = new Map(Object.entries({
22330
+ critical: "urgent",
22331
+ p1: "urgent",
22332
+ urgent: "urgent",
22333
+ high: "high",
22334
+ error: "high",
22335
+ p2: "high",
22336
+ warning: "medium",
22337
+ medium: "medium",
22338
+ p3: "medium",
22339
+ low: "low",
22340
+ info: "low",
22341
+ p4: "low"
22342
+ }));
22343
+ var priorityOf = (alert) => {
22344
+ const severity = firstText(alert, "severity", "priority", "level")?.toLowerCase();
22345
+ return (severity === undefined ? undefined : priorities.get(severity)) ?? "high";
22346
+ };
22347
+ var investigatePrompt = (alert) => [
22348
+ "Investigate this alert and conclude what happened and why.",
22349
+ "```json",
22350
+ JSON.stringify(alert, null, 2),
22351
+ "```",
22352
+ "Cite as sources the logs, metrics, commits or code that support your conclusion. List every telemetry source you tried and whether you could read it, with what you tried for one you could not; say how confident you are, lower when a source was out of reach.",
22353
+ "Say whether the cause is in code Kitman can change, and propose a fix only then."
22354
+ ].join(`
22355
+
22356
+ `);
22357
+ var conclusionText = (conclusion, resolvedDuring) => [
22358
+ conclusion.summary,
22359
+ ...resolvedDuring ? ["It resolved on its own while it was being investigated."] : [],
22360
+ `Cause: ${conclusion.cause} (confidence ${conclusion.confidence})`,
22361
+ "Sources:",
22362
+ ...conclusion.sources.map((source) => `- ${source}`),
22363
+ ...conclusion.telemetry.some((source) => !source.reached) ? [
22364
+ "Out of reach:",
22365
+ ...conclusion.telemetry.filter((source) => !source.reached).map((source) => `- ${source.source}${source.tried === undefined ? "" : `: ${source.tried}`}`)
22366
+ ] : [],
22367
+ ...conclusion.fix === undefined ? [] : [`Fix: ${conclusion.fix}`]
22368
+ ].join(`
22369
+ `);
22370
+ var firewatch_default = defineLoop({
22371
+ id: "firewatch",
22372
+ version: "1.0.0",
22373
+ title: "Firewatch",
22380
22374
  input: Input6,
22381
22375
  output: Output7,
22382
- agents: { planner: "planner", reviewer: "reviewer" },
22383
- checkout: "write",
22384
- capabilities: [
22385
- "agent_work",
22386
- "ask",
22387
- "call",
22388
- "verify",
22389
- "change_request",
22390
- "write_back",
22391
- "create_tasks",
22392
- "merge_change_request",
22393
- "read_change_request",
22394
- "post_comment"
22395
- ],
22376
+ outcome: "investigation",
22377
+ agents: { investigator: "investigator" },
22378
+ checkout: "read",
22379
+ when: [{ event: "monitoring.alert", input: { alert: "alert" } }],
22380
+ offers: [{ id: "build-fix", title: "Build the fix", loop: "build", from: "fixRequest" }],
22381
+ capabilities: ["agent_work", "ask", "create_tasks"],
22396
22382
  calls: () => [build_default],
22397
22383
  run: (input) => gen2(function* () {
22398
- const drafted = yield* agentWorkOnce("plan", {
22399
- agent: "planner",
22400
- skill: "grill-with-docs",
22401
- prompt: planPrompt(input.words),
22402
- output: Plan
22384
+ const fingerprint = fingerprintOf(input.alert);
22385
+ const resolved = isResolved(input.alert);
22386
+ const windowHours = input.windowHours ?? 6;
22387
+ const request = { fingerprint, title: alertTitle(input.alert), windowHours, priority: priorityOf(input.alert) };
22388
+ const placed = yield* alertTask("task", { ...request, ...resolved ? { resolved } : undefined });
22389
+ const taskId = placed.taskId === undefined ? undefined : { taskId: placed.taskId };
22390
+ if (placed.joined !== undefined)
22391
+ return {
22392
+ status: "folded",
22393
+ ...taskId,
22394
+ joinedRunId: placed.joined.runId,
22395
+ resolvedDuring: false,
22396
+ fix: "none"
22397
+ };
22398
+ if (resolved)
22399
+ return { status: "resolved", ...taskId, resolvedDuring: false, fix: "none" };
22400
+ const conclusion = yield* agentWork("investigate", {
22401
+ agent: "investigator",
22402
+ prompt: investigatePrompt(input.alert),
22403
+ output: Conclusion
22403
22404
  });
22404
- const docs = drafted.docs === undefined ? undefined : yield* changeRequest("docs", { title: drafted.docs.title, body: drafted.docs.summary });
22405
- let revision = 1;
22406
- let { plan, rejected } = yield* checked(drafted, revision);
22407
- let filed;
22408
- let opinions = 0;
22409
- yield* storePlan(plan, "");
22410
- const project = input.projectId === undefined ? undefined : { projectId: input.projectId };
22411
- const ended = (rest) => ({
22412
- title: plan.title,
22413
- outOfScope: plan.outOfScope,
22414
- afterMerge: plan.afterMerge,
22415
- rejected,
22416
- tickets: [],
22417
- deliveries: [],
22418
- ...filed === undefined ? undefined : { tasks: filed.tasks },
22419
- ...docs === undefined ? undefined : { docs },
22420
- ...rest
22405
+ const resolvedDuring = (yield* alertTask("task:check", request)).resolved === true;
22406
+ const stored = yield* artifact("conclusion", {
22407
+ kind: "investigation_conclusion",
22408
+ title: request.title,
22409
+ text: conclusionText(conclusion, resolvedDuring),
22410
+ data: { alert: input.alert, sources: conclusion.sources, confidence: conclusion.confidence }
22421
22411
  });
22422
- for (let round = 1;round <= maxRounds; round++) {
22423
- const isFiled = filed?.revision === revision;
22424
- const options = menu.filter((option) => !(option === "File as Tasks" && isFiled) && !(option === "Second opinion" && opinions >= maxOpinionsPerPlan));
22425
- const fallback = input.implement === true ? "Implement now" : isFiled ? "Finish" : "File as Tasks";
22426
- const answer = yield* ask(`next:${round}`, {
22427
- prompt: `What next for the plan "${plan.title}"?`,
22428
- options: options.map((option) => ({ value: option, description: menuDescriptions[option] })),
22429
- recommended: fallback,
22430
- recommendationReason: fallback === "Implement now" ? "You asked for the plan to be implemented." : fallback === "Finish" ? "The plan is filed as Tasks, so nothing is lost by finishing." : "Filing keeps the plan as Tasks a person or a Build can pick up later.",
22431
- default: fallback,
22432
- timeout: "1 hour"
22433
- });
22434
- const choice = menu.find((option) => option === answer) ?? fallback;
22435
- switch (choice) {
22436
- case "Finish":
22437
- return ended({ implemented: false });
22438
- case "Implement now": {
22439
- const delivered = yield* implement(input, plan, revision, filed);
22440
- return { ...ended({ implemented: true }), ...delivered };
22441
- }
22442
- case "File as Tasks": {
22443
- const made = yield* createTasks(`file-tasks:${round}`, { tickets: plan.tickets, ...project });
22444
- filed = { revision, tasks: made.tasks };
22445
- break;
22446
- }
22447
- case "Second opinion": {
22448
- opinions++;
22449
- const opinion = yield* agentWork(`second-opinion:${round}`, {
22450
- agent: "reviewer",
22451
- prompt: opinionPrompt(plan),
22452
- output: Opinion
22453
- });
22454
- yield* artifact(`opinion:${round}`, {
22455
- kind: "plan_review",
22456
- title: `Second opinion on ${plan.title}`,
22457
- text: [opinion.summary, ...opinion.concerns.map((concern) => `- ${concern}`)].join(`
22412
+ yield* note("summary", { text: conclusion.summary, artifactId: stored.artifactId });
22413
+ const concluded = {
22414
+ status: "concluded",
22415
+ ...taskId,
22416
+ conclusionArtifactId: stored.artifactId,
22417
+ resolvedDuring
22418
+ };
22419
+ if (conclusion.fix === undefined || !conclusion.inCode)
22420
+ return { ...concluded, fix: "none" };
22421
+ const fixRequest = {
22422
+ words: `${conclusion.fix}
22458
22423
 
22459
- `)
22460
- });
22461
- break;
22462
- }
22463
- case "Refine": {
22464
- const words = yield* ask(`refine:${round}`, {
22465
- prompt: "What should change in the plan?",
22466
- freeText: true,
22467
- informationOnly: true,
22468
- recommendationReason: "You chose to refine it, so only you know what should change."
22469
- });
22470
- const refined = yield* agentWorkOnce(`refine-plan:${round}`, {
22471
- agent: "planner",
22472
- prompt: refinePrompt(plan, words),
22473
- output: Plan
22474
- });
22475
- const before = plan;
22476
- revision++;
22477
- ({ plan, rejected } = yield* checked(refined, revision));
22478
- yield* storePlan(plan, `:${round}`);
22479
- yield* artifact(`changes:${round}`, {
22480
- kind: "plan_changes",
22481
- title: `Revision ${revision} of ${plan.title}`,
22482
- text: changelog(before, plan, words)
22483
- });
22484
- break;
22485
- }
22486
- }
22487
- }
22488
- if (filed?.revision !== revision)
22489
- filed = {
22490
- revision,
22491
- tasks: (yield* createTasks("file-tasks:last", { tickets: plan.tickets, ...project })).tasks
22492
- };
22493
- return ended({
22494
- implemented: false,
22495
- stopped: `No decision after ${maxRounds} rounds, so the tickets are filed as Tasks for a person to pick up`
22424
+ ${conclusionText(conclusion, resolvedDuring)}`,
22425
+ outcome: "bug"
22426
+ };
22427
+ const confident = conclusion.confidence === "high" && !resolvedDuring;
22428
+ const answer = yield* ask("start-fix", {
22429
+ prompt: `${conclusion.summary}
22430
+
22431
+ Start a Build that fixes it as a bug: ${conclusion.fix}?`,
22432
+ options: [
22433
+ { value: "Start a fix", description: "A Build fixes it as a bug and opens a ChangeRequest." },
22434
+ { value: "Not now", description: "Keeps the finding on the Task." }
22435
+ ],
22436
+ recommended: confident ? "Start a fix" : "Not now",
22437
+ recommendationReason: confident ? "Firewatch is confident of the cause and the alert is still firing." : resolvedDuring ? "The alert resolved while Firewatch looked, so a fix can wait for a person to judge." : "Firewatch is not confident of the cause, so a fix could aim at the wrong thing.",
22438
+ default: input.autoFix === true && confident ? "Start a fix" : "Not now",
22439
+ timeout: "1 hour"
22496
22440
  });
22441
+ if (answer === "Not now")
22442
+ return { ...concluded, fix: "declined", fixRequest };
22443
+ return yield* call("fix", build_default, {
22444
+ ...fixRequest,
22445
+ ...taskId,
22446
+ ...input.repoBindingId === undefined ? undefined : { repoBindingId: input.repoBindingId }
22447
+ }).pipe(map8((fixed) => ({ ...concluded, fix: "started", changeRequestUrl: fixed.changeRequest.url })), catchTag2("ChildFailed", () => succeed7({ ...concluded, fix: "failed", fixRequest })));
22497
22448
  })
22498
22449
  });
22499
22450
 
22500
- // src/project-update.ts
22501
- var Health2 = Literals(["on_track", "at_risk", "off_track"]);
22502
- var Input7 = Struct({ projectId: String5 });
22503
- var Output8 = Struct({
22504
- draftId: optional2(String5),
22505
- health: optional2(Health2)
22451
+ // src/plan.ts
22452
+ var Input7 = Struct({
22453
+ words: String5,
22454
+ repoBindingId: optional2(String5),
22455
+ branch: optional2(String5),
22456
+ projectId: optional2(String5),
22457
+ implement: optional2(Boolean3)
22506
22458
  });
22507
- var Written = Struct({
22508
- health: Health2,
22509
- note: String5,
22510
- healthReason: optional2(String5)
22459
+ var Plan = Struct({
22460
+ title: String5,
22461
+ spec: String5,
22462
+ tickets: PlanTickets,
22463
+ outOfScope: ArraySchema(String5),
22464
+ afterMerge: ArraySchema(String5),
22465
+ docs: optional2(Struct({ title: String5, summary: String5 }))
22511
22466
  });
22512
- var label2 = { on_track: "on track", at_risk: "at risk", off_track: "off track" };
22513
- var day = (iso) => new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
22514
- var prompt3 = (read) => [
22515
- `Write a Project Update for ${read.project.name} from what happened ${read.since === undefined ? "so far" : `since its last Update on ${day(read.since)}`}.`,
22516
- "```json",
22517
- JSON.stringify({ health: read.health, runs: read.runs, taskChanges: read.taskChanges }, null, 2),
22518
- "```",
22519
- "Say in a few short paragraphs what moved, what is stuck and what comes next, naming Tasks by key. Suggest a health: on_track, at_risk or off_track.",
22520
- ...read.health === undefined ? [] : [
22521
- `The last Update said ${read.health}. If your health differs, give in healthReason one sentence on why, naming the Runs or Tasks that moved it.`
22522
- ],
22523
- "A person reviews and posts it, so claim nothing the Runs and Task changes do not show."
22467
+ var CriteriaCheck = Struct({
22468
+ alreadyMet: ArraySchema(Struct({ key: String5, criterion: String5 }))
22469
+ });
22470
+ var Rejected = Struct({ key: String5, reason: String5 });
22471
+ var Opinion = Struct({ summary: String5, concerns: ArraySchema(String5) });
22472
+ var TicketResult = Struct({
22473
+ key: String5,
22474
+ title: String5,
22475
+ status: Literals(["merged", "failed", "skipped"]),
22476
+ repoBindingId: optional2(String5),
22477
+ changeRequest: optional2(ChangeRequestResult),
22478
+ reason: optional2(String5)
22479
+ });
22480
+ var Output8 = Struct({
22481
+ title: String5,
22482
+ outOfScope: ArraySchema(String5),
22483
+ afterMerge: ArraySchema(String5),
22484
+ rejected: ArraySchema(Rejected),
22485
+ stopped: optional2(String5),
22486
+ implemented: Boolean3,
22487
+ tickets: ArraySchema(TicketResult),
22488
+ deliveries: ArraySchema(ChangeRequestResult),
22489
+ tasks: optional2(CreateTasksResult.fields.tasks),
22490
+ docs: optional2(ChangeRequestResult)
22491
+ });
22492
+ var menu = ["Implement now", "File as Tasks", "Second opinion", "Refine", "Finish"];
22493
+ var maxRounds = 6;
22494
+ var maxOpinionsPerPlan = 2;
22495
+ var menuDescriptions = {
22496
+ "Implement now": "Builds the tickets in dependency order and delivers them.",
22497
+ "File as Tasks": "Makes a Task of each ticket, to build later.",
22498
+ "Second opinion": "Another Agent reviews the plan.",
22499
+ Refine: "You say what to change and the planner rewrites it.",
22500
+ Finish: "Ends the Run with the plan as it is."
22501
+ };
22502
+ var onFailure = ["Retry with a steer", "Skip it and its dependents", "Stop here"];
22503
+ var ticketRules = [
22504
+ "Each ticket is one Build: a vertical slice an agent can implement, review and deliver as one ChangeRequest. Give it a short unique key, and list in blockedBy the keys of the tickets whose code it needs; tickets never block each other in a cycle. Set repoBindingId only when it changes another repository than the plan, keep one repository per ticket, and no cycle across repositories. Set outcome to the kind of change: feature, bug, refactor, docs, migration, investigation or performance.",
22505
+ "Each acceptance criterion is something a Build can show before its ChangeRequest: a behaviour a test or a check proves. A text search is never a criterion, nor is something the code already does. Outcomes only visible after merge go under afterMerge, not under a ticket's criteria."
22524
22506
  ].join(`
22507
+
22525
22508
  `);
22526
- var noteOf = (read, written) => {
22527
- if (read.health === undefined || read.health === written.health)
22528
- return written.note;
22529
- const moved = `Health moved from ${label2[read.health]} to ${label2[written.health]}`;
22530
- return `${written.healthReason === undefined ? `${moved}.` : `${moved}: ${written.healthReason}`}
22509
+ var planPrompt = (words) => [
22510
+ `Plan this: ${words}`,
22511
+ "You run the grill-with-docs skill as a Kitman Step. Interview the person with the Kitman MCP ask tool, one question at a time, and ask only what changes what gets built: a clear goal needs no interview. Write the ADR and glossary changes the grilling settles in the checkout, and no other files.",
22512
+ "Then write the spec and the tickets from the goal, the code and the answers.",
22513
+ ticketRules,
22514
+ "List in outOfScope what the plan deliberately leaves out, one item each; each holds the Builds of its tickets.",
22515
+ "If you left ADR or glossary changes, give docs a ChangeRequest title and a summary of the decisions; Kitman opens it. Your Output is the plan, and Kitman stores it."
22516
+ ].join(`
22531
22517
 
22532
- ${written.note}`;
22518
+ `);
22519
+ var planText = (plan) => [
22520
+ `# ${plan.title}`,
22521
+ plan.spec,
22522
+ ...plan.outOfScope.length === 0 ? [] : ["## Out of scope", plan.outOfScope.map((item) => `- ${item}`).join(`
22523
+ `)],
22524
+ ...plan.afterMerge.length === 0 ? [] : ["## After merge", plan.afterMerge.map((item) => `- ${item}`).join(`
22525
+ `)],
22526
+ "## Tickets",
22527
+ ...plan.tickets.map(ticketText)
22528
+ ].join(`
22529
+
22530
+ `);
22531
+ var ticketText = (ticket) => [
22532
+ `### ${ticket.key}: ${ticket.title}`,
22533
+ ticket.body,
22534
+ ...ticket.acceptanceCriteria.map((criterion) => `- [ ] ${criterion}`),
22535
+ ...ticket.blockedBy.length === 0 ? [] : [`Blocked by: ${ticket.blockedBy.join(", ")}`]
22536
+ ].join(`
22537
+
22538
+ `);
22539
+ var opinionPrompt = (plan) => [
22540
+ "Review this plan as a second opinion. Look for missing scope, tickets too big to review, wrong blockers and risks. Change nothing.",
22541
+ planText(plan)
22542
+ ].join(`
22543
+
22544
+ `);
22545
+ var refinePrompt = (plan, words) => [`Change the plan as the user asks: ${words}`, ticketRules, planText(plan)].join(`
22546
+
22547
+ `);
22548
+ var criteriaPrompt2 = (plan) => [
22549
+ "Check each ticket's acceptance criteria against the base branch as it is now. List under alreadyMet each criterion the code already meets, with its ticket's key and the criterion word for word. Change nothing.",
22550
+ planText(plan)
22551
+ ].join(`
22552
+
22553
+ `);
22554
+ var guard2 = " (guard: the base already does this)";
22555
+ var guarded = (plan, check) => {
22556
+ const met = (ticket, criterion) => check.alreadyMet.some((entry) => entry.key === ticket.key && entry.criterion === criterion);
22557
+ const rejected = plan.tickets.flatMap((ticket) => ticket.acceptanceCriteria.length > 0 && ticket.acceptanceCriteria.every((criterion) => met(ticket, criterion)) ? [{ key: ticket.key, reason: "The base branch already meets every one of its criteria" }] : []);
22558
+ const gone = new Set(rejected.map((entry) => entry.key));
22559
+ const tickets = plan.tickets.flatMap((ticket) => gone.has(ticket.key) ? [] : [
22560
+ {
22561
+ ...ticket,
22562
+ acceptanceCriteria: ticket.acceptanceCriteria.map((criterion) => met(ticket, criterion) ? `${criterion}${guard2}` : criterion),
22563
+ blockedBy: ticket.blockedBy.filter((key) => !gone.has(key))
22564
+ }
22565
+ ]);
22566
+ return { plan: { ...plan, tickets }, rejected };
22533
22567
  };
22534
- var project_update_default = defineLoop({
22535
- id: "project-update",
22536
- version: "2.0.1",
22537
- title: "Project update",
22538
- input: Input7,
22539
- output: Output8,
22540
- agents: { writer: "writer" },
22541
- checkout: "none",
22542
- capabilities: ["read_project", "agent_work", "draft_update"],
22543
- run: ({ projectId }) => gen2(function* () {
22544
- const read = yield* projectActivity("read", { projectId });
22545
- if (read.runs.length === 0 && read.taskChanges.length === 0)
22546
- return {};
22547
- const written = yield* agentWork("write", { agent: "writer", prompt: prompt3(read), output: Written });
22548
- const draft = yield* draftUpdate("draft", {
22549
- projectId,
22550
- health: written.health,
22551
- note: noteOf(read, written),
22552
- writtenFrom: {
22553
- runs: read.runs.length,
22554
- taskChanges: read.taskChanges.length,
22555
- ...read.since === undefined ? undefined : { since: read.since }
22556
- }
22557
- });
22558
- return { draftId: draft.id, health: written.health };
22559
- })
22560
- });
22568
+ var checked = (plan, revision) => map8(agentWork(`criteria-check:${revision}`, { agent: "reviewer", prompt: criteriaPrompt2(plan), output: CriteriaCheck }), (check) => guarded(plan, check));
22569
+ var storePlan = (plan, suffix) => gen2(function* () {
22570
+ yield* artifact(`spec${suffix}`, {
22571
+ kind: "plan_spec",
22572
+ title: plan.title,
22573
+ text: plan.spec,
22574
+ data: { outOfScope: [...plan.outOfScope] }
22575
+ });
22576
+ yield* artifact(`tickets${suffix}`, {
22577
+ kind: "plan_tickets",
22578
+ title: `Tickets for ${plan.title}`,
22579
+ text: plan.tickets.map(ticketText).join(`
22561
22580
 
22562
- // src/release.ts
22563
- var Input8 = Struct({
22564
- tag: optional2(String5),
22565
- revision: optional2(String5),
22566
- changeRequestId: optional2(String5),
22567
- deployTarget: optional2(String5),
22568
- deployEvent: optional2(String5),
22569
- deployTimeoutMinutes: optional2(Int)
22570
- });
22571
- var Drafted = Struct({
22572
- tag: String5,
22573
- previousTag: optional2(String5),
22574
- title: String5,
22575
- notes: String5,
22576
- changeRequests: ArraySchema(String5)
22577
- });
22578
- var Output9 = Struct({
22579
- status: Literals([
22580
- "gate_failed",
22581
- "not_mergeable",
22582
- "rejected",
22583
- "deployed",
22584
- "rolled_back",
22585
- "deploy_failed",
22586
- "timed_out"
22587
- ]),
22588
- reason: String5,
22589
- tag: optional2(String5),
22590
- notesArtifactId: optional2(String5),
22591
- revision: optional2(String5),
22592
- url: optional2(String5)
22581
+ `),
22582
+ data: plan.tickets.map(({ repoBindingId, outcome, ...ticket }) => ({
22583
+ ...ticket,
22584
+ acceptanceCriteria: [...ticket.acceptanceCriteria],
22585
+ blockedBy: [...ticket.blockedBy],
22586
+ ...repoBindingId === undefined ? undefined : { repoBindingId },
22587
+ ...outcome === undefined ? undefined : { outcome }
22588
+ }))
22589
+ });
22593
22590
  });
22594
- var draftPrompt = (tag, changeRequestId) => [
22595
- "Draft the release notes for the ChangeRequests merged since the last release tag on the base branch, including those with no linked Task.",
22596
- ...changeRequestId === undefined ? [] : [`The release ChangeRequest ${changeRequestId} is part of it.`],
22597
- "Group them by what a user of the software notices, and name each ChangeRequest you cover.",
22598
- tag === undefined ? "Propose the next version tag, following the scheme earlier tags use. With no earlier tag, propose v0.1.0 and say so in the notes." : `The release is tagged ${tag}.`,
22599
- "Give the latest earlier release tag as previousTag, or leave it out when there is none."
22591
+ var changelog = (before, after, words) => {
22592
+ const was = new Map(before.tickets.map((ticket) => [ticket.key, ticketText(ticket)]));
22593
+ const now = new Map(after.tickets.map((ticket) => [ticket.key, ticketText(ticket)]));
22594
+ const lines = [
22595
+ ...after.tickets.filter((ticket) => !was.has(ticket.key)).map((ticket) => `- Added ${ticket.key}: ${ticket.title}`),
22596
+ ...before.tickets.filter((ticket) => !now.has(ticket.key)).map((ticket) => `- Removed ${ticket.key}: ${ticket.title}`),
22597
+ ...after.tickets.filter((ticket) => was.has(ticket.key) && was.get(ticket.key) !== now.get(ticket.key)).map((ticket) => `- Changed ${ticket.key}: ${ticket.title}`),
22598
+ ...before.spec === after.spec ? [] : ["- Changed the spec"],
22599
+ ...before.title === after.title ? [] : [`- Renamed the plan from "${before.title}"`]
22600
+ ];
22601
+ return [`Asked: ${words}`, ...lines.length === 0 ? ["Nothing changed."] : lines].join(`
22602
+ `);
22603
+ };
22604
+ var buildWords = (ticket) => [
22605
+ ticket.title,
22606
+ ticket.body,
22607
+ ...ticket.acceptanceCriteria.length === 0 ? [] : ["Acceptance criteria:", ...ticket.acceptanceCriteria.map((criterion) => `- ${criterion}`)]
22600
22608
  ].join(`
22601
22609
 
22602
22610
  `);
22603
- var redraftPrompt = (drafted, edit) => [
22604
- "Redraft these release notes as asked, keeping the tag and everything not mentioned.",
22605
- `Asked: ${edit}`,
22606
- "```json",
22607
- JSON.stringify(drafted, null, 2),
22608
- "```"
22609
- ].join(`
22611
+ var settled = (ticket, result) => ({
22612
+ key: ticket.key,
22613
+ title: ticket.title,
22614
+ ...result
22615
+ });
22616
+ var buildTicket = (ticket, start, taskId, retry) => gen2(function* () {
22617
+ let head = start;
22618
+ const name = retry === undefined ? ticket.key : `${ticket.key}:${retry.label}`;
22619
+ for (let attempt = 1;; attempt++) {
22620
+ const suffix = attempt === 1 ? "" : `:${attempt}`;
22621
+ const words = retry === undefined ? taskId === undefined ? { words: buildWords(ticket) } : undefined : { words: `${buildWords(ticket)}
22610
22622
 
22611
- `);
22612
- var readyToMerge = (changeRequestId, round = 1) => gen2(function* () {
22613
- for (let pass = 1;; pass++) {
22614
- const at = (name) => {
22615
- const named = pass === 1 ? name : `${name}:moved:${pass}`;
22616
- return round === 1 ? named : `${named}:${round}`;
22617
- };
22618
- let read = yield* pipelineStatus(at("gate"), { changeRequestId });
22619
- for (let look = 1;look <= 10 && (read.status === "running" || read.status === "none"); look++) {
22620
- const again = (name) => at(look === 1 ? name : `${name}:${look}`);
22621
- yield* pipelineMoved(again("gate:pipeline"), changeRequestId, read);
22622
- read = yield* pipelineStatus(again("gate:after"), { changeRequestId });
22623
- }
22624
- if (read.status !== "success") {
22625
- const failing = read.jobs.filter((job) => job.status === "failed").map((job) => job.name);
22626
- return {
22627
- status: "gate_failed",
22628
- reason: `The pipeline at ${read.head} is ${read.status}${failing.length === 0 ? "" : `: ${failing.join(", ")}`}`,
22629
- revision: read.head
22630
- };
22631
- }
22632
- const approved = yield* waitFor(at("approved"), {
22633
- event: "change_request.approval",
22634
- filter: { changeRequestId, approved: true, head: read.head },
22635
- timeout: "7 days",
22636
- or: read.readAt === undefined ? [] : [{ event: "change_request.pipeline", filter: { changeRequestId }, since: read.readAt }]
22623
+ An earlier Build of this failed. ${retry.steer}` };
22624
+ const built = yield* call(`build:${name}${suffix}`, build_default, {
22625
+ ...words,
22626
+ ...taskId === undefined ? undefined : { taskId },
22627
+ repoBindingId: head.repoBindingId,
22628
+ branch: head.branch,
22629
+ revision: head.revision,
22630
+ land: false
22631
+ }).pipe(catchTag2("ChildFailed", (failure) => succeed7(failure)));
22632
+ if (built instanceof ChildFailed)
22633
+ return settled(ticket, { status: "failed", repoBindingId: head.repoBindingId, reason: built.reason });
22634
+ const merged = yield* mergeChangeRequest(`merge:${name}${suffix}`, {
22635
+ changeRequestId: built.changeRequest.id,
22636
+ head: built.revision
22637
22637
  });
22638
- if (approved.type !== "change_request.approval") {
22639
- if (pass >= 10)
22640
- return {
22641
- status: "not_mergeable",
22642
- reason: `ChangeRequest ${changeRequestId} kept moving while it awaited approval`
22643
- };
22644
- continue;
22645
- }
22646
- const mergeable = yield* waitFor(at("mergeable"), {
22647
- event: "change_request.mergeable",
22648
- filter: { changeRequestId },
22638
+ const delivered = { repoBindingId: head.repoBindingId, changeRequest: built.changeRequest };
22639
+ if (merged.status === "merged")
22640
+ return settled(ticket, { status: "merged", ...delivered });
22641
+ const choice = yield* ask(`conflict:${name}${suffix}`, {
22642
+ prompt: `The ChangeRequest for ${ticket.key} (${built.changeRequest.url}) ${merged.status === "moved" ? "changed after it was built" : `conflicts with ${head.branch}`}. Rebuild ${ticket.key} on the integration branch as it is now, or skip it and the tickets it blocks?`,
22643
+ options: [
22644
+ {
22645
+ value: "Rebuild on the integration branch",
22646
+ description: "Builds the ticket again from the branch as it is now."
22647
+ },
22648
+ { value: "Skip", description: "Leaves it and the tickets it blocks undelivered." }
22649
+ ],
22650
+ recommended: "Rebuild on the integration branch",
22651
+ recommendationReason: "A rebuild on the current branch usually resolves a conflict, and skipping also drops every ticket it blocks.",
22652
+ default: "Skip",
22649
22653
  timeout: "1 hour"
22650
22654
  });
22651
- if (mergeable.payload.mergeable === false)
22652
- return {
22653
- status: "not_mergeable",
22654
- reason: `ChangeRequest ${changeRequestId} does not merge cleanly; bring it up to date first`
22655
- };
22656
- return { passed: read.head };
22657
- }
22658
- }).pipe(catchTags2({
22659
- WaitTimedOut: (timedOut) => succeed7({
22660
- status: "not_mergeable",
22661
- reason: `Nothing happened on ChangeRequest ${changeRequestId} while waiting for ${timedOut.event}`
22662
- }),
22663
- ChangeRequestClosed: (closed) => succeed7({
22664
- status: "not_mergeable",
22665
- reason: `ChangeRequest ${changeRequestId} was ${closed.merged ? "merged" : "closed"} before Kitman released it`
22666
- })
22667
- }));
22668
- var recovery = (previous) => previous === undefined ? ["Retry the deploy", "Leave it"] : ["Retry the deploy", `Roll back to ${previous}`, "Leave it"];
22669
- var release_default = defineLoop({
22670
- id: "release",
22671
- version: "2.0.0",
22672
- title: "Release",
22673
- input: Input8,
22674
- output: Output9,
22675
- agents: { releaser: "releaser" },
22676
- checkout: "read",
22677
- capabilities: [
22678
- "agent_work",
22679
- "ask",
22680
- "verify",
22681
- "claim",
22682
- "publish_release",
22683
- "read_change_request",
22684
- "merge_change_request"
22685
- ],
22686
- run: (input) => gen2(function* () {
22687
- const changeRequestId = input.changeRequestId;
22688
- let gated;
22689
- if (changeRequestId === undefined) {
22690
- const gate = yield* verify("gate", input.revision === undefined ? {} : { revision: input.revision });
22691
- if (!gate.passed) {
22692
- const failing = gate.checks.filter((check) => check.status !== "passed").map((check) => check.name);
22693
- return {
22694
- status: "gate_failed",
22695
- reason: `The checks fail at ${gate.revision}: ${failing.join(", ")}`,
22696
- revision: gate.revision
22697
- };
22698
- }
22699
- gated = gate.revision;
22700
- } else {
22701
- const ready = yield* readyToMerge(changeRequestId);
22702
- if (!("passed" in ready))
22703
- return ready;
22704
- gated = ready.passed;
22705
- }
22706
- let drafted = yield* agentWork("draft-notes", {
22707
- agent: "releaser",
22708
- prompt: draftPrompt(input.tag, changeRequestId),
22709
- output: Drafted
22655
+ if (choice === "Skip")
22656
+ return settled(ticket, { status: "failed", ...delivered, reason: `It conflicted with ${head.branch}` });
22657
+ head = yield* integrationBranch(`rebuild-from:${name}:${attempt + 1}`, {
22658
+ repoBindingId: head.repoBindingId
22710
22659
  });
22711
- const tag = input.tag ?? drafted.tag;
22712
- for (let round = 1;; round++) {
22713
- const answer = yield* ask(round === 1 ? "approve" : `approve:${round}`, {
22714
- prompt: `Release ${tag} with these notes?
22660
+ }
22661
+ });
22662
+ var deliveryBody = (plan, results) => {
22663
+ const line = (result) => result.status === "merged" && result.changeRequest !== undefined ? `- **${result.key}** [${result.title}](${result.changeRequest.url}); its evidence is on that ChangeRequest` : `- **${result.key}** ${result.title}: ${result.reason ?? result.status}`;
22664
+ const section = (title, status) => {
22665
+ const lines = results.flatMap((result) => result.status === status ? [line(result)] : []);
22666
+ return lines.length === 0 ? [] : [`## ${title}`, lines.join(`
22667
+ `)];
22668
+ };
22669
+ return [
22670
+ `Delivers the plan "${plan.title}".`,
22671
+ ...section("Merged", "merged"),
22672
+ ...section("Failed", "failed"),
22673
+ ...section("Skipped", "skipped")
22674
+ ].join(`
22715
22675
 
22716
- ${drafted.notes}`,
22717
- options: [
22718
- { value: "Approve", description: "Tags, publishes and deploys it." },
22719
- { value: "Edit notes", description: "Say what to change; the notes are redrafted." },
22720
- { value: "Reject", description: "Keeps the draft and releases nothing." }
22721
- ],
22722
- recommended: "Approve",
22723
- recommendationReason: "The release is proved: its checks pass, or its ChangeRequest is green, approved and mergeable.",
22724
- default: null
22725
- });
22726
- if (answer === "Approve")
22727
- break;
22728
- if (answer === "Reject") {
22729
- const why = yield* ask("reject-why", {
22730
- prompt: `Why not release ${tag}? The draft is kept.`,
22731
- freeText: true,
22732
- informationOnly: true,
22733
- recommendationReason: "Only you know why you rejected it.",
22734
- default: "No reason given",
22735
- timeout: "1 day"
22736
- });
22737
- const kept = yield* artifact("notes", {
22738
- kind: "release_notes",
22739
- title: drafted.title,
22740
- text: drafted.notes,
22741
- data: { tag, changeRequests: drafted.changeRequests }
22742
- });
22743
- return { status: "rejected", reason: why, tag, notesArtifactId: kept.artifactId };
22744
- }
22745
- const edit = yield* ask(`edit:${round}`, {
22746
- prompt: "What should change in the notes?",
22747
- freeText: true,
22748
- informationOnly: true,
22749
- recommendationReason: "You chose to edit them, so only you know what should change.",
22750
- default: "",
22751
- timeout: "1 day"
22752
- });
22753
- if (edit.trim() === "")
22754
- continue;
22755
- drafted = yield* agentWork(`redraft:${round}`, {
22756
- agent: "releaser",
22757
- prompt: redraftPrompt(drafted, edit),
22758
- output: Drafted
22759
- });
22676
+ `);
22677
+ };
22678
+ var implement = (input, plan, revision, filed) => gen2(function* () {
22679
+ const tasks = filed?.revision === revision ? filed.tasks : (yield* createTasks("tasks", {
22680
+ tickets: plan.tickets,
22681
+ ...input.projectId === undefined ? undefined : { projectId: input.projectId }
22682
+ })).tasks;
22683
+ const taskOf = new Map(tasks.map((task) => [task.key, task.taskId]));
22684
+ const results = new Map;
22685
+ const waves = yield* planWaves(plan.tickets);
22686
+ let stopped;
22687
+ for (const [index, wave] of waves.entries()) {
22688
+ const ready = [];
22689
+ for (const ticket of wave) {
22690
+ const unmerged = ticket.blockedBy.find((key) => results.get(key)?.status !== "merged");
22691
+ if (stopped !== undefined)
22692
+ results.set(ticket.key, settled(ticket, { status: "skipped", reason: stopped }));
22693
+ else if (unmerged === undefined)
22694
+ ready.push(ticket);
22695
+ else
22696
+ results.set(ticket.key, settled(ticket, { status: "skipped", reason: `blocked by ${unmerged}, which was not merged` }));
22760
22697
  }
22761
- const notes = yield* artifact("notes", {
22762
- kind: "release_notes",
22763
- title: drafted.title,
22764
- text: drafted.notes,
22765
- data: { tag, changeRequests: drafted.changeRequests }
22766
- });
22767
- let revision = gated;
22768
- if (changeRequestId !== undefined) {
22769
- for (let round = 1;; round++) {
22770
- const merged = yield* mergeChangeRequest(round === 1 ? "merge" : `merge:${round}`, {
22771
- changeRequestId,
22772
- head: gated
22698
+ const repoOf = (ticket) => ticket.repoBindingId ?? input.repoBindingId;
22699
+ const heads = new Map;
22700
+ for (const repo of new Set(ready.map(repoOf)))
22701
+ heads.set(repo, yield* integrationBranch(`integration:wave-${index + 1}:${repo ?? "default"}`, repo === undefined ? {} : { repoBindingId: repo }));
22702
+ const built = yield* forEach2(ready.flatMap((ticket) => {
22703
+ const head = heads.get(repoOf(ticket));
22704
+ return head === undefined ? [] : [buildTicket(ticket, head, taskOf.get(ticket.key))];
22705
+ }), (building) => building, { concurrency: "unbounded" });
22706
+ for (const first of built) {
22707
+ const ticket = ready.find((entry) => entry.key === first.key);
22708
+ let result = first;
22709
+ for (let retry = 1;ticket !== undefined && result.status === "failed" && stopped === undefined; retry++) {
22710
+ const choice = yield* ask(`failed:${ticket.key}:${retry}`, {
22711
+ prompt: `The Build of ${ticket.key} failed: ${result.reason ?? "no reason given"}. Retry it with a steer, skip it and the tickets it blocks, or stop here?`,
22712
+ options: onFailure,
22713
+ recommended: "Retry with a steer",
22714
+ recommendationReason: "The reason it failed is above, so a steer can aim the retry at it; skipping also drops the tickets it blocks.",
22715
+ default: "Skip it and its dependents",
22716
+ timeout: "1 hour"
22773
22717
  });
22774
- if (merged.status === "conflict")
22775
- return {
22776
- status: "not_mergeable",
22777
- reason: `ChangeRequest ${changeRequestId} no longer merges cleanly`,
22778
- tag,
22779
- notesArtifactId: notes.artifactId
22780
- };
22781
- if (merged.status === "merged") {
22782
- revision = merged.revision;
22718
+ if (choice === "Skip it and its dependents")
22719
+ break;
22720
+ if (choice === "Stop here") {
22721
+ stopped = `Stopped after ${ticket.key} failed`;
22783
22722
  break;
22784
22723
  }
22785
- if (round >= 3)
22786
- return {
22787
- status: "not_mergeable",
22788
- reason: `ChangeRequest ${changeRequestId} kept moving while it was gated; it is at ${merged.head}`,
22789
- tag,
22790
- notesArtifactId: notes.artifactId
22791
- };
22792
- const ready = yield* readyToMerge(changeRequestId, round + 1);
22793
- if (!("passed" in ready))
22794
- return { ...ready, tag, notesArtifactId: notes.artifactId };
22795
- gated = ready.passed;
22724
+ const steer = yield* ask(`steer:${ticket.key}:${retry}`, {
22725
+ prompt: `What should the retry of ${ticket.key} do differently?`,
22726
+ freeText: true,
22727
+ recommended: `Address the failure: ${result.reason ?? "find out why it failed first"}`,
22728
+ recommendationReason: "Pointing the retry at the reason it failed is the least a steer should do.",
22729
+ default: null
22730
+ });
22731
+ const head = yield* integrationBranch(`integration:${ticket.key}:retry-${retry}`, {
22732
+ ...repoOf(ticket) === undefined ? undefined : { repoBindingId: repoOf(ticket) }
22733
+ });
22734
+ result = yield* buildTicket(ticket, head, taskOf.get(ticket.key), { label: `retry-${retry}`, steer });
22796
22735
  }
22736
+ results.set(result.key, result);
22797
22737
  }
22798
- const deployEvent = input.deployEvent ?? "deploy";
22799
- yield* claim("deploy-target", { resource: `deploy:${input.deployTarget ?? deployEvent}` });
22800
- const released = yield* release2("tag", {
22801
- tag,
22802
- revision,
22803
- title: drafted.title,
22804
- notes: drafted.notes
22738
+ }
22739
+ const ordered = plan.tickets.flatMap((ticket) => results.get(ticket.key) ?? []);
22740
+ const repos = [
22741
+ ...new Set(ordered.flatMap((result) => result.status === "merged" ? result.repoBindingId ?? [] : []))
22742
+ ];
22743
+ const deliveries = yield* forEach2(repos, (repo) => changeRequest(`deliver:${repo}`, {
22744
+ title: plan.title,
22745
+ body: deliveryBody(plan, ordered.filter((result) => result.repoBindingId === repo || result.repoBindingId === undefined)),
22746
+ integration: repo
22747
+ }));
22748
+ return { tickets: ordered, deliveries, tasks, ...stopped === undefined ? undefined : { stopped } };
22749
+ });
22750
+ var plan_default = defineLoop({
22751
+ id: "plan",
22752
+ version: "2.0.0",
22753
+ title: "Plan",
22754
+ input: Input7,
22755
+ output: Output8,
22756
+ agents: { planner: "planner", reviewer: "reviewer" },
22757
+ checkout: "write",
22758
+ capabilities: [
22759
+ "agent_work",
22760
+ "ask",
22761
+ "call",
22762
+ "verify",
22763
+ "change_request",
22764
+ "write_back",
22765
+ "create_tasks",
22766
+ "merge_change_request",
22767
+ "read_change_request",
22768
+ "post_comment"
22769
+ ],
22770
+ calls: () => [build_default],
22771
+ run: (input) => gen2(function* () {
22772
+ const drafted = yield* agentWorkOnce("plan", {
22773
+ agent: "planner",
22774
+ skill: "grill-with-docs",
22775
+ prompt: planPrompt(input.words),
22776
+ output: Plan
22805
22777
  });
22806
- const deployed = (step, deployTag) => waitFor(step, {
22807
- event: deployEvent,
22808
- filter: { tag: deployTag },
22809
- timeout: `${input.deployTimeoutMinutes ?? 60} minutes`
22810
- }).pipe(map8((deploy) => deploy.payload.status === "failed" ? "deploy_failed" : "deployed"), catchTag2("WaitTimedOut", () => succeed7("timed_out")));
22811
- const settled = { tag, notesArtifactId: notes.artifactId, revision: released.revision, url: released.url };
22812
- let status = yield* deployed("deploy", tag);
22813
- for (let round = 1;status !== "deployed"; round++) {
22814
- const options = recovery(drafted.previousTag);
22815
- const answer = yield* ask(`recover:${round}`, {
22816
- prompt: `${tag} ${status === "timed_out" ? "did not report a deploy in time" : "failed to deploy"}. What now?`,
22817
- options,
22818
- recommended: "Retry the deploy",
22819
- recommendationReason: "A failed or late deploy is often passing, and a retry runs only the failed jobs of the same tag.",
22820
- default: "Leave it",
22778
+ const docs = drafted.docs === undefined ? undefined : yield* changeRequest("docs", { title: drafted.docs.title, body: drafted.docs.summary });
22779
+ let revision = 1;
22780
+ let { plan, rejected } = yield* checked(drafted, revision);
22781
+ let filed;
22782
+ let opinions = 0;
22783
+ yield* storePlan(plan, "");
22784
+ const project = input.projectId === undefined ? undefined : { projectId: input.projectId };
22785
+ const ended = (rest) => ({
22786
+ title: plan.title,
22787
+ outOfScope: plan.outOfScope,
22788
+ afterMerge: plan.afterMerge,
22789
+ rejected,
22790
+ tickets: [],
22791
+ deliveries: [],
22792
+ ...filed === undefined ? undefined : { tasks: filed.tasks },
22793
+ ...docs === undefined ? undefined : { docs },
22794
+ ...rest
22795
+ });
22796
+ for (let round = 1;round <= maxRounds; round++) {
22797
+ const isFiled = filed?.revision === revision;
22798
+ const options = menu.filter((option) => !(option === "File as Tasks" && isFiled) && !(option === "Second opinion" && opinions >= maxOpinionsPerPlan));
22799
+ const fallback = input.implement === true ? "Implement now" : isFiled ? "Finish" : "File as Tasks";
22800
+ const answer = yield* ask(`next:${round}`, {
22801
+ prompt: `What next for the plan "${plan.title}"?`,
22802
+ options: options.map((option) => ({ value: option, description: menuDescriptions[option] })),
22803
+ recommended: fallback,
22804
+ recommendationReason: fallback === "Implement now" ? "You asked for the plan to be implemented." : fallback === "Finish" ? "The plan is filed as Tasks, so nothing is lost by finishing." : "Filing keeps the plan as Tasks a person or a Build can pick up later.",
22805
+ default: fallback,
22821
22806
  timeout: "1 hour"
22822
22807
  });
22823
- if (answer === "Leave it")
22824
- return { status, reason: `${tag} at ${released.revision} was left undeployed`, ...settled };
22825
- if (answer === "Retry the deploy") {
22826
- yield* rerunPipeline(`deploy:rerun-${round}`, { ref: tag });
22827
- status = yield* deployed(`deploy:retry-${round}`, tag);
22828
- continue;
22808
+ const choice = menu.find((option) => option === answer) ?? fallback;
22809
+ switch (choice) {
22810
+ case "Finish":
22811
+ return ended({ implemented: false });
22812
+ case "Implement now": {
22813
+ const delivered = yield* implement(input, plan, revision, filed);
22814
+ return { ...ended({ implemented: true }), ...delivered };
22815
+ }
22816
+ case "File as Tasks": {
22817
+ const made = yield* createTasks(`file-tasks:${round}`, { tickets: plan.tickets, ...project });
22818
+ filed = { revision, tasks: made.tasks };
22819
+ break;
22820
+ }
22821
+ case "Second opinion": {
22822
+ opinions++;
22823
+ const opinion = yield* agentWork(`second-opinion:${round}`, {
22824
+ agent: "reviewer",
22825
+ prompt: opinionPrompt(plan),
22826
+ output: Opinion
22827
+ });
22828
+ yield* artifact(`opinion:${round}`, {
22829
+ kind: "plan_review",
22830
+ title: `Second opinion on ${plan.title}`,
22831
+ text: [opinion.summary, ...opinion.concerns.map((concern) => `- ${concern}`)].join(`
22832
+
22833
+ `)
22834
+ });
22835
+ break;
22836
+ }
22837
+ case "Refine": {
22838
+ const words = yield* ask(`refine:${round}`, {
22839
+ prompt: "What should change in the plan?",
22840
+ freeText: true,
22841
+ informationOnly: true,
22842
+ recommendationReason: "You chose to refine it, so only you know what should change."
22843
+ });
22844
+ const refined = yield* agentWorkOnce(`refine-plan:${round}`, {
22845
+ agent: "planner",
22846
+ prompt: refinePrompt(plan, words),
22847
+ output: Plan
22848
+ });
22849
+ const before = plan;
22850
+ revision++;
22851
+ ({ plan, rejected } = yield* checked(refined, revision));
22852
+ yield* storePlan(plan, `:${round}`);
22853
+ yield* artifact(`changes:${round}`, {
22854
+ kind: "plan_changes",
22855
+ title: `Revision ${revision} of ${plan.title}`,
22856
+ text: changelog(before, plan, words)
22857
+ });
22858
+ break;
22859
+ }
22829
22860
  }
22830
- const previous = drafted.previousTag ?? tag;
22831
- yield* rerunPipeline(`deploy:redeploy-${round}`, { ref: previous, all: true });
22832
- const back = yield* deployed(`deploy:rollback-${round}`, previous);
22833
- if (back === "deployed")
22834
- return { status: "rolled_back", reason: `Rolled back to ${previous}`, ...settled };
22835
- status = back;
22836
22861
  }
22837
- yield* artifact("report", {
22838
- kind: "release_report",
22839
- title: `${tag} deployed`,
22840
- text: `${tag} at ${released.revision} deployed. ${released.url}`,
22841
- data: { tag, status, revision: released.revision, url: released.url }
22862
+ if (filed?.revision !== revision)
22863
+ filed = {
22864
+ revision,
22865
+ tasks: (yield* createTasks("file-tasks:last", { tickets: plan.tickets, ...project })).tasks
22866
+ };
22867
+ return ended({
22868
+ implemented: false,
22869
+ stopped: `No decision after ${maxRounds} rounds, so the tickets are filed as Tasks for a person to pick up`
22842
22870
  });
22843
- return { status: "deployed", reason: `${tag} deployed`, ...settled };
22871
+ })
22872
+ });
22873
+
22874
+ // src/project-update.ts
22875
+ var Health2 = Literals(["on_track", "at_risk", "off_track"]);
22876
+ var Input8 = Struct({ projectId: String5 });
22877
+ var Output9 = Struct({
22878
+ draftId: optional2(String5),
22879
+ health: optional2(Health2)
22880
+ });
22881
+ var Written = Struct({
22882
+ health: Health2,
22883
+ note: String5,
22884
+ healthReason: optional2(String5)
22885
+ });
22886
+ var label2 = { on_track: "on track", at_risk: "at risk", off_track: "off track" };
22887
+ var day = (iso) => new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
22888
+ var prompt3 = (read) => [
22889
+ `Write a Project Update for ${read.project.name} from what happened ${read.since === undefined ? "so far" : `since its last Update on ${day(read.since)}`}.`,
22890
+ "```json",
22891
+ JSON.stringify({ health: read.health, runs: read.runs, taskChanges: read.taskChanges }, null, 2),
22892
+ "```",
22893
+ "Say in a few short paragraphs what moved, what is stuck and what comes next, naming Tasks by key. Suggest a health: on_track, at_risk or off_track.",
22894
+ ...read.health === undefined ? [] : [
22895
+ `The last Update said ${read.health}. If your health differs, give in healthReason one sentence on why, naming the Runs or Tasks that moved it.`
22896
+ ],
22897
+ "A person reviews and posts it, so claim nothing the Runs and Task changes do not show."
22898
+ ].join(`
22899
+ `);
22900
+ var noteOf = (read, written) => {
22901
+ if (read.health === undefined || read.health === written.health)
22902
+ return written.note;
22903
+ const moved = `Health moved from ${label2[read.health]} to ${label2[written.health]}`;
22904
+ return `${written.healthReason === undefined ? `${moved}.` : `${moved}: ${written.healthReason}`}
22905
+
22906
+ ${written.note}`;
22907
+ };
22908
+ var project_update_default = defineLoop({
22909
+ id: "project-update",
22910
+ version: "2.0.1",
22911
+ title: "Project update",
22912
+ input: Input8,
22913
+ output: Output9,
22914
+ agents: { writer: "writer" },
22915
+ checkout: "none",
22916
+ capabilities: ["read_project", "agent_work", "draft_update"],
22917
+ run: ({ projectId }) => gen2(function* () {
22918
+ const read = yield* projectActivity("read", { projectId });
22919
+ if (read.runs.length === 0 && read.taskChanges.length === 0)
22920
+ return {};
22921
+ const written = yield* agentWork("write", { agent: "writer", prompt: prompt3(read), output: Written });
22922
+ const draft = yield* draftUpdate("draft", {
22923
+ projectId,
22924
+ health: written.health,
22925
+ note: noteOf(read, written),
22926
+ writtenFrom: {
22927
+ runs: read.runs.length,
22928
+ taskChanges: read.taskChanges.length,
22929
+ ...read.since === undefined ? undefined : { since: read.since }
22930
+ }
22931
+ });
22932
+ return { draftId: draft.id, health: written.health };
22844
22933
  })
22845
22934
  });
22846
22935
 
@@ -23639,6 +23728,11 @@ var kitman_config_default = defineKit({
23639
23728
  skills: ["git-workflow-and-versioning", "shipping-and-launch"],
23640
23729
  instructions: { file: "agents/releaser.md" }
23641
23730
  },
23731
+ deployer: {
23732
+ effort: "low",
23733
+ fallbacks,
23734
+ instructions: { file: "agents/deployer.md" }
23735
+ },
23642
23736
  investigator: {
23643
23737
  effort: "high",
23644
23738
  fallbacks,
@@ -26266,6 +26360,7 @@ class RunnerCapabilityRpcs extends make30(make29("PutQuestion", {
26266
26360
  informationOnly: optional2(Boolean3),
26267
26361
  verification: optional2(SuggestedVerification),
26268
26362
  default: optional2(String5),
26363
+ auto: optional2(String5),
26269
26364
  timeoutMillis: optional2(Finite),
26270
26365
  deferredName: String5
26271
26366
  },
@@ -26777,6 +26872,7 @@ var runnerHost = (capabilities) => ({
26777
26872
  ...launch.options === undefined ? undefined : { options: launch.options },
26778
26873
  ...launch.verification === undefined ? undefined : { verification: launch.verification },
26779
26874
  ...launch.default === undefined ? undefined : { default: launch.default },
26875
+ ...launch.auto === undefined ? undefined : { auto: launch.auto },
26780
26876
  ...launch.timeoutMillis === undefined ? undefined : { timeoutMillis: launch.timeoutMillis }
26781
26877
  })).pipe(asVoid2),
26782
26878
  startVerification: (launch) => requested(launch.step, capabilities.startVerification({
@@ -26998,6 +27094,7 @@ var rpcProgramPorts = (rpc, work) => {
26998
27094
  ...question.options === undefined ? undefined : { options: [...question.options] },
26999
27095
  ...question.verification === undefined ? undefined : { verification: question.verification },
27000
27096
  ...question.default === undefined ? undefined : { default: question.default },
27097
+ ...question.auto === undefined ? undefined : { auto: question.auto },
27001
27098
  ...question.timeoutMillis === undefined ? undefined : { timeoutMillis: question.timeoutMillis }
27002
27099
  }),
27003
27100
  startVerification: (verification) => rpc.startVerification({
@@ -29299,4 +29396,4 @@ var runKit = (config, identity, packages) => {
29299
29396
  runMain2(program.pipe(flatMap3((code) => sync2(() => process.exit(code)))));
29300
29397
  };
29301
29398
  // .kitman-build-entry.ts
29302
- runKit(kitman_config_default, { name: "@kitman-sh/software-engineering", version: "0.10.58" }, []);
29399
+ runKit(kitman_config_default, { name: "@kitman-sh/software-engineering", version: "0.10.59" }, []);