@trycadence/cli 0.1.14-dev.0 → 0.1.18-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cadence +1422 -196
  2. package/package.json +1 -1
package/dist/cadence CHANGED
@@ -1520,7 +1520,7 @@ import { createInterface } from "readline/promises";
1520
1520
  // package.json
1521
1521
  var package_default = {
1522
1522
  name: "@trycadence/cli",
1523
- version: "0.1.14-dev.0",
1523
+ version: "0.1.18-dev.0",
1524
1524
  private: false,
1525
1525
  type: "module",
1526
1526
  bin: {
@@ -1554,12 +1554,15 @@ var workLogParentSelectors = ["last", "ticket-last", "session-last", "last-decis
1554
1554
  var changesetPrNoteSources = ["agent", "human", "system"];
1555
1555
  var hookScopes = ["repo", "global", "both"];
1556
1556
  var agentEventSources = ["codex", "claude-code", "opencode", "openrouter", "unknown"];
1557
+ var agentRunMemoryModes = ["checkpoint", "closeout"];
1558
+ var closeoutSessionActions = ["handoff", "end", "keep"];
1557
1559
  var defaultLeaseTtlSeconds = 15 * 60;
1558
1560
  var defaultCliApiBaseUrl = "https://cadenceapi.deploy.lvl8studios.com";
1559
1561
  var defaultCliWebBaseUrl = "https://cadence.deploy.lvl8studios.com";
1560
1562
  var defaultCheckpointThreshold = 3;
1561
- var defaultCheckpointCooldownSeconds = 10 * 60;
1563
+ var defaultCheckpointCooldownSeconds = 0;
1562
1564
  var defaultCheckpointWorkerTimeoutMs = 10 * 60 * 1000;
1565
+ var defaultCheckpointWorkerMaxAttempts = 3;
1563
1566
  var defaultHookCommand = `/bin/sh -lc 'root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0; cd "$root" || exit 0; [ -f .cadence/config.json ] || [ -f .cadence/config.local.json ] || exit 0; exec cadence agent-run ingest-stop --source codex --event stop >/dev/null'`;
1564
1567
  var agentLoopSuppressEnv = "CADENCE_AGENT_EVENT_SUPPRESS";
1565
1568
  var credentialRefreshSkewMs = 60 * 1000;
@@ -1599,6 +1602,7 @@ var knownCommandPaths = [
1599
1602
  ["changesets", "notes", "put"],
1600
1603
  ["changesets", "notes", "apply"],
1601
1604
  ["agent-run", "ingest-stop"],
1605
+ ["agent-run", "route"],
1602
1606
  ["agent-run", "checkpoint"],
1603
1607
  ["agent-run", "closeout"],
1604
1608
  ["agent-run", "sweep"],
@@ -2279,7 +2283,9 @@ function helpText() {
2279
2283
  " cadence changesets notes put [--changeset <id>|--branch current|<branch>] --title <text> --body-file <path> [--head-sha <sha>] [--base-sha <sha>] [--pr-url <url>] [--pr-number <n>] [--project <project-id>] [--json]",
2280
2284
  " cadence changesets notes apply [--changeset <id>|--branch current|<branch>] --provider github --pr-number <n> --pr-url <url> [--project <project-id>] [--json]",
2281
2285
  " cadence agent-run ingest-stop --source <codex|claude-code|opencode|openrouter> [--event <event>] [--threshold <n>] [--dry-run true|false] [--project <project-id>] [--json]",
2286
+ " cadence agent-run route --agent-session-key <key> --reason <missing_context|checkpoint_reroute|manual> [--event-file <path>] [--checkpoint-provider codex] [--checkpoint-model <model>] [--json]",
2282
2287
  " cadence agent-run checkpoint --agent-session-key <key> [--reason <threshold|idle|manual>] [--event-file <path>] [--ticket <ticket-id>] [--session <session-id>] [--changeset <changeset-id>] [--checkpoint-provider codex] [--checkpoint-model <model>] [--log-kind <kind>] [--update-summary true|false] [--json]",
2288
+ " cadence agent-run closeout --agent-session-key <key> [--session-action handoff|end|keep] [--complete-ticket true|false] [--event-file <path>] [--ticket <ticket-id>] [--session <session-id>] [--changeset <changeset-id>] [--checkpoint-provider codex] [--checkpoint-model <model>] [--update-summary true|false] [--json]",
2283
2289
  " cadence agent-run sweep [--idle-after-seconds <n>] [--dry-run true|false] [--json]",
2284
2290
  " cadence agent-run doctor [--json]",
2285
2291
  " cadence hooks install --provider codex [--scope global] [--command <command>] [--json]",
@@ -3085,15 +3091,6 @@ function normalizeGenericAgentEvent(input, base) {
3085
3091
  function agentSessionKey(source, agentSessionId) {
3086
3092
  return `${source}:${stableHash(agentSessionId)}`;
3087
3093
  }
3088
- function checkpointWorkLogMetadata(settings, tokenAccounting) {
3089
- return {
3090
- checkpoint: {
3091
- provider: settings.provider,
3092
- ...settings.model ? { model: settings.model } : {},
3093
- ...tokenAccounting ? { tokenAccounting } : {}
3094
- }
3095
- };
3096
- }
3097
3094
  function defaultAgentLoopState() {
3098
3095
  return {
3099
3096
  version: 2,
@@ -3248,12 +3245,59 @@ function readAgentLoopSessions(rawSessions) {
3248
3245
  ...recentTurns ? { recentTurns } : {},
3249
3246
  ...typeof record.lastCheckpointAt === "string" ? { lastCheckpointAt: record.lastCheckpointAt } : {},
3250
3247
  ...typeof record.lastCheckpointFingerprint === "string" ? { lastCheckpointFingerprint: record.lastCheckpointFingerprint } : {},
3248
+ ...readAgentRunFingerprints(record.lastCheckpointFingerprints),
3249
+ ...typeof record.lastCheckpointMode === "string" && agentRunMemoryModes.includes(record.lastCheckpointMode) ? { lastCheckpointMode: record.lastCheckpointMode } : {},
3250
+ ...readAgentRunCoverage(record.lastCheckpointCoverage),
3251
3251
  ...typeof record.previousCheckpointSummary === "string" ? { previousCheckpointSummary: record.previousCheckpointSummary } : {},
3252
+ ...typeof record.displayTitle === "string" ? { displayTitle: record.displayTitle } : {},
3253
+ ...typeof record.displayTitleUpdatedAt === "string" ? { displayTitleUpdatedAt: record.displayTitleUpdatedAt } : {},
3254
+ ...record.displayTitleSource === "route" ? { displayTitleSource: "route" } : {},
3255
+ ...typeof record.displayTitleConfidence === "string" && checkpointConfidenceLevels.includes(record.displayTitleConfidence) ? { displayTitleConfidence: record.displayTitleConfidence } : {},
3256
+ ...typeof record.displayTitleReason === "string" ? { displayTitleReason: record.displayTitleReason } : {},
3252
3257
  ...typeof record.lastCheckpointAuditFile === "string" ? { lastCheckpointAuditFile: record.lastCheckpointAuditFile } : {}
3253
3258
  };
3254
3259
  }
3255
3260
  return sessions;
3256
3261
  }
3262
+ function readAgentRunFingerprints(value) {
3263
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3264
+ return {};
3265
+ }
3266
+ const record = value;
3267
+ const event = typeof record.event === "string" ? record.event : undefined;
3268
+ const git = typeof record.git === "string" ? record.git : undefined;
3269
+ const cadenceContext = typeof record.cadenceContext === "string" ? record.cadenceContext : undefined;
3270
+ if (!event || !git || !cadenceContext) {
3271
+ return {};
3272
+ }
3273
+ return {
3274
+ lastCheckpointFingerprints: {
3275
+ event,
3276
+ git,
3277
+ cadenceContext,
3278
+ ...typeof record.verification === "string" ? { verification: record.verification } : {},
3279
+ ...typeof record.coverageDebt === "string" ? { coverageDebt: record.coverageDebt } : {}
3280
+ }
3281
+ };
3282
+ }
3283
+ function readAgentRunCoverage(value) {
3284
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3285
+ return {};
3286
+ }
3287
+ const record = value;
3288
+ const coverage = {
3289
+ outcome: record.outcome === true,
3290
+ implementation: record.implementation === true,
3291
+ decisions: record.decisions === true,
3292
+ corrections: record.corrections === true,
3293
+ verification: record.verification === true,
3294
+ blockers: record.blockers === true,
3295
+ scope: record.scope === true,
3296
+ handoff: record.handoff === true,
3297
+ hasDebt: record.hasDebt === true
3298
+ };
3299
+ return { lastCheckpointCoverage: coverage };
3300
+ }
3257
3301
  function readAgentSessionCadenceContext(record) {
3258
3302
  const value = record.cadenceContext;
3259
3303
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -3396,7 +3440,7 @@ function currentCliWorkerInvocation() {
3396
3440
  argsPrefix: []
3397
3441
  };
3398
3442
  }
3399
- async function spawnAgentRunCheckpoint(args, options) {
3443
+ async function spawnAgentRunWorker(args, options) {
3400
3444
  const cwd = options.cwd ?? process.cwd();
3401
3445
  const invocation = currentCliWorkerInvocation();
3402
3446
  const env = {
@@ -3415,6 +3459,9 @@ async function spawnAgentRunCheckpoint(args, options) {
3415
3459
  });
3416
3460
  child.unref();
3417
3461
  }
3462
+ async function spawnAgentRunCheckpoint(args, options) {
3463
+ await spawnAgentRunWorker(args, options);
3464
+ }
3418
3465
  function currentRecordTime(value, keys) {
3419
3466
  for (const key of keys) {
3420
3467
  const candidate = value[key];
@@ -3510,6 +3557,9 @@ function fingerprintForCheckpoint(event, options) {
3510
3557
  }));
3511
3558
  }
3512
3559
  function shouldSkipForCooldown(state, cooldownSeconds) {
3560
+ if (cooldownSeconds <= 0) {
3561
+ return false;
3562
+ }
3513
3563
  if (!state.lastCheckpointAt) {
3514
3564
  return false;
3515
3565
  }
@@ -3737,6 +3787,43 @@ function parseCheckpointPlanSession(value) {
3737
3787
  ...reason ? { reason } : {}
3738
3788
  };
3739
3789
  }
3790
+ function cleanAgentSessionTitleText(value) {
3791
+ const cleaned = value.replace(/\s+/g, " ").trim();
3792
+ if (!cleaned) {
3793
+ return;
3794
+ }
3795
+ const normalized = cleaned.toLowerCase();
3796
+ const genericTitles = new Set(["agent session", "session", "work session", "cadence session", "current session"]);
3797
+ if (genericTitles.has(normalized)) {
3798
+ return;
3799
+ }
3800
+ return cleaned.length > 80 ? cleaned.slice(0, 77).trimEnd() + "..." : cleaned;
3801
+ }
3802
+ function agentSessionTitleString(record, key) {
3803
+ const value = record[key];
3804
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
3805
+ }
3806
+ function parseAgentSessionTitle(value) {
3807
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3808
+ return;
3809
+ }
3810
+ const record = value;
3811
+ const text = agentSessionTitleString(record, "text") ?? agentSessionTitleString(record, "title") ?? agentSessionTitleString(record, "label");
3812
+ const cleanedText = text ? cleanAgentSessionTitleText(text) : undefined;
3813
+ if (!cleanedText) {
3814
+ return;
3815
+ }
3816
+ const confidenceValue = agentSessionTitleString(record, "confidence") ?? "medium";
3817
+ if (!checkpointConfidenceLevels.includes(confidenceValue)) {
3818
+ return;
3819
+ }
3820
+ const reason = agentSessionTitleString(record, "reason")?.replace(/\s+/g, " ").trim();
3821
+ return {
3822
+ text: cleanedText,
3823
+ confidence: confidenceValue,
3824
+ ...reason ? { reason: reason.length > 300 ? `${reason.slice(0, 297).trimEnd()}...` : reason } : {}
3825
+ };
3826
+ }
3740
3827
  function parseCheckpointPlanFiles(value) {
3741
3828
  if (!Array.isArray(value)) {
3742
3829
  return [];
@@ -3756,9 +3843,6 @@ function parseCheckpointPlanFiles(value) {
3756
3843
  };
3757
3844
  });
3758
3845
  }
3759
- function routeRequiresHighConfidence(route, session) {
3760
- return highAutonomyRouteActions.has(route.action) || session?.action === "complete_ticket";
3761
- }
3762
3846
  function forceNeedsHuman(plan, reason) {
3763
3847
  return {
3764
3848
  ...plan,
@@ -3771,9 +3855,6 @@ function forceNeedsHuman(plan, reason) {
3771
3855
  };
3772
3856
  }
3773
3857
  function normalizeCheckpointPlan(plan) {
3774
- if (routeRequiresHighConfidence(plan.route, plan.session) && plan.route.confidence !== "high") {
3775
- return forceNeedsHuman(plan, "Lifecycle mutation requires high confidence.");
3776
- }
3777
3858
  if (plan.route.action === "noop") {
3778
3859
  return {
3779
3860
  ...plan,
@@ -3782,6 +3863,51 @@ function normalizeCheckpointPlan(plan) {
3782
3863
  }
3783
3864
  return plan;
3784
3865
  }
3866
+ function checkpointPlanWithRoute(plan, route, warning) {
3867
+ return {
3868
+ ...plan,
3869
+ route,
3870
+ ...route.action === "noop" ? { entries: [] } : {},
3871
+ validationWarnings: warning ? [...plan.validationWarnings, warning] : plan.validationWarnings
3872
+ };
3873
+ }
3874
+ function safeAutomaticRoutePlan(plan, hasCurrentContext, reason) {
3875
+ return checkpointPlanWithRoute(plan, {
3876
+ action: hasCurrentContext ? "current" : "noop",
3877
+ confidence: plan.route.confidence,
3878
+ reason
3879
+ }, reason);
3880
+ }
3881
+ function routePlanAllowsLifecycle(plan) {
3882
+ return checkpointRouteRequiresIntake(plan.route.action) && plan.route.confidence === "high";
3883
+ }
3884
+ function planWithCandidateSelection(original, selected, currentTicketId, candidateTicketIds) {
3885
+ let nextPlan = selected;
3886
+ if (nextPlan.route.action === "needs_human") {
3887
+ nextPlan = safeAutomaticRoutePlan(nextPlan, Boolean(currentTicketId), nextPlan.route.reason ?? "Candidate routing was uncertain.");
3888
+ } else if (checkpointRouteRequiresIntake(nextPlan.route.action) && nextPlan.route.confidence !== "high") {
3889
+ nextPlan = safeAutomaticRoutePlan(nextPlan, Boolean(currentTicketId), "Candidate routing requires high confidence.");
3890
+ } else if ((nextPlan.route.action === "intake_attach" || nextPlan.route.action === "switch_existing") && !nextPlan.route.targetTicketId) {
3891
+ nextPlan = safeAutomaticRoutePlan(nextPlan, Boolean(currentTicketId), "Candidate routing to existing work requires a target ticket id.");
3892
+ } else if ((nextPlan.route.action === "intake_attach" || nextPlan.route.action === "switch_existing") && nextPlan.route.targetTicketId && !candidateTicketIds.has(nextPlan.route.targetTicketId)) {
3893
+ nextPlan = safeAutomaticRoutePlan(nextPlan, Boolean(currentTicketId), "Candidate routing selected a ticket outside the intake candidate list.");
3894
+ } else if (nextPlan.route.action === "current" && !currentTicketId) {
3895
+ nextPlan = checkpointPlanWithRoute(nextPlan, {
3896
+ action: "noop",
3897
+ confidence: nextPlan.route.confidence,
3898
+ reason: nextPlan.route.reason ?? "No current Cadence context exists."
3899
+ });
3900
+ }
3901
+ const summary = nextPlan.summary ?? original.summary;
3902
+ return {
3903
+ ...nextPlan,
3904
+ ...summary ? { summary } : {},
3905
+ ...nextPlan.sessionTitle ?? original.sessionTitle ? { sessionTitle: nextPlan.sessionTitle ?? original.sessionTitle } : {},
3906
+ entries: nextPlan.entries.length ? nextPlan.entries : original.entries,
3907
+ files: nextPlan.files.length ? nextPlan.files : original.files,
3908
+ validationWarnings: [...original.validationWarnings, ...nextPlan.validationWarnings]
3909
+ };
3910
+ }
3785
3911
  function parseCheckpointPlanRecord(record, rawText, fallbackKind) {
3786
3912
  if (!("route" in record)) {
3787
3913
  const legacy = parseCheckpointRecord(record, rawText, fallbackKind);
@@ -3805,12 +3931,14 @@ function parseCheckpointPlanRecord(record, rawText, fallbackKind) {
3805
3931
  const entries = Array.isArray(record.entries) ? record.entries.map(parseCheckpointPlanEntry) : [];
3806
3932
  const summary = checkpointPlanString(record, "summary");
3807
3933
  const summaryUpdate = parseCheckpointPlanSummaryUpdate(record.summaryUpdate ?? record.currentSummary);
3934
+ const sessionTitle = parseAgentSessionTitle(record.sessionTitle ?? record.displayTitle);
3808
3935
  const session = parseCheckpointPlanSession(record.session);
3809
3936
  return normalizeCheckpointPlan({
3810
3937
  ...summary ? { summary } : {},
3811
3938
  route,
3812
3939
  entries,
3813
3940
  ...summaryUpdate ? { summaryUpdate } : {},
3941
+ ...sessionTitle ? { sessionTitle } : {},
3814
3942
  ...session ? { session } : {},
3815
3943
  files: parseCheckpointPlanFiles(record.files),
3816
3944
  legacy: false,
@@ -3862,6 +3990,62 @@ function parseCheckpointPlanJson(raw, fallbackKind) {
3862
3990
  function checkpointRouteRequiresIntake(action) {
3863
3991
  return action === "intake_create" || action === "intake_attach" || action === "switch_existing";
3864
3992
  }
3993
+ function intakeCandidates(intake) {
3994
+ if (!intake || typeof intake !== "object" || Array.isArray(intake)) {
3995
+ return [];
3996
+ }
3997
+ const candidates = intake.candidates;
3998
+ return Array.isArray(candidates) ? candidates.filter((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate)) : [];
3999
+ }
4000
+ function intakeCandidateTicketId(candidate) {
4001
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
4002
+ return;
4003
+ }
4004
+ const record = candidate;
4005
+ const nestedTicket = record.ticket && typeof record.ticket === "object" && !Array.isArray(record.ticket) ? record.ticket : null;
4006
+ const value = record.ticketId ?? nestedTicket?.id ?? record.id;
4007
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
4008
+ }
4009
+ function intakeCandidateTicketIds(intake) {
4010
+ return new Set(intakeCandidates(intake).map(intakeCandidateTicketId).filter((id) => Boolean(id)));
4011
+ }
4012
+ function compactIntakeCandidate(candidate, index) {
4013
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
4014
+ return { index };
4015
+ }
4016
+ const record = candidate;
4017
+ const ticket = record.ticket && typeof record.ticket === "object" && !Array.isArray(record.ticket) ? record.ticket : {};
4018
+ const ticketId = intakeCandidateTicketId(candidate);
4019
+ const title = record.title ?? ticket.title;
4020
+ const status = record.status ?? ticket.status;
4021
+ const summary = record.summary ?? ticket.currentSummary ?? ticket.summary;
4022
+ return {
4023
+ index,
4024
+ ...ticketId ? { ticketId } : {},
4025
+ ...typeof title === "string" ? { title: truncateText(title, 200) } : {},
4026
+ ...typeof status === "string" ? { status } : {},
4027
+ ...typeof summary === "string" ? { summary: truncateText(summary, 600) } : {},
4028
+ ...typeof record.classification === "string" ? { classification: record.classification } : {},
4029
+ ...typeof record.reason === "string" ? { reason: truncateText(record.reason, 300) } : {},
4030
+ ...typeof record.score === "number" ? { score: record.score } : {},
4031
+ ...typeof record.confidence === "string" ? { confidence: record.confidence } : {}
4032
+ };
4033
+ }
4034
+ function compactIntakeResultForRouteSelection(intake) {
4035
+ if (!intake || typeof intake !== "object" || Array.isArray(intake)) {
4036
+ return null;
4037
+ }
4038
+ const record = intake;
4039
+ return {
4040
+ ...typeof record.id === "string" ? { id: record.id } : {},
4041
+ ...typeof record.status === "string" ? { status: record.status } : {},
4042
+ ...typeof record.classification === "string" ? { classification: record.classification } : {},
4043
+ ...typeof record.recommendedAction === "string" ? { recommendedAction: record.recommendedAction } : {},
4044
+ ...typeof record.attachedTicketId === "string" ? { attachedTicketId: record.attachedTicketId } : {},
4045
+ ...typeof record.convertedTicketId === "string" ? { convertedTicketId: record.convertedTicketId } : {},
4046
+ candidates: intakeCandidates(intake).map(compactIntakeCandidate)
4047
+ };
4048
+ }
3865
4049
  function checkpointPlanCompletionAllowed(event, summary) {
3866
4050
  const text = [
3867
4051
  summary,
@@ -3977,23 +4161,39 @@ function buildCheckpointTokenAccounting(event, prompt, tokenUsage) {
3977
4161
  function buildCheckpointPrompt(input) {
3978
4162
  const recentTurns = checkpointRecentTurns(input.event);
3979
4163
  const recentTurnsText = recentTurns.length ? formatCheckpointRecentTurns(recentTurns) : "";
4164
+ const modeInstructions = input.mode === "closeout" ? [
4165
+ "Mode: closeout. Produce final session memory, not an interim pulse.",
4166
+ "Closeout should cover the completed work segment: outcome, reviewable implementation actions, decisions/corrections, verification, blockers/follow-ups, scope or attribution notes, and handoff when needed.",
4167
+ "Write the minimum entries needed for coverage. Target 2-6 entries; avoid more than 8 by merging related facts or writing one concise session catch-up entry.",
4168
+ "Use session.action handoff, end, or keep when appropriate. Use complete_ticket only when explicit completion evidence is present."
4169
+ ] : [
4170
+ "Mode: checkpoint. Produce sparse incremental memory for an active session, not a full-session closeout.",
4171
+ "Checkpoint should commonly return route.action noop with entries [] when there is nothing durable to record.",
4172
+ "Target 0-2 entries. Only exceed that for a blocker or failed verification. Do not repeat unchanged intent, repeated dirty workspace warnings, or routine process actions."
4173
+ ];
3980
4174
  return [
3981
- "You are generating a compact Cadence dogfood operation plan for a hook-spawned checkpoint worker.",
4175
+ `You are generating a compact Cadence dogfood operation plan for an agent-run ${input.mode} worker.`,
3982
4176
  "The model judges; the Cadence CLI validates and executes. Return JSON only. Do not call tools to mutate Cadence yourself.",
3983
4177
  "Preserve durable ticket purpose. Identify user intent, changed intent, corrections, decisions, rationale, implementation actions, verification, blockers, and useful notes.",
3984
4178
  "Do not include raw diffs, raw transcripts, terminal logs, tool streams, secrets, file contents, or model reasoning in server-bound fields.",
3985
- 'Return this sparse JSON shape: {"summary":"short checkpoint summary","route":{"action":"current|noop|intake_create|intake_attach|switch_existing|needs_human","confidence":"low|medium|high","reason":"short reason","request":"natural language work request when intake is needed","targetTicketId":"optional existing ticket id"},"entries":[{"kind":"intent|decision|rationale|action|verification|blocker|correction|note","summary":"short entry summary","body":"safe Cadence work-log body","under":"optional: last, ticket-last, session-last, last-decision, last-correction, last-action, or entry UUID"}],"summaryUpdate":{"update":false,"value":null,"reason":"short reason"},"session":{"action":"keep|handoff|end|complete_ticket","summary":"optional handoff or completion summary","reason":"short reason"},"files":[{"path":"relative/path.ts","kind":"added|modified|deleted|renamed|unknown"}]}',
4179
+ 'Return this sparse JSON shape: {"summary":"short checkpoint summary","route":{"action":"current|noop|intake_create|intake_attach|switch_existing","confidence":"low|medium|high","reason":"short reason","request":"natural language work request when intake is needed","targetTicketId":"optional existing ticket id"},"entries":[{"kind":"intent|decision|rationale|action|verification|blocker|correction|note","summary":"short entry summary","body":"safe Cadence work-log body","under":"optional: last, ticket-last, session-last, last-decision, last-correction, last-action, or entry UUID"}],"summaryUpdate":{"update":false,"value":null,"reason":"short reason"},"session":{"action":"keep|handoff|end|complete_ticket","summary":"optional handoff or completion summary","reason":"short reason"},"files":[{"path":"relative/path.ts","kind":"added|modified|deleted|renamed|unknown"}]}',
3986
4180
  "Keep output sparse: omit summaryUpdate, session, and files unless needed. Use route.action noop with entries [] for filler, acknowledgements, or other turns with nothing durable to record.",
3987
- "Use route.action current for the same ticket. Use intake_create for clearly unrelated new work. Use intake_attach or switch_existing only when a specific existing ticket is clearly the right target. Use needs_human when routing is ambiguous.",
4181
+ "Use route.action current for the same ticket. Use noop when routing is ambiguous or there is nothing durable to record. Use intake_create, intake_attach, or switch_existing only when recent work clearly belongs somewhere else; the CLI will delegate those actions to agent-run route.",
3988
4182
  "Set route.confidence high only when the recent user/assistant context makes the route and lifecycle action clear. Lifecycle mutations require high confidence.",
3989
4183
  "Use note only as a last-resort context kind. Prefer intent, decision, rationale, action, verification, correction, or blocker when those fit.",
3990
4184
  "Each entry body should be concise narrative, not a transcript. Keep each entry under 800 characters and avoid duplicating the same fact across entries.",
3991
4185
  "Set summaryUpdate.update true only when the durable current work summary is missing or misleading; otherwise omit summaryUpdate or leave update false.",
3992
4186
  "Use session.action complete_ticket only when the context indicates completion, merge, push/PR finalization, or explicit user completion intent. Use handoff/end only when the current session should close.",
4187
+ ...modeInstructions,
3993
4188
  "",
3994
4189
  `Ticket: ${input.ticketId}`,
3995
4190
  input.sessionId ? `Session: ${input.sessionId}` : "",
3996
4191
  input.changesetId ? `ChangeSet: ${input.changesetId}` : "",
4192
+ input.currentWorkSummary ? `Current Work Summary:
4193
+ ${truncateText(input.currentWorkSummary, 1200)}` : "",
4194
+ input.recentWorkLog ? `Recent high-signal Work Log entries:
4195
+ ${truncateText(input.recentWorkLog, 3000)}` : "",
4196
+ input.coverage ? `Prior coverage: ${JSON.stringify(input.coverage)}` : "",
3997
4197
  `Agent event: ${input.event.source}/${input.event.event}`,
3998
4198
  input.previousCheckpointSummary ? `Previous checkpoint summary: ${input.previousCheckpointSummary}` : "",
3999
4199
  recentTurnsText ? `Recent user/assistant turns (most recent 3, local checkpoint context only):
@@ -4008,6 +4208,327 @@ ${truncateText(input.changedFiles, 2000)}` : "Changed files: unavailable"
4008
4208
  ].filter(Boolean).join(`
4009
4209
  `);
4010
4210
  }
4211
+ function buildRoutePrompt(input) {
4212
+ const recentTurns = checkpointRecentTurns(input.event);
4213
+ const recentTurnsText = recentTurns.length ? formatCheckpointRecentTurns(recentTurns) : "";
4214
+ const contextText = input.savedContext?.ticketId ? `Saved Cadence context: ticket ${input.savedContext.ticketId}${input.savedContext.sessionId ? `, session ${input.savedContext.sessionId}` : ""}${input.savedContext.changesetId ? `, ChangeSet ${input.savedContext.changesetId}` : ""}` : input.currentContext?.ticketId ? `No saved agent-session context. Current active Cadence context: ticket ${input.currentContext.ticketId}${input.currentContext.sessionId ? `, session ${input.currentContext.sessionId}` : ""}${input.currentContext.changesetId ? `, ChangeSet ${input.currentContext.changesetId}` : ""}` : "No saved agent-session context and no active Cadence context was found.";
4215
+ return [
4216
+ "You are generating a compact Cadence dogfood routing plan for an agent-run worker.",
4217
+ "The model judges; the Cadence CLI validates and executes. Return JSON only. Do not call tools to mutate Cadence yourself.",
4218
+ "Decide where this agent session belongs before checkpoint memory is written.",
4219
+ "Do not include raw diffs, raw transcripts, terminal logs, tool streams, secrets, file contents, or model reasoning in server-bound fields.",
4220
+ 'Return this sparse JSON shape: {"summary":"short routing summary","sessionTitle":{"text":"short GUI session title","confidence":"low|medium|high","reason":"why this title names the routed focus"},"route":{"action":"current|noop|intake_create|intake_attach|switch_existing","confidence":"low|medium|high","reason":"short reason","request":"natural language intake request when intake is needed","targetTicketId":"optional existing ticket id"},"entries":[{"kind":"intent|decision|rationale|action|verification|blocker|correction|note","summary":"short entry summary","body":"safe Cadence work-log body"}],"files":[{"path":"relative/path.ts","kind":"added|modified|deleted|renamed|unknown"}]}',
4221
+ "Generate sessionTitle only when the routed focus is durable enough to name a GUI row. Omit it for noop/filler. Keep it under 80 characters and do not reuse the latest checkpoint summary as the title.",
4222
+ "Use noop for filler, acknowledgements, setup chatter, or anything not durable enough for Cadence.",
4223
+ "Use current only when an existing Cadence context clearly fits the recent work.",
4224
+ "Use intake_create for a clearly durable new task without a good existing ticket.",
4225
+ "Use intake_attach or switch_existing only when a specific targetTicketId is clearly the correct ticket.",
4226
+ "Set confidence high only when the route is clear. Low or medium confidence lifecycle actions will be ignored by the CLI.",
4227
+ "Use note only as a last-resort context kind. Prefer intent for new routed work.",
4228
+ "Each entry body should be concise narrative, not a transcript. Keep each entry under 800 characters.",
4229
+ "",
4230
+ `Route reason: ${input.reason ?? "manual"}`,
4231
+ contextText,
4232
+ `Agent event: ${input.event.source}/${input.event.event}`,
4233
+ input.previousCheckpointSummary ? `Previous checkpoint summary: ${input.previousCheckpointSummary}` : "",
4234
+ recentTurnsText ? `Recent user/assistant turns (most recent 3, local routing context only):
4235
+ ${recentTurnsText}` : input.event.lastAssistantMessage ? `Last assistant message:
4236
+ ${truncateText(input.event.lastAssistantMessage, 3000)}` : "Recent turns: unavailable",
4237
+ input.gitStatus ? `Git status --short:
4238
+ ${truncateText(input.gitStatus, 2000)}` : "Git status --short: clean or unavailable",
4239
+ input.gitDiffStat ? `Git diff --stat origin/dev...:
4240
+ ${truncateText(input.gitDiffStat, 2000)}` : "Git diff stat: unavailable",
4241
+ input.changedFiles ? `Changed files:
4242
+ ${truncateText(input.changedFiles, 2000)}` : "Changed files: unavailable"
4243
+ ].filter(Boolean).join(`
4244
+ `);
4245
+ }
4246
+ function buildRouteCandidatePrompt(input) {
4247
+ const recentTurns = checkpointRecentTurns(input.event);
4248
+ const recentTurnsText = recentTurns.length ? formatCheckpointRecentTurns(recentTurns) : "";
4249
+ const intake = compactIntakeResultForRouteSelection(input.intakeResult);
4250
+ const contextText = input.savedContext?.ticketId ? `Saved Cadence context: ticket ${input.savedContext.ticketId}${input.savedContext.sessionId ? `, session ${input.savedContext.sessionId}` : ""}${input.savedContext.changesetId ? `, ChangeSet ${input.savedContext.changesetId}` : ""}` : input.currentContext?.ticketId ? `No saved agent-session context. Current active Cadence context: ticket ${input.currentContext.ticketId}${input.currentContext.sessionId ? `, session ${input.currentContext.sessionId}` : ""}${input.currentContext.changesetId ? `, ChangeSet ${input.currentContext.changesetId}` : ""}` : "No saved agent-session context and no active Cadence context was found.";
4251
+ return [
4252
+ "You are resolving Cadence agent-run routing after intake returned possible existing work.",
4253
+ "Intake is retrieval, not the final decision. The model judges; the Cadence CLI validates and executes. Return JSON only.",
4254
+ "Choose whether this agent session should create a new ticket, attach/switch to one concrete candidate ticket, keep the current context, or no-op.",
4255
+ "Use intake_create when the recent request is durable and the candidates are weak, adjacent, completed, or not concrete matches.",
4256
+ "Use intake_attach or switch_existing only when one candidate ticket is clearly the same work. You must include targetTicketId from the candidate list.",
4257
+ "Use current only when the saved/current Cadence context clearly fits the recent work.",
4258
+ "Use noop only for filler or if the work is not durable enough for Cadence.",
4259
+ "Do not include raw transcripts, raw diffs, terminal logs, tool streams, secrets, file contents, or model reasoning in server-bound fields.",
4260
+ 'Return this sparse JSON shape: {"summary":"short routing summary","sessionTitle":{"text":"short GUI session title","confidence":"low|medium|high","reason":"why this title names the selected routed focus"},"route":{"action":"current|noop|intake_create|intake_attach|switch_existing","confidence":"low|medium|high","reason":"short reason","request":"natural language intake request when creating","targetTicketId":"required for attach/switch"},"entries":[{"kind":"intent|decision|rationale|action|verification|blocker|correction|note","summary":"short entry summary","body":"safe Cadence work-log body"}],"files":[{"path":"relative/path.ts","kind":"added|modified|deleted|renamed|unknown"}]}',
4261
+ "The final candidate plan owns sessionTitle. Generate it only for the selected durable focus, keep it under 80 characters, and do not reuse the latest checkpoint summary as the title.",
4262
+ "Prefer preserving the initial plan's entries unless they are wrong for the selected ticket.",
4263
+ "",
4264
+ contextText,
4265
+ `Initial route plan:
4266
+ ${truncateText(JSON.stringify(input.initialPlan), 4000)}`,
4267
+ `Intake result and candidates:
4268
+ ${truncateText(JSON.stringify(intake), 6000)}`,
4269
+ recentTurnsText ? `Recent user/assistant turns (most recent 3, local routing context only):
4270
+ ${recentTurnsText}` : input.event.lastAssistantMessage ? `Last assistant message:
4271
+ ${truncateText(input.event.lastAssistantMessage, 3000)}` : "Recent turns: unavailable"
4272
+ ].filter(Boolean).join(`
4273
+ `);
4274
+ }
4275
+ function agentRunFingerprint(value) {
4276
+ return stableHash(JSON.stringify(value));
4277
+ }
4278
+ function buildAgentRunFingerprints(input) {
4279
+ return {
4280
+ event: agentRunFingerprint({
4281
+ source: input.event.source,
4282
+ event: input.event.event,
4283
+ agentSessionKey: input.event.agentSessionKey,
4284
+ agentSessionId: input.event.agentSessionId,
4285
+ threadId: input.event.threadId,
4286
+ turnId: input.event.turnId,
4287
+ lastAssistantMessage: input.event.lastAssistantMessage,
4288
+ recentTurns: input.event.recentTurns,
4289
+ payloadKeys: input.event.payloadKeys
4290
+ }),
4291
+ git: agentRunFingerprint({
4292
+ status: input.gitStatus,
4293
+ diffStat: input.gitDiffStat,
4294
+ changedFiles: input.changedFiles
4295
+ }),
4296
+ cadenceContext: agentRunFingerprint({
4297
+ ticketId: input.ticketId,
4298
+ sessionId: input.sessionId,
4299
+ changesetId: input.changesetId
4300
+ }),
4301
+ ...input.coverage?.hasDebt ? { coverageDebt: agentRunFingerprint(input.coverage) } : {}
4302
+ };
4303
+ }
4304
+ function fingerprintsEqual(left, right) {
4305
+ return Boolean(left && left.event === right.event && left.git === right.git && left.cadenceContext === right.cadenceContext && left.verification === right.verification && left.coverageDebt === right.coverageDebt);
4306
+ }
4307
+ function checkpointReasonBypassesFingerprintGate(reason) {
4308
+ return reason === "manual" || reason === "idle" || reason === "ticket_switch" || reason === "closeout";
4309
+ }
4310
+ function entryText(entry) {
4311
+ return `${entry.summary ?? ""}
4312
+ ${entry.body}`.toLowerCase();
4313
+ }
4314
+ function entryReductionSummary(entry) {
4315
+ return truncateText(entry.summary ?? checkpointEntrySummary(entry.body), 120);
4316
+ }
4317
+ function isRepeatedScopeNote(entry) {
4318
+ const text = entryText(entry);
4319
+ return entry.kind === "note" && (text.includes("dirty workspace") || text.includes("dirty files") || text.includes("unattributed"));
4320
+ }
4321
+ function isRepeatedNoVerificationNote(entry) {
4322
+ const text = entryText(entry);
4323
+ return entry.kind === "verification" && text.includes("no fresh") && (text.includes("test") || text.includes("verification"));
4324
+ }
4325
+ function isUnchangedIntent(entry) {
4326
+ const text = entryText(entry);
4327
+ return entry.kind === "intent" && (text.includes("remain") || text.includes("still")) && text.includes("active");
4328
+ }
4329
+ function isProcessOnlyAction(entry) {
4330
+ if (entry.kind !== "action") {
4331
+ return false;
4332
+ }
4333
+ const text = entryText(entry);
4334
+ const processSignals = ["read ", "inspected", "looked at", "ran git status", "gathered context", "paused", "discussed", "answered"];
4335
+ const reviewableSignals = [
4336
+ "added",
4337
+ "implemented",
4338
+ "updated",
4339
+ "changed",
4340
+ "removed",
4341
+ "refactored",
4342
+ "fixed",
4343
+ "moved",
4344
+ "persisted",
4345
+ "configured",
4346
+ "wrote",
4347
+ "split"
4348
+ ];
4349
+ return processSignals.some((signal) => text.includes(signal)) && !reviewableSignals.some((signal) => text.includes(signal));
4350
+ }
4351
+ function entryDedupeKey(entry) {
4352
+ if (isRepeatedScopeNote(entry)) {
4353
+ return "scope-note";
4354
+ }
4355
+ if (isRepeatedNoVerificationNote(entry)) {
4356
+ return "no-verification";
4357
+ }
4358
+ if (isUnchangedIntent(entry)) {
4359
+ return "unchanged-intent";
4360
+ }
4361
+ return `${entry.kind}:${entry.summary ?? checkpointEntrySummary(entry.body)}`.toLowerCase();
4362
+ }
4363
+ function mergeDecisionRationaleEntries(entries) {
4364
+ const merged = [];
4365
+ const records = [];
4366
+ for (let index = 0;index < entries.length; index += 1) {
4367
+ const entry = entries[index];
4368
+ const next = entries[index + 1];
4369
+ if (entry.kind === "decision" && next?.kind === "rationale") {
4370
+ merged.push({
4371
+ ...entry,
4372
+ body: truncateText(`${entry.body}
4373
+
4374
+ Rationale: ${next.body.replace(/^Rationale:\s*/i, "")}`, checkpointServerTextLimit)
4375
+ });
4376
+ records.push({
4377
+ summary: entryReductionSummary(next),
4378
+ reason: "merged_adjacent_decision_rationale"
4379
+ });
4380
+ index += 1;
4381
+ continue;
4382
+ }
4383
+ merged.push(entry);
4384
+ }
4385
+ return { entries: merged, records };
4386
+ }
4387
+ function checkpointAllowsExtraEntries(entries) {
4388
+ return entries.some((entry) => entry.kind === "blocker" || entry.kind === "verification" && /\b(fail|failed|failing|error|blocked)\b/i.test(entry.body));
4389
+ }
4390
+ function reduceCheckpointEntries(entries, mode) {
4391
+ const dropped = [];
4392
+ const seen = new Set;
4393
+ const filtered = [];
4394
+ for (const entry of entries) {
4395
+ if (isProcessOnlyAction(entry)) {
4396
+ dropped.push({ summary: entryReductionSummary(entry), reason: "process_only_action" });
4397
+ continue;
4398
+ }
4399
+ const dedupeKey = entryDedupeKey(entry);
4400
+ if (seen.has(dedupeKey)) {
4401
+ dropped.push({ summary: entryReductionSummary(entry), reason: "duplicate_entry" });
4402
+ continue;
4403
+ }
4404
+ seen.add(dedupeKey);
4405
+ filtered.push(entry);
4406
+ }
4407
+ const merged = mergeDecisionRationaleEntries(filtered);
4408
+ const maxEntries = mode === "closeout" ? 8 : checkpointAllowsExtraEntries(merged.entries) ? 4 : 3;
4409
+ const capped = merged.entries.length > maxEntries;
4410
+ const kept = capped ? merged.entries.slice(0, maxEntries) : merged.entries;
4411
+ for (const entry of merged.entries.slice(maxEntries)) {
4412
+ dropped.push({ summary: entryReductionSummary(entry), reason: "entry_budget" });
4413
+ }
4414
+ return {
4415
+ entries: kept,
4416
+ reduction: {
4417
+ mode,
4418
+ originalCount: entries.length,
4419
+ keptCount: kept.length,
4420
+ dropped,
4421
+ merged: merged.records,
4422
+ capped
4423
+ }
4424
+ };
4425
+ }
4426
+ function buildAgentRunCoverage(plan, mode) {
4427
+ const entries = plan.entries;
4428
+ const bodyText = [plan.summary ?? "", plan.session?.summary ?? "", ...entries.map((entry) => `${entry.summary ?? ""} ${entry.body}`)].join(`
4429
+ `).toLowerCase();
4430
+ const outcome = Boolean(plan.summary || plan.session?.summary);
4431
+ const implementation = entries.some((entry) => entry.kind === "action");
4432
+ const decisions = entries.some((entry) => entry.kind === "decision" || entry.kind === "rationale");
4433
+ const corrections = entries.some((entry) => entry.kind === "correction");
4434
+ const verification = entries.some((entry) => entry.kind === "verification");
4435
+ const blockers = entries.some((entry) => entry.kind === "blocker");
4436
+ const scope = /\b(scope|attribut|dirty workspace|dirty files|unassigned|unattributed)\b/.test(bodyText);
4437
+ const handoff = mode === "closeout" && Boolean(plan.session && plan.session.action !== "keep");
4438
+ const hasDebt = mode === "closeout" && (!outcome || !implementation || !verification || !handoff);
4439
+ return {
4440
+ outcome,
4441
+ implementation,
4442
+ decisions,
4443
+ corrections,
4444
+ verification,
4445
+ blockers,
4446
+ scope,
4447
+ handoff,
4448
+ hasDebt
4449
+ };
4450
+ }
4451
+ function applyCloseoutSessionDefaults(plan, sessionAction, completeTicket) {
4452
+ const requestedAction = completeTicket ? "complete_ticket" : sessionAction;
4453
+ const session = plan.session && (completeTicket || plan.session.action !== "complete_ticket") ? plan.session : {
4454
+ action: requestedAction,
4455
+ ...plan.summary ? { summary: plan.summary } : {},
4456
+ reason: "Closeout defaults to ending or handing off the active session."
4457
+ };
4458
+ if (!completeTicket && session.action === "complete_ticket") {
4459
+ return {
4460
+ ...plan,
4461
+ session: {
4462
+ ...session,
4463
+ action: sessionAction,
4464
+ reason: "Ticket completion was not requested for this closeout."
4465
+ }
4466
+ };
4467
+ }
4468
+ return {
4469
+ ...plan,
4470
+ session
4471
+ };
4472
+ }
4473
+ function parseCloseoutSessionAction(value) {
4474
+ if (!value) {
4475
+ return "handoff";
4476
+ }
4477
+ if (!closeoutSessionActions.includes(value)) {
4478
+ throw new CliError("CLI_USAGE", "--session-action must be one of handoff, end, or keep.");
4479
+ }
4480
+ return value;
4481
+ }
4482
+ function checkpointModeForCommand(parsed) {
4483
+ return parsed.command.name === "agent-run.closeout" ? "closeout" : "checkpoint";
4484
+ }
4485
+ function formatCloseoutWorkLogEvents(events) {
4486
+ if (!Array.isArray(events)) {
4487
+ return;
4488
+ }
4489
+ const entries = events.filter((event) => event && typeof event === "object").map((event) => {
4490
+ const record = event;
4491
+ const payload = record.payload && typeof record.payload === "object" && !Array.isArray(record.payload) ? record.payload : {};
4492
+ const type = typeof record.type === "string" ? record.type : undefined;
4493
+ const kind = typeof payload.entryKind === "string" ? payload.entryKind : undefined;
4494
+ const summary = typeof payload.summary === "string" ? payload.summary : undefined;
4495
+ const body = typeof payload.body === "string" ? payload.body : undefined;
4496
+ if (type !== "ticket.work_log_appended" || !summary && !body) {
4497
+ return;
4498
+ }
4499
+ return `- ${kind ?? "note"}: ${truncateText(summary ?? body ?? "", 180)}${body && summary ? ` \u2014 ${truncateText(body, 300)}` : ""}`;
4500
+ }).filter((entry) => Boolean(entry)).slice(-12);
4501
+ return entries.length ? entries.join(`
4502
+ `) : undefined;
4503
+ }
4504
+ async function readCloseoutPromptContext(input) {
4505
+ let currentWorkSummary;
4506
+ let recentWorkLog;
4507
+ try {
4508
+ const ticket = await input.client.tickets.get({
4509
+ projectId: input.projectId,
4510
+ ticketId: input.ticketId
4511
+ });
4512
+ if (ticket && typeof ticket === "object" && "currentSummary" in ticket && typeof ticket.currentSummary === "string") {
4513
+ currentWorkSummary = ticket.currentSummary;
4514
+ }
4515
+ } catch {}
4516
+ try {
4517
+ const events = await input.client.events.list({
4518
+ projectId: input.projectId,
4519
+ filters: {
4520
+ ticketId: input.ticketId,
4521
+ ...input.sessionId ? { sessionId: input.sessionId } : {},
4522
+ limit: 50
4523
+ }
4524
+ });
4525
+ recentWorkLog = formatCloseoutWorkLogEvents(events);
4526
+ } catch {}
4527
+ return {
4528
+ ...currentWorkSummary ? { currentWorkSummary } : {},
4529
+ ...recentWorkLog ? { recentWorkLog } : {}
4530
+ };
4531
+ }
4011
4532
  async function findRepoRoot(cwd) {
4012
4533
  const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
4013
4534
  cwd,
@@ -4246,6 +4767,8 @@ async function runAgentRunCommand(parsed, options) {
4246
4767
  switch (parsed.command.name) {
4247
4768
  case "agent-run.ingest-stop":
4248
4769
  return await runAgentRunIngestStop(parsed, options, config, meta);
4770
+ case "agent-run.route":
4771
+ return await runAgentRunRoute(parsed, options, config, meta);
4249
4772
  case "agent-run.checkpoint":
4250
4773
  case "agent-run.closeout":
4251
4774
  return await runAgentRunCheckpoint(parsed, options, config, meta);
@@ -4335,11 +4858,14 @@ async function runAgentRunIngestStop(parsed, options, config, meta) {
4335
4858
  const duplicateTurn = Boolean(normalized.turnId && existingSession?.lastObservedTurnId === normalized.turnId);
4336
4859
  const nextCount = (existingSession?.stopCount ?? 0) + (duplicateTurn ? 0 : 1);
4337
4860
  const recentTurns = mergeRecentAgentTurns(existingSession?.recentTurns, normalized.recentTurns);
4338
- let cadenceContext = existingSession?.cadenceContext;
4339
- try {
4340
- const client = await createClient(config, options);
4341
- cadenceContext = await readCurrentCadenceContext(client, projectId) ?? cadenceContext;
4342
- } catch {}
4861
+ const savedCadenceContext = existingSession?.cadenceContext;
4862
+ let cadenceContext = savedCadenceContext;
4863
+ if (savedCadenceContext?.ticketId) {
4864
+ try {
4865
+ const client = await createClient(config, options);
4866
+ cadenceContext = await readCurrentCadenceContext(client, projectId) ?? cadenceContext;
4867
+ } catch {}
4868
+ }
4343
4869
  const observedSession = {
4344
4870
  ...clearAgentSessionReason(existingSession ?? {
4345
4871
  source: normalized.source,
@@ -4364,6 +4890,124 @@ async function runAgentRunIngestStop(parsed, options, config, meta) {
4364
4890
  [normalized.agentSessionKey]: observedSession
4365
4891
  }
4366
4892
  };
4893
+ if (duplicateTurn) {
4894
+ await writeAgentLoopState(parsed, options, countedState);
4895
+ const data2 = {
4896
+ action: "updated",
4897
+ reason: "duplicate_turn",
4898
+ agentSessionKey: normalized.agentSessionKey,
4899
+ stopCount: nextCount,
4900
+ threshold
4901
+ };
4902
+ return {
4903
+ stdout: parsed.flags.json ? formatJson(successEnvelope(data2, meta)) : `${JSON.stringify(data2, null, 2)}
4904
+ `,
4905
+ stderr: "",
4906
+ exitCode: 0
4907
+ };
4908
+ }
4909
+ if (!savedCadenceContext?.ticketId) {
4910
+ const eventFile2 = await writeAgentEventFile(parsed, options, normalized);
4911
+ const lockPath2 = agentLoopLockPath(parsed, options, normalized.agentSessionKey);
4912
+ const workerArgs2 = [
4913
+ "agent-run",
4914
+ "route",
4915
+ "--agent-session-key",
4916
+ normalized.agentSessionKey,
4917
+ "--reason",
4918
+ "missing_context",
4919
+ "--event-file",
4920
+ eventFile2,
4921
+ "--lock",
4922
+ lockPath2,
4923
+ ...parsed.flags.project ? ["--project", parsed.flags.project] : [],
4924
+ ...parsed.flags.server ? ["--server", parsed.flags.server] : []
4925
+ ];
4926
+ if (dryRun) {
4927
+ await writeAgentLoopState(parsed, options, {
4928
+ ...state,
4929
+ sessions: {
4930
+ ...state.sessions,
4931
+ [normalized.agentSessionKey]: {
4932
+ ...observedSession,
4933
+ lastAction: "would_route",
4934
+ lastEventFile: eventFile2
4935
+ }
4936
+ }
4937
+ });
4938
+ const data3 = {
4939
+ action: "would_route",
4940
+ agentSessionKey: normalized.agentSessionKey,
4941
+ eventFile: eventFile2,
4942
+ workerArgs: workerArgs2
4943
+ };
4944
+ return {
4945
+ stdout: parsed.flags.json ? formatJson(successEnvelope(data3, meta)) : `${JSON.stringify(data3, null, 2)}
4946
+ `,
4947
+ stderr: "",
4948
+ exitCode: 0
4949
+ };
4950
+ }
4951
+ const lock2 = await acquireAgentLoopLock(parsed, options, normalized.agentSessionKey);
4952
+ if (!lock2.acquired) {
4953
+ await writeAgentLoopState(parsed, options, {
4954
+ ...state,
4955
+ sessions: {
4956
+ ...state.sessions,
4957
+ [normalized.agentSessionKey]: {
4958
+ ...observedSession,
4959
+ lastAction: "skipped",
4960
+ lastReason: "lock_held"
4961
+ }
4962
+ }
4963
+ });
4964
+ const data3 = {
4965
+ action: "skipped",
4966
+ reason: "lock_held",
4967
+ lockPath: lock2.lockPath,
4968
+ agentSessionKey: normalized.agentSessionKey
4969
+ };
4970
+ return {
4971
+ stdout: parsed.flags.json ? formatJson(successEnvelope(data3, meta)) : `${JSON.stringify(data3, null, 2)}
4972
+ `,
4973
+ stderr: "",
4974
+ exitCode: 0
4975
+ };
4976
+ }
4977
+ try {
4978
+ await spawnAgentRunWorker(workerArgs2, options);
4979
+ } catch (error) {
4980
+ await releaseAgentLoopLock(lock2.lockPath);
4981
+ throw error;
4982
+ }
4983
+ await writeAgentLoopState(parsed, options, {
4984
+ ...state,
4985
+ sessions: {
4986
+ ...state.sessions,
4987
+ [normalized.agentSessionKey]: {
4988
+ ...observedSession,
4989
+ stopCount: 0,
4990
+ threshold: defaultCheckpointThresholdValue(),
4991
+ lastAction: "route_spawned",
4992
+ lastEventFile: eventFile2,
4993
+ lastCheckpointAt: new Date().toISOString(),
4994
+ lastCheckpointFingerprint: fingerprintForCheckpoint(normalized, options)
4995
+ }
4996
+ }
4997
+ });
4998
+ const data2 = {
4999
+ action: "route_spawned",
5000
+ agentSessionKey: normalized.agentSessionKey,
5001
+ eventFile: eventFile2,
5002
+ lockPath: lock2.lockPath
5003
+ };
5004
+ return {
5005
+ stdout: parsed.flags.json ? formatJson(successEnvelope(data2, meta)) : `${JSON.stringify(data2, null, 2)}
5006
+ `,
5007
+ stderr: "",
5008
+ exitCode: 0
5009
+ };
5010
+ }
4367
5011
  if (nextCount < threshold) {
4368
5012
  await writeAgentLoopState(parsed, options, countedState);
4369
5013
  const data2 = {
@@ -4537,9 +5181,450 @@ async function runAgentRunIngestStop(parsed, options, config, meta) {
4537
5181
  exitCode: 0
4538
5182
  };
4539
5183
  }
4540
- async function runAgentRunCheckpoint(parsed, options, config, meta) {
4541
- const projectId = requireProjectId(config);
4542
- const client = await createClient(config, options);
5184
+ function objectStringId(value) {
5185
+ return value && typeof value === "object" && "id" in value && typeof value.id === "string" ? value.id : undefined;
5186
+ }
5187
+ function objectStringTitle(value) {
5188
+ return value && typeof value === "object" && "title" in value && typeof value.title === "string" ? cleanAgentSessionTitleText(value.title) : undefined;
5189
+ }
5190
+ function routeActionAllowsDisplayTitle(action, plan) {
5191
+ return action === "routed" || action === "switched" || action === "current" && plan.route.confidence === "high";
5192
+ }
5193
+ function routeDisplayTitle(action, plan, selectedTicket) {
5194
+ if (!routeActionAllowsDisplayTitle(action, plan)) {
5195
+ return;
5196
+ }
5197
+ const fallbackTitle = objectStringTitle(selectedTicket);
5198
+ const text = plan.sessionTitle?.text ?? fallbackTitle;
5199
+ if (!text) {
5200
+ return;
5201
+ }
5202
+ return {
5203
+ text,
5204
+ confidence: plan.sessionTitle?.confidence ?? plan.route.confidence,
5205
+ reason: plan.sessionTitle?.reason ?? plan.route.reason ?? (fallbackTitle ? "Recent route selected this ticket." : undefined)
5206
+ };
5207
+ }
5208
+ async function runAgentRunRoute(parsed, options, config, meta) {
5209
+ const projectId = requireProjectId(config);
5210
+ const client = await createClient(config, options);
5211
+ const agentSessionKeyValue = requireOption(parsed, "agent-session-key");
5212
+ const state = await readAgentLoopState(parsed, options);
5213
+ const sessionState = state.sessions[agentSessionKeyValue];
5214
+ if (!sessionState) {
5215
+ throw new CliError("AGENT_RUN_SESSION_NOT_FOUND", "No local agent-run session state exists for --agent-session-key.", {
5216
+ agentSessionKey: agentSessionKeyValue
5217
+ });
5218
+ }
5219
+ const savedContext = sessionState.cadenceContext;
5220
+ const currentContext = savedContext?.ticketId ? undefined : await readCurrentCadenceContext(client, projectId);
5221
+ const eventFile = parsed.options["event-file"] ?? sessionState.lastEventFile;
5222
+ const event = eventFile ? tryParseJsonObject(await readFile(eventFile, "utf8"), eventFile) : synthesizeAgentEventFromSession(agentSessionKeyValue, sessionState, options);
5223
+ const checkpointSettings = await resolveCheckpointSettings(parsed, options);
5224
+ const gitStatus = gitOutput(["status", "--short"], options);
5225
+ const gitDiffStat = gitOutput(["diff", "--stat", "origin/dev..."], options);
5226
+ const changedFiles = gitOutput(["diff", "--name-only", "origin/dev..."], options);
5227
+ const prompt = buildRoutePrompt({
5228
+ event,
5229
+ ...parsed.options.reason ? { reason: parsed.options.reason } : {},
5230
+ ...savedContext ? { savedContext } : {},
5231
+ ...currentContext ? { currentContext } : {},
5232
+ gitStatus,
5233
+ gitDiffStat,
5234
+ changedFiles,
5235
+ ...sessionState.previousCheckpointSummary ? { previousCheckpointSummary: sessionState.previousCheckpointSummary } : {}
5236
+ });
5237
+ const promptTokenAccounting = buildCheckpointTokenAccounting(event, prompt, undefined);
5238
+ const dryRun = parseBooleanOption(parsed.options["dry-run"], false);
5239
+ const lockPath = parsed.options.lock;
5240
+ if (dryRun) {
5241
+ const auditFile = await writeAgentCheckpointAuditFile(parsed, options, {
5242
+ version: 1,
5243
+ mode: "route",
5244
+ action: "would_route",
5245
+ createdAt: new Date().toISOString(),
5246
+ localOnly: true,
5247
+ localOnlyReason: "Raw hook context and route prompts stay on this machine and are not uploaded to Cadence.",
5248
+ agentSessionKey: agentSessionKeyValue,
5249
+ ...savedContext?.ticketId ? { ticketId: savedContext.ticketId } : currentContext?.ticketId ? { ticketId: currentContext.ticketId } : {},
5250
+ ...eventFile ? { eventFile } : {},
5251
+ event,
5252
+ prompt,
5253
+ checkpointSettings,
5254
+ tokenAccounting: promptTokenAccounting,
5255
+ cadenceWrites: []
5256
+ });
5257
+ const data = {
5258
+ action: "would_route",
5259
+ prompt,
5260
+ auditFile,
5261
+ agentSessionKey: agentSessionKeyValue
5262
+ };
5263
+ return {
5264
+ stdout: parsed.flags.json ? formatJson(successEnvelope(data, meta)) : `${JSON.stringify(data, null, 2)}
5265
+ `,
5266
+ stderr: "",
5267
+ exitCode: 0
5268
+ };
5269
+ }
5270
+ try {
5271
+ const codexCommand = parsed.options["codex-command"] ?? "codex";
5272
+ const codexCwd = options.cwd ?? process.cwd();
5273
+ const runRouteWorker = (workerPrompt, failureMessage) => {
5274
+ const codexStartedAtMs = Date.now();
5275
+ const codexArgs = [
5276
+ "exec",
5277
+ ...checkpointSettings.model ? ["-m", checkpointSettings.model] : [],
5278
+ "--disable",
5279
+ "hooks",
5280
+ "--sandbox",
5281
+ "read-only",
5282
+ "-C",
5283
+ codexCwd,
5284
+ workerPrompt
5285
+ ];
5286
+ const codex = runLocalCommand(codexCommand, codexArgs, options, {
5287
+ cwd: codexCwd,
5288
+ env: {
5289
+ [agentLoopSuppressEnv]: "1",
5290
+ CADENCE_HOOK_SUPPRESS: "1"
5291
+ },
5292
+ timeoutMs: defaultCheckpointWorkerTimeoutMs
5293
+ });
5294
+ const codexSessionTranscript = readCodexSessionTranscript(findCodexSessionFileCreatedAfter(options, codexStartedAtMs, codexCwd));
5295
+ const tokenAccounting2 = buildCheckpointTokenAccounting(event, workerPrompt, codexSessionTranscript?.tokenUsage);
5296
+ if (codex.status !== 0 || codex.error) {
5297
+ throw new CliError("AGENT_RUN_ROUTE_FAILED", failureMessage, {
5298
+ status: codex.status,
5299
+ stderr: truncateText(codex.stderr, 2000),
5300
+ error: codex.error?.message
5301
+ });
5302
+ }
5303
+ return { codex, codexSessionTranscript, tokenAccounting: tokenAccounting2 };
5304
+ };
5305
+ const initialRouteWorker = runRouteWorker(prompt, "Codex route generation failed.");
5306
+ let routePlan = parseCheckpointPlanJson(initialRouteWorker.codex.stdout, "intent");
5307
+ const currentTicketId = savedContext?.ticketId ?? currentContext?.ticketId;
5308
+ const currentSessionId = savedContext?.sessionId ?? currentContext?.sessionId;
5309
+ const currentChangesetId = savedContext?.changesetId ?? currentContext?.changesetId;
5310
+ if (routePlan.route.action === "needs_human") {
5311
+ routePlan = safeAutomaticRoutePlan(routePlan, Boolean(currentTicketId), routePlan.route.reason ?? "Route was uncertain.");
5312
+ } else if (checkpointRouteRequiresIntake(routePlan.route.action) && routePlan.route.confidence !== "high") {
5313
+ routePlan = safeAutomaticRoutePlan(routePlan, Boolean(currentTicketId), "Automatic routing requires high confidence.");
5314
+ } else if ((routePlan.route.action === "intake_attach" || routePlan.route.action === "switch_existing") && !routePlan.route.targetTicketId) {
5315
+ routePlan = safeAutomaticRoutePlan(routePlan, Boolean(currentTicketId), "Automatic routing to existing work requires a target ticket id.");
5316
+ } else if (routePlan.route.action === "current" && !currentTicketId) {
5317
+ routePlan = checkpointPlanWithRoute(routePlan, {
5318
+ action: "noop",
5319
+ confidence: routePlan.route.confidence,
5320
+ reason: routePlan.route.reason ?? "No current Cadence context exists."
5321
+ });
5322
+ }
5323
+ const cadenceWrites = [];
5324
+ const lifecycleOperations = [];
5325
+ let targetTicketId = currentTicketId;
5326
+ let targetSessionId = currentSessionId;
5327
+ let targetChangesetId = currentChangesetId;
5328
+ let intakeResult;
5329
+ let selectedTicket;
5330
+ let summary = routePlan.summary ?? routePlan.entries[0]?.summary ?? routePlan.route.reason ?? "Agent run route";
5331
+ let tokenAccounting = initialRouteWorker.tokenAccounting;
5332
+ const modelAudit = {
5333
+ provider: checkpointSettings.provider,
5334
+ command: codexCommand,
5335
+ ...checkpointSettings.model ? { model: checkpointSettings.model } : {},
5336
+ status: initialRouteWorker.codex.status,
5337
+ stdout: initialRouteWorker.codex.stdout.trim(),
5338
+ stderr: truncateText(initialRouteWorker.codex.stderr.trim(), 2000),
5339
+ tokenAccounting,
5340
+ ...initialRouteWorker.codexSessionTranscript?.tokenUsage ? { tokenUsage: initialRouteWorker.codexSessionTranscript.tokenUsage } : {},
5341
+ ...initialRouteWorker.codexSessionTranscript ? { sessionTranscript: initialRouteWorker.codexSessionTranscript } : {}
5342
+ };
5343
+ const finishRoute = async (action, reason) => {
5344
+ const checkedAt = new Date().toISOString();
5345
+ const displayTitle = routeDisplayTitle(action, routePlan, selectedTicket);
5346
+ const auditFile = await writeAgentCheckpointAuditFile(parsed, options, {
5347
+ version: 1,
5348
+ mode: "route",
5349
+ action,
5350
+ createdAt: checkedAt,
5351
+ localOnly: true,
5352
+ localOnlyReason: action === "noop" || action === "current" ? "Raw hook context, route prompts, and model output stay on this machine; no Cadence writes were needed." : "Raw hook context, route prompts, and model output stay on this machine; Cadence receives only the structured writes listed here.",
5353
+ agentSessionKey: agentSessionKeyValue,
5354
+ ...targetTicketId ? { ticketId: targetTicketId } : {},
5355
+ ...targetSessionId ? { sessionId: targetSessionId } : {},
5356
+ ...targetChangesetId ? { changesetId: targetChangesetId } : {},
5357
+ ...eventFile ? { eventFile } : {},
5358
+ event,
5359
+ prompt,
5360
+ model: modelAudit,
5361
+ checkpoint: routePlan,
5362
+ route: routePlan.route,
5363
+ ...displayTitle ? { sessionTitle: displayTitle } : {},
5364
+ intakeResult,
5365
+ selectedTicket,
5366
+ lifecycleOperations,
5367
+ cadenceWrites,
5368
+ summary,
5369
+ ...reason ? { reason } : {},
5370
+ entryCount: routePlan.entries.length
5371
+ });
5372
+ const nextSessionState = {
5373
+ ...sessionState,
5374
+ stopCount: 0,
5375
+ threshold: defaultCheckpointThresholdValue(),
5376
+ previousCheckpointSummary: summary,
5377
+ lastAction: action,
5378
+ ...reason ? { lastReason: reason } : {},
5379
+ ...eventFile ? { lastEventFile: eventFile } : {},
5380
+ ...targetTicketId ? {
5381
+ cadenceContext: {
5382
+ ticketId: targetTicketId,
5383
+ ...targetSessionId ? { sessionId: targetSessionId } : {},
5384
+ ...targetChangesetId ? { changesetId: targetChangesetId } : {},
5385
+ capturedAt: checkedAt
5386
+ }
5387
+ } : {},
5388
+ lastCheckpointAt: checkedAt,
5389
+ lastCheckpointMode: "checkpoint",
5390
+ ...displayTitle ? {
5391
+ displayTitle: displayTitle.text,
5392
+ displayTitleUpdatedAt: checkedAt,
5393
+ displayTitleSource: "route",
5394
+ displayTitleConfidence: displayTitle.confidence,
5395
+ ...displayTitle.reason ? { displayTitleReason: displayTitle.reason } : {}
5396
+ } : {},
5397
+ lastCheckpointAuditFile: auditFile
5398
+ };
5399
+ await writeAgentLoopState(parsed, options, {
5400
+ ...state,
5401
+ sessions: {
5402
+ ...state.sessions,
5403
+ [agentSessionKeyValue]: nextSessionState
5404
+ }
5405
+ });
5406
+ const data = {
5407
+ action,
5408
+ route: routePlan.route,
5409
+ ...reason ? { reason } : {},
5410
+ ...targetTicketId ? { ticketId: targetTicketId } : {},
5411
+ summary,
5412
+ ...displayTitle ? { sessionTitle: displayTitle } : {},
5413
+ entryCount: routePlan.entries.length,
5414
+ mode: "route",
5415
+ auditFile,
5416
+ agentSessionKey: agentSessionKeyValue,
5417
+ ...targetSessionId ? { sessionId: targetSessionId } : {},
5418
+ ...targetChangesetId ? { changesetId: targetChangesetId } : {}
5419
+ };
5420
+ return {
5421
+ stdout: parsed.flags.json ? formatJson(successEnvelope(data, meta)) : `${JSON.stringify(data, null, 2)}
5422
+ `,
5423
+ stderr: "",
5424
+ exitCode: 0
5425
+ };
5426
+ };
5427
+ if (routePlan.route.action === "noop") {
5428
+ return await finishRoute("noop", routePlan.route.reason ?? routePlan.summary ?? "Nothing durable to route.");
5429
+ }
5430
+ if (routePlan.route.action === "current") {
5431
+ return await finishRoute("current", routePlan.route.reason ?? "Kept current Cadence context.");
5432
+ }
5433
+ if (routePlanAllowsLifecycle(routePlan)) {
5434
+ const intakeRequest = routePlan.route.request ?? checkpointPlanDescription(routePlan);
5435
+ intakeResult = await client.intake.create({
5436
+ projectId,
5437
+ intake: {
5438
+ request: intakeRequest,
5439
+ ...commandMetadata()
5440
+ }
5441
+ });
5442
+ lifecycleOperations.push(checkpointLifecycleOperation("intake.created", true, { request: intakeRequest, result: intakeResult }));
5443
+ if (intakeCandidates(intakeResult).length > 0) {
5444
+ const candidatePrompt = buildRouteCandidatePrompt({
5445
+ event,
5446
+ initialPlan: routePlan,
5447
+ intakeResult,
5448
+ ...savedContext ? { savedContext } : {},
5449
+ ...currentContext ? { currentContext } : {}
5450
+ });
5451
+ const candidateWorker = runRouteWorker(candidatePrompt, "Codex route candidate selection failed.");
5452
+ const candidatePlan = parseCheckpointPlanJson(candidateWorker.codex.stdout, "intent");
5453
+ routePlan = planWithCandidateSelection(routePlan, candidatePlan, currentTicketId, intakeCandidateTicketIds(intakeResult));
5454
+ summary = routePlan.summary ?? routePlan.entries[0]?.summary ?? routePlan.route.reason ?? summary;
5455
+ tokenAccounting = candidateWorker.tokenAccounting;
5456
+ modelAudit.candidateSelection = {
5457
+ prompt: candidatePrompt,
5458
+ status: candidateWorker.codex.status,
5459
+ stdout: candidateWorker.codex.stdout.trim(),
5460
+ stderr: truncateText(candidateWorker.codex.stderr.trim(), 2000),
5461
+ tokenAccounting: candidateWorker.tokenAccounting,
5462
+ ...candidateWorker.codexSessionTranscript?.tokenUsage ? { tokenUsage: candidateWorker.codexSessionTranscript.tokenUsage } : {},
5463
+ ...candidateWorker.codexSessionTranscript ? { sessionTranscript: candidateWorker.codexSessionTranscript } : {}
5464
+ };
5465
+ if (routePlan.route.action === "noop") {
5466
+ return await finishRoute("noop", routePlan.route.reason ?? routePlan.summary ?? "Candidate routing produced no durable route.");
5467
+ }
5468
+ if (routePlan.route.action === "current") {
5469
+ return await finishRoute("current", routePlan.route.reason ?? "Candidate routing kept current Cadence context.");
5470
+ }
5471
+ if (!routePlanAllowsLifecycle(routePlan)) {
5472
+ routePlan = safeAutomaticRoutePlan(routePlan, Boolean(currentTicketId), routePlan.route.reason ?? "Candidate routing did not request an executable automatic action.");
5473
+ return await finishRoute(routePlan.route.action, routePlan.route.reason);
5474
+ }
5475
+ } else if (checkpointHasConflictingIntakeResult(intakeResult, routePlan)) {
5476
+ routePlan = safeAutomaticRoutePlan(routePlan, Boolean(currentTicketId), "Intake returned conflicting duplicate, overlap, or completed-before candidates.");
5477
+ return await finishRoute(routePlan.route.action, routePlan.route.reason);
5478
+ }
5479
+ if (routePlan.route.action === "intake_create") {
5480
+ selectedTicket = await client.tickets.create({
5481
+ projectId,
5482
+ ticket: {
5483
+ title: checkpointPlanTitle(routePlan),
5484
+ description: checkpointPlanDescription(routePlan),
5485
+ fromIntakeId: objectStringId(intakeResult),
5486
+ ...commandMetadata()
5487
+ }
5488
+ });
5489
+ lifecycleOperations.push(checkpointLifecycleOperation("ticket.created", true, { ticket: selectedTicket }));
5490
+ targetTicketId = objectStringId(selectedTicket);
5491
+ } else {
5492
+ const selectedTicketId = routePlan.route.targetTicketId;
5493
+ if (!selectedTicketId) {
5494
+ routePlan = safeAutomaticRoutePlan(routePlan, Boolean(currentTicketId), "Automatic routing to existing work requires a target ticket id.");
5495
+ return await finishRoute(routePlan.route.action, routePlan.route.reason);
5496
+ }
5497
+ targetTicketId = selectedTicketId;
5498
+ selectedTicket = await client.tickets.get({ projectId, ticketId: targetTicketId });
5499
+ lifecycleOperations.push(checkpointLifecycleOperation("ticket.selected", true, { ticketId: targetTicketId, ticket: selectedTicket }));
5500
+ const selectedVersion = selectedTicket && typeof selectedTicket === "object" && typeof selectedTicket.projectionVersion === "number" ? selectedTicket.projectionVersion : undefined;
5501
+ const intakeId = objectStringId(intakeResult);
5502
+ if (selectedVersion !== undefined && intakeId) {
5503
+ await client.tickets.attach({
5504
+ projectId,
5505
+ ticketId: targetTicketId,
5506
+ fromIntakeId: intakeId,
5507
+ ifVersion: selectedVersion
5508
+ });
5509
+ lifecycleOperations.push(checkpointLifecycleOperation("intake.attached", true, { ticketId: targetTicketId, intakeId }));
5510
+ }
5511
+ }
5512
+ if (!targetTicketId) {
5513
+ routePlan = checkpointPlanWithRoute(routePlan, {
5514
+ action: currentTicketId ? "current" : "noop",
5515
+ confidence: routePlan.route.confidence,
5516
+ reason: "Automatic routing did not return a target ticket."
5517
+ });
5518
+ return await finishRoute(routePlan.route.action, routePlan.route.reason);
5519
+ }
5520
+ if (currentSessionId && currentTicketId && targetTicketId !== currentTicketId) {
5521
+ await client.sessions.end({
5522
+ projectId,
5523
+ sessionId: currentSessionId,
5524
+ session: {
5525
+ summary: routePlan.route.reason ?? summary,
5526
+ ...commandMetadata()
5527
+ }
5528
+ });
5529
+ lifecycleOperations.push(checkpointLifecycleOperation("session.ended", true, { sessionId: currentSessionId, reason: "ticket_switch" }));
5530
+ targetSessionId = undefined;
5531
+ targetChangesetId = undefined;
5532
+ }
5533
+ const startedSession = await client.sessions.start({
5534
+ projectId,
5535
+ session: {
5536
+ ticketId: targetTicketId,
5537
+ ...commandMetadata()
5538
+ }
5539
+ });
5540
+ lifecycleOperations.push(checkpointLifecycleOperation("session.started", true, { ticketId: targetTicketId, session: startedSession }));
5541
+ targetSessionId = objectStringId(startedSession);
5542
+ if (targetSessionId) {
5543
+ const lease = await client.sessions.leases.create({
5544
+ projectId,
5545
+ lease: {
5546
+ ticketId: targetTicketId,
5547
+ sessionId: targetSessionId,
5548
+ expiresAt: leaseExpiresAt(defaultLeaseTtlSeconds),
5549
+ ...commandMetadata()
5550
+ }
5551
+ });
5552
+ lifecycleOperations.push(checkpointLifecycleOperation("lease.claimed", true, { ticketId: targetTicketId, sessionId: targetSessionId, lease }));
5553
+ }
5554
+ if (targetSessionId) {
5555
+ try {
5556
+ const branchName = await resolveCurrentBranch(options);
5557
+ const changeset = await client.changesets.create({
5558
+ projectId,
5559
+ changeset: {
5560
+ ticketId: targetTicketId,
5561
+ branchName,
5562
+ baseBranch: "dev",
5563
+ sessionId: targetSessionId,
5564
+ ...commandMetadata()
5565
+ }
5566
+ });
5567
+ lifecycleOperations.push(checkpointLifecycleOperation("changeset.linked", true, { ticketId: targetTicketId, branchName, changeset }));
5568
+ targetChangesetId = objectStringId(changeset);
5569
+ } catch (error) {
5570
+ lifecycleOperations.push(checkpointLifecycleOperation("changeset.linked", false, { error: error instanceof Error ? error.message : String(error) }));
5571
+ }
5572
+ }
5573
+ for (const entry of routePlan.entries) {
5574
+ const logEntry = {
5575
+ entryKind: entry.kind,
5576
+ body: entry.body,
5577
+ summary: entry.summary ?? checkpointEntrySummary(entry.body),
5578
+ ...targetSessionId ? { sessionId: targetSessionId } : {},
5579
+ ...targetChangesetId ? { changesetId: targetChangesetId } : {},
5580
+ ...commandMetadata()
5581
+ };
5582
+ await client.tickets.log({
5583
+ projectId,
5584
+ ticketId: targetTicketId,
5585
+ entry: logEntry
5586
+ });
5587
+ cadenceWrites.push({
5588
+ type: "ticket.work_log_appended",
5589
+ success: true,
5590
+ ticketId: targetTicketId,
5591
+ entry: logEntry
5592
+ });
5593
+ }
5594
+ if (routePlan.files.length && targetSessionId) {
5595
+ const filesByKind = new Map;
5596
+ for (const file of routePlan.files) {
5597
+ filesByKind.set(file.kind, [...filesByKind.get(file.kind) ?? [], file.path]);
5598
+ }
5599
+ for (const [kind, paths] of filesByKind) {
5600
+ const filesPayload = {
5601
+ ...targetChangesetId ? { changesetId: targetChangesetId } : {},
5602
+ files: paths.map((path) => ({
5603
+ path,
5604
+ changeKind: kind
5605
+ })),
5606
+ ...commandMetadata()
5607
+ };
5608
+ await client.sessions.files({
5609
+ projectId,
5610
+ sessionId: targetSessionId,
5611
+ files: filesPayload
5612
+ });
5613
+ lifecycleOperations.push(checkpointLifecycleOperation("session.files_attached", true, { sessionId: targetSessionId, kind, files: paths }));
5614
+ }
5615
+ }
5616
+ summary = routePlan.summary ?? routePlan.entries[0]?.summary ?? routePlan.route.reason ?? "Agent run routed";
5617
+ return await finishRoute(routePlan.route.action === "intake_create" ? "routed" : "switched");
5618
+ }
5619
+ routePlan = safeAutomaticRoutePlan(routePlan, Boolean(currentTicketId), "Route did not request an executable automatic action.");
5620
+ return await finishRoute(routePlan.route.action, routePlan.route.reason);
5621
+ } finally {
5622
+ await releaseAgentLoopLock(lockPath);
5623
+ }
5624
+ }
5625
+ async function runAgentRunCheckpoint(parsed, options, config, meta) {
5626
+ const projectId = requireProjectId(config);
5627
+ const client = await createClient(config, options);
4543
5628
  const agentSessionKeyValue = requireOption(parsed, "agent-session-key");
4544
5629
  const state = await readAgentLoopState(parsed, options);
4545
5630
  const sessionState = state.sessions[agentSessionKeyValue];
@@ -4579,15 +5664,36 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4579
5664
  }
4580
5665
  const eventFile = parsed.options["event-file"] ?? sessionState.lastEventFile;
4581
5666
  const event = eventFile ? tryParseJsonObject(await readFile(eventFile, "utf8"), eventFile) : synthesizeAgentEventFromSession(agentSessionKeyValue, sessionState, options);
5667
+ const mode = checkpointModeForCommand(parsed);
4582
5668
  const logKind = parseWorkLogEntryKind(parsed.options["log-kind"] ?? "note");
4583
5669
  const updateSummary = parseBooleanOption(parsed.options["update-summary"], false);
4584
5670
  const dryRun = parseBooleanOption(parsed.options["dry-run"], false);
5671
+ const closeoutSessionAction = parseCloseoutSessionAction(parsed.options["session-action"]);
5672
+ const completeTicket = parseBooleanOption(parsed.options["complete-ticket"], false);
4585
5673
  const lockPath = parsed.options.lock;
4586
5674
  const checkpointSettings = await resolveCheckpointSettings(parsed, options);
4587
5675
  const gitStatus = gitOutput(["status", "--short"], options);
4588
5676
  const gitDiffStat = gitOutput(["diff", "--stat", "origin/dev..."], options);
4589
5677
  const changedFiles = gitOutput(["diff", "--name-only", "origin/dev..."], options);
5678
+ const priorCoverage = sessionState.lastCheckpointCoverage;
5679
+ const fingerprints = buildAgentRunFingerprints({
5680
+ event,
5681
+ ticketId,
5682
+ ...sessionId ? { sessionId } : {},
5683
+ ...changesetId ? { changesetId } : {},
5684
+ gitStatus,
5685
+ gitDiffStat,
5686
+ changedFiles,
5687
+ ...priorCoverage ? { coverage: priorCoverage } : {}
5688
+ });
5689
+ const closeoutContext = mode === "closeout" ? await readCloseoutPromptContext({
5690
+ client,
5691
+ projectId,
5692
+ ticketId,
5693
+ ...sessionId ? { sessionId } : {}
5694
+ }) : {};
4590
5695
  const prompt = buildCheckpointPrompt({
5696
+ mode,
4591
5697
  event,
4592
5698
  ticketId,
4593
5699
  ...sessionId ? { sessionId } : {},
@@ -4595,12 +5701,68 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4595
5701
  gitStatus,
4596
5702
  gitDiffStat,
4597
5703
  changedFiles,
4598
- ...sessionState.previousCheckpointSummary ? { previousCheckpointSummary: sessionState.previousCheckpointSummary } : {}
5704
+ ...sessionState.previousCheckpointSummary ? { previousCheckpointSummary: sessionState.previousCheckpointSummary } : {},
5705
+ ...priorCoverage ? { coverage: priorCoverage } : {},
5706
+ ...closeoutContext
4599
5707
  });
4600
5708
  const promptTokenAccounting = buildCheckpointTokenAccounting(event, prompt, undefined);
5709
+ if (mode === "checkpoint" && !dryRun && !priorCoverage?.hasDebt && fingerprintsEqual(sessionState.lastCheckpointFingerprints, fingerprints) && !checkpointReasonBypassesFingerprintGate(parsed.options.reason)) {
5710
+ const checkedAt = new Date().toISOString();
5711
+ const auditFile = await writeAgentCheckpointAuditFile(parsed, options, {
5712
+ version: 1,
5713
+ mode,
5714
+ action: "skipped",
5715
+ reason: "unchanged_fingerprints",
5716
+ createdAt: checkedAt,
5717
+ localOnly: true,
5718
+ localOnlyReason: "Raw hook context and checkpoint prompts stay on this machine; unchanged fingerprints did not need a model call.",
5719
+ agentSessionKey: agentSessionKeyValue,
5720
+ ticketId,
5721
+ ...sessionId ? { sessionId } : {},
5722
+ ...changesetId ? { changesetId } : {},
5723
+ ...eventFile ? { eventFile } : {},
5724
+ event,
5725
+ fingerprints,
5726
+ cadenceWrites: []
5727
+ });
5728
+ await writeAgentLoopState(parsed, options, {
5729
+ ...state,
5730
+ sessions: {
5731
+ ...state.sessions,
5732
+ [agentSessionKeyValue]: {
5733
+ ...sessionState,
5734
+ stopCount: 0,
5735
+ threshold: defaultCheckpointThresholdValue(),
5736
+ lastAction: "skipped",
5737
+ lastReason: "unchanged_fingerprints",
5738
+ lastCheckpointAt: checkedAt,
5739
+ lastCheckpointMode: mode,
5740
+ lastCheckpointFingerprints: fingerprints,
5741
+ lastCheckpointAuditFile: auditFile
5742
+ }
5743
+ }
5744
+ });
5745
+ const data = {
5746
+ action: "skipped",
5747
+ reason: "unchanged_fingerprints",
5748
+ mode,
5749
+ ticketId,
5750
+ auditFile,
5751
+ agentSessionKey: agentSessionKeyValue,
5752
+ ...sessionId ? { sessionId } : {},
5753
+ ...changesetId ? { changesetId } : {}
5754
+ };
5755
+ return {
5756
+ stdout: parsed.flags.json ? formatJson(successEnvelope(data, meta)) : `${JSON.stringify(data, null, 2)}
5757
+ `,
5758
+ stderr: "",
5759
+ exitCode: 0
5760
+ };
5761
+ }
4601
5762
  if (dryRun) {
4602
5763
  const auditFile = await writeAgentCheckpointAuditFile(parsed, options, {
4603
5764
  version: 1,
5765
+ mode,
4604
5766
  action: "would_checkpoint",
4605
5767
  createdAt: new Date().toISOString(),
4606
5768
  localOnly: true,
@@ -4612,6 +5774,8 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4612
5774
  ...eventFile ? { eventFile } : {},
4613
5775
  event,
4614
5776
  prompt,
5777
+ fingerprints,
5778
+ ...mode === "closeout" ? { coverage: priorCoverage ?? null } : {},
4615
5779
  checkpointSettings,
4616
5780
  tokenAccounting: promptTokenAccounting,
4617
5781
  cadenceWrites: []
@@ -4634,7 +5798,6 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4634
5798
  }
4635
5799
  try {
4636
5800
  const codexCommand = parsed.options["codex-command"] ?? "codex";
4637
- const codexStartedAtMs = Date.now();
4638
5801
  const codexCwd = options.cwd ?? process.cwd();
4639
5802
  const codexArgs = [
4640
5803
  "exec",
@@ -4647,25 +5810,165 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4647
5810
  codexCwd,
4648
5811
  prompt
4649
5812
  ];
4650
- const codex = runLocalCommand(codexCommand, codexArgs, options, {
4651
- cwd: codexCwd,
4652
- env: {
4653
- [agentLoopSuppressEnv]: "1",
4654
- CADENCE_HOOK_SUPPRESS: "1"
4655
- },
4656
- timeoutMs: defaultCheckpointWorkerTimeoutMs
4657
- });
4658
- const codexSessionTranscript = readCodexSessionTranscript(findCodexSessionFileCreatedAfter(options, codexStartedAtMs, codexCwd));
4659
- const tokenAccounting = buildCheckpointTokenAccounting(event, prompt, codexSessionTranscript?.tokenUsage);
4660
- if (codex.status !== 0 || codex.error) {
4661
- throw new CliError("AGENT_RUN_CHECKPOINT_FAILED", "Codex checkpoint generation failed.", {
4662
- status: codex.status,
4663
- stderr: truncateText(codex.stderr, 2000),
4664
- error: codex.error?.message
5813
+ const generationAttempts = [];
5814
+ let codex = null;
5815
+ let codexSessionTranscript = null;
5816
+ let tokenAccounting = promptTokenAccounting;
5817
+ let checkpoint = null;
5818
+ let reductionResult = null;
5819
+ let lastFailureReason = "Codex checkpoint generation failed.";
5820
+ for (let attempt = 1;attempt <= defaultCheckpointWorkerMaxAttempts; attempt += 1) {
5821
+ const codexStartedAtMs = Date.now();
5822
+ codex = runLocalCommand(codexCommand, codexArgs, options, {
5823
+ cwd: codexCwd,
5824
+ env: {
5825
+ [agentLoopSuppressEnv]: "1",
5826
+ CADENCE_HOOK_SUPPRESS: "1"
5827
+ },
5828
+ timeoutMs: defaultCheckpointWorkerTimeoutMs
4665
5829
  });
5830
+ codexSessionTranscript = readCodexSessionTranscript(findCodexSessionFileCreatedAfter(options, codexStartedAtMs, codexCwd));
5831
+ tokenAccounting = buildCheckpointTokenAccounting(event, prompt, codexSessionTranscript?.tokenUsage);
5832
+ if (codex.status !== 0 || codex.error) {
5833
+ lastFailureReason = codex.error?.message ?? `Codex exited with status ${codex.status ?? "unknown"}.`;
5834
+ generationAttempts.push({
5835
+ attempt,
5836
+ success: false,
5837
+ status: codex.status,
5838
+ stderr: truncateText(codex.stderr.trim(), 2000),
5839
+ ...codex.error ? { error: codex.error.message } : {}
5840
+ });
5841
+ continue;
5842
+ }
5843
+ try {
5844
+ let parsedCheckpoint = parseCheckpointPlanJson(codex.stdout, logKind);
5845
+ if (mode === "closeout") {
5846
+ parsedCheckpoint = applyCloseoutSessionDefaults(parsedCheckpoint, closeoutSessionAction, completeTicket);
5847
+ }
5848
+ const parsedReductionResult = reduceCheckpointEntries(parsedCheckpoint.entries, mode);
5849
+ checkpoint = {
5850
+ ...parsedCheckpoint,
5851
+ entries: parsedReductionResult.entries
5852
+ };
5853
+ reductionResult = parsedReductionResult;
5854
+ generationAttempts.push({
5855
+ attempt,
5856
+ success: true,
5857
+ status: codex.status
5858
+ });
5859
+ break;
5860
+ } catch (error) {
5861
+ if (error instanceof CliError && error.code === "AGENT_RUN_CHECKPOINT_INVALID_PLAN") {
5862
+ throw error;
5863
+ }
5864
+ lastFailureReason = error instanceof Error ? error.message : String(error);
5865
+ generationAttempts.push({
5866
+ attempt,
5867
+ success: false,
5868
+ status: codex.status,
5869
+ error: lastFailureReason,
5870
+ stdout: truncateText(codex.stdout.trim(), 2000),
5871
+ stderr: truncateText(codex.stderr.trim(), 2000)
5872
+ });
5873
+ }
4666
5874
  }
4667
- let checkpoint = parseCheckpointPlanJson(codex.stdout, logKind);
4668
- let summary = checkpoint.summary ?? checkpoint.entries[0]?.summary ?? checkpoint.route.reason ?? "Agent run checkpoint";
5875
+ if (!codex || !checkpoint || !reductionResult) {
5876
+ const failedCodex = codex ?? { status: null, stdout: "", stderr: "" };
5877
+ const checkedAt = new Date().toISOString();
5878
+ const summary2 = `Checkpoint worker failed after ${generationAttempts.length} ${generationAttempts.length === 1 ? "attempt" : "attempts"}.`;
5879
+ const route = {
5880
+ action: "failed",
5881
+ confidence: "high",
5882
+ reason: lastFailureReason
5883
+ };
5884
+ const auditFile = await writeAgentCheckpointAuditFile(parsed, options, {
5885
+ version: 1,
5886
+ action: "failed",
5887
+ reason: lastFailureReason,
5888
+ createdAt: checkedAt,
5889
+ localOnly: true,
5890
+ localOnlyReason: "Raw hook context, checkpoint prompts, and model output stay on this machine; failed checkpoint attempts are recorded locally for UI visibility and retry diagnosis.",
5891
+ agentSessionKey: agentSessionKeyValue,
5892
+ ticketId,
5893
+ ...sessionId ? { sessionId } : {},
5894
+ ...changesetId ? { changesetId } : {},
5895
+ ...eventFile ? { eventFile } : {},
5896
+ event,
5897
+ prompt,
5898
+ model: {
5899
+ provider: checkpointSettings.provider,
5900
+ command: codexCommand,
5901
+ ...checkpointSettings.model ? { model: checkpointSettings.model } : {},
5902
+ status: failedCodex.status,
5903
+ stderr: truncateText(failedCodex.stderr.trim(), 2000),
5904
+ stdout: truncateText(failedCodex.stdout.trim(), 2000),
5905
+ ...failedCodex.error ? { error: failedCodex.error.message } : {},
5906
+ tokenAccounting,
5907
+ attempts: generationAttempts,
5908
+ ...codexSessionTranscript?.tokenUsage ? { tokenUsage: codexSessionTranscript.tokenUsage } : {},
5909
+ ...codexSessionTranscript ? { sessionTranscript: codexSessionTranscript } : {}
5910
+ },
5911
+ checkpointSettings,
5912
+ route,
5913
+ mode,
5914
+ fingerprints,
5915
+ lifecycleOperations: [],
5916
+ cadenceWrites: [],
5917
+ summary: summary2,
5918
+ entryCount: 0,
5919
+ needsHuman: true
5920
+ });
5921
+ await writeAgentLoopState(parsed, options, {
5922
+ ...state,
5923
+ sessions: {
5924
+ ...state.sessions,
5925
+ [agentSessionKeyValue]: {
5926
+ ...sessionState,
5927
+ stopCount: 0,
5928
+ threshold: defaultCheckpointThresholdValue(),
5929
+ previousCheckpointSummary: summary2,
5930
+ lastAction: "failed",
5931
+ lastReason: lastFailureReason,
5932
+ ...eventFile ? { lastEventFile: eventFile } : {},
5933
+ cadenceContext: {
5934
+ ticketId,
5935
+ ...sessionId ? { sessionId } : {},
5936
+ ...changesetId ? { changesetId } : {},
5937
+ capturedAt: checkedAt
5938
+ },
5939
+ lastCheckpointAt: checkedAt,
5940
+ lastCheckpointMode: mode,
5941
+ lastCheckpointFingerprints: fingerprints,
5942
+ lastCheckpointAuditFile: auditFile
5943
+ }
5944
+ }
5945
+ });
5946
+ const data = {
5947
+ action: "failed",
5948
+ reason: lastFailureReason,
5949
+ ticketId,
5950
+ summary: summary2,
5951
+ entryCount: 0,
5952
+ mode,
5953
+ auditFile,
5954
+ agentSessionKey: agentSessionKeyValue,
5955
+ attempts: generationAttempts.length,
5956
+ ...sessionId ? { sessionId } : {},
5957
+ ...changesetId ? { changesetId } : {},
5958
+ needsHuman: true
5959
+ };
5960
+ return {
5961
+ stdout: parsed.flags.json ? formatJson(successEnvelope(data, meta)) : `${JSON.stringify(data, null, 2)}
5962
+ `,
5963
+ stderr: "",
5964
+ exitCode: 0
5965
+ };
5966
+ }
5967
+ const completedCodex = codex;
5968
+ let checkpointPlan = checkpoint;
5969
+ const checkpointReductionResult = reductionResult;
5970
+ const coverage = buildAgentRunCoverage(checkpointPlan, mode);
5971
+ let summary = checkpointPlan.summary ?? checkpointPlan.entries[0]?.summary ?? checkpointPlan.route.reason ?? "Agent run checkpoint";
4669
5972
  let targetTicketId = ticketId;
4670
5973
  let targetSessionId = sessionId;
4671
5974
  let targetChangesetId = changesetId;
@@ -4677,10 +5980,11 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4677
5980
  provider: checkpointSettings.provider,
4678
5981
  command: codexCommand,
4679
5982
  ...checkpointSettings.model ? { model: checkpointSettings.model } : {},
4680
- status: codex.status,
4681
- stdout: codex.stdout.trim(),
4682
- stderr: truncateText(codex.stderr.trim(), 2000),
5983
+ status: completedCodex.status,
5984
+ stdout: completedCodex.stdout.trim(),
5985
+ stderr: truncateText(completedCodex.stderr.trim(), 2000),
4683
5986
  tokenAccounting,
5987
+ attempts: generationAttempts,
4684
5988
  ...codexSessionTranscript?.tokenUsage ? { tokenUsage: codexSessionTranscript.tokenUsage } : {},
4685
5989
  ...codexSessionTranscript ? { sessionTranscript: codexSessionTranscript } : {}
4686
5990
  };
@@ -4691,7 +5995,7 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4691
5995
  action,
4692
5996
  createdAt: checkedAt,
4693
5997
  localOnly: true,
4694
- localOnlyReason: action === "noop" || action === "needs_human" ? "Raw hook context, checkpoint prompts, and model output stay on this machine; no Cadence writes were needed." : "Raw hook context, checkpoint prompts, and model output stay on this machine; Cadence receives only the structured writes listed here.",
5998
+ localOnlyReason: action === "noop" || action === "needs_human" || action === "reroute" ? "Raw hook context, checkpoint prompts, and model output stay on this machine; no Cadence writes were needed." : "Raw hook context, checkpoint prompts, and model output stay on this machine; Cadence receives only the structured writes listed here.",
4695
5999
  agentSessionKey: agentSessionKeyValue,
4696
6000
  ticketId: targetTicketId,
4697
6001
  ...targetSessionId ? { sessionId: targetSessionId } : {},
@@ -4700,15 +6004,19 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4700
6004
  event,
4701
6005
  prompt,
4702
6006
  model: modelAudit,
4703
- checkpoint,
4704
- route: checkpoint.route,
6007
+ checkpoint: checkpointPlan,
6008
+ route: checkpointPlan.route,
6009
+ mode,
6010
+ fingerprints,
6011
+ reduction: checkpointReductionResult.reduction,
6012
+ ...mode === "closeout" ? { coverage } : {},
4705
6013
  ...intakeResult ? { intakeResult } : {},
4706
6014
  ...selectedTicket ? { selectedTicket } : {},
4707
6015
  lifecycleOperations,
4708
6016
  cadenceWrites,
4709
6017
  summary,
4710
6018
  ...reason ? { reason } : {},
4711
- entryCount: checkpoint.entries.length,
6019
+ entryCount: checkpointPlan.entries.length,
4712
6020
  needsHuman: action === "needs_human"
4713
6021
  });
4714
6022
  await writeAgentLoopState(parsed, options, {
@@ -4730,17 +6038,21 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4730
6038
  capturedAt: checkedAt
4731
6039
  },
4732
6040
  lastCheckpointAt: checkedAt,
6041
+ lastCheckpointMode: mode,
6042
+ lastCheckpointFingerprints: fingerprints,
6043
+ lastCheckpointCoverage: coverage,
4733
6044
  lastCheckpointAuditFile: auditFile
4734
6045
  }
4735
6046
  }
4736
6047
  });
4737
6048
  const data = {
4738
6049
  action,
4739
- route: checkpoint.route,
6050
+ route: checkpointPlan.route,
4740
6051
  ...reason ? { reason } : {},
4741
6052
  ticketId: targetTicketId,
4742
6053
  summary,
4743
- entryCount: checkpoint.entries.length,
6054
+ entryCount: checkpointPlan.entries.length,
6055
+ mode,
4744
6056
  auditFile,
4745
6057
  agentSessionKey: agentSessionKeyValue,
4746
6058
  ...targetSessionId ? { sessionId: targetSessionId } : {},
@@ -4754,124 +6066,48 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4754
6066
  exitCode: 0
4755
6067
  };
4756
6068
  };
4757
- if (checkpoint.session?.action === "complete_ticket" && !checkpointPlanCompletionAllowed(event, summary)) {
4758
- checkpoint = checkpointPlanNeedsHuman(checkpoint, "Completion requires explicit recent completion, push, PR, merge, clean branch, or verification intent.");
6069
+ if (checkpointPlan.session?.action === "complete_ticket" && mode === "closeout" && !completeTicket) {
6070
+ checkpointPlan = checkpointPlanNeedsHuman(checkpointPlan, "Closeout ticket completion requires --complete-ticket true.");
4759
6071
  }
4760
- if (checkpoint.route.action === "noop") {
4761
- return await finishCheckpoint("noop", checkpoint.route.reason ?? checkpoint.summary ?? "Nothing durable to record.");
6072
+ if (checkpointPlan.session?.action === "complete_ticket" && !checkpointPlanCompletionAllowed(event, summary)) {
6073
+ checkpointPlan = checkpointPlanNeedsHuman(checkpointPlan, "Completion requires explicit recent completion, push, PR, merge, clean branch, or verification intent.");
4762
6074
  }
4763
- if (checkpoint.route.action === "needs_human") {
4764
- return await finishCheckpoint("needs_human", checkpoint.route.reason ?? "Checkpoint routing needs human review.");
6075
+ if (checkpointPlan.route.action === "noop") {
6076
+ return await finishCheckpoint("noop", checkpointPlan.route.reason ?? checkpointPlan.summary ?? "Nothing durable to record.");
4765
6077
  }
4766
- if (checkpointRouteRequiresIntake(checkpoint.route.action)) {
4767
- const intakeRequest = checkpoint.route.request ?? checkpointPlanDescription(checkpoint);
4768
- intakeResult = await client.intake.create({
4769
- projectId,
4770
- intake: {
4771
- request: intakeRequest,
4772
- ...commandMetadata()
4773
- }
4774
- });
4775
- lifecycleOperations.push(checkpointLifecycleOperation("intake.created", true, { request: intakeRequest, result: intakeResult }));
4776
- if (checkpointHasConflictingIntakeResult(intakeResult, checkpoint)) {
4777
- checkpoint = checkpointPlanNeedsHuman(checkpoint, "Intake returned conflicting duplicate, overlap, or completed-before candidates.");
4778
- return await finishCheckpoint("needs_human", checkpoint.route.reason);
4779
- }
4780
- if (checkpoint.route.action === "intake_create") {
4781
- selectedTicket = await client.tickets.create({
4782
- projectId,
4783
- ticket: {
4784
- title: checkpointPlanTitle(checkpoint),
4785
- description: checkpointPlanDescription(checkpoint),
4786
- fromIntakeId: typeof intakeResult === "object" && intakeResult && "id" in intakeResult ? String(intakeResult.id) : undefined,
4787
- ...commandMetadata()
4788
- }
4789
- });
4790
- lifecycleOperations.push(checkpointLifecycleOperation("ticket.created", true, { ticket: selectedTicket }));
4791
- if (selectedTicket && typeof selectedTicket === "object" && "id" in selectedTicket) {
4792
- targetTicketId = String(selectedTicket.id);
4793
- }
4794
- } else {
4795
- if (!checkpoint.route.targetTicketId) {
4796
- checkpoint = checkpointPlanNeedsHuman(checkpoint, "Checkpoint route requires a target ticket id.");
4797
- return await finishCheckpoint("needs_human", checkpoint.route.reason);
4798
- }
4799
- targetTicketId = checkpoint.route.targetTicketId;
4800
- selectedTicket = await client.tickets.get({ projectId, ticketId: targetTicketId });
4801
- lifecycleOperations.push(checkpointLifecycleOperation("ticket.selected", true, { ticketId: targetTicketId, ticket: selectedTicket }));
4802
- const selectedVersion = selectedTicket && typeof selectedTicket === "object" && typeof selectedTicket.projectionVersion === "number" ? selectedTicket.projectionVersion : undefined;
4803
- const intakeId = intakeResult && typeof intakeResult === "object" && "id" in intakeResult ? String(intakeResult.id) : undefined;
4804
- if (selectedVersion !== undefined && intakeId) {
4805
- await client.tickets.attach({
4806
- projectId,
4807
- ticketId: targetTicketId,
4808
- fromIntakeId: intakeId,
4809
- ifVersion: selectedVersion
4810
- });
4811
- lifecycleOperations.push(checkpointLifecycleOperation("intake.attached", true, { ticketId: targetTicketId, intakeId }));
4812
- }
4813
- }
4814
- if (targetTicketId !== ticketId && targetSessionId) {
4815
- await client.sessions.end({
4816
- projectId,
4817
- sessionId: targetSessionId,
4818
- session: {
4819
- summary: checkpoint.session?.summary ?? checkpoint.route.reason ?? summary,
4820
- ...commandMetadata()
4821
- }
4822
- });
4823
- lifecycleOperations.push(checkpointLifecycleOperation("session.ended", true, { sessionId: targetSessionId, reason: "ticket_switch" }));
4824
- targetSessionId = undefined;
4825
- targetChangesetId = undefined;
4826
- }
4827
- const startedSession = await client.sessions.start({
4828
- projectId,
4829
- session: {
4830
- ticketId: targetTicketId,
4831
- ...commandMetadata()
4832
- }
6078
+ if (checkpointPlan.route.action === "needs_human") {
6079
+ checkpointPlan = checkpointPlanWithRoute(checkpointPlan, {
6080
+ action: "noop",
6081
+ confidence: checkpointPlan.route.confidence,
6082
+ reason: checkpointPlan.route.reason ?? "Checkpoint routing was uncertain."
4833
6083
  });
4834
- lifecycleOperations.push(checkpointLifecycleOperation("session.started", true, { ticketId: targetTicketId, session: startedSession }));
4835
- if (startedSession && typeof startedSession === "object" && "id" in startedSession) {
4836
- targetSessionId = String(startedSession.id);
4837
- }
4838
- if (targetSessionId) {
4839
- const lease = await client.sessions.leases.create({
4840
- projectId,
4841
- lease: {
4842
- ticketId: targetTicketId,
4843
- sessionId: targetSessionId,
4844
- expiresAt: leaseExpiresAt(defaultLeaseTtlSeconds),
4845
- replaceOwnActiveLease: true,
4846
- ...commandMetadata()
4847
- }
6084
+ return await finishCheckpoint("noop", checkpointPlan.route.reason);
6085
+ }
6086
+ if (checkpointRouteRequiresIntake(checkpointPlan.route.action)) {
6087
+ if (checkpointPlan.route.confidence !== "high") {
6088
+ checkpointPlan = checkpointPlanWithRoute(checkpointPlan, {
6089
+ action: "noop",
6090
+ confidence: checkpointPlan.route.confidence,
6091
+ reason: "Checkpoint reroute requires high confidence."
4848
6092
  });
4849
- lifecycleOperations.push(checkpointLifecycleOperation("lease.claimed", true, { ticketId: targetTicketId, sessionId: targetSessionId, lease }));
4850
- }
4851
- if (targetSessionId) {
4852
- try {
4853
- const branchName = await resolveCurrentBranch(options);
4854
- const changeset = await client.changesets.create({
4855
- projectId,
4856
- changeset: {
4857
- ticketId: targetTicketId,
4858
- branchName,
4859
- baseBranch: "dev",
4860
- sessionId: targetSessionId,
4861
- ...commandMetadata()
4862
- }
4863
- });
4864
- lifecycleOperations.push(checkpointLifecycleOperation("changeset.linked", true, { ticketId: targetTicketId, branchName, changeset }));
4865
- if (changeset && typeof changeset === "object" && "id" in changeset) {
4866
- targetChangesetId = String(changeset.id);
4867
- }
4868
- } catch (error) {
4869
- lifecycleOperations.push(checkpointLifecycleOperation("changeset.linked", false, { error: error instanceof Error ? error.message : String(error) }));
4870
- }
6093
+ return await finishCheckpoint("noop", checkpointPlan.route.reason);
4871
6094
  }
6095
+ const routeArgs = [
6096
+ "agent-run",
6097
+ "route",
6098
+ "--agent-session-key",
6099
+ agentSessionKeyValue,
6100
+ "--reason",
6101
+ "checkpoint_reroute",
6102
+ ...eventFile ? ["--event-file", eventFile] : [],
6103
+ ...parsed.flags.project ? ["--project", parsed.flags.project] : [],
6104
+ ...parsed.flags.server ? ["--server", parsed.flags.server] : []
6105
+ ];
6106
+ const rerouteResult = await finishCheckpoint("reroute", checkpointPlan.route.reason ?? "Checkpoint delegated routing to agent-run route.");
6107
+ await spawnAgentRunWorker(routeArgs, options);
6108
+ return rerouteResult;
4872
6109
  }
4873
- const checkpointMetadata = checkpointWorkLogMetadata(checkpointSettings, tokenAccounting);
4874
- for (const entry of checkpoint.entries) {
6110
+ for (const entry of checkpointPlan.entries) {
4875
6111
  const logEntry = {
4876
6112
  entryKind: entry.kind,
4877
6113
  body: entry.body,
@@ -4880,7 +6116,6 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4880
6116
  ...entry.parentEntryId ? { parentEntryId: entry.parentEntryId } : {},
4881
6117
  ...targetSessionId ? { sessionId: targetSessionId } : {},
4882
6118
  ...targetChangesetId ? { changesetId: targetChangesetId } : {},
4883
- metadata: checkpointMetadata,
4884
6119
  ...commandMetadata()
4885
6120
  };
4886
6121
  try {
@@ -4928,9 +6163,9 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4928
6163
  });
4929
6164
  }
4930
6165
  }
4931
- if (checkpoint.files.length && targetSessionId) {
6166
+ if (checkpointPlan.files.length && targetSessionId) {
4932
6167
  const filesByKind = new Map;
4933
- for (const file of checkpoint.files) {
6168
+ for (const file of checkpointPlan.files) {
4934
6169
  filesByKind.set(file.kind, [...filesByKind.get(file.kind) ?? [], file.path]);
4935
6170
  }
4936
6171
  for (const [kind, paths] of filesByKind) {
@@ -4950,7 +6185,7 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4950
6185
  lifecycleOperations.push(checkpointLifecycleOperation("session.files_attached", true, { sessionId: targetSessionId, kind, files: paths }));
4951
6186
  }
4952
6187
  }
4953
- if ((updateSummary || checkpoint.summaryUpdate?.update) && checkpoint.summaryUpdate?.value) {
6188
+ if ((updateSummary || checkpointPlan.summaryUpdate?.update) && checkpointPlan.summaryUpdate?.value) {
4954
6189
  const ticket = await client.tickets.get({ projectId, ticketId: targetTicketId });
4955
6190
  if (typeof ticket.projectionVersion === "number") {
4956
6191
  await client.tickets.update({
@@ -4958,7 +6193,7 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4958
6193
  ticketId: targetTicketId,
4959
6194
  ifVersion: ticket.projectionVersion,
4960
6195
  ticket: {
4961
- currentSummary: checkpoint.summaryUpdate.value,
6196
+ currentSummary: checkpointPlan.summaryUpdate.value,
4962
6197
  ...commandMetadata()
4963
6198
  }
4964
6199
  });
@@ -4968,36 +6203,36 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
4968
6203
  ticketId: targetTicketId,
4969
6204
  ifVersion: ticket.projectionVersion,
4970
6205
  ticket: {
4971
- currentSummary: checkpoint.summaryUpdate.value
6206
+ currentSummary: checkpointPlan.summaryUpdate.value
4972
6207
  }
4973
6208
  });
4974
6209
  }
4975
6210
  }
4976
- if (checkpoint.session?.action === "handoff" || checkpoint.session?.action === "end" || checkpoint.session?.action === "complete_ticket") {
6211
+ if (checkpointPlan.session?.action === "handoff" || checkpointPlan.session?.action === "end" || checkpointPlan.session?.action === "complete_ticket") {
4977
6212
  if (targetSessionId) {
4978
6213
  await client.sessions.end({
4979
6214
  projectId,
4980
6215
  sessionId: targetSessionId,
4981
6216
  session: {
4982
- summary: checkpoint.session.summary ?? summary,
6217
+ summary: checkpointPlan.session.summary ?? summary,
4983
6218
  ...commandMetadata()
4984
6219
  }
4985
6220
  });
4986
- lifecycleOperations.push(checkpointLifecycleOperation("session.ended", true, { sessionId: targetSessionId, action: checkpoint.session.action }));
6221
+ lifecycleOperations.push(checkpointLifecycleOperation("session.ended", true, { sessionId: targetSessionId, action: checkpointPlan.session.action }));
4987
6222
  }
4988
6223
  }
4989
- if (checkpoint.session?.action === "complete_ticket") {
6224
+ if (checkpointPlan.session?.action === "complete_ticket") {
4990
6225
  const latestTicket = await client.tickets.get({ projectId, ticketId: targetTicketId });
4991
6226
  if (typeof latestTicket.projectionVersion !== "number") {
4992
- checkpoint = checkpointPlanNeedsHuman(checkpoint, "Ticket completion requires a latest ticket projection version.");
4993
- return await finishCheckpoint("needs_human", checkpoint.route.reason);
6227
+ checkpointPlan = checkpointPlanNeedsHuman(checkpointPlan, "Ticket completion requires a latest ticket projection version.");
6228
+ return await finishCheckpoint("needs_human", checkpointPlan.route.reason);
4994
6229
  }
4995
6230
  await client.tickets.complete({
4996
6231
  projectId,
4997
6232
  ticketId: targetTicketId,
4998
6233
  ifVersion: latestTicket.projectionVersion,
4999
6234
  completion: {
5000
- currentSummary: checkpoint.session.summary ?? checkpoint.summaryUpdate?.value ?? summary,
6235
+ currentSummary: checkpointPlan.session.summary ?? checkpointPlan.summaryUpdate?.value ?? summary,
5001
6236
  ...commandMetadata()
5002
6237
  }
5003
6238
  });
@@ -5006,7 +6241,7 @@ async function runAgentRunCheckpoint(parsed, options, config, meta) {
5006
6241
  success: true,
5007
6242
  ticketId: targetTicketId,
5008
6243
  ifVersion: latestTicket.projectionVersion,
5009
- summary: checkpoint.session.summary ?? checkpoint.summaryUpdate?.value ?? summary
6244
+ summary: checkpointPlan.session.summary ?? checkpointPlan.summaryUpdate?.value ?? summary
5010
6245
  });
5011
6246
  }
5012
6247
  return await finishCheckpoint("checkpointed");
@@ -5033,26 +6268,17 @@ async function runAgentRunSweep(parsed, options, config, meta) {
5033
6268
  lastObservedAt: session.lastObservedAt
5034
6269
  }));
5035
6270
  if (!dryRun) {
5036
- const needsFallbackContext = staleSessions.some((staleSession) => !state.sessions[staleSession.agentSessionKey]?.cadenceContext);
5037
- let fallbackContext;
5038
- if (needsFallbackContext && config.projectId) {
5039
- try {
5040
- const client = await createClient(config, options);
5041
- fallbackContext = await readCurrentCadenceContext(client, config.projectId);
5042
- } catch {}
5043
- }
5044
6271
  let nextState = state;
5045
6272
  for (const staleSession of staleSessions) {
5046
6273
  const existingSession = nextState.sessions[staleSession.agentSessionKey];
5047
- const cadenceContext = existingSession?.cadenceContext ?? fallbackContext;
5048
- if (existingSession && cadenceContext) {
6274
+ const hasContext = Boolean(existingSession?.cadenceContext?.ticketId);
6275
+ if (existingSession) {
5049
6276
  nextState = {
5050
6277
  ...nextState,
5051
6278
  sessions: {
5052
6279
  ...nextState.sessions,
5053
6280
  [staleSession.agentSessionKey]: {
5054
6281
  ...existingSession,
5055
- cadenceContext,
5056
6282
  lastAction: "sweep_spawned"
5057
6283
  }
5058
6284
  }
@@ -5061,11 +6287,11 @@ async function runAgentRunSweep(parsed, options, config, meta) {
5061
6287
  }
5062
6288
  await spawnAgentRunCheckpoint([
5063
6289
  "agent-run",
5064
- "checkpoint",
6290
+ hasContext ? "checkpoint" : "route",
5065
6291
  "--agent-session-key",
5066
6292
  staleSession.agentSessionKey,
5067
6293
  "--reason",
5068
- "idle",
6294
+ hasContext ? "idle" : "missing_context",
5069
6295
  "--lock",
5070
6296
  agentLoopLockPath(parsed, options, staleSession.agentSessionKey),
5071
6297
  ...parsed.flags.project ? ["--project", parsed.flags.project] : [],
@@ -5733,7 +6959,7 @@ async function runCli(argv, options = {}) {
5733
6959
  if (parsed.command.name === "projects.list") {
5734
6960
  return await runProjectCommand(parsed, options);
5735
6961
  }
5736
- if (parsed.command.name === "agent-run.ingest-stop" || parsed.command.name === "agent-run.checkpoint" || parsed.command.name === "agent-run.closeout" || parsed.command.name === "agent-run.sweep" || parsed.command.name === "agent-run.doctor") {
6962
+ if (parsed.command.name === "agent-run.ingest-stop" || parsed.command.name === "agent-run.route" || parsed.command.name === "agent-run.checkpoint" || parsed.command.name === "agent-run.closeout" || parsed.command.name === "agent-run.sweep" || parsed.command.name === "agent-run.doctor") {
5737
6963
  return await runAgentRunCommand(parsed, options);
5738
6964
  }
5739
6965
  if (parsed.command.name === "hooks.install" || parsed.command.name === "hooks.doctor") {