@moretea-labs/forge 1.5.0-rc.1 → 1.5.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 (259) hide show
  1. package/AGENTS.md +9 -11
  2. package/CHANGELOG.md +19 -0
  3. package/CLAUDE.md +8 -10
  4. package/DESIGN.md +278 -0
  5. package/README.md +70 -90
  6. package/README.zh-CN.md +50 -55
  7. package/SKILL.md +8 -11
  8. package/THIRD_PARTY_NOTICES.md +9 -3
  9. package/assets/branding/README.md +4 -2
  10. package/assets/hooks/lib/workflow-state.sh +19 -18
  11. package/assets/hooks/post-edit-guard.sh +6 -75
  12. package/assets/hooks/prompt-guard.sh +24 -12
  13. package/assets/hooks/session-start-context.sh +17 -17
  14. package/assets/hooks/stop-orchestrator.sh +2 -2
  15. package/assets/partials/04-project-structure.partial.md +6 -6
  16. package/assets/partials/05-workflow.partial.md +6 -6
  17. package/assets/partials/07-footer.partial.md +1 -1
  18. package/assets/partials/08-orchestration.partial.md +2 -2
  19. package/assets/partials-agents/02-operating-mode.partial.md +5 -5
  20. package/assets/partials-agents/03-orchestration.partial.md +3 -3
  21. package/assets/partials-agents/04-task-protocol.partial.md +3 -3
  22. package/assets/partials-agents/08-deep-docs.partial.md +1 -1
  23. package/assets/reference-configs/agentic-development-flow.md +49 -86
  24. package/assets/reference-configs/external-tooling.md +21 -61
  25. package/assets/reference-configs/handoff-protocol.md +25 -20
  26. package/assets/reference-configs/harness-overview.md +8 -8
  27. package/assets/templates/helpers/archive-workflow.sh +0 -1
  28. package/assets/templates/helpers/capture-plan.sh +3 -3
  29. package/assets/templates/helpers/check-task-workflow.sh +2 -2
  30. package/assets/templates/helpers/codex-handoff-resume.sh +5 -5
  31. package/assets/templates/helpers/ensure-task-workflow.sh +37 -35
  32. package/assets/templates/helpers/new-plan.sh +1 -1
  33. package/assets/templates/helpers/plan-to-todo.sh +2 -2
  34. package/assets/templates/helpers/prepare-codex-handoff.sh +2 -2
  35. package/assets/templates/helpers/prepare-handoff.sh +9 -9
  36. package/assets/templates/helpers/refresh-current-status.sh +2 -2
  37. package/assets/templates/helpers/ship-worktrees.sh +1 -1
  38. package/assets/templates/helpers/sprint-backlog.sh +1 -1
  39. package/assets/templates/helpers/verify-sprint.sh +7 -7
  40. package/assets/templates/helpers/workstream-sync.sh +1 -1
  41. package/assets/templates/plan.template.md +1 -1
  42. package/assets/workflow-contract.v1.json +6 -3
  43. package/docs/architecture/current/README.md +10 -9
  44. package/docs/architecture/current/approved-target-architecture.zh-CN.md +2 -2
  45. package/docs/architecture/current/architecture-invariants.md +2 -2
  46. package/docs/architecture/current/automation-and-schedule-engine.md +15 -15
  47. package/docs/architecture/current/codegraph-context-provider.md +2 -2
  48. package/docs/architecture/current/control-plane-authority-inventory.md +12 -0
  49. package/docs/architecture/current/dispatch-and-agent-strategy.md +11 -11
  50. package/docs/architecture/current/entity-model.md +0 -27
  51. package/docs/architecture/current/failure-recovery.md +0 -24
  52. package/docs/architecture/current/governance.md +1 -1
  53. package/docs/architecture/current/implementation-status.md +1 -1
  54. package/docs/architecture/current/legacy-governance.md +58 -0
  55. package/docs/architecture/current/multi-repository-execution.md +1 -1
  56. package/docs/architecture/current/peer-supercontroller-runtime.md +1 -1
  57. package/docs/architecture/current/personal-assistant-plugin-baseline.md +4 -5
  58. package/docs/architecture/current/scheduler-and-resource-claims.md +0 -10
  59. package/docs/architecture/current/target-requirements-traceability.md +2 -2
  60. package/docs/architecture/current/thin-harness-governance-policy.md +1 -1
  61. package/docs/architecture/current/thin-harness-v1.md +9 -11
  62. package/docs/architecture/domains/runtime-harness.md +1 -1
  63. package/docs/forge-autonomous-goal-loop.md +3 -1
  64. package/docs/forge-chatgpt-mcp-setup.md +51 -184
  65. package/docs/images/forge-banner-cn.svg +26 -1
  66. package/docs/images/forge-banner.svg +26 -1
  67. package/docs/operations/features.md +1 -1
  68. package/docs/operations/homebrew.md +3 -3
  69. package/docs/operations/mcp-tool-exposure.md +5 -5
  70. package/docs/operations/platform-support.md +45 -37
  71. package/docs/operations/platform-support.zh-CN.md +40 -38
  72. package/docs/operations/releasing.md +3 -3
  73. package/docs/operations/releasing.zh-CN.md +3 -3
  74. package/docs/operations/troubleshooting.md +11 -6
  75. package/docs/operations/troubleshooting.zh-CN.md +11 -6
  76. package/docs/public-usage-guide.md +28 -21
  77. package/docs/public-usage-guide.zh-CN.md +28 -21
  78. package/docs/tutorials/01-install-and-start.md +56 -35
  79. package/docs/tutorials/01-install-and-start.zh-CN.md +56 -35
  80. package/docs/tutorials/02-connect-chatgpt.md +112 -46
  81. package/docs/tutorials/02-connect-chatgpt.zh-CN.md +113 -45
  82. package/docs/wiki/Architecture.md +1 -1
  83. package/docs/wiki/Installation.md +2 -2
  84. package/docs/wiki/Quick-Start.md +1 -1
  85. package/docs/wiki/Work-Lifecycle.md +1 -1
  86. package/install.ps1 +4 -5
  87. package/install.sh +4 -6
  88. package/package.json +15 -5
  89. package/scripts/archive-workflow.sh +0 -1
  90. package/scripts/benchmark-thin-harness-mcp-e2e.ts +27 -14
  91. package/scripts/build-controller-ui.ts +28 -0
  92. package/scripts/capture-plan.sh +4 -4
  93. package/scripts/check-agent-tooling.sh +12 -1
  94. package/scripts/check-platform-support.mjs +2 -2
  95. package/scripts/check-public-docs.sh +16 -5
  96. package/scripts/check-release-readiness.sh +5 -11
  97. package/scripts/check-runtime-architecture.mjs +14 -22
  98. package/scripts/check-tarball-install-smoke.sh +3 -2
  99. package/scripts/check-task-workflow.sh +2 -2
  100. package/scripts/codex-handoff-resume.sh +5 -5
  101. package/scripts/create-project-dirs.sh +3 -3
  102. package/scripts/ensure-task-workflow.sh +45 -35
  103. package/scripts/init-project.sh +4 -4
  104. package/scripts/install-desktop-operator-registration.ts +2 -2
  105. package/scripts/lib/project-init-lib.sh +46 -44
  106. package/scripts/migrate-project-template.sh +7 -4
  107. package/scripts/new-plan.sh +1 -1
  108. package/scripts/plan-to-todo.sh +2 -2
  109. package/scripts/prepare-codex-handoff.sh +2 -2
  110. package/scripts/prepare-handoff.sh +9 -9
  111. package/scripts/refresh-current-status.sh +2 -2
  112. package/scripts/setup-plugins.sh +3 -3
  113. package/scripts/ship-worktrees.sh +1 -1
  114. package/scripts/sprint-backlog.sh +5 -5
  115. package/scripts/sync-codex-installed-copies.sh +2 -3
  116. package/scripts/verify-sprint.sh +7 -7
  117. package/scripts/workstream-sync.sh +1 -1
  118. package/src/cli/chatgpt-browser/bridge-extension.ts +1 -1
  119. package/src/cli/chatgpt-browser/bridge-provider.ts +29 -0
  120. package/src/cli/chatgpt-browser/file-policy.ts +3 -1
  121. package/src/cli/commands/chatgpt.ts +51 -0
  122. package/src/cli/commands/doctor.ts +1 -1
  123. package/src/cli/commands/init-hook.ts +211 -70
  124. package/src/cli/commands/init.ts +5 -6
  125. package/src/cli/commands/mcp.ts +29 -5
  126. package/src/cli/commands/runtime.ts +60 -8
  127. package/src/cli/commands/setup-profile.ts +441 -0
  128. package/src/cli/controller/check-runner.ts +20 -3
  129. package/src/cli/controller/codex-continuation.ts +4 -7
  130. package/src/cli/controller/context-pack.ts +118 -14
  131. package/src/cli/controller/operational-plan.ts +4 -4
  132. package/src/cli/controller/task-ledger.ts +10 -10
  133. package/src/cli/controller/work-mode.ts +6 -13
  134. package/src/cli/index.ts +14 -35
  135. package/src/cli/local-bridge/connector-freshness.ts +1 -1
  136. package/src/cli/local-bridge/console-automations.ts +160 -0
  137. package/src/cli/local-bridge/dashboard.ts +8 -1303
  138. package/src/cli/local-bridge/facade-api.ts +16 -7
  139. package/src/cli/local-bridge/frontend/src/App.tsx +10 -0
  140. package/src/cli/local-bridge/frontend/src/components/Button.tsx +1 -0
  141. package/src/cli/local-bridge/frontend/src/components/DetailPane.tsx +3 -0
  142. package/src/cli/local-bridge/frontend/src/components/SearchField.tsx +1 -0
  143. package/src/cli/local-bridge/frontend/src/components/SectionHeader.tsx +1 -0
  144. package/src/cli/local-bridge/frontend/src/components/Segmented.tsx +1 -0
  145. package/src/cli/local-bridge/frontend/src/components/StatusText.tsx +3 -0
  146. package/src/cli/local-bridge/frontend/src/design/icons.tsx +11 -0
  147. package/src/cli/local-bridge/frontend/src/design/tokens.css +8 -0
  148. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +3 -0
  149. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +4 -0
  150. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +3 -0
  151. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +2 -0
  152. package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +2 -0
  153. package/src/cli/local-bridge/frontend/src/features/system/SystemView.tsx +2 -0
  154. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +3 -0
  155. package/src/cli/local-bridge/frontend/src/lib/api.ts +23 -0
  156. package/src/cli/local-bridge/frontend/src/lib/format.ts +4 -0
  157. package/src/cli/local-bridge/frontend/src/lib/routes.ts +7 -0
  158. package/src/cli/local-bridge/frontend/src/main.tsx +1 -0
  159. package/src/cli/local-bridge/frontend/src/shell/AppShell.tsx +1 -0
  160. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +2 -0
  161. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +3 -0
  162. package/src/cli/local-bridge/frontend/src/styles.css +12 -0
  163. package/src/cli/local-bridge/frontend/src/types.ts +14 -0
  164. package/src/cli/local-bridge/frontend/src/vite-env.d.ts +1 -0
  165. package/src/cli/local-bridge/frontend/vite.config.ts +27 -0
  166. package/src/cli/local-bridge/server.ts +44 -0
  167. package/src/cli/local-bridge/ui-dist/app.css +1 -0
  168. package/src/cli/local-bridge/ui-dist/app.js +11094 -0
  169. package/src/cli/mcp/instructions.ts +3 -3
  170. package/src/cli/mcp/legacy-tool-service.ts +27 -23
  171. package/src/cli/mcp/multi-repository.ts +2 -0
  172. package/src/cli/mcp/policy.ts +7 -5
  173. package/src/cli/mcp/repository-tools.ts +53 -14
  174. package/src/cli/mcp/server.ts +5 -10
  175. package/src/cli/mcp/setup.ts +211 -275
  176. package/src/cli/mcp/tool-capability-cache.ts +1 -1
  177. package/src/cli/mcp/toolset-names.ts +1 -1
  178. package/src/cli/mcp/toolset.ts +0 -1
  179. package/src/cli/mcp/transports/http.ts +24 -4
  180. package/src/cli/repositories/branch-name-policy.ts +0 -7
  181. package/src/cli/repositories/controller-home.ts +110 -3
  182. package/src/cli/repositories/safe-patch.ts +1 -1
  183. package/src/cli/repositories/selected-path-actions.ts +26 -24
  184. package/src/core/adoption/gitignore-plan.ts +2 -3
  185. package/src/core/adoption/plan.ts +4 -1
  186. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +556 -0
  187. package/src/runtime/control-plane/execution/work-handle-store.ts +128 -1
  188. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +277 -0
  189. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +169 -0
  190. package/src/runtime/control-plane/facade/capability-registry.ts +0 -10
  191. package/src/runtime/control-plane/facade/handoff-inbox-store.ts +44 -25
  192. package/src/runtime/control-plane/facade/suggested-actions.ts +7 -1
  193. package/src/runtime/control-plane/facade/types.ts +0 -1
  194. package/src/runtime/control-plane/global-scheduler/scheduler.ts +30 -8
  195. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +114 -0
  196. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +175 -0
  197. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +161 -0
  198. package/src/runtime/control-plane/launcher/provider-mcp-bootstrap.ts +64 -0
  199. package/src/runtime/control-plane/launcher/thin-launcher.ts +117 -24
  200. package/src/runtime/control-plane/routing/route-policy.ts +14 -14
  201. package/src/runtime/control-plane/runtime-cleanup.ts +0 -1
  202. package/src/runtime/evidence/event-ledger.ts +1 -1
  203. package/src/runtime/{workflow/campaigns/workspace.ts → execution/managed-workspace.ts} +41 -43
  204. package/src/runtime/execution/process-runtime/check-facade.ts +6 -0
  205. package/src/runtime/execution/process-runtime/check-result.ts +45 -0
  206. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +42 -16
  207. package/src/runtime/execution/process-runtime/command-facade.ts +4 -2
  208. package/src/runtime/execution/process-runtime/gc.ts +1 -1
  209. package/src/runtime/execution/process-runtime/index.ts +3 -0
  210. package/src/runtime/execution/process-runtime/interactive-admission.ts +14 -5
  211. package/src/runtime/execution/process-runtime/persisted-check.ts +285 -0
  212. package/src/runtime/execution/process-runtime/resource-claims.ts +30 -0
  213. package/src/runtime/execution/process-runtime/runtime.ts +18 -1
  214. package/src/runtime/execution/process-runtime/store.ts +1 -19
  215. package/src/runtime/execution/process-runtime/types.ts +4 -0
  216. package/src/runtime/execution/thin-harness/execution-router.ts +0 -1
  217. package/src/runtime/execution/thin-harness/fast-executor.ts +1 -1
  218. package/src/runtime/execution/thin-harness/lightweight-lanes.ts +1 -2
  219. package/src/runtime/execution/thin-harness/types.ts +0 -1
  220. package/src/runtime/execution/workers/executor.ts +1 -1
  221. package/src/runtime/gateway/mcp/execution-tools.ts +1 -1
  222. package/src/runtime/gateway/mcp/persisted-check-process.ts +3 -255
  223. package/src/runtime/gateway/mcp/process-tools.ts +16 -3
  224. package/src/runtime/gateway/mcp/resource-policy.ts +0 -9
  225. package/src/runtime/gateway/mcp/router.ts +117 -182
  226. package/src/runtime/gateway/mcp/runtime-tools.ts +331 -409
  227. package/src/runtime/gateway/mcp/work-validation-reconciler.ts +1 -219
  228. package/src/runtime/plugins/browser-adapter.ts +5 -0
  229. package/src/runtime/plugins/desktop-operator-registration.ts +1 -1
  230. package/src/runtime/plugins/store.ts +14 -6
  231. package/src/runtime/plugins/types.ts +1 -1
  232. package/src/runtime/projections/materialized-view.ts +1 -23
  233. package/src/runtime/recovery/maintenance-executor.ts +9 -0
  234. package/src/runtime/resources/ownership.ts +0 -1
  235. package/src/runtime/root/gateway-adapter.ts +2 -0
  236. package/src/runtime/root/mcp-transport.ts +10 -2
  237. package/src/runtime/root/package-runtime-release.ts +161 -0
  238. package/src/runtime/root/package-runtime-service.ts +172 -0
  239. package/src/runtime/root/runtime.ts +2 -1
  240. package/src/runtime/root/scheduler.ts +5 -0
  241. package/src/runtime/root/service.ts +18 -5
  242. package/src/runtime/standalone-recovery/core.ts +65 -16
  243. package/src/runtime/workflow/findings/store.ts +145 -106
  244. package/src/runtime/workflow/findings/types.ts +22 -0
  245. package/src/runtime/workflow/schedules/engine.ts +132 -2
  246. package/src/runtime/workflow/schedules/pending-activation.ts +139 -0
  247. package/src/runtime/workflow/schedules/work-continuation.ts +218 -0
  248. package/docs/CHANGELOG.md +0 -978
  249. package/docs/README.zh-CN.md +0 -58
  250. package/scripts/smoke-chatgpt-supervised-automation.ts +0 -208
  251. package/src/cli/commands/controller.ts +0 -680
  252. package/src/runtime/workflow/campaigns/cleanup.ts +0 -263
  253. package/src/runtime/workflow/campaigns/engine.ts +0 -112
  254. package/src/runtime/workflow/campaigns/normalize.ts +0 -100
  255. package/src/runtime/workflow/campaigns/review.ts +0 -315
  256. package/src/runtime/workflow/campaigns/store.ts +0 -526
  257. package/src/runtime/workflow/campaigns/supervisor.ts +0 -112
  258. package/src/runtime/workflow/campaigns/types.ts +0 -290
  259. /package/src/runtime/{workflow/campaigns → execution}/workspace-agent.ts +0 -0
