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,263 @@
1
+ import type { MergeReadiness } from "./autonomy-policy.js";
2
+ import type { AgentLoopConfig, AgentLoopEvent, AgentLoopGate, AgentTimelineEntry, WorkerRun } from "./types.js";
3
+ import { isRecord } from "./config.js";
4
+ import { redactSecrets } from "./redaction.js";
5
+
6
+ export type NotificationSeverity = "informational" | "attention" | "confirmation_required" | "blocked";
7
+
8
+ export interface LoopNotification {
9
+ id: string;
10
+ severity: NotificationSeverity;
11
+ title: string;
12
+ reason: string;
13
+ source: "event" | "gate" | "timeline" | "worker" | "merge";
14
+ sourceId: string;
15
+ createdAt: string;
16
+ payload?: unknown;
17
+ }
18
+
19
+ /** Derive dashboard notifications from events and gates without making progress noisy. */
20
+ export function deriveNotifications(input: {
21
+ config: AgentLoopConfig;
22
+ events: AgentLoopEvent[];
23
+ gates: AgentLoopGate[];
24
+ timelineEntries?: AgentTimelineEntry[];
25
+ workers?: WorkerRun[];
26
+ mergeReadiness?: MergeReadiness;
27
+ runId?: string;
28
+ now?: Date;
29
+ dismissedIds?: Set<string>;
30
+ }): LoopNotification[] {
31
+ const readIds = new Set([
32
+ ...notificationReadIds(input.events),
33
+ ...notificationDismissedIds(input.events),
34
+ ...(input.dismissedIds ?? [])
35
+ ]);
36
+ const gateNotifications = input.gates
37
+ .filter((gate) => gate.status === "open")
38
+ .map((gate): LoopNotification => ({
39
+ id: `gate:${gate.id}`,
40
+ severity: severityForGate(gate.kind),
41
+ title: gate.kind,
42
+ reason: reasonForGate(gate.kind),
43
+ source: "gate",
44
+ sourceId: gate.id,
45
+ createdAt: gate.createdAt,
46
+ payload: redactPayload(gate.details)
47
+ }));
48
+ const eventNotifications = input.events
49
+ .map((event) => notificationForEvent(event))
50
+ .filter((item): item is LoopNotification => item !== undefined);
51
+ const timelineNotifications = timelineDerivedNotifications(input.timelineEntries ?? [], input.workers ?? [], input.now ?? new Date());
52
+ const mergeRunId = input.runId ?? currentRunId(input.workers);
53
+ const mergeNotifications = input.mergeReadiness?.ready && mergeRunId
54
+ ? [mergeReadyNotification(mergeRunId, input.mergeReadiness, input.now ?? new Date())]
55
+ : [];
56
+ return [...gateNotifications, ...eventNotifications, ...timelineNotifications, ...mergeNotifications]
57
+ .filter((notification) => !readIds.has(notification.id))
58
+ .filter((notification) => isVisibleForMode(input.config.notifyMode, notification.severity))
59
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
60
+ }
61
+
62
+ function notificationReadIds(events: AgentLoopEvent[]): Set<string> {
63
+ return notificationIdsForKind(events, "notification_marked_read");
64
+ }
65
+
66
+ function notificationDismissedIds(events: AgentLoopEvent[]): Set<string> {
67
+ return notificationIdsForKind(events, "notification_dismissed");
68
+ }
69
+
70
+ function notificationIdsForKind(events: AgentLoopEvent[], kind: string): Set<string> {
71
+ const ids = new Set<string>();
72
+ for (const event of events) {
73
+ if (event.kind !== kind || !isRecord(event.payload)) {
74
+ continue;
75
+ }
76
+ const notificationIds = event.payload.notificationIds;
77
+ if (Array.isArray(notificationIds)) {
78
+ for (const id of notificationIds) {
79
+ if (typeof id === "string") ids.add(id);
80
+ }
81
+ }
82
+ }
83
+ return ids;
84
+ }
85
+
86
+ function notificationForEvent(event: AgentLoopEvent): LoopNotification | undefined {
87
+ const severity = severityForEvent(event.kind);
88
+ if (!severity) {
89
+ return undefined;
90
+ }
91
+ return {
92
+ id: `event:${event.id}`,
93
+ severity,
94
+ title: event.kind,
95
+ reason: reasonForEvent(event.kind, severity),
96
+ source: "event",
97
+ sourceId: event.id,
98
+ createdAt: event.createdAt,
99
+ payload: redactPayload(event.payload)
100
+ };
101
+ }
102
+
103
+ function timelineDerivedNotifications(entries: AgentTimelineEntry[], workers: WorkerRun[], now: Date): LoopNotification[] {
104
+ const notifications: LoopNotification[] = [];
105
+ const workerStatuses = new Map(workers.map((worker) => [worker.id, worker.status]));
106
+ for (const worker of workers) {
107
+ if (worker.status === "failed" || worker.status === "timed_out" || worker.status === "invalid_output") {
108
+ notifications.push({
109
+ id: `worker:${worker.id}:${worker.status}`,
110
+ severity: "attention",
111
+ title: worker.status === "failed" ? "worker_failed" : `worker_${worker.status}`,
112
+ reason: "A worker reached a terminal failure state.",
113
+ source: "worker",
114
+ sourceId: worker.id,
115
+ createdAt: worker.completedAt ?? worker.startedAt,
116
+ payload: redactPayload({ type: worker.type, error: worker.error })
117
+ });
118
+ }
119
+ }
120
+ for (const entry of entries) {
121
+ if (isPermissionRequestEntry(entry)) {
122
+ const workerId = entry.workerId ?? "unknown";
123
+ const itemId = timelineItemId(entry);
124
+ notifications.push({
125
+ id: `permission:${workerId}:${itemId}`,
126
+ severity: "confirmation_required",
127
+ title: "permission_requested",
128
+ reason: "A permission request is visible in the agent timeline.",
129
+ source: "timeline",
130
+ sourceId: entry.rawRef.id,
131
+ createdAt: entry.occurredAt,
132
+ payload: redactPayload({ title: entry.title, summary: entry.summary })
133
+ });
134
+ }
135
+ if (entry.source === "worker_event" && entry.kind === "command_execution" && !isTerminalWorkerStatus(workerStatuses.get(entry.workerId ?? "")) && isLongRunningCommand(entry, now)) {
136
+ const workerId = entry.workerId ?? "unknown";
137
+ const itemId = timelineItemId(entry);
138
+ notifications.push({
139
+ id: `longrunning:${workerId}:${itemId}`,
140
+ severity: "attention",
141
+ title: "long_running_command",
142
+ reason: "A command has been running for more than 60 seconds without a terminal event.",
143
+ source: "timeline",
144
+ sourceId: entry.rawRef.id,
145
+ createdAt: entry.occurredAt,
146
+ payload: redactPayload({ title: entry.title, summary: entry.summary })
147
+ });
148
+ }
149
+ }
150
+ return notifications;
151
+ }
152
+
153
+ function isPermissionRequestEntry(entry: AgentTimelineEntry): boolean {
154
+ return entry.kind === "PermissionRequest" ||
155
+ entry.kind === "permission_request" ||
156
+ entry.kind === "permission.requested" ||
157
+ entry.kind === "permission_requested";
158
+ }
159
+
160
+ function isTerminalWorkerStatus(status: WorkerRun["status"] | undefined): boolean {
161
+ return status === "succeeded" || status === "failed" || status === "timed_out" || status === "invalid_output";
162
+ }
163
+
164
+ function mergeReadyNotification(runId: string, mergeReadiness: MergeReadiness, now: Date): LoopNotification {
165
+ return {
166
+ id: `mergeready:${runId}`,
167
+ severity: "confirmation_required",
168
+ title: "merge_ready",
169
+ reason: "Merge readiness evidence is complete under the configured policy.",
170
+ source: "merge",
171
+ sourceId: runId,
172
+ createdAt: now.toISOString(),
173
+ payload: redactPayload({ state: mergeReadiness.state, evidence: mergeReadiness.evidence })
174
+ };
175
+ }
176
+
177
+ function currentRunId(workers: WorkerRun[] | undefined): string | undefined {
178
+ return workers?.[0]?.runId;
179
+ }
180
+
181
+ function isLongRunningCommand(entry: AgentTimelineEntry, now: Date): boolean {
182
+ if (entry.status && entry.status !== "started" && entry.status !== "running") {
183
+ return false;
184
+ }
185
+ const summary = parseSummary(entry.summary);
186
+ const startedAt = typeof summary?.startedAt === "string" ? summary.startedAt : entry.createdAt;
187
+ const startedMs = Date.parse(startedAt);
188
+ return !Number.isNaN(startedMs) && now.getTime() - startedMs > 60_000;
189
+ }
190
+
191
+ function parseSummary(summary: string): Record<string, unknown> | undefined {
192
+ try {
193
+ const parsed = JSON.parse(summary) as unknown;
194
+ return isRecord(parsed) ? parsed : undefined;
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ }
199
+
200
+ function timelineItemId(entry: AgentTimelineEntry): string {
201
+ const summary = parseSummary(entry.summary);
202
+ return typeof summary?.id === "string" && summary.id.length > 0 ? summary.id : entry.rawRef.id;
203
+ }
204
+
205
+ function redactPayload(value: unknown): unknown {
206
+ if (typeof value === "string") {
207
+ return redactSecrets(value);
208
+ }
209
+ if (Array.isArray(value)) {
210
+ return value.slice(0, 20).map(redactPayload);
211
+ }
212
+ if (!isRecord(value)) {
213
+ return value;
214
+ }
215
+ return Object.fromEntries(Object.entries(value).slice(0, 40).map(([key, nested]) => [
216
+ key,
217
+ /token|api_key|authorization|password|secret/i.test(key) ? "[redacted]" : redactPayload(nested)
218
+ ]));
219
+ }
220
+
221
+ function severityForGate(kind: string): NotificationSeverity {
222
+ if (kind === "merge_requires_confirmation") return "confirmation_required";
223
+ if (kind.includes("timeout") || kind.includes("policy") || kind.includes("unavailable")) return "blocked";
224
+ if (kind.includes("ci") || kind.includes("review") || kind.includes("github")) return "attention";
225
+ return "blocked";
226
+ }
227
+
228
+ function severityForEvent(kind: string): NotificationSeverity | undefined {
229
+ if (kind.includes("merge_completed") || kind.includes("pr_merged")) return "informational";
230
+ if (kind.includes("ci_failed") || kind.includes("review_arrived")) return "attention";
231
+ if (kind.includes("worker") && (kind.includes("failed") || kind.includes("invalid"))) return "attention";
232
+ if (kind.includes("loop_stopped")) return "informational";
233
+ return undefined;
234
+ }
235
+
236
+ function isVisibleForMode(mode: AgentLoopConfig["notifyMode"], severity: NotificationSeverity): boolean {
237
+ if (mode === "blockers_only") {
238
+ return severity === "blocked" || severity === "confirmation_required";
239
+ }
240
+ if (mode === "important_only") {
241
+ return severity !== "informational";
242
+ }
243
+ return true;
244
+ }
245
+
246
+ function reasonForGate(kind: string): string {
247
+ if (kind === "merge_requires_confirmation") {
248
+ return "Policy requires an explicit confirmation before the loop can continue.";
249
+ }
250
+ if (kind.includes("ci")) return "CI evidence is missing, pending, or failed.";
251
+ if (kind.includes("review")) return "Review evidence needs attention before autonomous progress.";
252
+ if (kind.includes("policy")) return "A policy guard blocked unsafe progress.";
253
+ return "The loop cannot safely continue until this gate is resolved.";
254
+ }
255
+
256
+ function reasonForEvent(kind: string, severity: NotificationSeverity): string {
257
+ if (severity === "informational") {
258
+ return "Progress was recorded without requiring operator attention.";
259
+ }
260
+ if (kind.includes("worker")) return "A worker output or execution issue needs attention.";
261
+ if (kind.includes("ci")) return "CI changed in a way that may affect loop progress.";
262
+ return "This event may require operator attention under the current policy.";
263
+ }
@@ -0,0 +1,206 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+
4
+ export interface PlanPrItem {
5
+ id: string;
6
+ title: string;
7
+ status: "completed" | "current" | "next" | "unknown";
8
+ file: string;
9
+ dependsOn: string[];
10
+ issueRefs: string[];
11
+ whySelected?: string;
12
+ }
13
+
14
+ export interface PlanNavigatorModel {
15
+ convention: string;
16
+ currentMilestone: string;
17
+ selectedNext?: PlanPrItem;
18
+ completed: PlanPrItem[];
19
+ candidates: PlanPrItem[];
20
+ ambiguous: boolean;
21
+ evidence: string[];
22
+ }
23
+
24
+ interface SpecIndex {
25
+ orderedIds: string[];
26
+ completedIds: Set<string>;
27
+ }
28
+
29
+ /** Parse documented PR plan/spec files for the dashboard Plan Navigator. */
30
+ export function parsePlanNavigator(repoRoot: string, plansDir: string): PlanNavigatorModel {
31
+ const convention = "PR plan documents use files named pr-<letter>-<slug>.md with a top-level `# PR X ...` heading; legacy spec indexes are supported when present.";
32
+ const specDir = join(repoRoot, "docs", "specs");
33
+ const planDir = join(repoRoot, plansDir);
34
+ const specIndex = readSpecIndex(specDir);
35
+ const files = [
36
+ ...markdownFiles(specDir).filter((file) => /^pr-[a-z0-9]+-/i.test(basename(file))),
37
+ ...markdownFiles(planDir)
38
+ ];
39
+ const items = files.map((file) => parsePlanFile(file)).filter((item): item is PlanPrItem => item !== undefined);
40
+ const unique = inferStatuses(dedupeById(items).sort(compareBySpecIndex(specIndex)), specIndex);
41
+ const completed = unique.filter((item) => item.status === "completed");
42
+ const candidates = unique.filter((item) => item.status === "next" || item.status === "current" || item.status === "unknown");
43
+ const nextCandidates = candidates.filter((item) => item.status === "next");
44
+ const selectedNext = nextCandidates.length === 1 ? nextCandidates[0] : candidates[0];
45
+ return {
46
+ convention,
47
+ currentMilestone: selectedNext?.id ?? completed.at(-1)?.id ?? "unknown",
48
+ ...(selectedNext ? { selectedNext } : {}),
49
+ completed,
50
+ candidates,
51
+ ambiguous: nextCandidates.length > 1 || (!selectedNext && unique.length === 0),
52
+ evidence: evidenceFor(unique, nextCandidates)
53
+ };
54
+ }
55
+
56
+ function markdownFiles(dir: string): string[] {
57
+ if (!existsSync(dir)) {
58
+ return [];
59
+ }
60
+ return readdirSync(dir)
61
+ .filter((name) => name.endsWith(".md"))
62
+ .map((name) => join(dir, name));
63
+ }
64
+
65
+ function readSpecIndex(specDir: string): SpecIndex {
66
+ const readmePath = join(specDir, "README.md");
67
+ if (!existsSync(readmePath)) {
68
+ return { orderedIds: [], completedIds: new Set() };
69
+ }
70
+ const text = readFileSync(readmePath, "utf8");
71
+ const completedIds = new Set<string>();
72
+ const orderedIds: string[] = [];
73
+ let inFutureSection = false;
74
+ for (const line of text.split(/\r?\n/)) {
75
+ if (isFutureSpecSection(line)) {
76
+ inFutureSection = true;
77
+ }
78
+ const id = /^\s*\d+\.\s+\[PR\s+([A-Z0-9]+)/i.exec(line)?.[1]?.toUpperCase();
79
+ if (!id) {
80
+ continue;
81
+ }
82
+ const normalized = `PR ${id}`;
83
+ orderedIds.push(normalized);
84
+ if (!inFutureSection) {
85
+ completedIds.add(normalized);
86
+ }
87
+ }
88
+ return { orderedIds, completedIds };
89
+ }
90
+
91
+ function isFutureSpecSection(line: string): boolean {
92
+ return /(?:后续|未来|待办)\s*PR\s*顺序/i.test(line) || /future\s+PR\s+order/i.test(line);
93
+ }
94
+
95
+ function parsePlanFile(file: string): PlanPrItem | undefined {
96
+ const text = readFileSync(file, "utf8");
97
+ const heading = /^#\s+(?:SPEC[::]\s*)?(PR\s+[A-Z0-9]+[^\n]*)/m.exec(text)?.[1];
98
+ const id = /PR\s+([A-Z0-9]+)/i.exec(heading ?? basename(file))?.[1]?.toUpperCase();
99
+ if (!id) {
100
+ return undefined;
101
+ }
102
+ const markerStatus = /status:\s*(completed|current|next|unknown)/i.exec(text)?.[1]?.toLowerCase();
103
+ return {
104
+ id: `PR ${id}`,
105
+ title: heading ?? basename(file, ".md"),
106
+ status: statusFromMarker(markerStatus),
107
+ file,
108
+ dependsOn: [...text.matchAll(/depends(?:On| on)[::]\s*([A-Z0-9,\s]+)/gi)].flatMap((match) =>
109
+ (match[1] ?? "").split(/,\s*/).filter(Boolean)
110
+ ),
111
+ issueRefs: [...text.matchAll(/#(\d+)/g)].map((match) => `#${match[1]}`)
112
+ };
113
+ }
114
+
115
+ function statusFromMarker(markerStatus: string | undefined): PlanPrItem["status"] {
116
+ if (markerStatus === "completed" || markerStatus === "current" || markerStatus === "next" || markerStatus === "unknown") {
117
+ return markerStatus;
118
+ }
119
+ return "unknown";
120
+ }
121
+
122
+ function inferStatuses(items: PlanPrItem[], specIndex: SpecIndex): PlanPrItem[] {
123
+ if (items.some((item) => item.status === "next" || item.status === "current")) {
124
+ return items.map((item) => item.status === "next" && item.whySelected === undefined
125
+ ? { ...item, whySelected: "Marked next in the plan/spec document." }
126
+ : item);
127
+ }
128
+ const indexedNext = specIndex.orderedIds.find((id) => !specIndex.completedIds.has(id) && items.some((item) => item.id === id));
129
+ if (indexedNext) {
130
+ return items.map((item) => {
131
+ if (item.status !== "unknown") return item;
132
+ if (specIndex.completedIds.has(item.id)) return { ...item, status: "completed" };
133
+ if (item.id === indexedNext) {
134
+ return { ...item, status: "next", whySelected: "Selected as the first uncompleted PR from the legacy spec index." };
135
+ }
136
+ return item;
137
+ });
138
+ }
139
+ if (specIndex.orderedIds.length > 0) {
140
+ return items.map((item) => item.status === "unknown" && specIndex.completedIds.has(item.id)
141
+ ? { ...item, status: "completed" }
142
+ : item);
143
+ }
144
+ const lastUnknownIndex = findLastIndex(items, (item) => item.status === "unknown");
145
+ if (lastUnknownIndex < 0) {
146
+ return items;
147
+ }
148
+ return items.map((item, index) => {
149
+ if (item.status !== "unknown") return item;
150
+ if (index === lastUnknownIndex) {
151
+ return { ...item, status: "next", whySelected: "Selected as the highest uncompleted PR from parsed plan/spec documents." };
152
+ }
153
+ return { ...item, status: "completed" };
154
+ });
155
+ }
156
+
157
+ function dedupeById(items: PlanPrItem[]): PlanPrItem[] {
158
+ const map = new Map<string, PlanPrItem>();
159
+ for (const item of items) {
160
+ const existing = map.get(item.id);
161
+ if (!existing || item.file.includes("/docs/specs/")) {
162
+ map.set(item.id, item);
163
+ }
164
+ }
165
+ return [...map.values()];
166
+ }
167
+
168
+ function evidenceFor(items: PlanPrItem[], nextCandidates: PlanPrItem[]): string[] {
169
+ if (items.length === 0) {
170
+ return ["No parseable PR plan/spec files found."];
171
+ }
172
+ if (nextCandidates.length > 1) {
173
+ return nextCandidates.map((item) => `${item.id}: ${item.file}`);
174
+ }
175
+ return [`Parsed ${items.length} PR plan/spec documents.`];
176
+ }
177
+
178
+ function compareBySpecIndex(specIndex: SpecIndex): (a: PlanPrItem, b: PlanPrItem) => number {
179
+ return (a, b) => {
180
+ const left = specIndex.orderedIds.indexOf(a.id);
181
+ const right = specIndex.orderedIds.indexOf(b.id);
182
+ if (left >= 0 && right >= 0) return left - right;
183
+ if (left >= 0) return -1;
184
+ if (right >= 0) return 1;
185
+ return comparePlanItems(a, b);
186
+ };
187
+ }
188
+
189
+ function comparePlanItems(a: PlanPrItem, b: PlanPrItem): number {
190
+ return planSortKey(a.id).localeCompare(planSortKey(b.id), undefined, { numeric: true });
191
+ }
192
+
193
+ function planSortKey(id: string): string {
194
+ const value = /PR\s+([A-Z]+)(\d*)/i.exec(id);
195
+ if (!value) {
196
+ return id;
197
+ }
198
+ return `${value[1]}${value[2] ? value[2].padStart(3, "0") : "000"}`;
199
+ }
200
+
201
+ function findLastIndex<T>(items: T[], predicate: (item: T) => boolean): number {
202
+ for (let index = items.length - 1; index >= 0; index -= 1) {
203
+ if (predicate(items[index] as T)) return index;
204
+ }
205
+ return -1;
206
+ }
@@ -0,0 +1,32 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /** Resolve the repository/package root that contains this plugin from a module URL. */
5
+ export function packageRootFromUrl(metaUrl: string): string {
6
+ return resolve(dirname(fileURLToPath(metaUrl)), "../../..");
7
+ }
8
+
9
+ /** Resolve the repository/package root that contains this plugin. */
10
+ export function defaultPackageRoot(): string {
11
+ return packageRootFromUrl(import.meta.url);
12
+ }
13
+
14
+ /** Resolve the HOLO-Codex plugin runtime directory. */
15
+ export function autonomousPrLoopRoot(packageRoot = defaultPackageRoot()): string {
16
+ return join(packageRoot, "plugins", "autonomous-pr-loop");
17
+ }
18
+
19
+ /** Resolve the dashboard UI source directory bundled with this plugin. */
20
+ export function dashboardUiRoot(packageRoot = defaultPackageRoot()): string {
21
+ return join(autonomousPrLoopRoot(packageRoot), "ui");
22
+ }
23
+
24
+ /** Resolve the hook source directory bundled with this plugin. */
25
+ export function hookSourceRoot(packageRoot = defaultPackageRoot()): string {
26
+ return join(autonomousPrLoopRoot(packageRoot), "hooks");
27
+ }
28
+
29
+ /** Resolve the compiled hook runner directory bundled with this plugin. */
30
+ export function hookDistRoot(packageRoot = defaultPackageRoot()): string {
31
+ return join(hookSourceRoot(packageRoot), "dist");
32
+ }
@@ -0,0 +1,65 @@
1
+ import { AgentLoopError } from "./errors.js";
2
+ import type { AgentLoopConfig } from "./types.js";
3
+
4
+ /** Assert that a path is not blocked by the repository policy protectedPaths globs. */
5
+ export function assertAllowedPath(config: AgentLoopConfig, path: string): void {
6
+ const blocked = config.protectedPaths.some((pattern) => matchesProtectedPath(pattern, path));
7
+
8
+ if (blocked) {
9
+ throw new AgentLoopError(
10
+ "policy_violation",
11
+ `Path is protected by agent-loop policy: ${path}`,
12
+ { details: { path } }
13
+ );
14
+ }
15
+ }
16
+
17
+ /** Match the small glob subset used by protectedPaths: `*`, `**`, and `/**` directory roots. */
18
+ export function matchesProtectedPath(pattern: string, path: string): boolean {
19
+ const normalizedPattern = normalizePath(pattern);
20
+ const normalizedPath = normalizePath(path);
21
+ if (!normalizedPattern.includes("/")) {
22
+ const basename = normalizedPath.split("/").at(-1) ?? normalizedPath;
23
+ return globToRegExp(normalizedPattern).test(basename);
24
+ }
25
+ if (normalizedPattern.endsWith("/**")) {
26
+ const prefix = normalizedPattern.slice(0, -3);
27
+ if (normalizedPath === prefix) {
28
+ return true;
29
+ }
30
+ }
31
+ return globToRegExp(normalizedPattern).test(normalizedPath);
32
+ }
33
+
34
+ function normalizePath(path: string): string {
35
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
36
+ }
37
+
38
+ function globToRegExp(pattern: string): RegExp {
39
+ let source = "";
40
+ for (let index = 0; index < pattern.length; index += 1) {
41
+ const char = pattern[index];
42
+ const next = pattern[index + 1];
43
+ const afterNext = pattern[index + 2];
44
+ if (char === "*" && next === "*" && afterNext === "/") {
45
+ source += "(?:.*/)?";
46
+ index += 2;
47
+ continue;
48
+ }
49
+ if (char === "*" && next === "*") {
50
+ source += ".*";
51
+ index += 1;
52
+ continue;
53
+ }
54
+ if (char === "*") {
55
+ source += "[^/]*";
56
+ continue;
57
+ }
58
+ source += escapeRegExp(char ?? "");
59
+ }
60
+ return new RegExp(`^${source}$`);
61
+ }
62
+
63
+ function escapeRegExp(value: string): string {
64
+ return value.replace(/[\\^$+?.()|[\]{}]/g, "\\$&");
65
+ }