holo-codex 0.1.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 (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/README.zh-CN.md +215 -0
  6. package/SECURITY.md +39 -0
  7. package/assets/brand/README.md +35 -0
  8. package/assets/brand/holo-codex-icon.svg +28 -0
  9. package/assets/brand/holo-codex-lockup.svg +49 -0
  10. package/assets/brand/holo-codex-mark.svg +33 -0
  11. package/assets/brand/holo-codex-plugin-card.png +0 -0
  12. package/assets/brand/holo-codex-plugin-card.svg +81 -0
  13. package/assets/brand/holo-codex-readme-hero.png +0 -0
  14. package/assets/brand/holo-codex-readme-hero.svg +140 -0
  15. package/assets/brand/holo-codex-social-preview.png +0 -0
  16. package/assets/brand/holo-codex-social-preview.svg +130 -0
  17. package/assets/brand/holo-codex-wordmark-options.svg +52 -0
  18. package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
  19. package/docs/examples/generic-loop-repo-hygiene.md +168 -0
  20. package/docs/install.md +190 -0
  21. package/docs/local-release-readiness.md +206 -0
  22. package/docs/release-checklist.md +144 -0
  23. package/docs/self-bootstrap.md +150 -0
  24. package/docs/trust-and-safety.md +45 -0
  25. package/package.json +83 -0
  26. package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
  27. package/plugins/autonomous-pr-loop/.mcp.json +13 -0
  28. package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
  29. package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
  30. package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
  31. package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
  32. package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
  33. package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
  34. package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
  35. package/plugins/autonomous-pr-loop/core/command.ts +47 -0
  36. package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
  37. package/plugins/autonomous-pr-loop/core/config.ts +293 -0
  38. package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
  39. package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
  40. package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
  41. package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
  42. package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
  43. package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
  44. package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
  45. package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
  46. package/plugins/autonomous-pr-loop/core/git.ts +213 -0
  47. package/plugins/autonomous-pr-loop/core/github.ts +269 -0
  48. package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
  49. package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
  50. package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
  51. package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
  52. package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
  53. package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
  54. package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
  55. package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
  56. package/plugins/autonomous-pr-loop/core/index.ts +32 -0
  57. package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
  58. package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
  59. package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
  60. package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
  61. package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
  62. package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
  63. package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
  64. package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
  65. package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
  66. package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
  67. package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
  68. package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
  69. package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
  70. package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
  71. package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
  72. package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
  73. package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
  74. package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
  75. package/plugins/autonomous-pr-loop/core/types.ts +567 -0
  76. package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
  77. package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
  78. package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
  79. package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
  80. package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
  81. package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
  82. package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
  83. package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
  84. package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
  85. package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
  86. package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
  87. package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
  88. package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
  89. package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
  90. package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
  91. package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
  92. package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
  93. package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
  94. package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
  95. package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
  96. package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
  97. package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
  98. package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
  99. package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
  100. package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
  101. package/plugins/autonomous-pr-loop/package.json +9 -0
  102. package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
  103. package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
  104. package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
  105. package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
  106. package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
  107. package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
  108. package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
  109. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
  110. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
  111. package/plugins/autonomous-pr-loop/ui/index.html +26 -0
  112. package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
  113. package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
  114. package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
  115. package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
  116. package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
  117. package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
  118. package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
  119. package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
  120. package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
  121. package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
  122. package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
  123. package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
  124. package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
  125. package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
  126. package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
  127. package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
  128. package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
  129. package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
  130. package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
  131. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
  132. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
  133. package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
  134. package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
  135. package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
  136. package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
  137. package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
  138. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
  139. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
  140. package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
  141. package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
  142. package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
  143. package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
  144. package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
  145. package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
  146. package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
  147. package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
  148. package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
  149. package/tsconfig.json +18 -0
@@ -0,0 +1,176 @@
1
+ import { isRecord, loadConfig, statePath } from "./config.js";
2
+ import { SqliteAgentLoopStorage } from "./storage.js";
3
+ import type { AgentLoopGateKind } from "./types.js";
4
+
5
+ export interface GateRecoveryResult {
6
+ ok: true;
7
+ recovered: number;
8
+ scope: "repo";
9
+ kind: AgentLoopGateKind;
10
+ warnings: string[];
11
+ }
12
+
13
+ /** Terminal worker failure kinds that an operator can explicitly recover so resume re-attempts the worker. */
14
+ export const TERMINAL_WORKER_GATE_KINDS: AgentLoopGateKind[] = [
15
+ "worker_failed",
16
+ "worker_output_invalid",
17
+ "worker_timeout"
18
+ ];
19
+
20
+ /** Decision kind recorded when an operator marks an active worker failure obsolete for resume. */
21
+ export const WORKER_FAILURE_RECOVERED_DECISION = "worker_failure_recovered";
22
+
23
+ export interface RepoGateRecovery {
24
+ recovered: number;
25
+ kind: "needs_repo_init";
26
+ }
27
+
28
+ export interface WorkerGateRecovery {
29
+ recovered: number;
30
+ runId?: string;
31
+ gateKinds: AgentLoopGateKind[];
32
+ gateIds: string[];
33
+ workerIds: string[];
34
+ }
35
+
36
+ export interface RunRecoveryResult {
37
+ ok: true;
38
+ /** Total gates recovered across repo-level and run-scoped recovery (kept stable for callers that expect a single count). */
39
+ recovered: number;
40
+ repo: RepoGateRecovery;
41
+ worker: WorkerGateRecovery;
42
+ warnings: string[];
43
+ }
44
+
45
+ /** Explicitly recover repo-level gates whose blocking condition has already cleared. */
46
+ export function recoverSatisfiedRepoGates(repoRoot: string, source = "cli"): GateRecoveryResult {
47
+ loadConfig(repoRoot);
48
+ const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
49
+ try {
50
+ const before = storage.listGates().filter((gate) =>
51
+ gate.kind === "needs_repo_init" && gate.status === "open" && gate.runId === undefined
52
+ );
53
+ storage.resolveOpenGatesByKind("needs_repo_init", { scope: "repo" });
54
+ const after = storage.listGates().filter((gate) =>
55
+ gate.kind === "needs_repo_init" && gate.status === "open" && gate.runId === undefined
56
+ );
57
+ const recovered = before.length - after.length;
58
+ if (recovered > 0) {
59
+ const gateIds = before.slice(0, recovered).map((gate) => gate.id);
60
+ const payload = {
61
+ source,
62
+ scope: "repo",
63
+ kind: "needs_repo_init",
64
+ recovered,
65
+ gateIds,
66
+ reason: "config_exists_and_valid"
67
+ };
68
+ storage.appendEvent({
69
+ kind: "gate_recovery",
70
+ message: "Recovered repo-level needs_repo_init gate after config became valid.",
71
+ payload
72
+ });
73
+ const run = storage.getCurrentRun();
74
+ if (run) {
75
+ storage.appendDecision({
76
+ runId: run.id,
77
+ kind: "gate_recovery",
78
+ message: "Explicit recovery resolved repo-level needs_repo_init gate.",
79
+ details: payload
80
+ });
81
+ }
82
+ }
83
+ return { ok: true, recovered, scope: "repo", kind: "needs_repo_init", warnings: [] };
84
+ } finally {
85
+ storage.close();
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Resolve active terminal-worker gates on the current run so `resume` can re-attempt the worker.
91
+ *
92
+ * The gate is resolved (never deleted), the failed worker row is preserved, and a visible
93
+ * `gate_recovery` event plus a `worker_failure_recovered` decision are appended so the
94
+ * recovery stays auditable and `blockRunForTerminalWorker` will not silently re-open the gate.
95
+ * Returns a zero recovery when the current run has no active terminal-worker gate.
96
+ */
97
+ export function recoverTerminalWorkerGate(
98
+ storage: SqliteAgentLoopStorage,
99
+ source: "cli" | "dashboard" | "ui" | "api" | "test" = "cli"
100
+ ): WorkerGateRecovery {
101
+ const empty: WorkerGateRecovery = { recovered: 0, gateKinds: [], gateIds: [], workerIds: [] };
102
+ const run = storage.getCurrentRun();
103
+ if (!run || run.status === "STOPPED") {
104
+ return empty;
105
+ }
106
+ const openWorkerGates = storage
107
+ .listGates(run.id)
108
+ .filter((gate) => gate.status === "open" && TERMINAL_WORKER_GATE_KINDS.includes(gate.kind));
109
+ if (openWorkerGates.length === 0) {
110
+ return { ...empty, runId: run.id };
111
+ }
112
+ const gateKinds = [...new Set(openWorkerGates.map((gate) => gate.kind))];
113
+ const gateIds = openWorkerGates.map((gate) => gate.id);
114
+ const workerIds = [...new Set(openWorkerGates.map((gate) => gateWorkerId(gate.details)).filter((id): id is string => Boolean(id)))];
115
+ for (const kind of gateKinds) {
116
+ storage.resolveOpenGatesByKind(kind, { scope: "run", runId: run.id });
117
+ }
118
+ const payload = {
119
+ source,
120
+ scope: "run" as const,
121
+ reason: "operator_marked_obsolete",
122
+ gateKinds,
123
+ gateIds,
124
+ workerIds,
125
+ runId: run.id
126
+ };
127
+ storage.appendEvent({
128
+ runId: run.id,
129
+ kind: "gate_recovery",
130
+ message: "Recovered active terminal-worker gate; resume will re-attempt the worker.",
131
+ payload
132
+ });
133
+ storage.appendDecision({
134
+ runId: run.id,
135
+ kind: WORKER_FAILURE_RECOVERED_DECISION,
136
+ message: "Operator marked the active worker failure obsolete and cleared the gate for resume.",
137
+ details: payload
138
+ });
139
+ // Flip the run back to RUNNING so the next reconcile does not report BLOCKED and `resume` can re-run the worker.
140
+ // blockRunForTerminalWorker honors the recovery decision and will not re-open the gate for these workers.
141
+ if (run.status === "BLOCKED") {
142
+ storage.updateRunStatus(run.id, run.version, "RUNNING", run.currentState ? { currentState: run.currentState } : {});
143
+ }
144
+ return { recovered: openWorkerGates.length, runId: run.id, gateKinds, gateIds, workerIds };
145
+ }
146
+
147
+ function gateWorkerId(details: unknown): string | undefined {
148
+ if (!isRecord(details)) return undefined;
149
+ return typeof details.workerId === "string" ? details.workerId : undefined;
150
+ }
151
+
152
+ /**
153
+ * Single recovery entry point for CLI, MCP, and Dashboard: recover repo-level init gates
154
+ * whose config is now valid, then recover any active terminal-worker gate on the current run.
155
+ * Both legs preserve audit history and emit visible events/decisions.
156
+ */
157
+ export function recoverBlockedRun(
158
+ repoRoot: string,
159
+ source: "cli" | "dashboard" | "ui" | "api" | "test" = "cli"
160
+ ): RunRecoveryResult {
161
+ loadConfig(repoRoot);
162
+ const repoResult = recoverSatisfiedRepoGates(repoRoot, source);
163
+ const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
164
+ try {
165
+ const worker = recoverTerminalWorkerGate(storage, source);
166
+ return {
167
+ ok: true,
168
+ recovered: repoResult.recovered + worker.recovered,
169
+ repo: { recovered: repoResult.recovered, kind: "needs_repo_init" },
170
+ worker,
171
+ warnings: repoResult.warnings
172
+ };
173
+ } finally {
174
+ storage.close();
175
+ }
176
+ }
@@ -0,0 +1,26 @@
1
+ import type { AgentLoopGateKind } from "./types.js";
2
+
3
+ /** Canonical gate constants shared by CLI, policy, storage tests, and future control surfaces. */
4
+ export const GATES: Record<AgentLoopGateKind, AgentLoopGateKind> = {
5
+ needs_repo_init: "needs_repo_init",
6
+ unsupported_remote: "unsupported_remote",
7
+ needs_secret_or_login: "needs_secret_or_login",
8
+ policy_violation: "policy_violation",
9
+ ambiguous_next_pr: "ambiguous_next_pr",
10
+ dirty_unowned_worktree: "dirty_unowned_worktree",
11
+ required_tool_unavailable: "required_tool_unavailable",
12
+ ci_required_checks_missing: "ci_required_checks_missing",
13
+ ci_pending_timeout: "ci_pending_timeout",
14
+ merge_requires_confirmation: "merge_requires_confirmation",
15
+ github_transient_failure: "github_transient_failure",
16
+ gitnexus_check_failed: "gitnexus_check_failed",
17
+ github_resource_not_found: "github_resource_not_found",
18
+ worker_failed: "worker_failed",
19
+ worker_output_invalid: "worker_output_invalid",
20
+ review_out_of_scope: "review_out_of_scope",
21
+ worker_timeout: "worker_timeout",
22
+ worker_already_running: "worker_already_running",
23
+ generic_goal_needs_confirmation: "generic_goal_needs_confirmation",
24
+ generic_human_gate: "generic_human_gate",
25
+ generic_scope_change_requested: "generic_scope_change_requested"
26
+ };
@@ -0,0 +1,399 @@
1
+ import { writeArtifact } from "./artifacts.js";
2
+ import { AgentLoopError } from "./errors.js";
3
+ import { workflowProfileDefinition } from "./profiles.js";
4
+ import type { AgentLoopConfig, AgentLoopDecision, AgentLoopRun, AgentLoopStorage, WorkerResult } from "./types.js";
5
+ import type { AgentLoopState, ArtifactRecord, TransitionGuard } from "./state-types.js";
6
+
7
+ export interface GenericLifecycleResult {
8
+ nextState?: AgentLoopState;
9
+ transitionGuard?: TransitionGuard;
10
+ artifacts?: ArtifactRecord[];
11
+ status?: "RUNNING" | "READY" | "STOPPED";
12
+ }
13
+
14
+ export function executeGenericPreWorkerStep(input: {
15
+ storage: AgentLoopStorage;
16
+ run: AgentLoopRun;
17
+ state: AgentLoopState;
18
+ dryRun: boolean;
19
+ }): GenericLifecycleResult {
20
+ if (input.state !== "EXECUTE_STEP") {
21
+ return {};
22
+ }
23
+ const scopeDecision = latestGateDecision(input.storage, input.run.id, "generic_scope_change_requested", "EXECUTE_STEP");
24
+ if (!scopeDecision) {
25
+ return {};
26
+ }
27
+ if (decisionStatus(scopeDecision) === "rejected") {
28
+ if (!input.dryRun) markConsumed(input.storage, input.run.id, scopeDecision, "STOPPED");
29
+ return { transitionGuard: "rejected", status: "STOPPED" };
30
+ }
31
+ const nextState = decisionNextState(scopeDecision, ["PLAN_WORK", "STOPPED"], "generic_scope_change_requested", "EXECUTE_STEP", "PLAN_WORK");
32
+ if (!input.dryRun) markConsumed(input.storage, input.run.id, scopeDecision, nextState);
33
+ return {
34
+ transitionGuard: nextState === "PLAN_WORK" ? "scope_change_approved" : "rejected",
35
+ ...(nextState === "STOPPED" ? { status: "STOPPED" as const } : {})
36
+ };
37
+ }
38
+
39
+ export async function executeGenericLifecycleStep(input: {
40
+ repoRoot: string;
41
+ storage: AgentLoopStorage;
42
+ run: AgentLoopRun;
43
+ config: AgentLoopConfig;
44
+ state: AgentLoopState;
45
+ dryRun: boolean;
46
+ workerResult?: WorkerResult;
47
+ }): Promise<GenericLifecycleResult> {
48
+ const profile = workflowProfileDefinition(input.config.workflowProfile);
49
+ if (input.state === "DEFINE_GOAL") {
50
+ const gateDecision = latestGateDecision(input.storage, input.run.id, "generic_goal_needs_confirmation", "DEFINE_GOAL");
51
+ if (!gateDecision) {
52
+ throw new AgentLoopError("generic_goal_needs_confirmation", "Generic loop goal needs confirmation before work starts.", {
53
+ details: {
54
+ loopShape: "generic-loop",
55
+ workflowProfile: input.config.workflowProfile,
56
+ state: "DEFINE_GOAL",
57
+ expectedDeliverable: profile.expectedDeliverable,
58
+ allowedNextStates: ["COLLECT_CONTEXT", "PLAN_WORK", "STOPPED"],
59
+ defaultNextState: "COLLECT_CONTEXT",
60
+ requiredPayload: { nextState: "COLLECT_CONTEXT", source: "ui" }
61
+ },
62
+ exitCode: 2
63
+ });
64
+ }
65
+ if (decisionStatus(gateDecision) === "rejected") {
66
+ if (!input.dryRun) markConsumed(input.storage, input.run.id, gateDecision, "STOPPED");
67
+ return { transitionGuard: "rejected", status: "STOPPED" };
68
+ }
69
+ const nextState = decisionNextState(gateDecision, ["COLLECT_CONTEXT", "PLAN_WORK", "STOPPED"], "generic_goal_needs_confirmation", "DEFINE_GOAL", "COLLECT_CONTEXT");
70
+ if (!input.dryRun) markConsumed(input.storage, input.run.id, gateDecision, nextState);
71
+ return { transitionGuard: guardForGoalDecision(nextState), ...(nextState === "STOPPED" ? { status: "STOPPED" as const } : {}) };
72
+ }
73
+ if (input.state === "COLLECT_CONTEXT") {
74
+ return {
75
+ transitionGuard: "always",
76
+ artifacts: [writeGenericArtifact(input, "generic-context", "context.md", genericArtifactContent(input, "Context collected"))]
77
+ };
78
+ }
79
+ if (input.state === "PLAN_WORK") {
80
+ if (!input.dryRun) {
81
+ input.storage.appendDecision({
82
+ runId: input.run.id,
83
+ kind: "generic_plan_ready",
84
+ message: "Generic loop plan is ready.",
85
+ details: { workflowProfile: input.config.workflowProfile, expectedDeliverable: profile.expectedDeliverable }
86
+ });
87
+ }
88
+ return {
89
+ transitionGuard: "always",
90
+ artifacts: [writeGenericArtifact(input, "generic-plan", "plan.md", genericArtifactContent(input, "Plan ready"))]
91
+ };
92
+ }
93
+ if (input.state === "EXECUTE_STEP") {
94
+ return { transitionGuard: "always" };
95
+ }
96
+ if (input.state === "SELF_REVIEW") {
97
+ const anchor = latestReviewCycleAnchor(input.storage, input.run.id);
98
+ const cycles = executionReviewCycles(input.storage, input.run.id, anchor);
99
+ const maxCycles = profile.maxExecutionReviewCycles ?? 3;
100
+ const review = classifySelfReview(input.workerResult);
101
+ if (!review.needsFix) {
102
+ if (!input.dryRun) {
103
+ input.storage.appendDecision({
104
+ runId: input.run.id,
105
+ kind: "generic_review_passed",
106
+ message: "Generic self-review passed.",
107
+ details: { anchorId: anchor?.id, workflowProfile: input.config.workflowProfile, summary: input.workerResult?.summary }
108
+ });
109
+ }
110
+ return { transitionGuard: "review_passed" };
111
+ }
112
+ if (cycles < maxCycles) {
113
+ if (!input.dryRun) {
114
+ input.storage.appendDecision({
115
+ runId: input.run.id,
116
+ kind: "generic_execute_review_cycle",
117
+ message: "Generic self-review requested another execution pass.",
118
+ details: {
119
+ cycle: cycles + 1,
120
+ maxCycles,
121
+ anchorId: anchor?.id,
122
+ workflowProfile: input.config.workflowProfile,
123
+ reasons: review.reasons,
124
+ followUps: input.workerResult?.followUps ?? [],
125
+ outOfScope: input.workerResult?.outOfScope ?? []
126
+ }
127
+ });
128
+ }
129
+ return { transitionGuard: "fix_needed_cycles_remain" };
130
+ }
131
+ if (!input.dryRun) {
132
+ input.storage.appendDecision({
133
+ runId: input.run.id,
134
+ kind: "generic_review_cycles_exhausted",
135
+ message: "Generic self-review cycles exhausted; escalating to human gate.",
136
+ details: {
137
+ cycles,
138
+ maxCycles,
139
+ anchorId: anchor?.id,
140
+ workflowProfile: input.config.workflowProfile,
141
+ reasons: review.reasons,
142
+ followUps: input.workerResult?.followUps ?? [],
143
+ outOfScope: input.workerResult?.outOfScope ?? []
144
+ }
145
+ });
146
+ }
147
+ return { transitionGuard: "review_cycles_exhausted" };
148
+ }
149
+ if (input.state === "HUMAN_GATE") {
150
+ const reason = humanGateReason(input.storage, input.run.id);
151
+ const approval = latestGateDecision(input.storage, input.run.id, "generic_human_gate", "HUMAN_GATE");
152
+ if (!approval) {
153
+ throw new AgentLoopError("generic_human_gate", "Generic deliverable needs human approval before delivery.", {
154
+ details: {
155
+ loopShape: "generic-loop",
156
+ workflowProfile: input.config.workflowProfile,
157
+ state: "HUMAN_GATE",
158
+ expectedDeliverable: profile.expectedDeliverable,
159
+ reason,
160
+ allowedNextStates: ["DELIVER", "EXECUTE_STEP", "STOPPED"],
161
+ defaultNextState: "DELIVER",
162
+ requiredPayload: { nextState: "DELIVER", source: "ui" }
163
+ },
164
+ exitCode: 2
165
+ });
166
+ }
167
+ if (decisionStatus(approval) === "rejected") {
168
+ if (!input.dryRun) markConsumed(input.storage, input.run.id, approval, "STOPPED");
169
+ return { transitionGuard: "rejected", status: "STOPPED" };
170
+ }
171
+ const nextState = decisionNextState(approval, ["DELIVER", "EXECUTE_STEP", "STOPPED"], "generic_human_gate", "HUMAN_GATE", "DELIVER");
172
+ if (!input.dryRun) markConsumed(input.storage, input.run.id, approval, nextState);
173
+ return { transitionGuard: guardForHumanGateDecision(nextState), ...(nextState === "STOPPED" ? { status: "STOPPED" as const } : {}) };
174
+ }
175
+ if (input.state === "DELIVER") {
176
+ return {
177
+ transitionGuard: "always",
178
+ artifacts: [writeGenericArtifact(input, "generic-deliverable", "deliverable.md", genericArtifactContent(input, "Deliverable approved"))]
179
+ };
180
+ }
181
+ if (input.state === "COMPLETE") {
182
+ if (!input.storage.listDecisions(input.run.id).some((decision) => decision.kind === "generic_loop_completed")) {
183
+ input.storage.appendDecision({
184
+ runId: input.run.id,
185
+ kind: "generic_loop_completed",
186
+ message: "Generic loop completed.",
187
+ details: { workflowProfile: input.config.workflowProfile, expectedDeliverable: profile.expectedDeliverable }
188
+ });
189
+ input.storage.appendEvent({
190
+ runId: input.run.id,
191
+ kind: "generic_loop_completed",
192
+ message: "Generic loop completed.",
193
+ stateBefore: "DELIVER",
194
+ stateAfter: "COMPLETE",
195
+ payload: { workflowProfile: input.config.workflowProfile, expectedDeliverable: profile.expectedDeliverable }
196
+ });
197
+ }
198
+ return { nextState: "COMPLETE", status: "READY" };
199
+ }
200
+ return {};
201
+ }
202
+
203
+ function writeGenericArtifact(
204
+ input: {
205
+ repoRoot: string;
206
+ storage: AgentLoopStorage;
207
+ run: AgentLoopRun;
208
+ config: AgentLoopConfig;
209
+ state: AgentLoopState;
210
+ dryRun: boolean;
211
+ },
212
+ kind: "generic-context" | "generic-plan" | "generic-deliverable",
213
+ name: string,
214
+ content: string
215
+ ): ArtifactRecord {
216
+ return writeArtifact(input.repoRoot, input.storage, input.run.id, kind, name, content);
217
+ }
218
+
219
+ function genericArtifactContent(input: { config: AgentLoopConfig; state: AgentLoopState }, title: string): string {
220
+ const profile = workflowProfileDefinition(input.config.workflowProfile);
221
+ return [
222
+ `# ${title}`,
223
+ "",
224
+ `- loopShape: ${input.config.loopShape}`,
225
+ `- workflowProfile: ${input.config.workflowProfile}`,
226
+ `- state: ${input.state}`,
227
+ `- expectedDeliverable: ${profile.expectedDeliverable ?? "deliverable"}`,
228
+ `- allowedWriteRoots: ${(profile.allowedWriteRoots ?? []).join(", ") || "none"}`,
229
+ `- requiredEvidence: ${(profile.requiredEvidence ?? []).join(", ") || "none"}`,
230
+ `- reviewChecklist: ${(profile.reviewChecklist ?? []).join(", ") || "none"}`,
231
+ `- handoff: ${profile.handoffTemplate}`,
232
+ "",
233
+ "This artifact records the generic-loop lifecycle handoff. Worker output and detailed evidence remain in worker artifacts and timeline entries."
234
+ ].join("\n");
235
+ }
236
+
237
+ function latestGateDecision(storage: AgentLoopStorage, runId: string, gateKind: string, state?: AgentLoopState): AgentLoopDecision | undefined {
238
+ const gate = storage.listGates(runId).find((item) => {
239
+ if (item.kind !== gateKind || item.status === "open") {
240
+ return false;
241
+ }
242
+ return state === undefined || gateState(item.details) === state;
243
+ });
244
+ if (!gate) {
245
+ return undefined;
246
+ }
247
+ return storage.listDecisions(runId).find((decision) => {
248
+ if (decision.kind !== "gate_approved" && decision.kind !== "gate_rejected") {
249
+ return false;
250
+ }
251
+ const details = decision.details;
252
+ if (typeof details !== "object" || details === null || (details as { gateKind?: unknown }).gateKind !== gateKind) {
253
+ return false;
254
+ }
255
+ const matches = (details as { gateId?: unknown }).gateId === gate.id && (state === undefined || (details as { state?: unknown }).state === state);
256
+ return matches && !isConsumed(storage, runId, decision);
257
+ });
258
+ }
259
+
260
+ function gateState(details: unknown): AgentLoopState | undefined {
261
+ if (typeof details !== "object" || details === null || Array.isArray(details)) return undefined;
262
+ const state = (details as { state?: unknown }).state;
263
+ return typeof state === "string" ? state as AgentLoopState : undefined;
264
+ }
265
+
266
+ function decisionGateId(decision: AgentLoopDecision): string | undefined {
267
+ if (typeof decision.details !== "object" || decision.details === null || Array.isArray(decision.details)) return undefined;
268
+ const gateId = (decision.details as { gateId?: unknown }).gateId;
269
+ return typeof gateId === "string" ? gateId : undefined;
270
+ }
271
+
272
+ function decisionGateKind(decision: AgentLoopDecision): string | undefined {
273
+ if (typeof decision.details !== "object" || decision.details === null || Array.isArray(decision.details)) return undefined;
274
+ const gateKind = (decision.details as { gateKind?: unknown }).gateKind;
275
+ return typeof gateKind === "string" ? gateKind : undefined;
276
+ }
277
+
278
+ function isConsumed(storage: AgentLoopStorage, runId: string, decision: AgentLoopDecision): boolean {
279
+ const gateId = decisionGateId(decision);
280
+ return storage.listDecisions(runId).some((item) => {
281
+ if (item.kind !== "generic_gate_decision_consumed" || typeof item.details !== "object" || item.details === null || Array.isArray(item.details)) {
282
+ return false;
283
+ }
284
+ const details = item.details as { gateId?: unknown; decisionId?: unknown };
285
+ return details.decisionId === decision.id || (gateId !== undefined && details.gateId === gateId);
286
+ });
287
+ }
288
+
289
+ function markConsumed(storage: AgentLoopStorage, runId: string, decision: AgentLoopDecision, nextState: AgentLoopState): void {
290
+ storage.appendDecision({
291
+ runId,
292
+ kind: "generic_gate_decision_consumed",
293
+ message: `Consumed generic gate decision for ${nextState}.`,
294
+ details: { gateId: decisionGateId(decision), decisionId: decision.id, nextState }
295
+ });
296
+ if (nextState === "EXECUTE_STEP" && decisionGateKind(decision) === "generic_human_gate") {
297
+ storage.appendDecision({
298
+ runId,
299
+ kind: "generic_review_cycles_reset",
300
+ message: "Generic review cycles reset after human requested changes.",
301
+ details: { gateId: decisionGateId(decision), decisionId: decision.id, nextState }
302
+ });
303
+ }
304
+ }
305
+
306
+ function classifySelfReview(result: WorkerResult | undefined): { needsFix: boolean; reasons: string[] } {
307
+ if (!result) {
308
+ return { needsFix: false, reasons: ["no reviewer output; treating dry-run review as passed"] };
309
+ }
310
+ const blockingFollowUps = result.followUps.filter(isBlockingFollowUp);
311
+ const reasons = [
312
+ ...(blockingFollowUps.length > 0 ? [`blockingFollowUps:${blockingFollowUps.length}`] : []),
313
+ ...(result.outOfScope.length > 0 ? [`outOfScope:${result.outOfScope.length}`] : []),
314
+ ...(result.error ? [`error:${result.error.kind}`] : [])
315
+ ];
316
+ return { needsFix: reasons.length > 0, reasons };
317
+ }
318
+
319
+ function guardForGoalDecision(nextState: AgentLoopState): TransitionGuard {
320
+ if (nextState === "COLLECT_CONTEXT") return "goal_clear";
321
+ if (nextState === "PLAN_WORK") return "skip_context";
322
+ if (nextState === "STOPPED") return "rejected";
323
+ return "rejected";
324
+ }
325
+
326
+ function guardForHumanGateDecision(nextState: AgentLoopState): TransitionGuard {
327
+ if (nextState === "DELIVER") return "deliverable_approved";
328
+ if (nextState === "EXECUTE_STEP") return "request_changes";
329
+ if (nextState === "STOPPED") return "rejected";
330
+ return "rejected";
331
+ }
332
+
333
+ function isBlockingFollowUp(value: string): boolean {
334
+ return /^(fix|fix-needed|needs-fix|must-fix|blocker|blocking|required|request-changes|changes-required)(?=[:\s-]|$)|^(必须|阻塞|需要修复)(?=[::\s-]|$)/i.test(value.trim());
335
+ }
336
+
337
+ function humanGateReason(storage: AgentLoopStorage, runId: string): "review_passed" | "review_overridden" {
338
+ const anchor = latestReviewCycleAnchor(storage, runId);
339
+ const latest = decisionsSinceLatestPlan(storage, runId, anchor)
340
+ .find((decision) => (decision.kind === "generic_review_cycles_exhausted" || decision.kind === "generic_review_passed") && decisionMatchesAnchor(decision, anchor));
341
+ return latest?.kind === "generic_review_cycles_exhausted" ? "review_overridden" : "review_passed";
342
+ }
343
+
344
+ function decisionStatus(decision: AgentLoopDecision): "approved" | "rejected" {
345
+ return decision.kind === "gate_rejected" ? "rejected" : "approved";
346
+ }
347
+
348
+ function decisionNextState(
349
+ decision: AgentLoopDecision,
350
+ allowed: AgentLoopState[],
351
+ gateKind: "generic_goal_needs_confirmation" | "generic_human_gate" | "generic_scope_change_requested",
352
+ state: AgentLoopState,
353
+ defaultNextState: AgentLoopState
354
+ ): AgentLoopState {
355
+ const details = typeof decision.details === "object" && decision.details !== null
356
+ ? decision.details as { payload?: { nextState?: unknown }; nextState?: unknown }
357
+ : {};
358
+ const value = details.payload?.nextState ?? details.nextState;
359
+ if (typeof value === "string" && allowed.includes(value as AgentLoopState)) {
360
+ return value as AgentLoopState;
361
+ }
362
+ throw new AgentLoopError(gateKind, "Generic gate decision payload must include a valid next state.", {
363
+ details: {
364
+ gateKind,
365
+ state,
366
+ allowedNextStates: allowed,
367
+ defaultNextState,
368
+ requiredPayload: { nextState: defaultNextState, source: "ui" },
369
+ receivedNextState: value
370
+ },
371
+ exitCode: 2
372
+ });
373
+ }
374
+
375
+ function executionReviewCycles(storage: AgentLoopStorage, runId: string, anchor: AgentLoopDecision | undefined): number {
376
+ return decisionsSinceLatestPlan(storage, runId, anchor)
377
+ .filter((decision) => decision.kind === "generic_execute_review_cycle" && decisionMatchesAnchor(decision, anchor))
378
+ .length;
379
+ }
380
+
381
+ function decisionsSinceLatestPlan(storage: AgentLoopStorage, runId: string, anchor = latestReviewCycleAnchor(storage, runId)): AgentLoopDecision[] {
382
+ const decisions = storage.listDecisions(runId);
383
+ return anchor ? decisions.filter((decision) => decision.createdAt >= anchor.createdAt) : decisions;
384
+ }
385
+
386
+ function latestReviewCycleAnchor(storage: AgentLoopStorage, runId: string): AgentLoopDecision | undefined {
387
+ return storage
388
+ .listDecisions(runId)
389
+ .find((decision) => decision.kind === "generic_plan_ready" || decision.kind === "generic_review_cycles_reset");
390
+ }
391
+
392
+ function decisionMatchesAnchor(decision: AgentLoopDecision, anchor: AgentLoopDecision | undefined): boolean {
393
+ if (!anchor) return true;
394
+ if (typeof decision.details !== "object" || decision.details === null || Array.isArray(decision.details)) {
395
+ return anchor.kind === "generic_review_cycles_reset" ? false : decision.createdAt >= anchor.createdAt;
396
+ }
397
+ const anchorId = (decision.details as { anchorId?: unknown }).anchorId;
398
+ return anchorId === anchor.id || (anchorId === undefined && anchor.kind !== "generic_review_cycles_reset" && decision.createdAt >= anchor.createdAt);
399
+ }