@gh-symphony/cli 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,7 +88,7 @@ whether `WORKFLOW.md`, `.gh-symphony/context.yaml`,
88
88
  `.gh-symphony/reference-workflow.md`, and runtime skill files would be created,
89
89
  updated, or left unchanged, and then exits without modifying the repository.
90
90
 
91
- The same detected environment data is applied to the generated artifacts, so `WORKFLOW.md`, `.gh-symphony/reference-workflow.md`, and the runtime skill templates already include repository-aware validation guidance for the detected package manager, monorepo layout, and explicit validation commands when they exist.
91
+ The same detected environment data is applied to the generated artifacts, so `WORKFLOW.md`, `.gh-symphony/reference-workflow.md`, and the runtime skill templates already include repository-aware validation guidance for the detected package manager, monorepo layout, and explicit validation commands when they exist. The `/gh-symphony` skill also ships a `references/` directory with workflow schema details and composable prompt-body postures for implementation, review, and maintenance workflows.
92
92
 
93
93
  The detector is language-agnostic by default:
94
94
 
@@ -102,7 +102,7 @@ Examples of generated validation guidance include `make test`, `just build`, `uv
102
102
 
103
103
  ### Customizing Agent Behavior
104
104
 
105
- `gh-symphony workflow init` generates skill files under `.codex/skills/` (or `.claude/skills/` for Claude Code). These skills define how the AI agent handles commits, pushes, pulls, and project status transitions.
105
+ `gh-symphony workflow init` generates skill files under `.codex/skills/` (or `.claude/skills/` for Claude Code). These skills define how the AI agent handles commits, pushes, pulls, and project status transitions. The generated `/gh-symphony` skill includes `references/` files that can be customized or extended without adding CLI flags.
106
106
 
107
107
  You can further customize the agent's behavior by editing `WORKFLOW.md` — this is the policy layer that controls what the agent does at each workflow phase.
108
108
 
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  workflow_init_default
4
- } from "./chunk-GL55AKBI.js";
4
+ } from "./chunk-RU5GQG6A.js";
5
5
  import {
6
6
  fetchGithubProjectIssueByRepositoryAndNumber,
7
7
  inspectManagedProjectSelection,
8
8
  resolveTrackerAdapter
9
- } from "./chunk-VHEGRYK7.js";
9
+ } from "./chunk-BBUCDKSL.js";
10
10
  import {
11
11
  GitHubApiError,
12
12
  createClient,
@@ -19,10 +19,10 @@ import {
19
19
  buildPromptVariables,
20
20
  parseWorkflowMarkdown,
21
21
  renderPrompt
22
- } from "./chunk-5HQLPZR5.js";
22
+ } from "./chunk-YKC6CGD6.js";
23
23
  import {
24
24
  loadActiveProjectConfig
25
- } from "./chunk-4ICDSQCJ.js";
25
+ } from "./chunk-YZP5N5XP.js";
26
26
 
27
27
  // src/commands/workflow.ts
28
28
  import { readFile } from "fs/promises";
@@ -377,7 +377,7 @@ Commands:
377
377
  preview Render the final worker prompt from a sample or live issue
378
378
 
379
379
  Options:
380
- workflow init [--non-interactive] [--project <id>] [--output <path>] [--skip-skills] [--skip-context] [--dry-run]
380
+ workflow init [--non-interactive] [--project <id>] [--output <path>] [--skip-skills] [--skip-context (deprecated no-op)] [--dry-run]
381
381
  workflow validate [--file <path>]
382
382
  workflow preview [issue] [--file <path>] [--issue <owner/repo#number|ENG-123>] [--project-id <projectId>] [--sample <json>] [--attempt <n>]
383
383
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  REPO_RUNTIME_DIR
4
- } from "./chunk-4ICDSQCJ.js";
4
+ } from "./chunk-YZP5N5XP.js";
5
5
 
6
6
  // src/orchestrator-runtime.ts
7
7
  import { resolve } from "path";
@@ -30,11 +30,11 @@ import {
30
30
  resolveWorkflowRuntimeTimeouts,
31
31
  safeReadDir,
32
32
  scheduleRetryAt
33
- } from "./chunk-5HQLPZR5.js";
33
+ } from "./chunk-YKC6CGD6.js";
34
34
  import {
35
35
  loadGlobalConfig,
36
36
  loadProjectConfig
37
- } from "./chunk-4ICDSQCJ.js";
37
+ } from "./chunk-YZP5N5XP.js";
38
38
 
39
39
  // ../tracker-github/src/adapter.ts
40
40
  import { createHash } from "crypto";
@@ -1865,7 +1865,9 @@ var linearTrackerAdapter = {
1865
1865
  return listLinearIssues(
1866
1866
  project,
1867
1867
  project.tracker.settings?.activeStates,
1868
- dependencies
1868
+ dependencies,
1869
+ void 0,
1870
+ { applyPickupLabels: true }
1869
1871
  );
1870
1872
  },
1871
1873
  async listIssuesByStates(project, states, dependencies = {}) {
@@ -1914,7 +1916,7 @@ var linearTrackerAdapter = {
1914
1916
  };
1915
1917
  }
1916
1918
  };
1917
- async function listLinearIssues(project, stateNamesInput, dependencies, issueIds) {
1919
+ async function listLinearIssues(project, stateNamesInput, dependencies, issueIds, options = {}) {
1918
1920
  const config = resolveLinearTrackerConfig(project, dependencies);
1919
1921
  const client = createLinearGraphqlClient(config, dependencies.fetchImpl);
1920
1922
  const stateNames = readStringArray(stateNamesInput);
@@ -1931,10 +1933,15 @@ async function listLinearIssues(project, stateNamesInput, dependencies, issueIds
1931
1933
  assignedOnly: config.assignedOnly,
1932
1934
  pageSize: config.pageSize
1933
1935
  });
1934
- const issues = result.nodes.map(
1936
+ const fetchedIssues = result.nodes.map(
1935
1937
  (node) => normalizeLinearIssue(project, config.projectSlug, node, result.rateLimits)
1936
1938
  );
1937
- Object.defineProperty(issues, "rateLimits", {
1939
+ const filteredIssues = options.applyPickupLabels ? filterIssuesByPickupLabels(
1940
+ fetchedIssues,
1941
+ config.pickupLabels,
1942
+ config.projectSlug
1943
+ ) : fetchedIssues;
1944
+ Object.defineProperty(filteredIssues, "rateLimits", {
1938
1945
  configurable: true,
1939
1946
  enumerable: false,
1940
1947
  value: result.rateLimits,
@@ -1943,10 +1950,10 @@ async function listLinearIssues(project, stateNamesInput, dependencies, issueIds
1943
1950
  if (config.assignedOnly) {
1944
1951
  emitAssignedOnlyFilterEvent2({
1945
1952
  projectSlug: config.projectSlug,
1946
- includedCount: issues.length
1953
+ includedCount: filteredIssues.length
1947
1954
  });
1948
1955
  }
1949
- return issues;
1956
+ return filteredIssues;
1950
1957
  }
1951
1958
  async function fetchPaginatedLinearIssues(client, input) {
1952
1959
  const issues = [];
@@ -2100,6 +2107,7 @@ function resolveLinearTrackerConfig(project, dependencies) {
2100
2107
  endpoint: resolveLinearEndpoint(project.tracker),
2101
2108
  assignedOnly: resolveAssignedOnly2(project.tracker, dependencies),
2102
2109
  pageSize: readPositiveIntegerSetting(project.tracker, "pageSize") ?? DEFAULT_PAGE_SIZE2,
2110
+ pickupLabels: resolvePickupLabels(project.tracker),
2103
2111
  projectSlug,
2104
2112
  token
2105
2113
  };
@@ -2155,6 +2163,24 @@ function readBooleanSetting(tracker, key) {
2155
2163
  const value = tracker.settings?.[key];
2156
2164
  return value === true || value === "true";
2157
2165
  }
2166
+ function resolvePickupLabels(tracker) {
2167
+ const pickupLabels = readObjectSetting(tracker, "pickupLabels") ?? readObjectSetting(tracker, "pickup_labels");
2168
+ return {
2169
+ include: normalizeConfiguredLabels(
2170
+ readStringArray(pickupLabels?.include) ?? []
2171
+ ),
2172
+ exclude: normalizeConfiguredLabels(
2173
+ readStringArray(pickupLabels?.exclude) ?? []
2174
+ )
2175
+ };
2176
+ }
2177
+ function readObjectSetting(tracker, key) {
2178
+ const value = tracker.settings?.[key];
2179
+ if (value === void 0 || value === null || Array.isArray(value) || typeof value !== "object") {
2180
+ return void 0;
2181
+ }
2182
+ return value;
2183
+ }
2158
2184
  function readStringArray(value) {
2159
2185
  if (value === void 0) {
2160
2186
  return void 0;
@@ -2167,6 +2193,35 @@ function readStringArray(value) {
2167
2193
  }
2168
2194
  return value.filter((entry) => typeof entry === "string");
2169
2195
  }
2196
+ function normalizeConfiguredLabels(labels) {
2197
+ return Array.from(
2198
+ new Set(
2199
+ labels.map((label) => label.trim()).filter((label) => label.length > 0)
2200
+ )
2201
+ );
2202
+ }
2203
+ function filterIssuesByPickupLabels(issues, config, projectSlug) {
2204
+ if (config.include.length === 0 && config.exclude.length === 0) {
2205
+ return issues;
2206
+ }
2207
+ const includeLabels = new Set(config.include);
2208
+ const excludeLabels = new Set(config.exclude);
2209
+ const filtered = issues.filter((issue) => {
2210
+ const issueLabels = new Set(issue.labels);
2211
+ if (config.exclude.some((label) => issueLabels.has(label))) {
2212
+ return false;
2213
+ }
2214
+ return includeLabels.size === 0 || config.include.some((label) => issueLabels.has(label));
2215
+ });
2216
+ emitPickupLabelFilterEvent({
2217
+ projectSlug,
2218
+ include: [...includeLabels],
2219
+ exclude: [...excludeLabels],
2220
+ includedCount: filtered.length,
2221
+ excludedCount: issues.length - filtered.length
2222
+ });
2223
+ return filtered;
2224
+ }
2170
2225
  function emitAssignedOnlyFilterEvent2(input) {
2171
2226
  console.info(
2172
2227
  JSON.stringify({
@@ -2179,6 +2234,19 @@ function emitAssignedOnlyFilterEvent2(input) {
2179
2234
  })
2180
2235
  );
2181
2236
  }
2237
+ function emitPickupLabelFilterEvent(input) {
2238
+ console.info(
2239
+ JSON.stringify({
2240
+ event: "tracker-pickup-label-filtered",
2241
+ tracker: "linear",
2242
+ projectSlug: input.projectSlug,
2243
+ include: input.include,
2244
+ exclude: input.exclude,
2245
+ includedCount: input.includedCount,
2246
+ excludedCount: input.excludedCount
2247
+ })
2248
+ );
2249
+ }
2182
2250
  function requireString(value, label) {
2183
2251
  if (typeof value !== "string" || value.length === 0) {
2184
2252
  throw new Error(`${label} is required.`);
@@ -2313,14 +2381,21 @@ async function syncRepositoryForRun(input) {
2313
2381
  });
2314
2382
  }
2315
2383
  async function ensureIssueWorkspaceRepository(input) {
2316
- const repositoryDirectory = input.existingWorkspace ? await syncExistingIssueWorkspaceRepository({
2317
- ...input,
2318
- skipPull: Boolean(input.pullRequestBranch)
2319
- }) : await cloneRepositoryForRun({
2384
+ let dirtyExistingWorkspaceAllowed = false;
2385
+ const repositoryDirectory = input.existingWorkspace ? await syncExistingIssueWorkspaceRepository(
2386
+ {
2387
+ ...input,
2388
+ skipPull: Boolean(input.pullRequestBranch),
2389
+ allowDirty: input.allowDirtyExistingWorkspace
2390
+ },
2391
+ (dirtyAllowed) => {
2392
+ dirtyExistingWorkspaceAllowed = dirtyAllowed;
2393
+ }
2394
+ ) : await cloneRepositoryForRun({
2320
2395
  repository: input.repository,
2321
2396
  targetDirectory: input.issueWorkspacePath
2322
2397
  });
2323
- if (input.pullRequestBranch) {
2398
+ if (input.pullRequestBranch && !dirtyExistingWorkspaceAllowed) {
2324
2399
  await checkoutPullRequestBranch(
2325
2400
  repositoryDirectory,
2326
2401
  input.pullRequestBranch
@@ -2328,6 +2403,20 @@ async function ensureIssueWorkspaceRepository(input) {
2328
2403
  }
2329
2404
  return repositoryDirectory;
2330
2405
  }
2406
+ async function inspectIssueWorkspaceDirtyStatus(input) {
2407
+ const repositoryDirectory = join(input.issueWorkspacePath, "repository");
2408
+ const hasGit = await pathExists(join(repositoryDirectory, ".git"));
2409
+ if (!hasGit) {
2410
+ return null;
2411
+ }
2412
+ const status = await readGitStatusPorcelain(repositoryDirectory);
2413
+ return {
2414
+ repositoryDirectory,
2415
+ dirty: status.trim().length > 0,
2416
+ dirtyFiles: parseGitStatusFiles(status),
2417
+ summary: status.trim() ? summarizeGitStatus(status) : null
2418
+ };
2419
+ }
2331
2420
  async function loadRepositoryWorkflow(repositoryDirectory, _repository) {
2332
2421
  const workflowPath = join(repositoryDirectory, "WORKFLOW.md");
2333
2422
  try {
@@ -2377,7 +2466,7 @@ async function readGitHead(repositoryDirectory) {
2377
2466
  return null;
2378
2467
  }
2379
2468
  }
2380
- async function syncExistingIssueWorkspaceRepository(input) {
2469
+ async function syncExistingIssueWorkspaceRepository(input, onDirtyAllowed) {
2381
2470
  await mkdir(input.issueWorkspacePath, { recursive: true });
2382
2471
  const repositoryDirectory = join(input.issueWorkspacePath, "repository");
2383
2472
  const lockDirectory = join(input.issueWorkspacePath, "repository.lock");
@@ -2394,6 +2483,10 @@ async function syncExistingIssueWorkspaceRepository(input) {
2394
2483
  `could not be inspected: ${formatCommandError(error, "git status --porcelain failed")}`
2395
2484
  );
2396
2485
  }
2486
+ if (dirtyStatus.trim() && input.allowDirty) {
2487
+ onDirtyAllowed?.(true);
2488
+ return repositoryDirectory;
2489
+ }
2397
2490
  if (dirtyStatus.trim()) {
2398
2491
  throw createIssueWorkspacePreservedError(
2399
2492
  repositoryDirectory,
@@ -2537,6 +2630,9 @@ function summarizeGitStatus(status) {
2537
2630
  const summary = lines.slice(0, 5).join("; ");
2538
2631
  return lines.length > 5 ? `${summary}; ...` : summary;
2539
2632
  }
2633
+ function parseGitStatusFiles(status) {
2634
+ return status.trim().split(/\r?\n/).map((line) => line.slice(3).trim()).filter(Boolean);
2635
+ }
2540
2636
  function normalizeWhitespace(value) {
2541
2637
  return value.replace(/\s+/g, " ").trim();
2542
2638
  }
@@ -3207,6 +3303,25 @@ function explainRuntimeOwnership(issue, issueRecords, runs) {
3207
3303
  };
3208
3304
  }
3209
3305
  }
3306
+ if (latestRun?.status === "suppressed" && latestRun.recovery?.kind === "incomplete-turn-dirty-workspace") {
3307
+ return {
3308
+ id: "runtime_ownership",
3309
+ status: "warn",
3310
+ message: "Latest run has a recoverable incomplete-turn dirty workspace; dispatch will start a recovery turn.",
3311
+ details: {
3312
+ runId: latestRun.recovery.runId,
3313
+ issueId: latestRun.recovery.issueId,
3314
+ workspacePath: latestRun.recovery.workspacePath,
3315
+ dirtyFiles: latestRun.recovery.dirtyFiles,
3316
+ lastEvent: latestRun.recovery.lastEvent,
3317
+ lastEventAt: latestRun.recovery.lastEventAt,
3318
+ sessionId: latestRun.recovery.sessionId,
3319
+ threadId: latestRun.recovery.threadId,
3320
+ suggestedCommand: latestRun.recovery.suggestedCommand
3321
+ },
3322
+ hint: latestRun.recovery.suggestedCommand
3323
+ };
3324
+ }
3210
3325
  return {
3211
3326
  id: "runtime_ownership",
3212
3327
  status: "pass",
@@ -3311,6 +3426,7 @@ var CONTINUATION_RETRY_DELAY_MS = 1e3;
3311
3426
  var DEFAULT_WORKER_COMMAND = "node packages/worker/dist/index.js";
3312
3427
  var DEFAULT_MAX_NONPRODUCTIVE_TURNS = 3;
3313
3428
  var LOW_RATE_LIMIT_WARNING_THRESHOLD = 0.05;
3429
+ var MAX_RECOVERY_DIRTY_FILES_IN_CONTEXT = 50;
3314
3430
  var MAX_FAILURE_RETRIES_EXCEEDED_REASON2 = "max_failure_retries_exceeded";
3315
3431
  var LINKED_PR_ACTIVE_ISSUE_INACTIVE_MARKER_PREFIX = "gh-symphony:linked-pr-active-while-issue-inactive";
3316
3432
  var STUCK_WORKER_TIMEOUT_MS = 30 * 60 * 1e3;
@@ -3556,6 +3672,7 @@ var OrchestratorService = class {
3556
3672
  kind: currentRun?.retryKind ?? null,
3557
3673
  error: currentRun?.lastError ?? issueRecord.retryEntry?.error ?? null
3558
3674
  } : null,
3675
+ recovery: currentRun?.recovery ?? null,
3559
3676
  logs: {
3560
3677
  codex_session_logs: currentRun === null ? [] : [
3561
3678
  {
@@ -3786,6 +3903,12 @@ var OrchestratorService = class {
3786
3903
  },
3787
3904
  issue.identifier
3788
3905
  );
3906
+ const recoveryContext = await this.resolveIncompleteTurnRecoveryContext(
3907
+ tenant,
3908
+ issue,
3909
+ latestRunsByIssueId.get(issue.id) ?? null,
3910
+ preferredWorkspaceKey
3911
+ );
3789
3912
  issueRecords = upsertIssueOrchestration(issueRecords, {
3790
3913
  issueId: issue.id,
3791
3914
  identifier: issue.identifier,
@@ -3798,7 +3921,9 @@ var OrchestratorService = class {
3798
3921
  });
3799
3922
  let run;
3800
3923
  try {
3801
- run = await this.startRun(tenant, issue);
3924
+ run = await this.startRun(tenant, issue, {
3925
+ recovery: recoveryContext
3926
+ });
3802
3927
  } catch (error) {
3803
3928
  issueRecords = releaseIssueOrchestration(issueRecords, issue.id, now);
3804
3929
  throw error;
@@ -3855,6 +3980,11 @@ var OrchestratorService = class {
3855
3980
  const activeWorkerPid = activeRun.processId;
3856
3981
  this.sendSignal(activeWorkerPid, "SIGTERM");
3857
3982
  this.retireWorkerPid(activeWorkerPid);
3983
+ const recovery = await this.classifyIncompleteTurnDirtyWorkspace(
3984
+ tenant,
3985
+ activeRun,
3986
+ now
3987
+ );
3858
3988
  const suppressedRun = {
3859
3989
  ...activeRun,
3860
3990
  status: "suppressed",
@@ -3862,7 +3992,17 @@ var OrchestratorService = class {
3862
3992
  completedAt: now.toISOString(),
3863
3993
  updatedAt: now.toISOString(),
3864
3994
  runPhase: "canceled_by_reconciliation",
3865
- lastError: "Run suppressed because the tracker issue is no longer tracked."
3995
+ runtimeSession: recovery ? buildRuntimeSession(
3996
+ activeRun.runtimeSession,
3997
+ recovery.sessionId,
3998
+ recovery.threadId,
3999
+ "completed",
4000
+ activeRun.runtimeSession?.startedAt ?? activeRun.startedAt ?? now.toISOString(),
4001
+ now.toISOString(),
4002
+ "incomplete-turn-dirty-workspace"
4003
+ ) : activeRun.runtimeSession,
4004
+ recovery,
4005
+ lastError: recovery ? "Run suppressed with recoverable incomplete-turn dirty workspace." : "Run suppressed because the tracker issue is no longer tracked."
3866
4006
  };
3867
4007
  await this.store.saveRun(suppressedRun);
3868
4008
  this.logVerbose(
@@ -3888,6 +4028,11 @@ var OrchestratorService = class {
3888
4028
  }
3889
4029
  if (activeRun) {
3890
4030
  const terminalState = isStateTerminal(issue.state, lifecycle);
4031
+ const recovery = terminalState ? null : await this.classifyIncompleteTurnDirtyWorkspace(
4032
+ tenant,
4033
+ activeRun,
4034
+ now
4035
+ );
3891
4036
  const suppressedRun = {
3892
4037
  ...activeRun,
3893
4038
  status: "suppressed",
@@ -3895,7 +4040,17 @@ var OrchestratorService = class {
3895
4040
  completedAt: now.toISOString(),
3896
4041
  updatedAt: now.toISOString(),
3897
4042
  runPhase: "canceled_by_reconciliation",
3898
- lastError: terminalState ? "Run suppressed because the tracker issue moved to a terminal state." : "Run suppressed because the tracker state is no longer actionable."
4043
+ runtimeSession: recovery ? buildRuntimeSession(
4044
+ activeRun.runtimeSession,
4045
+ recovery.sessionId,
4046
+ recovery.threadId,
4047
+ "completed",
4048
+ activeRun.runtimeSession?.startedAt ?? activeRun.startedAt ?? now.toISOString(),
4049
+ now.toISOString(),
4050
+ "incomplete-turn-dirty-workspace"
4051
+ ) : activeRun.runtimeSession,
4052
+ recovery,
4053
+ lastError: recovery ? "Run suppressed with recoverable incomplete-turn dirty workspace." : terminalState ? "Run suppressed because the tracker issue moved to a terminal state." : "Run suppressed because the tracker state is no longer actionable."
3899
4054
  };
3900
4055
  await this.store.saveRun(suppressedRun);
3901
4056
  this.logVerbose(
@@ -4247,7 +4402,7 @@ var OrchestratorService = class {
4247
4402
  true
4248
4403
  );
4249
4404
  }
4250
- async startRun(tenant, issue) {
4405
+ async startRun(tenant, issue, options = {}) {
4251
4406
  if (this.shuttingDown || !this.running) {
4252
4407
  throw new Error(
4253
4408
  "Orchestrator is shutting down and cannot start new runs."
@@ -4289,7 +4444,8 @@ var OrchestratorService = class {
4289
4444
  repository: issue.repository,
4290
4445
  issueWorkspacePath,
4291
4446
  existingWorkspace: Boolean(existingWorkspaceRecord),
4292
- pullRequestBranch
4447
+ pullRequestBranch,
4448
+ allowDirtyExistingWorkspace: options.recovery?.kind === "incomplete-turn-dirty-workspace"
4293
4449
  });
4294
4450
  if (!existingWorkspaceRecord) {
4295
4451
  const workspaceRecord = {
@@ -4340,9 +4496,10 @@ var OrchestratorService = class {
4340
4496
  attempt: null
4341
4497
  // first execution
4342
4498
  });
4343
- const renderedPrompt = renderPrompt(
4499
+ const renderedPrompt = appendIncompleteTurnRecoveryPrompt(
4344
4500
  workflow.promptTemplate,
4345
- promptVariables
4501
+ promptVariables,
4502
+ options.recovery ?? null
4346
4503
  );
4347
4504
  await this.runHook(
4348
4505
  "before_run",
@@ -4434,6 +4591,9 @@ var OrchestratorService = class {
4434
4591
  SYMPHONY_CUMULATIVE_OUTPUT_TOKENS: "0",
4435
4592
  SYMPHONY_CUMULATIVE_TOTAL_TOKENS: "0",
4436
4593
  SYMPHONY_LAST_TURN_SUMMARY: "",
4594
+ SYMPHONY_RECOVERY_KIND: options.recovery?.kind ?? "",
4595
+ SYMPHONY_RECOVERY_DIRTY_FILES: options.recovery ? formatRecoveryDirtyFilesForContext(options.recovery.dirtyFiles) : "",
4596
+ SYMPHONY_RECOVERY_SUGGESTED_COMMAND: options.recovery?.suggestedCommand ?? "",
4437
4597
  SYMPHONY_SESSION_STARTED_AT: "",
4438
4598
  SYMPHONY_READ_TIMEOUT_MS: String(runtimeTimeouts.readTimeoutMs),
4439
4599
  SYMPHONY_TURN_TIMEOUT_MS: String(runtimeTimeouts.turnTimeoutMs)
@@ -4547,7 +4707,7 @@ var OrchestratorService = class {
4547
4707
  issueWorkspaceKey: workspaceKey,
4548
4708
  workspaceRuntimeDir,
4549
4709
  workflowPath: workflow.workflowPath,
4550
- retryKind: null,
4710
+ retryKind: options.recovery ? "recovery" : null,
4551
4711
  threadId: null,
4552
4712
  cumulativeTurnCount: 0,
4553
4713
  lastTurnSummary: null,
@@ -4558,7 +4718,8 @@ var OrchestratorService = class {
4558
4718
  lastError: null,
4559
4719
  nextRetryAt: null,
4560
4720
  runPhase: "preparing_workspace",
4561
- rateLimits: issue.rateLimits ?? null
4721
+ rateLimits: issue.rateLimits ?? null,
4722
+ recovery: options.recovery ?? null
4562
4723
  };
4563
4724
  }
4564
4725
  async syncActiveRunIssueStates(tenant, trackerAdapter, activeRuns, now) {
@@ -5182,6 +5343,66 @@ var OrchestratorService = class {
5182
5343
  );
5183
5344
  return issue ? { issue, issues } : null;
5184
5345
  }
5346
+ async classifyIncompleteTurnDirtyWorkspace(tenant, run, now) {
5347
+ if (run.runtimeSession?.status !== "active" || run.runtimeSession.exitClassification !== null || run.lastEvent === "turn_completed") {
5348
+ return null;
5349
+ }
5350
+ const workspaceKey = run.issueWorkspaceKey ?? deriveIssueWorkspaceKey(
5351
+ {
5352
+ adapter: tenant.tracker.adapter,
5353
+ issueSubjectId: run.issueSubjectId
5354
+ },
5355
+ run.issueIdentifier
5356
+ );
5357
+ const issueWorkspacePath = resolveIssueWorkspaceDirectory(
5358
+ this.store.projectDir(tenant.projectId),
5359
+ workspaceKey
5360
+ );
5361
+ const dirtyStatus = await inspectIssueWorkspaceDirtyStatus({
5362
+ issueWorkspacePath
5363
+ });
5364
+ if (!dirtyStatus?.dirty) {
5365
+ return null;
5366
+ }
5367
+ return {
5368
+ kind: "incomplete-turn-dirty-workspace",
5369
+ runId: run.runId,
5370
+ issueId: run.issueId,
5371
+ issueIdentifier: run.issueIdentifier,
5372
+ workspacePath: dirtyStatus.repositoryDirectory,
5373
+ dirtyFiles: dirtyStatus.dirtyFiles,
5374
+ lastEvent: run.lastEvent ?? null,
5375
+ lastEventAt: run.lastEventAt ?? null,
5376
+ sessionId: run.runtimeSession.sessionId ?? null,
5377
+ threadId: run.threadId ?? run.runtimeSession.threadId ?? null,
5378
+ suggestedCommand: `cd ${shellQuote(dirtyStatus.repositoryDirectory)} && git status --short && git diff`,
5379
+ detectedAt: now.toISOString()
5380
+ };
5381
+ }
5382
+ async resolveIncompleteTurnRecoveryContext(tenant, issue, latestRun, preferredWorkspaceKey) {
5383
+ const recovery = latestRun?.recovery;
5384
+ if (latestRun?.status !== "suppressed" || recovery?.kind !== "incomplete-turn-dirty-workspace" || latestRun.runtimeSession?.exitClassification !== "incomplete-turn-dirty-workspace") {
5385
+ return null;
5386
+ }
5387
+ const workspaceKey = latestRun.issueWorkspaceKey ?? preferredWorkspaceKey;
5388
+ const dirtyStatus = await inspectIssueWorkspaceDirtyStatus({
5389
+ issueWorkspacePath: resolveIssueWorkspaceDirectory(
5390
+ this.store.projectDir(tenant.projectId),
5391
+ workspaceKey
5392
+ )
5393
+ });
5394
+ if (!dirtyStatus?.dirty) {
5395
+ return null;
5396
+ }
5397
+ return {
5398
+ ...recovery,
5399
+ issueId: issue.id,
5400
+ issueIdentifier: issue.identifier,
5401
+ workspacePath: dirtyStatus.repositoryDirectory,
5402
+ dirtyFiles: dirtyStatus.dirtyFiles,
5403
+ suggestedCommand: `cd ${shellQuote(dirtyStatus.repositoryDirectory)} && git status --short && git diff`
5404
+ };
5405
+ }
5185
5406
  async fetchWorkerRunInfo(run) {
5186
5407
  const latestRun = await this.store.loadRun(run.runId, run.projectId) ?? run;
5187
5408
  const persistedTokenUsage = await this.readPersistedWorkerTokenUsage(latestRun);
@@ -5758,9 +5979,50 @@ function buildRuntimeSession(existing, sessionId, threadId, status, startedAt, u
5758
5979
  exitClassification: exitClassification === void 0 ? existing?.exitClassification ?? null : exitClassification
5759
5980
  };
5760
5981
  }
5982
+ function appendIncompleteTurnRecoveryPrompt(promptTemplate, promptVariables, recovery) {
5983
+ const renderedPrompt = renderPrompt(promptTemplate, promptVariables);
5984
+ if (!recovery) {
5985
+ return renderedPrompt;
5986
+ }
5987
+ return [
5988
+ renderedPrompt,
5989
+ "",
5990
+ "## Recovery Context \u2014 Incomplete Turn Dirty Workspace",
5991
+ "",
5992
+ `Previous run: ${recovery.runId}`,
5993
+ `Workspace: ${recovery.workspacePath}`,
5994
+ `Last event: ${recovery.lastEvent ?? "unknown"}`,
5995
+ `Last event time: ${recovery.lastEventAt ?? "unknown"}`,
5996
+ `Session id: ${recovery.sessionId ?? "unknown"}`,
5997
+ `Thread id: ${recovery.threadId ?? "unknown"}`,
5998
+ "",
5999
+ "Dirty files:",
6000
+ ...formatRecoveryDirtyFileLinesForPrompt(recovery.dirtyFiles),
6001
+ "",
6002
+ "Inspect the dirty diff before editing. If the partial work is correct, validate it, commit it, and push it. If it is invalid, revert it explicitly and record a blocker/comment with the reason. Do not discard uncommitted work without making an intentional recovery decision.",
6003
+ `Suggested operator command: ${recovery.suggestedCommand}`
6004
+ ].join("\n");
6005
+ }
6006
+ function formatRecoveryDirtyFilesForContext(dirtyFiles) {
6007
+ return formatRecoveryDirtyFiles(dirtyFiles).join("\n");
6008
+ }
6009
+ function formatRecoveryDirtyFileLinesForPrompt(dirtyFiles) {
6010
+ return formatRecoveryDirtyFiles(dirtyFiles).map((file) => `- ${file}`);
6011
+ }
6012
+ function formatRecoveryDirtyFiles(dirtyFiles) {
6013
+ const visibleFiles = dirtyFiles.slice(0, MAX_RECOVERY_DIRTY_FILES_IN_CONTEXT);
6014
+ const remaining = dirtyFiles.length - visibleFiles.length;
6015
+ if (remaining <= 0) {
6016
+ return visibleFiles;
6017
+ }
6018
+ return [...visibleFiles, `... and ${remaining} more`];
6019
+ }
5761
6020
  function resolvePersistedCumulativeTurnCount(run) {
5762
6021
  return run.cumulativeTurnCount ?? run.turnCount ?? 0;
5763
6022
  }
6023
+ function shellQuote(value) {
6024
+ return `'${value.replace(/'/g, "'\\''")}'`;
6025
+ }
5764
6026
  function resolveCumulativeTurnCount(run, turnCount) {
5765
6027
  const carriedTotal = resolvePersistedCumulativeTurnCount(run);
5766
6028
  if (turnCount === null) {
@@ -10,7 +10,7 @@ import {
10
10
  resolveGitHubGraphQLToken,
11
11
  shouldReuseAgentCredentialCache,
12
12
  writeAgentCredentialCache
13
- } from "./chunk-5HQLPZR5.js";
13
+ } from "./chunk-YKC6CGD6.js";
14
14
 
15
15
  // ../runtime-codex/src/runtime.ts
16
16
  import { spawn } from "child_process";
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  resolveRepoRuntimeRoot
4
- } from "./chunk-RZ3WO7OV.js";
4
+ } from "./chunk-3IRPSPAF.js";
5
5
  import {
6
6
  parseWorkflowMarkdown
7
- } from "./chunk-5HQLPZR5.js";
7
+ } from "./chunk-YKC6CGD6.js";
8
8
  import {
9
9
  saveGlobalConfig,
10
10
  saveProjectConfig
11
- } from "./chunk-4ICDSQCJ.js";
11
+ } from "./chunk-YZP5N5XP.js";
12
12
 
13
13
  // src/repo-runtime.ts
14
14
  import { execFileSync } from "child_process";
@@ -68,6 +68,9 @@ async function initRepoRuntime(flags) {
68
68
  ...trackerAdapter === "linear" ? { activeStates: workflow.tracker.activeStates.join("\n") } : {},
69
69
  repository: `${repository.owner}/${repository.name}`
70
70
  };
71
+ if (trackerAdapter === "linear" && (workflow.tracker.pickupLabels.include.length > 0 || workflow.tracker.pickupLabels.exclude.length > 0)) {
72
+ trackerSettings.pickupLabels = workflow.tracker.pickupLabels;
73
+ }
71
74
  if (workflow.tracker.priorityFieldName) {
72
75
  trackerSettings.priorityFieldName = workflow.tracker.priorityFieldName;
73
76
  }