@@ -1,55 +1,84 @@
1
1
  import { createHash, randomUUID } from 'crypto';
2
- import { existsSync, readdirSync } from 'fs';
2
+ import { readdirSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { repositoryControllerRoot } from '../../../cli/repositories/controller-home';
5
- import { withControllerLock } from '../../../cli/repositories/locks';
5
+ import {
6
+ listControlPlaneRecords,
7
+ mutateControlPlaneRecord,
8
+ readOrImportControlPlaneRecord,
9
+ } from '../../control-plane/persistence/sqlite-store';
6
10
  import { appendRuntimeEvent } from '../../evidence/event-ledger';
7
- import { readJsonFile, sanitizeFileComponent, writeJsonAtomic } from '../../shared/json-files';
11
+ import { readJsonFile, sanitizeFileComponent } from '../../shared/json-files';
8
12
  import type { CandidateFinding, RecordCandidateFindingInput } from './types';
9
13
 
10
- interface FindingIndex {
11
- schemaVersion: 1;
12
- updatedAt: string;
13
- candidates: Array<{ findingId: string; semanticKey: string; severity: CandidateFinding['severity']; lastSeenAt: string }>;
14
- recent: Array<{ findingId: string; semanticKey: string; status: CandidateFinding['status']; lastSeenAt: string }>;
15
- }
14
+ const FINDING_NAMESPACE = 'candidate_finding';
15
+ const FINDING_SCHEMA_VERSION = 1;
16
+ const MAX_EVIDENCE = 100;
17
+ const MAX_FINDINGS = 5_000;
16
18
 
17
- function root(controllerHome: string, repoId: string): string {
19
+ function legacyRoot(controllerHome: string, repoId: string): string {
18
20
  return join(repositoryControllerRoot(controllerHome, repoId), 'candidate-findings');
19
21
  }
20
- function recordPath(controllerHome: string, repoId: string, findingId: string): string {
21
- return join(root(controllerHome, repoId), 'records', `${sanitizeFileComponent(findingId)}.json`);
22
+ function legacyRecordPath(controllerHome: string, repoId: string, findingId: string): string {
23
+ return join(legacyRoot(controllerHome, repoId), 'records', `${sanitizeFileComponent(findingId)}.json`);
24
+ }
25
+ function legacySemanticPath(controllerHome: string, repoId: string, semanticKey: string): string {
26
+ return join(legacyRoot(controllerHome, repoId), 'indexes', 'semantic', `${findingKey(semanticKey)}.json`);
27
+ }
28
+ function findingKey(semanticKey: string): string {
29
+ return createHash('sha256').update(semanticKey).digest('hex');
22
30
  }
23
- function semanticPath(controllerHome: string, repoId: string, semanticKey: string): string {
24
- return join(root(controllerHome, repoId), 'indexes', 'semantic', `${createHash('sha256').update(semanticKey).digest('hex')}.json`);
31
+ function legacyFinding(controllerHome: string, repoId: string, findingId: string): CandidateFinding | undefined {
32
+ try { return readJsonFile<CandidateFinding>(legacyRecordPath(controllerHome, repoId, findingId)); } catch { return undefined; }
25
33
  }
26
- function indexPath(controllerHome: string, repoId: string): string {
27
- return join(root(controllerHome, repoId), 'indexes', 'findings.json');
34
+ function legacyFindingForSemanticKey(controllerHome: string, repoId: string, semanticKey: string): CandidateFinding | undefined {
35
+ try {
36
+ const reference = readJsonFile<{ findingId?: string }>(legacySemanticPath(controllerHome, repoId, semanticKey));
37
+ return typeof reference.findingId === 'string' ? legacyFinding(controllerHome, repoId, reference.findingId) : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
28
41
  }
29
- function emptyIndex(): FindingIndex {
30
- return { schemaVersion: 1, updatedAt: new Date().toISOString(), candidates: [], recent: [] };
42
+ function records(controllerHome: string, repoId: string): CandidateFinding[] {
43
+ return listControlPlaneRecords<CandidateFinding>(controllerHome, {
44
+ namespace: FINDING_NAMESPACE,
45
+ scope: repoId,
46
+ limit: MAX_FINDINGS,
47
+ }).map((record) => record.value);
31
48
  }
32
- function readIndex(controllerHome: string, repoId: string): FindingIndex {
33
- return readJsonFile<FindingIndex>(indexPath(controllerHome, repoId), emptyIndex());
49
+ function importLegacyFinding(controllerHome: string, repoId: string, finding: CandidateFinding): CandidateFinding {
50
+ return readOrImportControlPlaneRecord<CandidateFinding>(controllerHome, {
51
+ namespace: FINDING_NAMESPACE,
52
+ scope: repoId,
53
+ key: findingKey(finding.semanticKey),
54
+ schemaVersion: FINDING_SCHEMA_VERSION,
55
+ readLegacy: () => finding,
56
+ })!.value;
34
57
  }
35
- function upsertIndexUnlocked(controllerHome: string, finding: CandidateFinding): void {
36
- const index = readIndex(controllerHome, finding.repoId);
37
- index.candidates = index.candidates.filter((entry) => entry.findingId !== finding.findingId);
38
- index.recent = index.recent.filter((entry) => entry.findingId !== finding.findingId);
39
- if (finding.status === 'candidate') {
40
- index.candidates.push({ findingId: finding.findingId, semanticKey: finding.semanticKey, severity: finding.severity, lastSeenAt: finding.lastSeenAt });
58
+ function importLegacyFindings(controllerHome: string, repoId: string): CandidateFinding[] {
59
+ const directory = join(legacyRoot(controllerHome, repoId), 'records');
60
+ try {
61
+ for (const name of readdirSync(directory).filter((entry) => entry.endsWith('.json')).slice(0, MAX_FINDINGS)) {
62
+ const finding = legacyFinding(controllerHome, repoId, name.slice(0, -'.json'.length));
63
+ if (finding?.repoId === repoId && finding.semanticKey) importLegacyFinding(controllerHome, repoId, finding);
64
+ }
65
+ } catch {
66
+ // No legacy records is the normal v2 path.
41
67
  }
42
- index.recent.push({ findingId: finding.findingId, semanticKey: finding.semanticKey, status: finding.status, lastSeenAt: finding.lastSeenAt });
43
- index.candidates.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
44
- index.recent.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
45
- index.candidates = index.candidates.slice(0, 5000);
46
- index.recent = index.recent.slice(0, 5000);
47
- index.updatedAt = new Date().toISOString();
48
- writeJsonAtomic(indexPath(controllerHome, finding.repoId), index);
68
+ return records(controllerHome, repoId);
69
+ }
70
+ function existingFinding(controllerHome: string, repoId: string, findingId: string): CandidateFinding | undefined {
71
+ return records(controllerHome, repoId).find((finding) => finding.findingId === findingId)
72
+ ?? (() => {
73
+ const legacy = legacyFinding(controllerHome, repoId, findingId);
74
+ return legacy?.repoId === repoId ? importLegacyFinding(controllerHome, repoId, legacy) : undefined;
75
+ })();
49
76
  }
50
77
 
51
78
  export function getCandidateFinding(controllerHome: string, repoId: string, findingId: string): CandidateFinding {
52
- return readJsonFile<CandidateFinding>(recordPath(controllerHome, repoId, findingId));
79
+ const finding = existingFinding(controllerHome, repoId, findingId);
80
+ if (!finding) throw new Error(`CANDIDATE_FINDING_NOT_FOUND: ${findingId}`);
81
+ return finding;
53
82
  }
54
83
 
55
84
  export function recordCandidateFinding(controllerHome: string, input: RecordCandidateFindingInput): CandidateFinding {
@@ -59,31 +88,38 @@ export function recordCandidateFinding(controllerHome: string, input: RecordCand
59
88
  if (!semanticKey) throw new Error('CANDIDATE_SEMANTIC_KEY_REQUIRED');
60
89
  if (!title) throw new Error('CANDIDATE_TITLE_REQUIRED');
61
90
  if (!requestId) throw new Error('CANDIDATE_REQUEST_ID_REQUIRED');
62
- const lockId = createHash('sha256').update(semanticKey).digest('hex').slice(0, 20);
63
- return withControllerLock(controllerHome, { scope: 'task', repoId: input.repoId, taskId: `candidate-${lockId}` }, `record-candidate:${lockId}`, () => {
64
- const semanticIndex = semanticPath(controllerHome, input.repoId, semanticKey);
65
- const timestamp = input.evidence?.observedAt ?? new Date().toISOString();
66
- let finding: CandidateFinding;
67
- if (existsSync(semanticIndex)) {
68
- const reference = readJsonFile<{ findingId: string }>(semanticIndex);
69
- const current = getCandidateFinding(controllerHome, input.repoId, reference.findingId);
91
+ const timestamp = input.evidence?.observedAt ?? new Date().toISOString();
92
+ const record = mutateControlPlaneRecord<CandidateFinding>(controllerHome, {
93
+ namespace: FINDING_NAMESPACE,
94
+ scope: input.repoId,
95
+ key: findingKey(semanticKey),
96
+ schemaVersion: FINDING_SCHEMA_VERSION,
97
+ action: 'candidate_finding_observed',
98
+ readLegacy: () => legacyFindingForSemanticKey(controllerHome, input.repoId, semanticKey),
99
+ mutate: (current) => {
100
+ const previous = current?.value;
70
101
  const evidence = input.evidence
71
- ? [...current.evidence, { ...input.evidence, observedAt: timestamp }].slice(-100)
72
- : current.evidence;
73
- finding = {
74
- ...current,
75
- revision: current.revision + 1,
102
+ ? [...(previous?.evidence ?? []), { ...input.evidence, observedAt: timestamp }].slice(-MAX_EVIDENCE)
103
+ : previous?.evidence ?? [];
104
+ return previous ? {
105
+ ...previous,
106
+ revision: previous.revision + 1,
76
107
  title,
77
- summary: input.summary ?? current.summary,
78
- severity: input.severity ?? current.severity,
79
- status: current.status === 'dismissed' ? 'candidate' : current.status,
80
- observationCount: current.observationCount + 1,
108
+ summary: input.summary ?? previous.summary,
109
+ severity: input.severity ?? previous.severity,
110
+ status: previous.status === 'dismissed' ? 'candidate' : previous.status,
111
+ observationCount: previous.observationCount + 1,
81
112
  evidence,
82
113
  lastSeenAt: timestamp,
83
- dismissedReason: current.status === 'dismissed' ? undefined : current.dismissedReason,
84
- };
85
- } else {
86
- finding = {
114
+ dismissedReason: previous.status === 'dismissed' ? undefined : previous.dismissedReason,
115
+ requirementId: input.requirementId ?? previous.requirementId,
116
+ kind: input.kind ?? previous.kind,
117
+ sourceRepoId: input.sourceRepoId ?? previous.sourceRepoId,
118
+ sourceWorkId: input.sourceWorkId ?? previous.sourceWorkId,
119
+ sourceProcessId: input.sourceProcessId ?? previous.sourceProcessId,
120
+ sourcePluginId: input.sourcePluginId ?? previous.sourcePluginId,
121
+ externalRefs: input.externalRefs ?? previous.externalRefs,
122
+ } : {
87
123
  schemaVersion: 1,
88
124
  revision: 1,
89
125
  findingId: `FIND-${Date.now()}-${randomUUID().slice(0, 8)}`,
@@ -94,25 +130,30 @@ export function recordCandidateFinding(controllerHome: string, input: RecordCand
94
130
  severity: input.severity ?? 'medium',
95
131
  status: 'candidate',
96
132
  observationCount: 1,
97
- evidence: input.evidence ? [{ ...input.evidence, observedAt: timestamp }] : [],
133
+ evidence,
98
134
  firstSeenAt: timestamp,
99
135
  lastSeenAt: timestamp,
136
+ requirementId: input.requirementId,
137
+ kind: input.kind,
138
+ sourceRepoId: input.sourceRepoId,
139
+ sourceWorkId: input.sourceWorkId,
140
+ sourceProcessId: input.sourceProcessId,
141
+ sourcePluginId: input.sourcePluginId,
142
+ externalRefs: input.externalRefs,
100
143
  };
101
- writeJsonAtomic(semanticIndex, { semanticKey, findingId: finding.findingId, createdAt: timestamp });
102
- }
103
- writeJsonAtomic(recordPath(controllerHome, finding.repoId, finding.findingId), finding);
104
- upsertIndexUnlocked(controllerHome, finding);
105
- appendRuntimeEvent(controllerHome, {
106
- repoId: finding.repoId,
107
- entityType: 'candidate-finding',
108
- entityId: finding.findingId,
109
- eventType: finding.observationCount === 1 ? 'candidate_finding_created' : 'candidate_finding_observed',
110
- requestId,
111
- revision: finding.revision,
112
- data: { semanticKey, observationCount: finding.observationCount, severity: finding.severity },
113
- });
114
- return finding;
115
- }, 10_000);
144
+ },
145
+ });
146
+ const finding = record.value;
147
+ appendRuntimeEvent(controllerHome, {
148
+ repoId: finding.repoId,
149
+ entityType: 'candidate-finding',
150
+ entityId: finding.findingId,
151
+ eventType: finding.observationCount === 1 ? 'candidate_finding_created' : 'candidate_finding_observed',
152
+ requestId,
153
+ revision: finding.revision,
154
+ data: { semanticKey, observationCount: finding.observationCount, severity: finding.severity },
155
+ });
156
+ return finding;
116
157
  }
117
158
 
118
159
  export function updateCandidateFinding(
@@ -123,43 +164,41 @@ export function updateCandidateFinding(
123
164
  requestId: string,
124
165
  eventType: string,
125
166
  ): CandidateFinding {
126
- return withControllerLock(controllerHome, { scope: 'task', repoId, taskId: `candidate-${findingId}` }, `update-candidate:${findingId}`, () => {
127
- const current = getCandidateFinding(controllerHome, repoId, findingId);
128
- const next = updater(structuredClone(current));
129
- if (next.findingId !== current.findingId || next.repoId !== current.repoId || next.semanticKey !== current.semanticKey) {
130
- throw new Error('CANDIDATE_IDENTITY_IMMUTABLE');
131
- }
132
- next.revision = current.revision + 1;
133
- next.lastSeenAt = new Date().toISOString();
134
- writeJsonAtomic(recordPath(controllerHome, repoId, findingId), next);
135
- upsertIndexUnlocked(controllerHome, next);
136
- appendRuntimeEvent(controllerHome, {
137
- repoId,
138
- entityType: 'candidate-finding',
139
- entityId: findingId,
140
- eventType,
141
- requestId,
142
- revision: next.revision,
143
- data: { status: next.status, promotedJobId: next.promotedJobId },
144
- });
145
- return next;
146
- }, 10_000);
167
+ const existing = getCandidateFinding(controllerHome, repoId, findingId);
168
+ const record = mutateControlPlaneRecord<CandidateFinding>(controllerHome, {
169
+ namespace: FINDING_NAMESPACE,
170
+ scope: repoId,
171
+ key: findingKey(existing.semanticKey),
172
+ schemaVersion: FINDING_SCHEMA_VERSION,
173
+ action: eventType,
174
+ mutate: (current) => {
175
+ const previous = current?.value ?? existing;
176
+ const next = updater(structuredClone(previous));
177
+ if (next.findingId !== previous.findingId || next.repoId !== previous.repoId || next.semanticKey !== previous.semanticKey) {
178
+ throw new Error('CANDIDATE_IDENTITY_IMMUTABLE');
179
+ }
180
+ return { ...next, revision: previous.revision + 1, lastSeenAt: new Date().toISOString() };
181
+ },
182
+ });
183
+ const finding = record.value;
184
+ appendRuntimeEvent(controllerHome, {
185
+ repoId,
186
+ entityType: 'candidate-finding',
187
+ entityId: findingId,
188
+ eventType,
189
+ requestId,
190
+ revision: finding.revision,
191
+ data: { status: finding.status, promotedJobId: finding.promotedJobId },
192
+ });
193
+ return finding;
147
194
  }
148
195
 
149
196
  export function listCandidateFindings(controllerHome: string, repoId: string, options: { includeTerminal?: boolean; limit?: number } = {}): CandidateFinding[] {
150
197
  const bounded = Math.max(1, Math.min(options.limit ?? 100, 1000));
151
- const index = readIndex(controllerHome, repoId);
152
- const entries = options.includeTerminal ? index.recent : index.candidates;
153
- const indexed = entries.slice(0, bounded).flatMap((entry) => {
154
- try { return [getCandidateFinding(controllerHome, repoId, entry.findingId)]; } catch { return []; }
155
- });
156
- if (indexed.length > 0) return indexed;
157
- try {
158
- return readdirSync(join(root(controllerHome, repoId), 'records'))
159
- .filter((name) => name.endsWith('.json'))
160
- .map((name) => readJsonFile<CandidateFinding>(join(root(controllerHome, repoId), 'records', name)))
161
- .filter((finding) => options.includeTerminal || finding.status === 'candidate')
162
- .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
163
- .slice(0, bounded);
164
- } catch { return []; }
198
+ const stored = records(controllerHome, repoId);
199
+ const all = stored.length > 0 ? stored : importLegacyFindings(controllerHome, repoId);
200
+ return all
201
+ .filter((finding) => options.includeTerminal || finding.status === 'candidate')
202
+ .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
203
+ .slice(0, bounded);
165
204
  }
@@ -7,6 +7,13 @@ export interface CandidateFindingEvidence {
7
7
  details?: Record<string, unknown>;
8
8
  }
9
9
 
10
+ export interface CandidateFindingExternalRef {
11
+ kind: string;
12
+ url?: string;
13
+ number?: number;
14
+ label?: string;
15
+ }
16
+
10
17
  export interface CandidateFinding {
11
18
  schemaVersion: 1;
12
19
  revision: number;
@@ -23,6 +30,14 @@ export interface CandidateFinding {
23
30
  lastSeenAt: string;
24
31
  promotedJobId?: string;
25
32
  dismissedReason?: string;
33
+ /** Optional Requirement/operational provenance; repoId remains the owner. */
34
+ requirementId?: string;
35
+ kind?: 'defect' | 'performance' | 'reliability' | 'usability';
36
+ sourceRepoId?: string;
37
+ sourceWorkId?: string;
38
+ sourceProcessId?: string;
39
+ sourcePluginId?: string;
40
+ externalRefs?: CandidateFindingExternalRef[];
26
41
  }
27
42
 
28
43
  export interface RecordCandidateFindingInput {
@@ -32,5 +47,12 @@ export interface RecordCandidateFindingInput {
32
47
  summary?: string;
33
48
  severity?: CandidateFinding['severity'];
34
49
  evidence?: Omit<CandidateFindingEvidence, 'observedAt'> & { observedAt?: string };
50
+ requirementId?: string;
51
+ kind?: CandidateFinding['kind'];
52
+ sourceRepoId?: string;
53
+ sourceWorkId?: string;
54
+ sourceProcessId?: string;
55
+ sourcePluginId?: string;
56
+ externalRefs?: CandidateFindingExternalRef[];
35
57
  requestId: string;
36
58
  }
@@ -10,6 +10,9 @@ import {
10
10
  type RuntimeMaintenanceActionId,
11
11
  } from '../../recovery/maintenance-executor';
12
12
  import { listCandidateFindings } from '../findings/store';
13
+ import { getControllerSession, getWorkContract, isTerminalWorkContractStatus, listHandoffItems } from '../../control-plane/facade';
14
+ import { launchSuperController } from '../../control-plane/launcher/thin-launcher';
15
+ import { getExternalControllerLaunchReservation } from '../../control-plane/launcher/launch-reservation-store';
13
16
  import {
14
17
  getOccurrence,
15
18
  getSchedule,
@@ -31,11 +34,13 @@ import type {
31
34
 
32
35
  const execFileAsync = promisify(execFile);
33
36
  const LIVE_MAINTENANCE_OPERATION = 'runtime_maintenance_apply';
37
+ const EXTERNAL_CONTROLLER_WAKE_OPERATION = 'external_controller_wake';
34
38
 
35
39
  function isDeterministicSchedule(schedule: RepositorySchedule): boolean {
36
40
  // Kernel may execute only fully deterministic, allowlisted schedule operations.
37
41
  // Semantic / model-backed schedules remain external-controller handoffs.
38
- return schedule.action.operation === LIVE_MAINTENANCE_OPERATION;
42
+ return schedule.action.operation === LIVE_MAINTENANCE_OPERATION
43
+ || schedule.action.operation === EXTERNAL_CONTROLLER_WAKE_OPERATION;
39
44
  }
40
45
 
41
46
  function normalizedWindow(minutes: number, at = Date.now()): string {
@@ -229,9 +234,34 @@ async function triggerDue(
229
234
  }
230
235
  }
231
236
 
237
+ function continuationWorkId(schedule: RepositorySchedule): string | undefined {
238
+ if (schedule.action.operation !== EXTERNAL_CONTROLLER_WAKE_OPERATION) return undefined;
239
+ const value = schedule.action.arguments?.work_id;
240
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
241
+ }
242
+
243
+ function executionJobBelongsToWork(job: ReturnType<typeof listExecutionJobs>[number], workId: string): boolean {
244
+ if (job.resourceClaims.some((claim) => claim.workId === workId)) return true;
245
+ const payload = job.payload as Record<string, unknown>;
246
+ const args = payload.arguments && typeof payload.arguments === 'object' && !Array.isArray(payload.arguments)
247
+ ? payload.arguments as Record<string, unknown>
248
+ : undefined;
249
+ return [payload.workId, payload.work_id, args?.workId, args?.work_id].some((value) => value === workId);
250
+ }
251
+
232
252
  async function stopReason(controllerHome: string, schedule: RepositorySchedule): Promise<string | undefined> {
233
253
  const projection = readRepositoryProjection(controllerHome, schedule.repoId);
234
- if (schedule.stopConditions.includes('human_review_required') && projection.currentAttention.length > 0) return 'Repository has jobs requiring human attention.';
254
+ const workId = continuationWorkId(schedule);
255
+ const work = workId ? getWorkContract({ controllerHome, repoId: schedule.repoId }, workId) : undefined;
256
+ if (schedule.stopConditions.includes('human_review_required')) {
257
+ if (workId) {
258
+ const activeHandoff = listHandoffItems({ controllerHome, repoId: schedule.repoId, status: 'active', limit: 100 })
259
+ .find((item) => item.workId === workId);
260
+ if (activeHandoff) return `Work ${workId} has active Handoff ${activeHandoff.id} requiring review.`;
261
+ } else if (projection.currentAttention.length > 0) {
262
+ return 'Repository has jobs requiring human attention.';
263
+ }
264
+ }
235
265
  if (schedule.stopConditions.includes('release_ready') && projection.releaseFrozen) return 'Repository is in release freeze.';
236
266
  if (schedule.stopConditions.includes('external_blocker')) {
237
267
  try {
@@ -242,6 +272,10 @@ async function stopReason(controllerHome: string, schedule: RepositorySchedule):
242
272
  }
243
273
  const recentInfrastructureFailure = listExecutionJobs(controllerHome, schedule.repoId, 20).find((job) => {
244
274
  if (!['failed', 'timed_out', 'orphaned'].includes(job.status) || !job.error) return false;
275
+ if (workId) {
276
+ if (!work || Date.parse(job.createdAt) < Date.parse(work.createdAt)) return false;
277
+ if (!executionJobBelongsToWork(job, workId)) return false;
278
+ }
245
279
  if (job.error.retryable) return true;
246
280
  return /(?:network|connection|upstream|external|remote|github|tunnel|502|503|timeout)/i.test(`${job.error.code} ${job.error.message}`);
247
281
  });
@@ -513,6 +547,102 @@ export async function evaluateSchedule(
513
547
  }
514
548
 
515
549
  const args = schedule.action.arguments ?? {};
550
+ if (schedule.action.operation === EXTERNAL_CONTROLLER_WAKE_OPERATION) {
551
+ const workId = typeof args.work_id === 'string' ? args.work_id.trim() : '';
552
+ const controllerType = typeof args.controller_type === 'string' ? args.controller_type.trim() : 'chatgpt';
553
+ if (!workId) {
554
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'EXTERNAL_CONTROLLER_WAKE_WORK_ID_REQUIRED');
555
+ }
556
+ if (!['chatgpt', 'codex', 'claude', 'grok'].includes(controllerType)) {
557
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'EXTERNAL_CONTROLLER_WAKE_TYPE_INVALID');
558
+ }
559
+ const workStore = { controllerHome, repoId: schedule.repoId };
560
+ const work = getWorkContract(workStore, workId);
561
+ if (!work) {
562
+ saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} no longer exists.`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
563
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', `EXTERNAL_CONTROLLER_WAKE_WORK_NOT_FOUND:${workId}`);
564
+ }
565
+ if (isTerminalWorkContractStatus(work.status)) {
566
+ saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} is terminal (${work.status}).`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
567
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} is terminal (${work.status}); automatic continuation stopped.`);
568
+ }
569
+ const existingOwner = getControllerSession(workStore, workId);
570
+ if (existingOwner) {
571
+ saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
572
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has an active Controller ${existingOwner.controllerId}.`);
573
+ }
574
+ const launchReservation = getExternalControllerLaunchReservation(workStore, workId);
575
+ if (launchReservation) {
576
+ saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
577
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has a pending external Controller launch ${launchReservation.reservationId}.`);
578
+ }
579
+ const wakeDecision = decideOccurrence(
580
+ controllerHome,
581
+ schedule,
582
+ occurrence,
583
+ 'execute',
584
+ 'running',
585
+ 'Deterministic external Controller wake is starting through Thin Launcher.',
586
+ occurrenceDecisionEvidence({ operation: schedule.action.operation, workId, controllerType }),
587
+ );
588
+ try {
589
+ const repository = getRepository(schedule.repoId, controllerHome, { includeRemoved: true });
590
+ const launched = launchSuperController({
591
+ work: { controllerHome, repoId: schedule.repoId },
592
+ handoff: { controllerHome, repoId: schedule.repoId },
593
+ }, {
594
+ controllerType: controllerType as 'chatgpt' | 'codex' | 'claude' | 'grok',
595
+ executable: typeof args.executable === 'string' ? args.executable : undefined,
596
+ args: Array.isArray(args.launch_args) ? args.launch_args.map(String) : [],
597
+ workId,
598
+ handoffId: typeof args.handoff_id === 'string' ? args.handoff_id : undefined,
599
+ browserSessionId: typeof args.browser_session_id === 'string' ? args.browser_session_id : undefined,
600
+ conversationUrl: typeof args.conversation_url === 'string' ? args.conversation_url : undefined,
601
+ launchReservationMs: typeof args.launch_reservation_ms === 'number' ? args.launch_reservation_ms : typeof args.lease_ms === 'number' ? args.lease_ms : undefined,
602
+ continuationPrompt: typeof args.continuation_prompt === 'string'
603
+ ? args.continuation_prompt
604
+ : `Scheduled continuation ${occurrence.occurrenceId} from ${schedule.scheduleId}. Read current Forge state and continue only the bounded Work objective.`,
605
+ cwd: repository.canonicalRoot,
606
+ });
607
+ const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
608
+ saveSchedule(controllerHome, {
609
+ ...latest,
610
+ lastTriggeredAt: timestamp,
611
+ lastOccurrenceId: occurrenceId,
612
+ consecutiveFailures: 0,
613
+ nextEligibleAt: undefined,
614
+ pausedReason: undefined,
615
+ });
616
+ return saveOccurrence(controllerHome, {
617
+ ...wakeDecision,
618
+ status: 'succeeded',
619
+ reason: `External Controller wake started ${launched.controllerType} pid=${String(launched.pid ?? 'unknown')} for Work ${workId}.`,
620
+ });
621
+ } catch (error) {
622
+ const reason = error instanceof Error ? error.message : String(error);
623
+ const failed = applyScheduleFailure(controllerHome, schedule.scheduleId, schedule.repoId, occurrence.occurrenceId, {
624
+ outcome: 'failed',
625
+ decision: 'execute',
626
+ reason,
627
+ handoff: {
628
+ title: `External Controller wake ${occurrence.occurrenceId} failed`,
629
+ summary: 'Forge recorded the schedule trigger but could not wake the configured external Controller.',
630
+ reason,
631
+ creationReason: 'repeated_infrastructure_failure',
632
+ blockingDecision: 'Repair the external Controller/browser launch path or update the saved session reference.',
633
+ recommendedDecision: 'Inspect launcher/browser readiness, then retrigger this bounded continuation.',
634
+ recommendedPrompt: `Resume Work ${workId} manually, inspect failed wake occurrence ${occurrence.occurrenceId}, and repair the configured external Controller launch path before unattended continuation resumes.`,
635
+ statusSummary: 'Scheduled external Controller wake failed.',
636
+ blockedBy: ['external_controller_wake_failed'],
637
+ attemptedActions: [`schedule:${schedule.scheduleId}`, `work:${workId}`, `controller:${controllerType}`],
638
+ },
639
+ });
640
+ const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
641
+ saveSchedule(controllerHome, { ...latest, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
642
+ return failed.occurrence ?? saveOccurrence(controllerHome, { ...wakeDecision, status: 'failed', reason });
643
+ }
644
+ }
645
+
516
646
  const actionIdRaw = typeof args.action_id === 'string'
517
647
  ? args.action_id
518
648
  : typeof args.actionId === 'string'