@gh-symphony/cli 0.2.5 → 0.4.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.
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
 
@@ -5,7 +5,8 @@ var DEFAULT_WORKFLOW_LIFECYCLE = {
5
5
  stateFieldName: "Status",
6
6
  activeStates: ["Todo", "In Progress"],
7
7
  terminalStates: ["Done"],
8
- blockerCheckStates: ["Todo"]
8
+ blockerCheckStates: [],
9
+ planningStates: []
9
10
  };
10
11
  function isStateActive(state, lifecycle) {
11
12
  return matchesWorkflowState(state, lifecycle.activeStates);
@@ -15,7 +16,9 @@ function isStateTerminal(state, lifecycle) {
15
16
  }
16
17
  function matchesWorkflowState(state, candidates) {
17
18
  const normalizedState = normalizeWorkflowState(state);
18
- return candidates.some((candidate) => normalizeWorkflowState(candidate) === normalizedState);
19
+ return candidates.some(
20
+ (candidate) => normalizeWorkflowState(candidate) === normalizedState
21
+ );
19
22
  }
20
23
  function normalizeWorkflowState(state) {
21
24
  return state.trim().toLowerCase();
@@ -55,7 +58,8 @@ var DEFAULT_WORKFLOW_TRACKER = {
55
58
  stateFieldName: DEFAULT_WORKFLOW_LIFECYCLE.stateFieldName,
56
59
  priority: null,
57
60
  priorityFieldName: null,
58
- blockerCheckStates: DEFAULT_WORKFLOW_LIFECYCLE.blockerCheckStates
61
+ blockerCheckStates: DEFAULT_WORKFLOW_LIFECYCLE.blockerCheckStates,
62
+ planningStates: DEFAULT_WORKFLOW_LIFECYCLE.planningStates
59
63
  };
60
64
  var DEFAULT_WORKFLOW_WORKSPACE = {
61
65
  root: null
@@ -135,6 +139,7 @@ function parseWorkflowMarkdown(markdown, env = process.env, options = {}) {
135
139
  const activeStates = readStringList(tracker, "active_states") ?? DEFAULT_WORKFLOW_TRACKER.activeStates;
136
140
  const terminalStates = readStringList(tracker, "terminal_states") ?? DEFAULT_WORKFLOW_TRACKER.terminalStates;
137
141
  const blockerCheckStates = readStringList(tracker, "blocker_check_states") ?? DEFAULT_WORKFLOW_TRACKER.blockerCheckStates;
142
+ const planningStates = readStringList(tracker, "planning_states") ?? blockerCheckStates;
138
143
  const maxConcurrentAgentsByState = readNumberMap(
139
144
  agent,
140
145
  "max_concurrent_agents_by_state"
@@ -172,7 +177,8 @@ function parseWorkflowMarkdown(markdown, env = process.env, options = {}) {
172
177
  stateFieldName: readOptionalString(tracker, "state_field", env) ?? DEFAULT_WORKFLOW_TRACKER.stateFieldName,
173
178
  priority: readPriorityConfig(tracker, env),
174
179
  priorityFieldName: readOptionalString(tracker, "priority_field", env),
175
- blockerCheckStates
180
+ blockerCheckStates,
181
+ planningStates
176
182
  },
177
183
  polling: {
178
184
  intervalMs: readOptionalIntegerLike(polling, "interval_ms") ?? DEFAULT_POLL_INTERVAL_MS
@@ -201,7 +207,8 @@ function parseWorkflowMarkdown(markdown, env = process.env, options = {}) {
201
207
  stateFieldName: readOptionalString(tracker, "state_field", env) ?? DEFAULT_WORKFLOW_TRACKER.stateFieldName,
202
208
  activeStates,
203
209
  terminalStates,
204
- blockerCheckStates
210
+ blockerCheckStates,
211
+ planningStates
205
212
  },
206
213
  format: "front-matter",
207
214
  githubProjectId: readOptionalString(tracker, "project_id", env),
@@ -451,7 +458,9 @@ function parseScalar(value) {
451
458
  return parsed;
452
459
  }
453
460
  } catch {
454
- throw new Error(`Invalid quoted workflow front matter scalar "${value}".`);
461
+ throw new Error(
462
+ `Invalid quoted workflow front matter scalar "${value}".`
463
+ );
455
464
  }
456
465
  }
457
466
  if (value.startsWith("'") && value.endsWith("'")) {
@@ -880,7 +889,8 @@ var SESSION_EXIT_CLASSIFICATIONS = [
880
889
  "max-turns-reached",
881
890
  "user-input-required",
882
891
  "timeout",
883
- "error"
892
+ "error",
893
+ "incomplete-turn-dirty-workspace"
884
894
  ];
885
895
  function isSessionExitClassification(value) {
886
896
  return typeof value === "string" && SESSION_EXIT_CLASSIFICATIONS.includes(value);
@@ -1462,11 +1472,30 @@ function buildProjectSnapshot(input) {
1462
1472
  retryKind: run.retryKind ?? "failure",
1463
1473
  nextRetryAt: run.nextRetryAt
1464
1474
  })),
1475
+ recovery: findLatestRecovery([...allRuns ?? [], ...activeRuns]),
1465
1476
  lastError,
1466
1477
  codexTotals: aggregateTokenUsage(allRuns ?? activeRuns, lastTickAt),
1467
1478
  rateLimits: rateLimits ?? null
1468
1479
  };
1469
1480
  }
1481
+ function findLatestRecovery(runs) {
1482
+ return [...runs].filter((run) => isUnresolvedRecoveryRun(run, runs)).sort((left, right) => {
1483
+ const leftTime = new Date(left.updatedAt).getTime();
1484
+ const rightTime = new Date(right.updatedAt).getTime();
1485
+ return rightTime - leftTime;
1486
+ }).find((run) => run.recovery)?.recovery ?? null;
1487
+ }
1488
+ function isUnresolvedRecoveryRun(run, runs) {
1489
+ if (!run.recovery) {
1490
+ return false;
1491
+ }
1492
+ if (run.status === "suppressed" && runs.some(
1493
+ (candidate) => candidate.runId !== run.runId && candidate.retryKind === "recovery" && candidate.recovery?.runId === run.recovery?.runId && new Date(candidate.updatedAt).getTime() > new Date(run.updatedAt).getTime() && candidate.status !== "running" && candidate.status !== "retrying"
1494
+ )) {
1495
+ return false;
1496
+ }
1497
+ return run.status === "suppressed" || run.retryKind === "recovery" && (run.status === "running" || run.status === "retrying");
1498
+ }
1470
1499
  function aggregateTokenUsageByIssue(runs) {
1471
1500
  const totals = /* @__PURE__ */ new Map();
1472
1501
  for (const run of runs) {
@@ -30,7 +30,7 @@ import {
30
30
  resolveWorkflowRuntimeTimeouts,
31
31
  safeReadDir,
32
32
  scheduleRetryAt
33
- } from "./chunk-3SKN5L3I.js";
33
+ } from "./chunk-6OPRRC2J.js";
34
34
  import {
35
35
  loadGlobalConfig,
36
36
  loadProjectConfig
@@ -2313,14 +2313,21 @@ async function syncRepositoryForRun(input) {
2313
2313
  });
2314
2314
  }
2315
2315
  async function ensureIssueWorkspaceRepository(input) {
2316
- const repositoryDirectory = input.existingWorkspace ? await syncExistingIssueWorkspaceRepository({
2317
- ...input,
2318
- skipPull: Boolean(input.pullRequestBranch)
2319
- }) : await cloneRepositoryForRun({
2316
+ let dirtyExistingWorkspaceAllowed = false;
2317
+ const repositoryDirectory = input.existingWorkspace ? await syncExistingIssueWorkspaceRepository(
2318
+ {
2319
+ ...input,
2320
+ skipPull: Boolean(input.pullRequestBranch),
2321
+ allowDirty: input.allowDirtyExistingWorkspace
2322
+ },
2323
+ (dirtyAllowed) => {
2324
+ dirtyExistingWorkspaceAllowed = dirtyAllowed;
2325
+ }
2326
+ ) : await cloneRepositoryForRun({
2320
2327
  repository: input.repository,
2321
2328
  targetDirectory: input.issueWorkspacePath
2322
2329
  });
2323
- if (input.pullRequestBranch) {
2330
+ if (input.pullRequestBranch && !dirtyExistingWorkspaceAllowed) {
2324
2331
  await checkoutPullRequestBranch(
2325
2332
  repositoryDirectory,
2326
2333
  input.pullRequestBranch
@@ -2328,6 +2335,20 @@ async function ensureIssueWorkspaceRepository(input) {
2328
2335
  }
2329
2336
  return repositoryDirectory;
2330
2337
  }
2338
+ async function inspectIssueWorkspaceDirtyStatus(input) {
2339
+ const repositoryDirectory = join(input.issueWorkspacePath, "repository");
2340
+ const hasGit = await pathExists(join(repositoryDirectory, ".git"));
2341
+ if (!hasGit) {
2342
+ return null;
2343
+ }
2344
+ const status = await readGitStatusPorcelain(repositoryDirectory);
2345
+ return {
2346
+ repositoryDirectory,
2347
+ dirty: status.trim().length > 0,
2348
+ dirtyFiles: parseGitStatusFiles(status),
2349
+ summary: status.trim() ? summarizeGitStatus(status) : null
2350
+ };
2351
+ }
2331
2352
  async function loadRepositoryWorkflow(repositoryDirectory, _repository) {
2332
2353
  const workflowPath = join(repositoryDirectory, "WORKFLOW.md");
2333
2354
  try {
@@ -2377,7 +2398,7 @@ async function readGitHead(repositoryDirectory) {
2377
2398
  return null;
2378
2399
  }
2379
2400
  }
2380
- async function syncExistingIssueWorkspaceRepository(input) {
2401
+ async function syncExistingIssueWorkspaceRepository(input, onDirtyAllowed) {
2381
2402
  await mkdir(input.issueWorkspacePath, { recursive: true });
2382
2403
  const repositoryDirectory = join(input.issueWorkspacePath, "repository");
2383
2404
  const lockDirectory = join(input.issueWorkspacePath, "repository.lock");
@@ -2394,6 +2415,10 @@ async function syncExistingIssueWorkspaceRepository(input) {
2394
2415
  `could not be inspected: ${formatCommandError(error, "git status --porcelain failed")}`
2395
2416
  );
2396
2417
  }
2418
+ if (dirtyStatus.trim() && input.allowDirty) {
2419
+ onDirtyAllowed?.(true);
2420
+ return repositoryDirectory;
2421
+ }
2397
2422
  if (dirtyStatus.trim()) {
2398
2423
  throw createIssueWorkspacePreservedError(
2399
2424
  repositoryDirectory,
@@ -2537,6 +2562,9 @@ function summarizeGitStatus(status) {
2537
2562
  const summary = lines.slice(0, 5).join("; ");
2538
2563
  return lines.length > 5 ? `${summary}; ...` : summary;
2539
2564
  }
2565
+ function parseGitStatusFiles(status) {
2566
+ return status.trim().split(/\r?\n/).map((line) => line.slice(3).trim()).filter(Boolean);
2567
+ }
2540
2568
  function normalizeWhitespace(value) {
2541
2569
  return value.replace(/\s+/g, " ").trim();
2542
2570
  }
@@ -3207,6 +3235,25 @@ function explainRuntimeOwnership(issue, issueRecords, runs) {
3207
3235
  };
3208
3236
  }
3209
3237
  }
3238
+ if (latestRun?.status === "suppressed" && latestRun.recovery?.kind === "incomplete-turn-dirty-workspace") {
3239
+ return {
3240
+ id: "runtime_ownership",
3241
+ status: "warn",
3242
+ message: "Latest run has a recoverable incomplete-turn dirty workspace; dispatch will start a recovery turn.",
3243
+ details: {
3244
+ runId: latestRun.recovery.runId,
3245
+ issueId: latestRun.recovery.issueId,
3246
+ workspacePath: latestRun.recovery.workspacePath,
3247
+ dirtyFiles: latestRun.recovery.dirtyFiles,
3248
+ lastEvent: latestRun.recovery.lastEvent,
3249
+ lastEventAt: latestRun.recovery.lastEventAt,
3250
+ sessionId: latestRun.recovery.sessionId,
3251
+ threadId: latestRun.recovery.threadId,
3252
+ suggestedCommand: latestRun.recovery.suggestedCommand
3253
+ },
3254
+ hint: latestRun.recovery.suggestedCommand
3255
+ };
3256
+ }
3210
3257
  return {
3211
3258
  id: "runtime_ownership",
3212
3259
  status: "pass",
@@ -3311,6 +3358,7 @@ var CONTINUATION_RETRY_DELAY_MS = 1e3;
3311
3358
  var DEFAULT_WORKER_COMMAND = "node packages/worker/dist/index.js";
3312
3359
  var DEFAULT_MAX_NONPRODUCTIVE_TURNS = 3;
3313
3360
  var LOW_RATE_LIMIT_WARNING_THRESHOLD = 0.05;
3361
+ var MAX_RECOVERY_DIRTY_FILES_IN_CONTEXT = 50;
3314
3362
  var MAX_FAILURE_RETRIES_EXCEEDED_REASON2 = "max_failure_retries_exceeded";
3315
3363
  var LINKED_PR_ACTIVE_ISSUE_INACTIVE_MARKER_PREFIX = "gh-symphony:linked-pr-active-while-issue-inactive";
3316
3364
  var STUCK_WORKER_TIMEOUT_MS = 30 * 60 * 1e3;
@@ -3556,6 +3604,7 @@ var OrchestratorService = class {
3556
3604
  kind: currentRun?.retryKind ?? null,
3557
3605
  error: currentRun?.lastError ?? issueRecord.retryEntry?.error ?? null
3558
3606
  } : null,
3607
+ recovery: currentRun?.recovery ?? null,
3559
3608
  logs: {
3560
3609
  codex_session_logs: currentRun === null ? [] : [
3561
3610
  {
@@ -3786,6 +3835,12 @@ var OrchestratorService = class {
3786
3835
  },
3787
3836
  issue.identifier
3788
3837
  );
3838
+ const recoveryContext = await this.resolveIncompleteTurnRecoveryContext(
3839
+ tenant,
3840
+ issue,
3841
+ latestRunsByIssueId.get(issue.id) ?? null,
3842
+ preferredWorkspaceKey
3843
+ );
3789
3844
  issueRecords = upsertIssueOrchestration(issueRecords, {
3790
3845
  issueId: issue.id,
3791
3846
  identifier: issue.identifier,
@@ -3798,7 +3853,9 @@ var OrchestratorService = class {
3798
3853
  });
3799
3854
  let run;
3800
3855
  try {
3801
- run = await this.startRun(tenant, issue);
3856
+ run = await this.startRun(tenant, issue, {
3857
+ recovery: recoveryContext
3858
+ });
3802
3859
  } catch (error) {
3803
3860
  issueRecords = releaseIssueOrchestration(issueRecords, issue.id, now);
3804
3861
  throw error;
@@ -3855,6 +3912,11 @@ var OrchestratorService = class {
3855
3912
  const activeWorkerPid = activeRun.processId;
3856
3913
  this.sendSignal(activeWorkerPid, "SIGTERM");
3857
3914
  this.retireWorkerPid(activeWorkerPid);
3915
+ const recovery = await this.classifyIncompleteTurnDirtyWorkspace(
3916
+ tenant,
3917
+ activeRun,
3918
+ now
3919
+ );
3858
3920
  const suppressedRun = {
3859
3921
  ...activeRun,
3860
3922
  status: "suppressed",
@@ -3862,7 +3924,17 @@ var OrchestratorService = class {
3862
3924
  completedAt: now.toISOString(),
3863
3925
  updatedAt: now.toISOString(),
3864
3926
  runPhase: "canceled_by_reconciliation",
3865
- lastError: "Run suppressed because the tracker issue is no longer tracked."
3927
+ runtimeSession: recovery ? buildRuntimeSession(
3928
+ activeRun.runtimeSession,
3929
+ recovery.sessionId,
3930
+ recovery.threadId,
3931
+ "completed",
3932
+ activeRun.runtimeSession?.startedAt ?? activeRun.startedAt ?? now.toISOString(),
3933
+ now.toISOString(),
3934
+ "incomplete-turn-dirty-workspace"
3935
+ ) : activeRun.runtimeSession,
3936
+ recovery,
3937
+ lastError: recovery ? "Run suppressed with recoverable incomplete-turn dirty workspace." : "Run suppressed because the tracker issue is no longer tracked."
3866
3938
  };
3867
3939
  await this.store.saveRun(suppressedRun);
3868
3940
  this.logVerbose(
@@ -3888,6 +3960,11 @@ var OrchestratorService = class {
3888
3960
  }
3889
3961
  if (activeRun) {
3890
3962
  const terminalState = isStateTerminal(issue.state, lifecycle);
3963
+ const recovery = terminalState ? null : await this.classifyIncompleteTurnDirtyWorkspace(
3964
+ tenant,
3965
+ activeRun,
3966
+ now
3967
+ );
3891
3968
  const suppressedRun = {
3892
3969
  ...activeRun,
3893
3970
  status: "suppressed",
@@ -3895,7 +3972,17 @@ var OrchestratorService = class {
3895
3972
  completedAt: now.toISOString(),
3896
3973
  updatedAt: now.toISOString(),
3897
3974
  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."
3975
+ runtimeSession: recovery ? buildRuntimeSession(
3976
+ activeRun.runtimeSession,
3977
+ recovery.sessionId,
3978
+ recovery.threadId,
3979
+ "completed",
3980
+ activeRun.runtimeSession?.startedAt ?? activeRun.startedAt ?? now.toISOString(),
3981
+ now.toISOString(),
3982
+ "incomplete-turn-dirty-workspace"
3983
+ ) : activeRun.runtimeSession,
3984
+ recovery,
3985
+ 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
3986
  };
3900
3987
  await this.store.saveRun(suppressedRun);
3901
3988
  this.logVerbose(
@@ -4156,7 +4243,8 @@ var OrchestratorService = class {
4156
4243
  stateFieldName: "Status",
4157
4244
  activeStates: ["Todo", "In Progress"],
4158
4245
  terminalStates: ["Done"],
4159
- blockerCheckStates: ["Todo"]
4246
+ blockerCheckStates: ["Todo"],
4247
+ planningStates: ["Todo"]
4160
4248
  }
4161
4249
  };
4162
4250
  }
@@ -4246,7 +4334,7 @@ var OrchestratorService = class {
4246
4334
  true
4247
4335
  );
4248
4336
  }
4249
- async startRun(tenant, issue) {
4337
+ async startRun(tenant, issue, options = {}) {
4250
4338
  if (this.shuttingDown || !this.running) {
4251
4339
  throw new Error(
4252
4340
  "Orchestrator is shutting down and cannot start new runs."
@@ -4288,7 +4376,8 @@ var OrchestratorService = class {
4288
4376
  repository: issue.repository,
4289
4377
  issueWorkspacePath,
4290
4378
  existingWorkspace: Boolean(existingWorkspaceRecord),
4291
- pullRequestBranch
4379
+ pullRequestBranch,
4380
+ allowDirtyExistingWorkspace: options.recovery?.kind === "incomplete-turn-dirty-workspace"
4292
4381
  });
4293
4382
  if (!existingWorkspaceRecord) {
4294
4383
  const workspaceRecord = {
@@ -4339,9 +4428,10 @@ var OrchestratorService = class {
4339
4428
  attempt: null
4340
4429
  // first execution
4341
4430
  });
4342
- const renderedPrompt = renderPrompt(
4431
+ const renderedPrompt = appendIncompleteTurnRecoveryPrompt(
4343
4432
  workflow.promptTemplate,
4344
- promptVariables
4433
+ promptVariables,
4434
+ options.recovery ?? null
4345
4435
  );
4346
4436
  await this.runHook(
4347
4437
  "before_run",
@@ -4433,6 +4523,9 @@ var OrchestratorService = class {
4433
4523
  SYMPHONY_CUMULATIVE_OUTPUT_TOKENS: "0",
4434
4524
  SYMPHONY_CUMULATIVE_TOTAL_TOKENS: "0",
4435
4525
  SYMPHONY_LAST_TURN_SUMMARY: "",
4526
+ SYMPHONY_RECOVERY_KIND: options.recovery?.kind ?? "",
4527
+ SYMPHONY_RECOVERY_DIRTY_FILES: options.recovery ? formatRecoveryDirtyFilesForContext(options.recovery.dirtyFiles) : "",
4528
+ SYMPHONY_RECOVERY_SUGGESTED_COMMAND: options.recovery?.suggestedCommand ?? "",
4436
4529
  SYMPHONY_SESSION_STARTED_AT: "",
4437
4530
  SYMPHONY_READ_TIMEOUT_MS: String(runtimeTimeouts.readTimeoutMs),
4438
4531
  SYMPHONY_TURN_TIMEOUT_MS: String(runtimeTimeouts.turnTimeoutMs)
@@ -4546,7 +4639,7 @@ var OrchestratorService = class {
4546
4639
  issueWorkspaceKey: workspaceKey,
4547
4640
  workspaceRuntimeDir,
4548
4641
  workflowPath: workflow.workflowPath,
4549
- retryKind: null,
4642
+ retryKind: options.recovery ? "recovery" : null,
4550
4643
  threadId: null,
4551
4644
  cumulativeTurnCount: 0,
4552
4645
  lastTurnSummary: null,
@@ -4557,7 +4650,8 @@ var OrchestratorService = class {
4557
4650
  lastError: null,
4558
4651
  nextRetryAt: null,
4559
4652
  runPhase: "preparing_workspace",
4560
- rateLimits: issue.rateLimits ?? null
4653
+ rateLimits: issue.rateLimits ?? null,
4654
+ recovery: options.recovery ?? null
4561
4655
  };
4562
4656
  }
4563
4657
  async syncActiveRunIssueStates(tenant, trackerAdapter, activeRuns, now) {
@@ -5181,6 +5275,66 @@ var OrchestratorService = class {
5181
5275
  );
5182
5276
  return issue ? { issue, issues } : null;
5183
5277
  }
5278
+ async classifyIncompleteTurnDirtyWorkspace(tenant, run, now) {
5279
+ if (run.runtimeSession?.status !== "active" || run.runtimeSession.exitClassification !== null || run.lastEvent === "turn_completed") {
5280
+ return null;
5281
+ }
5282
+ const workspaceKey = run.issueWorkspaceKey ?? deriveIssueWorkspaceKey(
5283
+ {
5284
+ adapter: tenant.tracker.adapter,
5285
+ issueSubjectId: run.issueSubjectId
5286
+ },
5287
+ run.issueIdentifier
5288
+ );
5289
+ const issueWorkspacePath = resolveIssueWorkspaceDirectory(
5290
+ this.store.projectDir(tenant.projectId),
5291
+ workspaceKey
5292
+ );
5293
+ const dirtyStatus = await inspectIssueWorkspaceDirtyStatus({
5294
+ issueWorkspacePath
5295
+ });
5296
+ if (!dirtyStatus?.dirty) {
5297
+ return null;
5298
+ }
5299
+ return {
5300
+ kind: "incomplete-turn-dirty-workspace",
5301
+ runId: run.runId,
5302
+ issueId: run.issueId,
5303
+ issueIdentifier: run.issueIdentifier,
5304
+ workspacePath: dirtyStatus.repositoryDirectory,
5305
+ dirtyFiles: dirtyStatus.dirtyFiles,
5306
+ lastEvent: run.lastEvent ?? null,
5307
+ lastEventAt: run.lastEventAt ?? null,
5308
+ sessionId: run.runtimeSession.sessionId ?? null,
5309
+ threadId: run.threadId ?? run.runtimeSession.threadId ?? null,
5310
+ suggestedCommand: `cd ${shellQuote(dirtyStatus.repositoryDirectory)} && git status --short && git diff`,
5311
+ detectedAt: now.toISOString()
5312
+ };
5313
+ }
5314
+ async resolveIncompleteTurnRecoveryContext(tenant, issue, latestRun, preferredWorkspaceKey) {
5315
+ const recovery = latestRun?.recovery;
5316
+ if (latestRun?.status !== "suppressed" || recovery?.kind !== "incomplete-turn-dirty-workspace" || latestRun.runtimeSession?.exitClassification !== "incomplete-turn-dirty-workspace") {
5317
+ return null;
5318
+ }
5319
+ const workspaceKey = latestRun.issueWorkspaceKey ?? preferredWorkspaceKey;
5320
+ const dirtyStatus = await inspectIssueWorkspaceDirtyStatus({
5321
+ issueWorkspacePath: resolveIssueWorkspaceDirectory(
5322
+ this.store.projectDir(tenant.projectId),
5323
+ workspaceKey
5324
+ )
5325
+ });
5326
+ if (!dirtyStatus?.dirty) {
5327
+ return null;
5328
+ }
5329
+ return {
5330
+ ...recovery,
5331
+ issueId: issue.id,
5332
+ issueIdentifier: issue.identifier,
5333
+ workspacePath: dirtyStatus.repositoryDirectory,
5334
+ dirtyFiles: dirtyStatus.dirtyFiles,
5335
+ suggestedCommand: `cd ${shellQuote(dirtyStatus.repositoryDirectory)} && git status --short && git diff`
5336
+ };
5337
+ }
5184
5338
  async fetchWorkerRunInfo(run) {
5185
5339
  const latestRun = await this.store.loadRun(run.runId, run.projectId) ?? run;
5186
5340
  const persistedTokenUsage = await this.readPersistedWorkerTokenUsage(latestRun);
@@ -5757,9 +5911,50 @@ function buildRuntimeSession(existing, sessionId, threadId, status, startedAt, u
5757
5911
  exitClassification: exitClassification === void 0 ? existing?.exitClassification ?? null : exitClassification
5758
5912
  };
5759
5913
  }
5914
+ function appendIncompleteTurnRecoveryPrompt(promptTemplate, promptVariables, recovery) {
5915
+ const renderedPrompt = renderPrompt(promptTemplate, promptVariables);
5916
+ if (!recovery) {
5917
+ return renderedPrompt;
5918
+ }
5919
+ return [
5920
+ renderedPrompt,
5921
+ "",
5922
+ "## Recovery Context \u2014 Incomplete Turn Dirty Workspace",
5923
+ "",
5924
+ `Previous run: ${recovery.runId}`,
5925
+ `Workspace: ${recovery.workspacePath}`,
5926
+ `Last event: ${recovery.lastEvent ?? "unknown"}`,
5927
+ `Last event time: ${recovery.lastEventAt ?? "unknown"}`,
5928
+ `Session id: ${recovery.sessionId ?? "unknown"}`,
5929
+ `Thread id: ${recovery.threadId ?? "unknown"}`,
5930
+ "",
5931
+ "Dirty files:",
5932
+ ...formatRecoveryDirtyFileLinesForPrompt(recovery.dirtyFiles),
5933
+ "",
5934
+ "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.",
5935
+ `Suggested operator command: ${recovery.suggestedCommand}`
5936
+ ].join("\n");
5937
+ }
5938
+ function formatRecoveryDirtyFilesForContext(dirtyFiles) {
5939
+ return formatRecoveryDirtyFiles(dirtyFiles).join("\n");
5940
+ }
5941
+ function formatRecoveryDirtyFileLinesForPrompt(dirtyFiles) {
5942
+ return formatRecoveryDirtyFiles(dirtyFiles).map((file) => `- ${file}`);
5943
+ }
5944
+ function formatRecoveryDirtyFiles(dirtyFiles) {
5945
+ const visibleFiles = dirtyFiles.slice(0, MAX_RECOVERY_DIRTY_FILES_IN_CONTEXT);
5946
+ const remaining = dirtyFiles.length - visibleFiles.length;
5947
+ if (remaining <= 0) {
5948
+ return visibleFiles;
5949
+ }
5950
+ return [...visibleFiles, `... and ${remaining} more`];
5951
+ }
5760
5952
  function resolvePersistedCumulativeTurnCount(run) {
5761
5953
  return run.cumulativeTurnCount ?? run.turnCount ?? 0;
5762
5954
  }
5955
+ function shellQuote(value) {
5956
+ return `'${value.replace(/'/g, "'\\''")}'`;
5957
+ }
5763
5958
  function resolveCumulativeTurnCount(run, turnCount) {
5764
5959
  const carriedTotal = resolvePersistedCumulativeTurnCount(run);
5765
5960
  if (turnCount === null) {
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-RZ3WO7OV.js";
5
5
  import {
6
6
  parseWorkflowMarkdown
7
- } from "./chunk-3SKN5L3I.js";
7
+ } from "./chunk-6OPRRC2J.js";
8
8
  import {
9
9
  saveGlobalConfig,
10
10
  saveProjectConfig
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  workflow_init_default
4
- } from "./chunk-DTPIJO6S.js";
4
+ } from "./chunk-ZPS4CQZJ.js";
5
5
  import {
6
6
  fetchGithubProjectIssueByRepositoryAndNumber,
7
7
  inspectManagedProjectSelection,
8
8
  resolveTrackerAdapter
9
- } from "./chunk-NRABQNAX.js";
9
+ } from "./chunk-B6G3KGBB.js";
10
10
  import {
11
11
  GitHubApiError,
12
12
  createClient,
@@ -19,7 +19,7 @@ import {
19
19
  buildPromptVariables,
20
20
  parseWorkflowMarkdown,
21
21
  renderPrompt
22
- } from "./chunk-3SKN5L3I.js";
22
+ } from "./chunk-6OPRRC2J.js";
23
23
  import {
24
24
  loadActiveProjectConfig
25
25
  } from "./chunk-4ICDSQCJ.js";
@@ -705,7 +705,9 @@ async function loadLinearIssue(issueIdentifier, workflow, options) {
705
705
  repository: projectConfig.repository,
706
706
  tracker: projectConfig.tracker
707
707
  };
708
- const trackerAdapter = workflowCommandDependencies.resolveTrackerAdapter(projectConfig.tracker);
708
+ const trackerAdapter = workflowCommandDependencies.resolveTrackerAdapter(
709
+ projectConfig.tracker
710
+ );
709
711
  const [issue] = await trackerAdapter.fetchIssueStatesByIds(
710
712
  orchestratorProject,
711
713
  [issueIdentifier.trim().toUpperCase()],
@@ -752,6 +754,7 @@ function validateWorkflow(workflowPath, markdown) {
752
754
  activeStates: workflow.lifecycle.activeStates,
753
755
  terminalStates: workflow.lifecycle.terminalStates,
754
756
  blockerCheckStates: workflow.lifecycle.blockerCheckStates,
757
+ planningStates: workflow.lifecycle.planningStates,
755
758
  pollingIntervalMs: workflow.polling.intervalMs,
756
759
  workspaceRoot: workflow.workspace.root,
757
760
  agentCommand: workflow.agentCommand,
@@ -791,6 +794,7 @@ Lifecycle
791
794
  active_states=${report.summary.activeStates.join(", ") || "(none)"}
792
795
  terminal_states=${report.summary.terminalStates.join(", ") || "(none)"}
793
796
  blocker_check_states=${report.summary.blockerCheckStates.join(", ") || "(none)"}
797
+ planning_states=${report.summary.planningStates.join(", ") || "(none)"}
794
798
 
795
799
  Runtime
796
800
  polling.interval_ms=${report.summary.pollingIntervalMs}
@@ -10,7 +10,7 @@ import {
10
10
  resolveGitHubGraphQLToken,
11
11
  shouldReuseAgentCredentialCache,
12
12
  writeAgentCredentialCache
13
- } from "./chunk-3SKN5L3I.js";
13
+ } from "./chunk-6OPRRC2J.js";
14
14
 
15
15
  // ../runtime-codex/src/runtime.ts
16
16
  import { spawn } from "child_process";