@moretea-labs/forge 1.5.1 → 1.6.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 (124) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/DESIGN.md +7 -2
  3. package/README.md +4 -4
  4. package/README.zh-CN.md +2 -2
  5. package/THIRD_PARTY_NOTICES.md +1 -1
  6. package/assets/hooks/pre-edit-guard.sh +8 -39
  7. package/assets/hooks/prompt-guard.sh +2 -2
  8. package/assets/plugin-registry.v1.json +54 -3
  9. package/assets/reference-configs/hook-operations.md +1 -1
  10. package/assets/reference-configs/sprint-contracts.md +2 -2
  11. package/assets/skill-version.json +2 -2
  12. package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
  13. package/assets/templates/helpers/sprint-backlog.sh +6 -6
  14. package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
  15. package/docs/architecture/current/codegraph-context-provider.md +15 -3
  16. package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
  17. package/docs/architecture/current/entity-model.md +20 -0
  18. package/docs/architecture/current/external-plugin-governance.md +3 -3
  19. package/docs/architecture/current/system-overview.md +26 -1
  20. package/docs/architecture/current/thin-harness-v1.md +10 -0
  21. package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
  22. package/docs/operations/controller-browser-plugin.md +12 -16
  23. package/docs/operations/features.md +1 -1
  24. package/docs/operations/homebrew.md +2 -2
  25. package/docs/tutorials/01-install-and-start.md +1 -1
  26. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  27. package/docs/wiki/Installation.md +1 -1
  28. package/docs/wiki/Quick-Start.md +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check-public-docs.sh +14 -1
  31. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  32. package/scripts/sprint-backlog.sh +1 -1
  33. package/src/cli/commands/chatgpt.ts +31 -2
  34. package/src/cli/commands/plugin.ts +36 -9
  35. package/src/cli/controller/context-pack.ts +158 -6
  36. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  37. package/src/cli/hook/prompt-intents.ts +1 -1
  38. package/src/cli/local-bridge/console-automations.ts +205 -23
  39. package/src/cli/local-bridge/console-view-models.ts +2 -0
  40. package/src/cli/local-bridge/facade-api.ts +2 -0
  41. package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
  42. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
  43. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
  44. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
  45. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  46. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  47. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
  48. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  49. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
  50. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  51. package/src/cli/local-bridge/frontend/src/types.ts +6 -2
  52. package/src/cli/local-bridge/server.ts +29 -19
  53. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  54. package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
  55. package/src/cli/mcp/paths.ts +8 -2
  56. package/src/cli/mcp/repository-tools.ts +124 -9
  57. package/src/cli/mcp/server.ts +164 -19
  58. package/src/cli/mcp/transports/http.ts +25 -2
  59. package/src/cli/mcp/transports/session-registry.ts +24 -0
  60. package/src/cli/repositories/command-classifier.ts +113 -0
  61. package/src/cli/repositories/safe-patch.ts +6 -4
  62. package/src/cli/repositories/structured-git.ts +49 -7
  63. package/src/cli/repository/inspector.ts +16 -3
  64. package/src/runtime/assistant/intent.ts +3 -3
  65. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
  66. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  67. package/src/runtime/control-plane/execution/validation.ts +15 -2
  68. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
  69. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
  70. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
  71. package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
  72. package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
  73. package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
  74. package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
  75. package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
  76. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  77. package/src/runtime/control-plane/facade/types.ts +33 -6
  78. package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
  79. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
  80. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  81. package/src/runtime/diagnostics/mcp-timing.ts +6 -0
  82. package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
  83. package/src/runtime/execution/process-runtime/check-result.ts +10 -2
  84. package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
  85. package/src/runtime/execution/process-runtime/gc.ts +64 -10
  86. package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
  87. package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
  88. package/src/runtime/execution/process-runtime/runtime.ts +16 -1
  89. package/src/runtime/execution/process-runtime/store.ts +6 -0
  90. package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
  91. package/src/runtime/gateway/mcp/router.ts +1 -1
  92. package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
  93. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  94. package/src/runtime/plugins/browser-adapter.ts +19 -72
  95. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  96. package/src/runtime/plugins/browser-handoff.ts +0 -3
  97. package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
  98. package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
  99. package/src/runtime/plugins/external-registration.ts +84 -5
  100. package/src/runtime/plugins/first-party-registry.ts +2 -2
  101. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  102. package/src/runtime/plugins/local-system-adapter.ts +2 -2
  103. package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
  104. package/src/runtime/plugins/store.ts +107 -5
  105. package/src/runtime/plugins/types.ts +4 -0
  106. package/src/runtime/recovery/actions.ts +0 -10
  107. package/src/runtime/recovery/classifier.ts +0 -9
  108. package/src/runtime/recovery/maintenance-executor.ts +43 -11
  109. package/src/runtime/recovery/probe.ts +0 -1
  110. package/src/runtime/recovery/types.ts +0 -1
  111. package/src/runtime/root/release-materialize.ts +143 -19
  112. package/src/runtime/root/service.ts +6 -4
  113. package/src/runtime/safe-tooling/index.ts +0 -1
  114. package/src/runtime/safe-tooling/types.ts +0 -30
  115. package/src/runtime/standalone-recovery/core.ts +2 -2
  116. package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
  117. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  118. package/src/runtime/workflow/schedules/engine.ts +357 -64
  119. package/src/runtime/workflow/schedules/types.ts +5 -0
  120. package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
  121. package/bin/forge-desktop-helper.mjs +0 -204
  122. package/docs/operations/controller-desktop-plugin.md +0 -112
  123. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  124. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
@@ -67,6 +67,8 @@ export interface ControllerContextPackOptions {
67
67
  maxSnippets?: number;
68
68
  maxCharsPerSnippet?: number;
69
69
  structuralContext?: StructuralContextMode;
70
+ /** Optional repository-level CodeGraph root used as structural baseline when the selected checkout has no local index. */
71
+ structuralIndexRoot?: string;
70
72
  retrievalMode?: ControllerContextRetrievalMode;
71
73
  /** GPT-selected cross-cutting evidence dimensions. Forge expands mechanically and never treats them as semantic completeness proof. */
72
74
  impactDomains?: ControllerContextImpactDomain[];
@@ -138,6 +140,9 @@ export interface ControllerContextPackProjection {
138
140
  requestedMode: StructuralContextMode;
139
141
  status: "disabled" | "ready" | "stale" | "unavailable" | "degraded";
140
142
  requiredSatisfied: boolean;
143
+ indexSource: "current_checkout" | "repository_baseline";
144
+ overlayChangedFiles: string[];
145
+ baselineRevisionMatches: boolean;
141
146
  query?: string;
142
147
  entryPoints: Array<Pick<CodeGraphNodeSummary, "id" | "kind" | "name" | "filePath" | "startLine" | "endLine">>;
143
148
  relatedFiles: string[];
@@ -146,6 +151,26 @@ export interface ControllerContextPackProjection {
146
151
  timingsMs?: CodeGraphReadProviderResponse["timingsMs"];
147
152
  truncated: boolean;
148
153
  };
154
+ impactContext: {
155
+ status: "not_requested" | "ready" | "degraded";
156
+ confidence: "low" | "medium" | "high";
157
+ primaryTargets: string[];
158
+ mustInspect: string[];
159
+ likelyAffected: string[];
160
+ relevantTests: string[];
161
+ relevantChecks: string[];
162
+ architectureContracts: string[];
163
+ coverageGaps: string[];
164
+ relationSources: Array<"codegraph" | "lexical" | "forge_checks">;
165
+ freshness: {
166
+ structuralStatus: "disabled" | "ready" | "stale" | "unavailable" | "degraded";
167
+ lastIndexedAt?: number | null;
168
+ changedFileCount: number;
169
+ indexSource: "current_checkout" | "repository_baseline";
170
+ overlayChangedFileCount: number;
171
+ baselineRevisionMatches: boolean;
172
+ };
173
+ };
149
174
  files: ControllerContextPackFile[];
150
175
  deniedPaths: Array<{ path: string; reason: string }>;
151
176
  omitted: Array<{ path: string; reason: string }>;
@@ -273,6 +298,18 @@ function coveredByGlob(path: string, globs: string[]): boolean {
273
298
  return globs.length === 0 || globs.some((glob) => globMatches(glob, path));
274
299
  }
275
300
 
301
+ function gitStatusChangedPaths(status: string): string[] {
302
+ const paths: string[] = [];
303
+ for (const line of status.split(/\r?\n/)) {
304
+ if (!line.trim() || line.startsWith("##") || line.length < 4) continue;
305
+ let path = line.slice(3).trim();
306
+ if (path.includes(" -> ")) path = path.slice(path.lastIndexOf(" -> ") + 4).trim();
307
+ if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
308
+ if (path && !paths.includes(path)) paths.push(path);
309
+ }
310
+ return paths;
311
+ }
312
+
276
313
  function addReason(map: Map<string, { reasons: Set<string>; lines: Set<number> }>, path: string, reason: string, line?: number): void {
277
314
  const entry = map.get(path) ?? { reasons: new Set<string>(), lines: new Set<number>() };
278
315
  entry.reasons.add(reason);
@@ -453,11 +490,19 @@ export function buildControllerContextPack(
453
490
  let skippedBinaryFiles = 0;
454
491
  let searchTruncated = false;
455
492
  const structuralMode = options.structuralContext ?? "off";
493
+ const structuralIndexRoot = options.structuralIndexRoot?.trim() || repoRoot;
494
+ const indexSource = structuralIndexRoot === repoRoot ? "current_checkout" : "repository_baseline";
495
+ const overlayChangedFiles = indexSource === "repository_baseline" ? gitStatusChangedPaths(git.status) : [];
496
+ const baselineRevisionMatches = indexSource === "current_checkout" || gitSnapshot(structuralIndexRoot).head === git.head;
497
+ const graphImpactFiles = new Set<string>();
456
498
  let structuralContext: ControllerContextPackProjection["structuralContext"] = {
457
499
  provider: "codegraph",
458
500
  requestedMode: structuralMode,
459
501
  status: "disabled",
460
502
  requiredSatisfied: structuralMode !== "required",
503
+ indexSource,
504
+ overlayChangedFiles,
505
+ baselineRevisionMatches,
461
506
  entryPoints: [],
462
507
  relatedFiles: [],
463
508
  truncated: false,
@@ -474,7 +519,7 @@ export function buildControllerContextPack(
474
519
  };
475
520
  } else {
476
521
  const queryCodeGraph = dependencies.queryCodeGraph ?? queryCodeGraphReadProvider;
477
- const structural = queryCodeGraph(repoRoot, {
522
+ const structural = queryCodeGraph(structuralIndexRoot, {
478
523
  operation: "context",
479
524
  query: structuralQuery,
480
525
  limit: Math.min(Math.max(maxFiles, 4), 12),
@@ -496,8 +541,11 @@ export function buildControllerContextPack(
496
541
  structuralContext = {
497
542
  provider: "codegraph",
498
543
  requestedMode: structuralMode,
499
- status: structural.status,
500
- requiredSatisfied: structuralMode !== "required" || structural.status === "ready",
544
+ status: indexSource === "repository_baseline" && (!baselineRevisionMatches || overlayChangedFiles.length > 0) && structural.status === "ready" ? "stale" : structural.status,
545
+ requiredSatisfied: structuralMode !== "required" || (structural.status === "ready" && baselineRevisionMatches && overlayChangedFiles.length === 0),
546
+ indexSource,
547
+ overlayChangedFiles,
548
+ baselineRevisionMatches,
501
549
  query: structuralQuery,
502
550
  entryPoints: graph.entryPoints.slice(0, 12).map((node) => ({
503
551
  id: node.id,
@@ -530,17 +578,50 @@ export function buildControllerContextPack(
530
578
  addStructuralPath(node.filePath, `codegraph:entry:${node.name}`, node.startLine);
531
579
  }
532
580
  for (const path of graph.relatedFiles.slice(0, maxFiles * 6)) addStructuralPath(path, "codegraph:related");
581
+ // Context search identifies the structural entry points. For a bounded
582
+ // number of those files, enrich the same Context Plane call with direct
583
+ // file dependencies/dependents. This is impact evidence, not a second
584
+ // routing/tool surface, and remains completely off for ordinary Direct.
585
+ if (structural.status === "ready") {
586
+ const primaryFiles = Array.from(new Set(graph.entryPoints.map((node) => node.filePath).filter(Boolean))).slice(0, 4);
587
+ for (const filePath of primaryFiles) {
588
+ const adjacency = queryCodeGraph(structuralIndexRoot, { operation: "file_dependencies", filePath, limit: 40 });
589
+ if (!adjacency.ok || adjacency.status !== "ready") continue;
590
+ const dependencies = Array.isArray(adjacency.result?.dependencies)
591
+ ? adjacency.result.dependencies.filter((value): value is string => typeof value === "string")
592
+ : [];
593
+ const dependents = Array.isArray(adjacency.result?.dependents)
594
+ ? adjacency.result.dependents.filter((value): value is string => typeof value === "string")
595
+ : [];
596
+ for (const path of dependencies.slice(0, 40)) {
597
+ graphImpactFiles.add(path);
598
+ addStructuralPath(path, `codegraph:dependency:${filePath}`);
599
+ }
600
+ for (const path of dependents.slice(0, 40)) {
601
+ graphImpactFiles.add(path);
602
+ addStructuralPath(path, `codegraph:dependent:${filePath}`);
603
+ }
604
+ }
605
+ }
533
606
  for (const path of metadata?.changedFiles.added ?? []) addStructuralPath(path, "codegraph:changed-file", 1);
534
607
  for (const path of metadata?.changedFiles.modified ?? []) addStructuralPath(path, "codegraph:changed-file", 1);
535
608
  for (const path of metadata?.changedFiles.removed ?? []) omitted.push({ path, reason: "codegraph reports file removed since index" });
609
+ for (const path of overlayChangedFiles) addStructuralPath(path, "worktree:changed-file", 1);
536
610
  }
537
611
  }
538
612
 
613
+ const exactKnownFiles: string[] = [];
614
+ let exactKnownFileScope = explicitKnownPaths.length > 0;
539
615
  for (const path of explicitKnownPaths) {
540
616
  const expanded = expandKnownPath(repoRoot, policy, path, Math.max(maxFiles * 4, 40));
541
617
  for (const file of expanded.files) {
542
618
  addReason(candidates, file, expanded.directory ? `explicit-known-directory:${expanded.directory}` : "explicit-known-path", 1);
543
619
  }
620
+ if (!expanded.directory && expanded.files.length === 1 && expanded.denied.length === 0 && !expanded.truncated) {
621
+ exactKnownFiles.push(expanded.files[0]!);
622
+ } else {
623
+ exactKnownFileScope = false;
624
+ }
544
625
  deniedPaths.push(...expanded.denied);
545
626
  scannedFiles += expanded.files.length;
546
627
  searchTruncated = searchTruncated || expanded.truncated;
@@ -550,16 +631,27 @@ export function buildControllerContextPack(
550
631
  if (!includeGlobs.includes(glob)) includeGlobs.push(glob);
551
632
  }
552
633
 
634
+ // Exact known files are already a caller-selected implementation scope. When
635
+ // no broader impact/structural/include scope was requested, search those files
636
+ // for useful hit lines instead of rereading the whole repository. Planning,
637
+ // debugging, directories and explicit broader scopes retain full discovery.
638
+ const scopedExactKnownFileSearch = exactKnownFileScope
639
+ && retrievalMode === "implementation"
640
+ && structuralMode === "off"
641
+ && impactDomains.length === 0
642
+ && searchIncludeGlobs.length === 0;
643
+
553
644
  // Lexical retrieval is one bounded pass across candidate files for all terms.
554
645
  // The old per-term loop reread the same source files up to 14 times even
555
646
  // though inventory itself was cached.
556
647
  if (searchQueries.length > 0 && candidates.size < maxFiles * 3) {
557
648
  const search = searchRepositoryMany(repoRoot, policy, {
558
649
  queries: searchQueries,
559
- includeGlobs: searchIncludeGlobs,
650
+ files: scopedExactKnownFileSearch ? exactKnownFiles : undefined,
651
+ includeGlobs: scopedExactKnownFileSearch ? undefined : searchIncludeGlobs,
560
652
  excludeGlobs,
561
653
  maxResultsPerQuery: Math.max(maxFiles * 4, 12),
562
- maxFiles: MAX_TOTAL_SEARCHED_FILES,
654
+ maxFiles: scopedExactKnownFileSearch ? exactKnownFiles.length : MAX_TOTAL_SEARCHED_FILES,
563
655
  caseSensitive: false,
564
656
  cacheKey: JSON.stringify({ head: git.head, status: git.status, diffStat: git.diffStat }),
565
657
  });
@@ -685,6 +777,65 @@ export function buildControllerContextPack(
685
777
  const rawCodeRequiredForImplementation = retrievalMode !== "implementation"
686
778
  || files.length === 0
687
779
  || rawSnippetTruncated;
780
+ const primaryTargets = Array.from(new Set(structuralContext.entryPoints.map((entry) => entry.filePath).filter(Boolean))).slice(0, 20);
781
+ const structuralUniverse = Array.from(new Set([
782
+ ...primaryTargets,
783
+ ...graphImpactFiles,
784
+ ...structuralContext.relatedFiles,
785
+ ...selected.map((entry) => entry.path),
786
+ ])).slice(0, 160);
787
+ const relevantTests = structuralUniverse.filter((path) => /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(path)).slice(0, 40);
788
+ const architectureContracts = structuralUniverse.filter((path) =>
789
+ path === "AGENTS.md"
790
+ || path === "CLAUDE.md"
791
+ || path.startsWith("docs/architecture/")
792
+ || path.startsWith(".ai/context/"),
793
+ ).slice(0, 40);
794
+ const mustInspect = Array.from(new Set([...primaryTargets, ...graphImpactFiles, ...relevantTests, ...architectureContracts])).slice(0, 80);
795
+ const mustInspectSet = new Set(mustInspect);
796
+ const likelyAffected = structuralUniverse.filter((path) => !mustInspectSet.has(path)).slice(0, 80);
797
+ const changedFileCount = structuralContext.metadata
798
+ ? structuralContext.metadata.changedFiles.added.length
799
+ + structuralContext.metadata.changedFiles.modified.length
800
+ + structuralContext.metadata.changedFiles.removed.length
801
+ : 0;
802
+ const coverageGaps = [
803
+ ...(structuralContext.status === "stale" ? ["structural_index_stale"] : []),
804
+ ...(structuralContext.indexSource === "repository_baseline" ? ["structural_repository_baseline_overlay"] : []),
805
+ ...(!structuralContext.baselineRevisionMatches ? ["structural_baseline_revision_mismatch"] : []),
806
+ ...(structuralContext.status === "unavailable" || structuralContext.status === "degraded" ? ["structural_provider_unavailable"] : []),
807
+ ...(structuralContext.truncated ? ["structural_result_truncated"] : []),
808
+ ...(changedFileCount > 0 ? ["structural_index_has_unindexed_changes"] : []),
809
+ ...impactCoverage.filter((coverage) => coverage.status !== "selected").map((coverage) => `impact_domain_${coverage.status}:${coverage.domain}`),
810
+ ];
811
+ const impactContext: ControllerContextPackProjection["impactContext"] = {
812
+ status: structuralMode === "off" ? "not_requested" : structuralContext.status === "ready" ? "ready" : "degraded",
813
+ confidence: structuralContext.status === "ready" && primaryTargets.length > 0 && !structuralContext.truncated
814
+ ? "high"
815
+ : structuralContext.status === "ready" || files.length > 0
816
+ ? "medium"
817
+ : "low",
818
+ primaryTargets,
819
+ mustInspect,
820
+ likelyAffected,
821
+ relevantTests,
822
+ relevantChecks: taskChecks,
823
+ architectureContracts,
824
+ coverageGaps: Array.from(new Set(coverageGaps)).slice(0, 40),
825
+ relationSources: [
826
+ ...(structuralMode !== "off" ? ["codegraph" as const] : []),
827
+ ...(files.length > 0 ? ["lexical" as const] : []),
828
+ ...(taskChecks.length > 0 ? ["forge_checks" as const] : []),
829
+ ],
830
+ freshness: {
831
+ structuralStatus: structuralContext.status,
832
+ lastIndexedAt: structuralContext.metadata?.lastIndexedAt,
833
+ changedFileCount,
834
+ indexSource: structuralContext.indexSource,
835
+ overlayChangedFileCount: structuralContext.overlayChangedFiles.length,
836
+ baselineRevisionMatches: structuralContext.baselineRevisionMatches,
837
+ },
838
+ };
688
839
 
689
840
  return {
690
841
  schemaVersion: CONTEXT_PACK_SCHEMA_VERSION,
@@ -712,6 +863,7 @@ export function buildControllerContextPack(
712
863
  truncated: searchTruncated,
713
864
  },
714
865
  structuralContext,
866
+ impactContext,
715
867
  files,
716
868
  deniedPaths: deniedPaths.slice(0, 20),
717
869
  omitted: omitted.slice(0, 30),
@@ -732,7 +884,7 @@ export function buildControllerContextPack(
732
884
  retrievalMode === "implementation"
733
885
  ? "The pack already contains policy-approved current raw source snippets with file SHA identities. Request wider/exact ranges only when ChatGPT judges the impact surface ambiguous or an expansion signal makes the raw evidence mechanically incomplete."
734
886
  : "Investigation modes may intentionally expand exact ranges, structural relationships, tests, and neighboring modules before any implementation decision.",
735
- "Search/CodeGraph ranking is discovery evidence, not a business-semantics authority. Forge never decides that the returned files are the complete semantic impact surface.",
887
+ "Search/CodeGraph ranking is discovery evidence, not a business-semantics authority. impactContext makes the bounded evidence surface and coverage gaps explicit; ChatGPT still decides semantic sufficiency.",
736
888
  impactDomains.length > 0 ? `Impact domains were selected by ChatGPT and expanded mechanically in this same retrieval call: ${impactDomains.join(", ")}. Missing or omitted domain evidence is an ambiguity signal, not proof that the domain is irrelevant.` : "No explicit cross-cutting impact domains were requested; ChatGPT may add them when state, scheduling, notifications, events, caching, API, or concurrency could materially change the implementation.",
737
889
  "CodeGraph structural evidence is discovery evidence. Raw source access still passes through Forge repository policy and current-file reads.",
738
890
  structuralContext.status === "stale" ? "CodeGraph reports stale structural evidence. Treat graph relationships as hints and prefer the returned current raw source for changed files." : structuralContext.status === "unavailable" || structuralContext.status === "degraded" ? "Structural context was unavailable or degraded; bounded text search remains the fallback." : structuralContext.status === "ready" ? "CodeGraph structural context was queried read-only with index sync disabled." : "Structural context was not requested.",
@@ -112,40 +112,38 @@ function isExecutionIntent(
112
112
  );
113
113
  }
114
114
 
115
+ function explicitPlanExecution(intent: PromptGuardExecutionIntent): boolean {
116
+ return intent === 'embedded_approved_plan' || intent === 'plan_execution_projection';
117
+ }
118
+
115
119
  function decideNoActivePlanAction(
116
120
  intent: PromptGuardExecutionIntent,
117
121
  state: PromptGuardState,
118
122
  ): PromptGuardAction {
119
- if (intent !== 'bug_fix_execution' && state.pending === 'fresh') {
120
- return 'plan_capture_pending_advice';
123
+ // Host hooks never decide that ordinary implementation requires a Plan.
124
+ // ChatGPT may inspect first and then explicitly commit to Plan execution.
125
+ if (!explicitPlanExecution(intent)) {
126
+ return state.worktree === 'linked_target' ? 'worktree_execution_advice' : 'allow';
121
127
  }
128
+ if (state.pending === 'fresh') return 'plan_capture_pending_advice';
122
129
  if (state.worktree === 'linked_target') return 'worktree_execution_advice';
123
- if (intent === 'plan_execution_projection') {
124
- return 'plan_capture_missing_active_advice';
125
- }
126
- return 'plan_status_no_active_block';
130
+ return 'plan_capture_missing_active_advice';
127
131
  }
128
132
 
129
133
  function decideDraftPlanAction(
130
134
  intent: PromptGuardExecutionIntent,
131
135
  ): PromptGuardAction {
132
- if (intent === 'plan_execution_projection') return 'plan_capture_draft_advice';
133
- return 'plan_status_not_approved_block';
136
+ return explicitPlanExecution(intent) ? 'plan_capture_draft_advice' : 'allow';
134
137
  }
135
138
 
136
139
  function decideApprovedPlanAction(
137
140
  intent: PromptGuardExecutionIntent,
138
141
  state: PromptGuardState,
139
142
  ): PromptGuardAction {
140
- if (state.evidence === 'incomplete') return 'evidence_contract_block';
141
- if (
142
- state.plan === 'approved' &&
143
- intent === 'plan_execution_projection' &&
144
- state.contract !== 'present'
145
- ) {
146
- return 'plan_execution_scaffold_advice';
147
- }
148
- if (state.contract !== 'present') return 'contract_missing_block';
143
+ // Plan contracts may guide an explicitly selected Plan execution, but their
144
+ // presence/evidence is not a semantic permission gate for ordinary edits.
145
+ if (!explicitPlanExecution(intent)) return 'allow';
146
+ if (state.contract !== 'present') return 'plan_execution_scaffold_advice';
149
147
  return 'allow';
150
148
  }
151
149
 
@@ -233,6 +231,7 @@ export function decidePromptGuardAction(
233
231
  ): PromptGuardAction {
234
232
  if (intent === 'done') return decideDoneAction(state);
235
233
  if (!isExecutionIntent(intent)) return 'allow';
236
- if (state.spec === 'missing') return 'spec_block';
234
+ // Product/spec/Plan state is context for the Controller, not execution
235
+ // authorization. Hard path, permission and resource safety live elsewhere.
237
236
  return PROMPT_GUARD_EXECUTION_TABLE[state.plan][intent](state);
238
237
  }
@@ -401,7 +401,7 @@ export function isThinkPlanStartIntent(ctx: PromptIntentContext): boolean {
401
401
  if (isBugOrHuntIntent(ctx)) return false;
402
402
  if (THINK_COMMAND.test(ctx.text)) return true;
403
403
  if (isPlanConsultationIntent(ctx)) return false;
404
- return THINK_PLAN_PHRASE.test(ctx.text) || isPlainFeaturePlanStartIntent(ctx);
404
+ return THINK_PLAN_PHRASE.test(ctx.text);
405
405
  }
406
406
 
407
407
  export function normalizePlanSlug(value: string): string {
@@ -2,10 +2,22 @@ import type { RepositoryRecord } from '../repositories/types';
2
2
  import { listAssistantRoutines } from '../../runtime/assistant/store';
3
3
  import { runAssistantRoutineNow } from '../../runtime/assistant/intent';
4
4
  import { getAssistantRoutineScheduleBinding, updateAssistantRoutineLifecycle } from '../../runtime/assistant/schedule-binding';
5
+ import { getWorkContract } from '../../runtime/control-plane/facade';
5
6
  import { evaluateSchedule } from '../../runtime/workflow/schedules/engine';
6
7
  import { getSchedule, listOccurrences, listSchedules, saveSchedule } from '../../runtime/workflow/schedules/store';
7
8
  import type { RepositorySchedule, ScheduleOccurrence } from '../../runtime/workflow/schedules/types';
8
9
 
10
+ export type ConsoleAutomationMode = 'browser_watch' | 'browser_keepalive' | 'continuation' | 'chatgpt_prompt' | 'routine' | 'schedule';
11
+
12
+ export interface ConsoleAutomationHistoryView {
13
+ id: string;
14
+ at: string;
15
+ result: string;
16
+ tone: 'green' | 'amber' | 'red' | 'blue' | 'gray';
17
+ reason?: string;
18
+ trigger?: string;
19
+ }
20
+
9
21
  export interface ConsoleAutomationView {
10
22
  id: string;
11
23
  source: 'schedule' | 'routine';
@@ -17,23 +29,46 @@ export interface ConsoleAutomationView {
17
29
  schedule: string;
18
30
  timezone?: string;
19
31
  delivery?: string;
32
+ mode: ConsoleAutomationMode;
33
+ modeLabel: string;
34
+ live?: boolean;
35
+ targetLabel?: string;
36
+ boundWorkId?: string;
37
+ boundWorkObjective?: string;
38
+ observationStatus?: RepositorySchedule['lastObservationStatus'];
39
+ observationAt?: string;
40
+ failureCount?: number;
41
+ policySummary?: string;
42
+ agentModel?: string;
43
+ reasoningLevel?: string;
44
+ tabPolicy?: string;
20
45
  lastRunAt?: string;
21
46
  lastResult?: string;
22
47
  nextRunHint?: string;
23
48
  pausedReason?: string;
49
+ history: ConsoleAutomationHistoryView[];
24
50
  actions: Array<'run' | 'pause' | 'resume'>;
25
51
  }
26
52
 
53
+ function dailyCronLabel(expression?: string): string | undefined {
54
+ const match = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(expression?.trim() ?? '');
55
+ if (!match) return undefined;
56
+ const minute = Number(match[1]);
57
+ const hour = Number(match[2]);
58
+ if (minute > 59 || hour > 23) return undefined;
59
+ return `每天 ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
60
+ }
61
+
27
62
  function triggerLabel(schedule: RepositorySchedule): string {
28
63
  const trigger = schedule.trigger;
29
64
  switch (trigger.type) {
30
- case 'interval': return `Every ${trigger.everyMinutes ?? '?'} min`;
31
- case 'cron': return `Cron ${trigger.cronExpression ?? '—'}`;
32
- case 'calendar': return trigger.calendarAt ? `At ${trigger.calendarAt}` : 'Calendar';
33
- case 'condition': return `Watch ${trigger.condition?.kind ?? 'condition'}`;
34
- case 'repository-event': return `On ${trigger.eventName ?? 'repository event'}`;
35
- case 'dependency-checkpoint': return 'Dependency checkpoint';
36
- case 'manual': return 'Manual';
65
+ case 'interval': return `每 ${trigger.everyMinutes ?? '?'} 分钟`;
66
+ case 'cron': return dailyCronLabel(trigger.cronExpression) ?? `Cron ${trigger.cronExpression ?? '—'}`;
67
+ case 'calendar': return trigger.calendarAt ? `单次 · ${trigger.calendarAt}` : '指定时间';
68
+ case 'condition': return `条件满足时 · ${trigger.condition?.kind ?? 'condition'}`;
69
+ case 'repository-event': return `仓库事件 · ${trigger.eventName ?? 'repository event'}`;
70
+ case 'dependency-checkpoint': return '依赖完成时';
71
+ case 'manual': return '仅手动运行';
37
72
  }
38
73
  }
39
74
 
@@ -45,11 +80,11 @@ function nextScheduleHint(schedule: RepositorySchedule): string | undefined {
45
80
  const base = Date.parse(schedule.lastTriggeredAt ?? schedule.createdAt);
46
81
  if (Number.isFinite(base)) {
47
82
  const next = base + schedule.trigger.everyMinutes * 60_000;
48
- return next > Date.now() ? new Date(next).toISOString() : 'Due on next scheduler tick';
83
+ return next > Date.now() ? new Date(next).toISOString() : '下一次调度周期';
49
84
  }
50
85
  }
51
- if (schedule.trigger.type === 'cron') return 'Scheduler-calculated';
52
- if (schedule.trigger.type === 'condition') return 'When condition matches';
86
+ if (schedule.trigger.type === 'cron') return dailyCronLabel(schedule.trigger.cronExpression) ?? '由 Scheduler 计算';
87
+ if (schedule.trigger.type === 'condition') return '条件满足时';
53
88
  return undefined;
54
89
  }
55
90
 
@@ -60,13 +95,135 @@ function safeBoundSchedule(controllerHome: string, repoId: string, scheduleId?:
60
95
 
61
96
  function occurrenceResult(occurrence?: ScheduleOccurrence): string | undefined {
62
97
  if (!occurrence) return undefined;
63
- if (occurrence.decision === 'nothing_to_do') return 'No action needed';
64
- if (occurrence.status === 'failed') return 'Failed';
65
- if (occurrence.status === 'succeeded') return 'Succeeded';
66
- if (occurrence.status === 'running' || occurrence.status === 'queued' || occurrence.status === 'created') return 'Running';
98
+ if (occurrence.decision === 'nothing_to_do') return '无变化';
99
+ if (occurrence.decision === 'would_execute') return '预演完成';
100
+ if (occurrence.decision === 'stopped') return '已停止';
101
+ if (occurrence.status === 'failed') return '失败';
102
+ if (occurrence.status === 'succeeded') return '执行成功';
103
+ if (occurrence.status === 'running' || occurrence.status === 'queued' || occurrence.status === 'created') return '运行中';
104
+ if (occurrence.status === 'skipped') return '已跳过';
67
105
  return occurrence.status;
68
106
  }
69
107
 
108
+ function occurrenceTone(occurrence: ScheduleOccurrence): ConsoleAutomationHistoryView['tone'] {
109
+ if (occurrence.status === 'failed' || occurrence.decision === 'operation_blocked') return 'red';
110
+ if (occurrence.decision === 'stopped' || occurrence.decision === 'budget_exhausted') return 'amber';
111
+ if (occurrence.status === 'succeeded') return 'green';
112
+ if (occurrence.status === 'running' || occurrence.status === 'queued' || occurrence.status === 'created') return 'blue';
113
+ return 'gray';
114
+ }
115
+
116
+ function displayOccurrenceReason(reason?: string): string | undefined {
117
+ const value = reason?.trim();
118
+ if (!value) return undefined;
119
+ if (value === 'Browser session keepalive refreshed successfully.') return '登录会话刷新成功,未发现认证问题。';
120
+ if (value === 'Browser watcher baseline recorded; no Controller wake was emitted.') return '已建立观察基线;没有唤醒 ChatGPT。';
121
+ if (value === 'Browser watcher observation is unchanged; no Controller wake was emitted.') return '页面没有变化;保持静默。';
122
+ if (value.startsWith('Browser watcher requires authentication:')) return '目标登录状态已失效,需要重新认证。';
123
+ if (value.startsWith('Work ') && value.includes(' is terminal')) return '绑定的 Work 已完成,自动任务已停止。';
124
+ return value;
125
+ }
126
+
127
+ function occurrenceHistory(occurrences: ScheduleOccurrence[]): ConsoleAutomationHistoryView[] {
128
+ return occurrences.slice(0, 8).map((occurrence) => ({
129
+ id: occurrence.occurrenceId,
130
+ at: occurrence.updatedAt,
131
+ result: occurrenceResult(occurrence) ?? '已记录',
132
+ tone: occurrenceTone(occurrence),
133
+ reason: displayOccurrenceReason(occurrence.reason),
134
+ trigger: occurrence.triggerContext?.source === 'manual' ? '手动' : occurrence.triggerContext?.source === 'timer' ? '定时' : occurrence.triggerContext?.source,
135
+ }));
136
+ }
137
+
138
+ function scheduleMode(schedule: RepositorySchedule): ConsoleAutomationMode {
139
+ if (schedule.action.operation === 'external_controller_wake') return 'continuation';
140
+ if (schedule.action.operation === 'chatgpt_browser_prompt') return 'chatgpt_prompt';
141
+ if (schedule.action.operation === 'browser_probe') return schedule.action.arguments?.keepalive_only === true ? 'browser_keepalive' : 'browser_watch';
142
+ return 'schedule';
143
+ }
144
+
145
+ function modeLabel(mode: ConsoleAutomationMode): string {
146
+ if (mode === 'browser_watch') return '网页变更监听';
147
+ if (mode === 'browser_keepalive') return '登录保活';
148
+ if (mode === 'continuation') return '自动继续 Work';
149
+ if (mode === 'chatgpt_prompt') return 'ChatGPT 自动任务';
150
+ if (mode === 'routine') return '助手例行任务';
151
+ return '自动任务';
152
+ }
153
+
154
+ function scheduleSummary(mode: ConsoleAutomationMode): string {
155
+ if (mode === 'browser_watch') return '静默观察目标页面;只有内容发生变化时才恢复 ChatGPT。';
156
+ if (mode === 'browser_keepalive') return '静默刷新登录态;正常时不打扰,认证失效时再恢复 ChatGPT。';
157
+ if (mode === 'continuation') return '按计划恢复绑定的 Work,让外部 Controller 继续未完成目标。';
158
+ if (mode === 'chatgpt_prompt') return '由 Forge 定时唤醒 ChatGPT 执行保存的任务提示,不占用 ChatGPT Schedule 名额。';
159
+ return '由 Forge Schedule Engine 管理的持久自动任务。';
160
+ }
161
+
162
+ function scheduleDelivery(schedule: RepositorySchedule, mode: ConsoleAutomationMode): string | undefined {
163
+ const controller = typeof schedule.action.arguments?.controller_type === 'string' ? schedule.action.arguments.controller_type : 'chatgpt';
164
+ if (mode === 'browser_watch') return `变化时唤醒 ${controller === 'chatgpt' ? 'ChatGPT' : controller}`;
165
+ if (mode === 'browser_keepalive') return `登录失效时唤醒 ${controller === 'chatgpt' ? 'ChatGPT' : controller}`;
166
+ if (mode === 'continuation') return `恢复 ${controller === 'chatgpt' ? 'ChatGPT' : controller}`;
167
+ if (mode === 'chatgpt_prompt') return '发送到 ChatGPT';
168
+ return undefined;
169
+ }
170
+
171
+ function chatgptExecutionProfile(schedule: RepositorySchedule, mode: ConsoleAutomationMode): { agentModel?: string; reasoningLevel?: string; tabPolicy?: string } {
172
+ if (mode !== 'continuation' && mode !== 'chatgpt_prompt') return {};
173
+ const args = schedule.action.arguments ?? {};
174
+ const controller = typeof args.controller_type === 'string' ? args.controller_type : 'chatgpt';
175
+ if (mode === 'continuation' && controller !== 'chatgpt') return {};
176
+ return {
177
+ agentModel: typeof args.model === 'string' && args.model.trim() ? args.model.trim() : 'gpt-5.6',
178
+ reasoningLevel: typeof args.reasoning === 'string' && args.reasoning.trim() ? args.reasoning.trim() : 'high',
179
+ tabPolicy: typeof args.tab_policy === 'string' && args.tab_policy.trim() ? args.tab_policy.trim() : 'auto',
180
+ };
181
+ }
182
+
183
+ function scheduleTarget(schedule: RepositorySchedule, mode: ConsoleAutomationMode): string | undefined {
184
+ const args = schedule.action.arguments ?? {};
185
+ if (typeof args.probe_url === 'string') {
186
+ try { return new URL(args.probe_url).hostname; } catch { return '网页目标'; }
187
+ }
188
+ if (typeof args.probe_session_id === 'string') return mode === 'browser_keepalive' ? '已绑定登录会话' : '已绑定浏览器会话';
189
+ if (typeof args.browser_session_id === 'string') return '已绑定 ChatGPT 会话';
190
+ return undefined;
191
+ }
192
+
193
+ function observationResult(schedule: RepositorySchedule, fallback?: ScheduleOccurrence): string | undefined {
194
+ switch (schedule.lastObservationStatus) {
195
+ case 'baseline': return '已建立基线';
196
+ case 'unchanged': return '无变化';
197
+ case 'changed': return '检测到变化';
198
+ case 'keepalive': return '登录保持正常';
199
+ case 'auth_required': return '需要重新登录';
200
+ default: return occurrenceResult(fallback);
201
+ }
202
+ }
203
+
204
+ function scheduleNeedsAttention(schedule: RepositorySchedule): boolean {
205
+ if (schedule.lastObservationStatus === 'auth_required') return true;
206
+ if (schedule.consecutiveFailures >= schedule.policy.maxFailures) return true;
207
+ return Boolean(schedule.pausedReason && /(?:fail|error|auth|login|block|attention|maximum)/i.test(schedule.pausedReason));
208
+ }
209
+
210
+ function scheduleStatus(schedule: RepositorySchedule): ConsoleAutomationView['status'] {
211
+ if (schedule.enabled) return scheduleNeedsAttention(schedule) ? 'attention' : 'enabled';
212
+ return scheduleNeedsAttention(schedule) ? 'attention' : 'paused';
213
+ }
214
+
215
+ function workBinding(controllerHome: string, repoId: string, schedule: RepositorySchedule): { id?: string; objective?: string } {
216
+ const value = schedule.action.arguments?.work_id;
217
+ const workId = typeof value === 'string' && value.trim() ? value.trim() : undefined;
218
+ if (!workId) return {};
219
+ const work = getWorkContract({ controllerHome, repoId }, workId);
220
+ return { id: workId, objective: work?.objective };
221
+ }
222
+
223
+ function policySummary(schedule: RepositorySchedule): string {
224
+ return `连续失败 ${schedule.consecutiveFailures}/${schedule.policy.maxFailures} · 冷却 ${schedule.policy.cooldownMinutes} 分钟 · 每日预算 ${schedule.policy.dailyBudgetMinutes} 分钟`;
225
+ }
226
+
70
227
  export function listConsoleAutomations(controllerHome: string, repositories: RepositoryRecord[]): ConsoleAutomationView[] {
71
228
  const items: ConsoleAutomationView[] = [];
72
229
  for (const repository of repositories.filter((entry) => entry.enabled && !entry.removedAt)) {
@@ -76,22 +233,38 @@ export function listConsoleAutomations(controllerHome: string, repositories: Rep
76
233
 
77
234
  for (const schedule of listSchedules(controllerHome, repository.repoId)) {
78
235
  if (boundScheduleIds.has(schedule.scheduleId)) continue;
79
- const last = listOccurrences(controllerHome, repository.repoId, schedule.scheduleId, 1)[0];
80
- const status: ConsoleAutomationView['status'] = schedule.enabled ? 'enabled' : schedule.pausedReason ? 'attention' : 'paused';
236
+ const occurrences = listOccurrences(controllerHome, repository.repoId, schedule.scheduleId, 8);
237
+ const last = occurrences[0];
238
+ const mode = scheduleMode(schedule);
239
+ const work = workBinding(controllerHome, repository.repoId, schedule);
240
+ const executionProfile = chatgptExecutionProfile(schedule, mode);
81
241
  items.push({
82
242
  id: schedule.scheduleId,
83
243
  source: 'schedule',
84
244
  repoId: repository.repoId,
85
245
  repositoryName: repository.displayName,
86
246
  name: schedule.name,
87
- summary: schedule.action.operation,
88
- status,
247
+ summary: scheduleSummary(mode),
248
+ status: scheduleStatus(schedule),
89
249
  schedule: triggerLabel(schedule),
90
250
  timezone: schedule.trigger.timezone,
251
+ delivery: scheduleDelivery(schedule, mode),
252
+ mode,
253
+ modeLabel: modeLabel(mode),
254
+ live: !schedule.policy.shadowMode,
255
+ targetLabel: scheduleTarget(schedule, mode),
256
+ boundWorkId: work.id,
257
+ boundWorkObjective: work.objective,
258
+ observationStatus: schedule.lastObservationStatus,
259
+ observationAt: schedule.lastObservationAt,
260
+ failureCount: schedule.consecutiveFailures,
261
+ policySummary: policySummary(schedule),
262
+ ...executionProfile,
91
263
  lastRunAt: last?.updatedAt ?? schedule.lastTriggeredAt,
92
- lastResult: occurrenceResult(last),
264
+ lastResult: observationResult(schedule, last),
93
265
  nextRunHint: nextScheduleHint(schedule),
94
266
  pausedReason: schedule.pausedReason,
267
+ history: occurrenceHistory(occurrences),
95
268
  actions: schedule.enabled ? ['run', 'pause'] : ['resume'],
96
269
  });
97
270
  }
@@ -99,8 +272,9 @@ export function listConsoleAutomations(controllerHome: string, repositories: Rep
99
272
  for (const routine of routines) {
100
273
  const binding = bindings.get(routine.routineId);
101
274
  const boundSchedule = safeBoundSchedule(controllerHome, repository.repoId, binding?.scheduleId);
102
- const last = boundSchedule ? listOccurrences(controllerHome, repository.repoId, boundSchedule.scheduleId, 1)[0] : undefined;
103
- const scheduleAttention = routine.status === 'enabled' && boundSchedule && !boundSchedule.enabled && Boolean(boundSchedule.pausedReason);
275
+ const occurrences = boundSchedule ? listOccurrences(controllerHome, repository.repoId, boundSchedule.scheduleId, 8) : [];
276
+ const last = occurrences[0];
277
+ const scheduleAttention = routine.status === 'enabled' && boundSchedule && scheduleNeedsAttention(boundSchedule);
104
278
  const status: ConsoleAutomationView['status'] = scheduleAttention ? 'attention' : routine.status === 'enabled' ? 'enabled' : routine.status === 'paused' ? 'paused' : 'disabled';
105
279
  items.push({
106
280
  id: routine.routineId,
@@ -112,11 +286,19 @@ export function listConsoleAutomations(controllerHome: string, repositories: Rep
112
286
  status,
113
287
  schedule: routine.scheduleText,
114
288
  timezone: routine.timezone,
115
- delivery: routine.output === 'assistant_inbox' ? 'Assistant inbox' : routine.output === 'gmail_draft' ? 'Gmail draft' : 'None',
289
+ delivery: routine.output === 'assistant_inbox' ? 'ChatGPT 助手收件箱' : routine.output === 'gmail_draft' ? 'Gmail 草稿' : '不外发',
290
+ mode: 'routine',
291
+ modeLabel: modeLabel('routine'),
292
+ live: boundSchedule ? !boundSchedule.policy.shadowMode : undefined,
293
+ observationStatus: boundSchedule?.lastObservationStatus,
294
+ observationAt: boundSchedule?.lastObservationAt,
295
+ failureCount: boundSchedule?.consecutiveFailures,
296
+ policySummary: boundSchedule ? policySummary(boundSchedule) : undefined,
116
297
  lastRunAt: last?.updatedAt ?? routine.lastRunAt,
117
- lastResult: occurrenceResult(last) ?? (routine.lastRunAt ? 'Triggered' : undefined),
298
+ lastResult: boundSchedule ? observationResult(boundSchedule, last) : occurrenceResult(last) ?? (routine.lastRunAt ? '已触发' : undefined),
118
299
  nextRunHint: boundSchedule ? nextScheduleHint(boundSchedule) : routine.nextRunHint,
119
300
  pausedReason: boundSchedule?.pausedReason,
301
+ history: occurrenceHistory(occurrences),
120
302
  actions: scheduleAttention ? ['resume'] : routine.status === 'enabled' ? ['run', 'pause'] : routine.status === 'paused' ? ['resume'] : [],
121
303
  });
122
304
  }