headlesscode 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5 — GitHub issue watcher: poll a label, fan out into batches, spawn
|
|
3
|
+
* worktrees through the EXISTING bash spawner, and track idempotency in a
|
|
4
|
+
* durable state file.
|
|
5
|
+
*
|
|
6
|
+
* Sweep flow (per tick):
|
|
7
|
+
* 1. listIssues({ owner, repo, label: targetLabel, state: open }).
|
|
8
|
+
* 2. Partition: new (no entry / status pending / failed-with-retry) vs
|
|
9
|
+
* already processed (everything else — skipped this sweep).
|
|
10
|
+
* 3. Apply the maxPerSweep cap: the first `maxPerSweep` new issues are
|
|
11
|
+
* spawned; the rest are recorded as status "pending" so the NEXT sweep
|
|
12
|
+
* picks them up (a label storm never spawns more than N worktrees per
|
|
13
|
+
* sweep).
|
|
14
|
+
* 4. For each spawned issue (write-ahead ordering):
|
|
15
|
+
* a. markProcessed(status "spawned") + save ← durable BEFORE spawn
|
|
16
|
+
* b. splitIssues([issue]) -> specs
|
|
17
|
+
* c. writeTaskFiles(...) + spawn via
|
|
18
|
+
* scripts/spawn-parallel-worktrees.sh (bash -c — the bash script
|
|
19
|
+
* remains the ACTUAL spawner; no worktree creation in TS)
|
|
20
|
+
* d. markProcessed(status "done" | "failed") + save
|
|
21
|
+
* 5. Log the sweep (issues found / new / skipped / spawned / deferred).
|
|
22
|
+
*
|
|
23
|
+
* The state file is the idempotency source of truth: a restart loads it and
|
|
24
|
+
* continues; a crash between (a) and (d) leaves a "spawned" entry which the
|
|
25
|
+
* next restart treats as processed — a double-spawn is impossible.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawnSync } from "node:child_process"
|
|
29
|
+
import * as fs from "node:fs"
|
|
30
|
+
import * as path from "node:path"
|
|
31
|
+
import { fileURLToPath } from "node:url"
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
activeSessionCount,
|
|
35
|
+
ConcurrencyLimiter,
|
|
36
|
+
maxConcurrentSessionsFromEnv,
|
|
37
|
+
} from "../budget/concurrency.js"
|
|
38
|
+
import { Logger } from "../engine/logger.js"
|
|
39
|
+
import { splitIssues, type SplitIssue, type WorktreeSpec } from "../orchestrator/split.js"
|
|
40
|
+
import { loadStateSync } from "../orchestrator/state.js"
|
|
41
|
+
import { writeTaskFiles } from "../orchestrator/cli.js"
|
|
42
|
+
import { createGhClient, type GhClient, type GitHubIssue } from "./github.js"
|
|
43
|
+
import {
|
|
44
|
+
isProcessed,
|
|
45
|
+
loadWatcherState,
|
|
46
|
+
markProcessed,
|
|
47
|
+
saveWatcherState,
|
|
48
|
+
type WatcherIssueEntry,
|
|
49
|
+
type WatcherIssueStatus,
|
|
50
|
+
type WatcherState,
|
|
51
|
+
} from "./state.js"
|
|
52
|
+
|
|
53
|
+
/** The harness repo root (parent of src/watcher) — same pattern as the CLI. */
|
|
54
|
+
const HARNESS_ROOT = fileURLToPath(new URL("../..", import.meta.url))
|
|
55
|
+
|
|
56
|
+
export const DEFAULT_POLL_INTERVAL_MS = 60_000
|
|
57
|
+
export const DEFAULT_MAX_PER_SWEEP = 5
|
|
58
|
+
|
|
59
|
+
export interface SpawnBatch {
|
|
60
|
+
issue: GitHubIssue
|
|
61
|
+
/** Worktree groups from splitIssues([issue]). */
|
|
62
|
+
specs: WorktreeSpec[]
|
|
63
|
+
/** Local target repo root where worktrees are created. */
|
|
64
|
+
repoRoot: string
|
|
65
|
+
/** Batch id (watcher-<date>). */
|
|
66
|
+
batch: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SpawnResult {
|
|
70
|
+
ok: boolean
|
|
71
|
+
/** The exact bash command that was (or would be) run. */
|
|
72
|
+
command?: string
|
|
73
|
+
/** spawnSync exit status (null = spawn failed to launch). */
|
|
74
|
+
status?: number | null
|
|
75
|
+
error?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface WatcherBatchInfo {
|
|
79
|
+
issue: GitHubIssue
|
|
80
|
+
specs: WorktreeSpec[]
|
|
81
|
+
batch: string
|
|
82
|
+
statePath: string
|
|
83
|
+
/** Final recorded status after the batch processed: done | failed. */
|
|
84
|
+
status: WatcherIssueStatus
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WatcherConfig {
|
|
88
|
+
/** GitHub owner (e.g. "my-org"). */
|
|
89
|
+
owner: string
|
|
90
|
+
/** GitHub repo name (e.g. "my-repo"). */
|
|
91
|
+
repo: string
|
|
92
|
+
/** The label that triggers processing (e.g. "needs-agent"). */
|
|
93
|
+
targetLabel: string
|
|
94
|
+
/** GitHub token (GH_TOKEN / GITHUB_TOKEN). Never logged or persisted. */
|
|
95
|
+
token: string
|
|
96
|
+
/** Sweep interval (default 60000ms). */
|
|
97
|
+
pollIntervalMs?: number
|
|
98
|
+
/** GitHub API base URL (default: $GITHUB_API_BASE_URL or api.github.com). */
|
|
99
|
+
ghBaseUrl?: string
|
|
100
|
+
/** Durable state file (default: <repoRoot>/.worktrees/.watcher-state.json). */
|
|
101
|
+
stateFile?: string
|
|
102
|
+
/** Local target repo root where worktrees are spawned (default: cwd). */
|
|
103
|
+
repoRoot?: string
|
|
104
|
+
/** Called after each batch is processed (spawn done/failed). */
|
|
105
|
+
onBatch?: (info: WatcherBatchInfo) => void | Promise<void>
|
|
106
|
+
/** true = one sweep then return; false = continuous loop. */
|
|
107
|
+
runOnce?: boolean
|
|
108
|
+
/** Abort the loop (continuous mode clean shutdown). */
|
|
109
|
+
signal?: AbortSignal
|
|
110
|
+
/** Per-sweep cap on newly spawned issues (default 5). */
|
|
111
|
+
maxPerSweep?: number
|
|
112
|
+
/**
|
|
113
|
+
* Phase 6 GLOBAL cap on concurrent sessions across processes (default
|
|
114
|
+
* $HEADLESSCODE_MAX_CONCURRENT_SESSIONS or 3). Interplay with maxPerSweep:
|
|
115
|
+
* maxPerSweep bounds a single sweep's burst; this bounds the TOTAL number
|
|
116
|
+
* of running/spawned sessions seen across the orchestrator + watcher state
|
|
117
|
+
* files — a label storm can never push the fleet past the cap. Issues
|
|
118
|
+
* exceeding the cap stay 'pending' in watcher state (deferred exactly like
|
|
119
|
+
* maxPerSweep overflow) and are picked up on a later sweep.
|
|
120
|
+
*/
|
|
121
|
+
maxConcurrentSessions?: number
|
|
122
|
+
/** Retry previously-failed issues on the next sweep (default: no). */
|
|
123
|
+
retryFailed?: boolean
|
|
124
|
+
/** Sweep + report without spawning or writing state. */
|
|
125
|
+
dryRun?: boolean
|
|
126
|
+
/** Injected GitHub client for tests (default: createGhClient(token)). */
|
|
127
|
+
gh?: GhClient
|
|
128
|
+
/** Injected spawn function for tests (default: the real bash spawner). */
|
|
129
|
+
spawn?: (batch: SpawnBatch) => SpawnResult | Promise<SpawnResult>
|
|
130
|
+
/** Harness mode for workers (forwarded to the spawner as ORCHESTRATOR_MODE). */
|
|
131
|
+
mode?: string
|
|
132
|
+
/** Memory dir for workers (forwarded to the spawner as HEADLESSCODE_MEMORY_DIR). */
|
|
133
|
+
memoryDir?: string
|
|
134
|
+
/** Phase 4 pass-through: recorded on the batch entry (see docs). */
|
|
135
|
+
qa?: boolean
|
|
136
|
+
/** Phase 4 pass-through: recorded on the batch entry (see docs). */
|
|
137
|
+
deploy?: boolean
|
|
138
|
+
logger?: Logger
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface SweepResult {
|
|
142
|
+
issuesFound: number
|
|
143
|
+
newIssues: number
|
|
144
|
+
skipped: number
|
|
145
|
+
spawned: number
|
|
146
|
+
deferred: number
|
|
147
|
+
failures: number
|
|
148
|
+
state: WatcherState
|
|
149
|
+
/** Dry-run only: what WOULD be spawned per new issue. */
|
|
150
|
+
dryRunPlan?: Array<{ issue: GitHubIssue; specs: WorktreeSpec[] }>
|
|
151
|
+
error?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface WatchResult {
|
|
155
|
+
/** One entry per completed sweep. */
|
|
156
|
+
sweeps: SweepResult[]
|
|
157
|
+
/** Total batches spawned across all sweeps. */
|
|
158
|
+
spawned: number
|
|
159
|
+
/** 0 = ok; 1 = one or more spawns failed. */
|
|
160
|
+
exitCode: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Run the watcher. Continuous mode loops until the abort signal fires;
|
|
165
|
+
* `runOnce` performs a single sweep and returns. State is loaded from disk at
|
|
166
|
+
* startup (restart-safe) and saved after every sweep.
|
|
167
|
+
*/
|
|
168
|
+
export async function watchIssues(config: WatcherConfig): Promise<WatchResult> {
|
|
169
|
+
const owner = config.owner
|
|
170
|
+
const repo = config.repo
|
|
171
|
+
const targetLabel = config.targetLabel
|
|
172
|
+
const token = config.token
|
|
173
|
+
if (!token) {
|
|
174
|
+
throw new Error("watcher: no GitHub token (set GH_TOKEN or GITHUB_TOKEN)")
|
|
175
|
+
}
|
|
176
|
+
if (!owner || !repo || !targetLabel) {
|
|
177
|
+
throw new Error("watcher: owner, repo and targetLabel are required")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const repoRoot = path.resolve(config.repoRoot ?? process.cwd())
|
|
181
|
+
const statePath = path.resolve(config.stateFile ?? path.join(repoRoot, ".worktrees", ".watcher-state.json"))
|
|
182
|
+
const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
|
183
|
+
const maxPerSweep = config.maxPerSweep ?? DEFAULT_MAX_PER_SWEEP
|
|
184
|
+
// Phase 6: the global concurrency cap (flag wins; else env; else 3). One
|
|
185
|
+
// in-process limiter per watchIssues run guards the sweep spawn loop; the
|
|
186
|
+
// durable-state view (activeSessionCount) guards the cross-process window.
|
|
187
|
+
const maxConcurrentSessions = config.maxConcurrentSessions ?? maxConcurrentSessionsFromEnv()
|
|
188
|
+
const limiter = new ConcurrencyLimiter(maxConcurrentSessions)
|
|
189
|
+
const gh = config.gh ?? createGhClient({ token, baseUrl: config.ghBaseUrl })
|
|
190
|
+
const logger = config.logger ?? new Logger({ level: "info" })
|
|
191
|
+
const spawn = config.spawn ?? ((batch: SpawnBatch): SpawnResult => defaultSpawn(batch, config))
|
|
192
|
+
const qa = config.qa ?? false
|
|
193
|
+
const deploy = config.deploy ?? false
|
|
194
|
+
|
|
195
|
+
let state = await loadWatcherState(statePath)
|
|
196
|
+
const sweeps: SweepResult[] = []
|
|
197
|
+
let spawnedTotal = 0
|
|
198
|
+
let exitCode = 0
|
|
199
|
+
|
|
200
|
+
for (;;) {
|
|
201
|
+
if (config.signal?.aborted) {
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const sweep = await sweepOnce({
|
|
206
|
+
gh,
|
|
207
|
+
owner,
|
|
208
|
+
repo,
|
|
209
|
+
targetLabel,
|
|
210
|
+
state,
|
|
211
|
+
statePath,
|
|
212
|
+
repoRoot,
|
|
213
|
+
maxPerSweep,
|
|
214
|
+
maxConcurrentSessions,
|
|
215
|
+
limiter,
|
|
216
|
+
retryFailed: config.retryFailed,
|
|
217
|
+
dryRun: config.dryRun,
|
|
218
|
+
spawn,
|
|
219
|
+
onBatch: config.onBatch,
|
|
220
|
+
logger,
|
|
221
|
+
qa,
|
|
222
|
+
deploy,
|
|
223
|
+
})
|
|
224
|
+
state = sweep.state
|
|
225
|
+
sweeps.push(sweep)
|
|
226
|
+
spawnedTotal += sweep.spawned
|
|
227
|
+
// A list failure OR any failed spawn makes the run "error out"
|
|
228
|
+
// (runOnce exits 1; continuous mode logs and keeps going).
|
|
229
|
+
if (sweep.error || sweep.failures > 0) {
|
|
230
|
+
exitCode = 1
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (config.runOnce) {
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
await sleepOrAbort(pollIntervalMs, config.signal)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Final save so lastSweep is durable even after a clean abort.
|
|
240
|
+
// Dry-run writes nothing at all.
|
|
241
|
+
if (!config.dryRun) {
|
|
242
|
+
await saveWatcherState(statePath, state)
|
|
243
|
+
}
|
|
244
|
+
return { sweeps, spawned: spawnedTotal, exitCode }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface SweepInput {
|
|
248
|
+
gh: GhClient
|
|
249
|
+
owner: string
|
|
250
|
+
repo: string
|
|
251
|
+
targetLabel: string
|
|
252
|
+
state: WatcherState
|
|
253
|
+
statePath: string
|
|
254
|
+
repoRoot: string
|
|
255
|
+
maxPerSweep: number
|
|
256
|
+
/** Phase 6: global concurrent-session cap (see WatcherConfig). */
|
|
257
|
+
maxConcurrentSessions: number
|
|
258
|
+
/** Phase 6: in-process limiter guarding the sweep spawn loop. */
|
|
259
|
+
limiter: ConcurrencyLimiter
|
|
260
|
+
retryFailed?: boolean
|
|
261
|
+
dryRun?: boolean
|
|
262
|
+
spawn: (batch: SpawnBatch) => SpawnResult | Promise<SpawnResult>
|
|
263
|
+
onBatch?: (info: WatcherBatchInfo) => void | Promise<void>
|
|
264
|
+
logger: Logger
|
|
265
|
+
/** Recorded on each batch entry (pass-through for the follow-up round). */
|
|
266
|
+
qa: boolean
|
|
267
|
+
/** Recorded on each batch entry (pass-through for the follow-up round). */
|
|
268
|
+
deploy: boolean
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** One full sweep: list → partition → cap (per-sweep + global) → spawn → log. */
|
|
272
|
+
async function sweepOnce(input: SweepInput): Promise<SweepResult> {
|
|
273
|
+
const {
|
|
274
|
+
gh,
|
|
275
|
+
owner,
|
|
276
|
+
repo,
|
|
277
|
+
targetLabel,
|
|
278
|
+
statePath,
|
|
279
|
+
repoRoot,
|
|
280
|
+
maxPerSweep,
|
|
281
|
+
maxConcurrentSessions,
|
|
282
|
+
limiter,
|
|
283
|
+
retryFailed,
|
|
284
|
+
dryRun,
|
|
285
|
+
spawn,
|
|
286
|
+
logger,
|
|
287
|
+
} = input
|
|
288
|
+
|
|
289
|
+
let issues: GitHubIssue[]
|
|
290
|
+
try {
|
|
291
|
+
issues = await gh.listIssues({ owner, repo, label: targetLabel, state: "open" })
|
|
292
|
+
} catch (err) {
|
|
293
|
+
const error = `watcher sweep failed listing issues for ${owner}/${repo}: ${
|
|
294
|
+
err instanceof Error ? err.message : String(err)
|
|
295
|
+
}`
|
|
296
|
+
logger.error(error)
|
|
297
|
+
return { issuesFound: 0, newIssues: 0, skipped: 0, spawned: 0, deferred: 0, failures: 0, state: input.state, error }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Deterministic processing order (ascending issue number).
|
|
301
|
+
issues = [...issues].sort((a, b) => a.number - b.number)
|
|
302
|
+
|
|
303
|
+
const newIssues = issues.filter((i) => !isProcessedForSweep(input.state, i.number, retryFailed))
|
|
304
|
+
const skipped = issues.length - newIssues.length
|
|
305
|
+
|
|
306
|
+
// ── Phase 6 GLOBAL cap (spec 6.3) ──────────────────────────────────────
|
|
307
|
+
// maxPerSweep bounds a single sweep's burst; the global cap bounds the
|
|
308
|
+
// TOTAL running/spawned sessions seen across the durable state files
|
|
309
|
+
// (orchestrator groups in spawned/running + watcher in-flight 'spawned'
|
|
310
|
+
// entries). Effective new spawns this sweep:
|
|
311
|
+
// min(maxPerSweep, maxConcurrentSessions - active)
|
|
312
|
+
// Issues beyond it stay 'pending' (same durable deferral as maxPerSweep
|
|
313
|
+
// overflow) and are picked up on a later sweep. Missing orchestrator state
|
|
314
|
+
// file → active = 0 (the cap still applies to this pipeline alone).
|
|
315
|
+
const orchPath = path.join(repoRoot, ".worktrees", ".orchestrator-state.json")
|
|
316
|
+
const orchState = loadStateSync(orchPath)
|
|
317
|
+
const active = activeSessionCount(orchState, input.state)
|
|
318
|
+
const available = Math.max(0, maxConcurrentSessions - active)
|
|
319
|
+
const toSpawn = newIssues.slice(0, Math.min(maxPerSweep, available))
|
|
320
|
+
const deferred = newIssues.slice(toSpawn.length)
|
|
321
|
+
|
|
322
|
+
// Accounting for the sweep log: how much of the deferral was maxPerSweep
|
|
323
|
+
// vs the global cap (both ≥ 0).
|
|
324
|
+
const maxPerSweepDeferred = newIssues.length - Math.min(newIssues.length, maxPerSweep)
|
|
325
|
+
const globalCapDeferred = deferred.length - maxPerSweepDeferred
|
|
326
|
+
|
|
327
|
+
let state = input.state
|
|
328
|
+
const dryRunPlan: SweepResult["dryRunPlan"] = dryRun ? [] : undefined
|
|
329
|
+
let spawned = 0
|
|
330
|
+
let failures = 0
|
|
331
|
+
|
|
332
|
+
// Naming collision avoidance (mirrors orchestrator/cli.ts's orchestrate
|
|
333
|
+
// path): seed from whatever `.worktrees/wN` dirs already exist — a
|
|
334
|
+
// still-running `orchestrate` round, or an earlier sweep this same
|
|
335
|
+
// process — then reserve each issue's assigned names as we go, so two
|
|
336
|
+
// issues spawned in the SAME sweep never both land on w1 either.
|
|
337
|
+
let occupiedNames: Set<string>
|
|
338
|
+
try {
|
|
339
|
+
occupiedNames = new Set(
|
|
340
|
+
fs
|
|
341
|
+
.readdirSync(path.join(repoRoot, ".worktrees"), { withFileTypes: true })
|
|
342
|
+
.filter((d) => d.isDirectory())
|
|
343
|
+
.map((d) => d.name),
|
|
344
|
+
)
|
|
345
|
+
} catch {
|
|
346
|
+
occupiedNames = new Set()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Record deferred issues as "pending" so the next sweep picks them up
|
|
350
|
+
// (persisted by the per-issue saves below, or the final save). Dry-run
|
|
351
|
+
// writes nothing.
|
|
352
|
+
if (!dryRun) {
|
|
353
|
+
for (const issue of deferred) {
|
|
354
|
+
state = markProcessed(state, pendingEntry(issue, targetLabel))
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const issue of toSpawn) {
|
|
359
|
+
const specs = splitIssues([issue], { occupiedNames })
|
|
360
|
+
for (const spec of specs) {
|
|
361
|
+
occupiedNames.add(spec.name)
|
|
362
|
+
}
|
|
363
|
+
if (dryRun) {
|
|
364
|
+
dryRunPlan!.push({ issue, specs })
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// In-process fail-fast guard (ConcurrencyLimiter). The durable-state
|
|
369
|
+
// view above already pre-capped toSpawn, so this only fires if the
|
|
370
|
+
// fleet picture changed mid-sweep (another process spawned); the issue
|
|
371
|
+
// is deferred to 'pending' rather than over-spawning.
|
|
372
|
+
const acq = limiter.acquire()
|
|
373
|
+
if (!acq.ok) {
|
|
374
|
+
state = markProcessed(state, pendingEntry(issue, targetLabel))
|
|
375
|
+
logger.warn(`watcher: issue #${issue.number} deferred by the global concurrency cap — ${acq.reason}`, {})
|
|
376
|
+
continue
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
// ── Write-ahead: durable "spawned" entry BEFORE the spawn ──
|
|
381
|
+
const batch = batchId()
|
|
382
|
+
const spawnedAt = new Date().toISOString()
|
|
383
|
+
const entry: WatcherIssueEntry = {
|
|
384
|
+
number: issue.number,
|
|
385
|
+
title: issue.title,
|
|
386
|
+
label: targetLabel,
|
|
387
|
+
batch,
|
|
388
|
+
groups: specs,
|
|
389
|
+
spawnedAt,
|
|
390
|
+
status: "spawned",
|
|
391
|
+
updatedAt: spawnedAt,
|
|
392
|
+
...(input.qa ? { qa: true } : {}),
|
|
393
|
+
...(input.deploy ? { deploy: true } : {}),
|
|
394
|
+
}
|
|
395
|
+
state = markProcessed(state, entry)
|
|
396
|
+
await saveWatcherState(statePath, state)
|
|
397
|
+
|
|
398
|
+
const batchInfo = { issue, specs, repoRoot, batch }
|
|
399
|
+
let result: SpawnResult
|
|
400
|
+
try {
|
|
401
|
+
result = await spawn(batchInfo)
|
|
402
|
+
} catch (err) {
|
|
403
|
+
result = { ok: false, error: err instanceof Error ? err.message : String(err) }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (result.ok) {
|
|
407
|
+
state = markProcessed(state, { ...entry, status: "done", updatedAt: new Date().toISOString() })
|
|
408
|
+
spawned++
|
|
409
|
+
logger.info(`watcher: spawned issue #${issue.number} (${specs.map((s) => s.name).join(", ")})`, {
|
|
410
|
+
batch,
|
|
411
|
+
groups: specs.map((s) => s.taskFile),
|
|
412
|
+
})
|
|
413
|
+
} else {
|
|
414
|
+
const error = result.error ?? `spawn exited ${result.status ?? "unknown"}`
|
|
415
|
+
state = markProcessed(state, { ...entry, status: "failed", error, updatedAt: new Date().toISOString() })
|
|
416
|
+
failures++
|
|
417
|
+
// Loud by design: a failed spawn is NOT silently retried (default).
|
|
418
|
+
// Re-run with --retry-failed to force a retry next sweep.
|
|
419
|
+
logger.error(`watcher: spawn FAILED for issue #${issue.number} — ${error}`, { batch })
|
|
420
|
+
}
|
|
421
|
+
await saveWatcherState(statePath, state)
|
|
422
|
+
await input.onBatch?.({ issue, specs, batch, statePath, status: result.ok ? "done" : "failed" })
|
|
423
|
+
} finally {
|
|
424
|
+
limiter.release()
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
state.lastSweep = new Date().toISOString()
|
|
429
|
+
if (!dryRun) {
|
|
430
|
+
await saveWatcherState(statePath, state)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
logger.info(`watcher sweep: ${issues.length} issue(s) with label "${targetLabel}"`, {
|
|
434
|
+
found: issues.length,
|
|
435
|
+
new: newIssues.length,
|
|
436
|
+
skipped,
|
|
437
|
+
spawned,
|
|
438
|
+
deferred: dryRun ? newIssues.length - toSpawn.length : deferred.length,
|
|
439
|
+
global_cap_deferred: globalCapDeferred,
|
|
440
|
+
max_per_sweep_deferred: maxPerSweepDeferred,
|
|
441
|
+
active_sessions: active,
|
|
442
|
+
failures,
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
issuesFound: issues.length,
|
|
447
|
+
newIssues: newIssues.length,
|
|
448
|
+
skipped,
|
|
449
|
+
spawned,
|
|
450
|
+
deferred: dryRun ? newIssues.length - toSpawn.length : deferred.length,
|
|
451
|
+
failures,
|
|
452
|
+
state,
|
|
453
|
+
...(dryRunPlan ? { dryRunPlan } : {}),
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** isProcessed plus the retry-failed override (see state.ts). */
|
|
458
|
+
function isProcessedForSweep(state: WatcherState, number: number, retryFailed?: boolean): boolean {
|
|
459
|
+
return isProcessed(state, number, { retryFailed })
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function pendingEntry(issue: GitHubIssue, label: string): WatcherIssueEntry {
|
|
463
|
+
return {
|
|
464
|
+
number: issue.number,
|
|
465
|
+
title: issue.title,
|
|
466
|
+
label,
|
|
467
|
+
status: "pending",
|
|
468
|
+
updatedAt: new Date().toISOString(),
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function batchId(): string {
|
|
473
|
+
return `watcher-${new Date().toISOString().slice(0, 10)}`
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The REAL spawn path: generate task files (reusing the orchestrator's
|
|
478
|
+
* builder) and run the existing bash spawner via `bash -c` — the same
|
|
479
|
+
* invocation pattern as src/orchestrator/cli.ts (spawnSync("bash", ["-c", …])
|
|
480
|
+
* with cwd = the target repo so `git rev-parse --show-toplevel` resolves).
|
|
481
|
+
* No worktree creation happens in TS; the bash script remains the spawner.
|
|
482
|
+
*/
|
|
483
|
+
export function defaultSpawn(batch: SpawnBatch, config: Pick<WatcherConfig, "mode" | "memoryDir">): SpawnResult {
|
|
484
|
+
const issues: SplitIssue[] = [
|
|
485
|
+
{ number: batch.issue.number, title: batch.issue.title, body: batch.issue.body },
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
// 1. Task files under <repoRoot>/plans/parallel-tasks/ (the convention
|
|
489
|
+
// the spawner copies them into each worktree).
|
|
490
|
+
try {
|
|
491
|
+
writeTaskFiles(batch.repoRoot, batch.specs, issues)
|
|
492
|
+
} catch (err) {
|
|
493
|
+
return { ok: false, error: `task file generation failed: ${err instanceof Error ? err.message : String(err)}` }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 2. Spawn via the existing bash script (env override allows the e2e to
|
|
497
|
+
// stub it; default is the real spawner).
|
|
498
|
+
const spawnScript =
|
|
499
|
+
process.env.HEADLESSCODE_SPAWN_SCRIPT ?? path.join(HARNESS_ROOT, "scripts", "spawn-parallel-worktrees.sh")
|
|
500
|
+
const triples = batch.specs
|
|
501
|
+
.map((spec) => `${spec.name}:${batch.specs.indexOf(spec)}:plans/parallel-tasks/${spec.taskFile}`)
|
|
502
|
+
.join(" ")
|
|
503
|
+
const command = `bash ${spawnScript} ${triples}`
|
|
504
|
+
|
|
505
|
+
// `bash -c <command>` (NOT spawnSync("bash", [command], { shell: true }),
|
|
506
|
+
// which would run `bash bash <script> …` and die — same gotcha the
|
|
507
|
+
// orchestrator CLI documents).
|
|
508
|
+
const result = spawnSync("bash", ["-c", command], {
|
|
509
|
+
cwd: batch.repoRoot,
|
|
510
|
+
env: {
|
|
511
|
+
...process.env,
|
|
512
|
+
TARGET_REPO: batch.repoRoot,
|
|
513
|
+
ORCHESTRATOR_MODE: config.mode ?? "code",
|
|
514
|
+
HEADLESSCODE_PROJECT: path.basename(batch.repoRoot),
|
|
515
|
+
...(config.memoryDir ? { HEADLESSCODE_MEMORY_DIR: path.resolve(config.memoryDir) } : {}),
|
|
516
|
+
},
|
|
517
|
+
stdio: "inherit",
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
if (result.status === 0) {
|
|
521
|
+
return { ok: true, command, status: 0 }
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
command,
|
|
526
|
+
status: result.status ?? null,
|
|
527
|
+
error:
|
|
528
|
+
result.error !== undefined
|
|
529
|
+
? `spawn script failed to launch: ${result.error.message}`
|
|
530
|
+
: `spawn script exited ${result.status}`,
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Sleep for `ms` but resolve immediately when the signal aborts. */
|
|
535
|
+
function sleepOrAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
536
|
+
return new Promise((resolve) => {
|
|
537
|
+
if (signal?.aborted) {
|
|
538
|
+
resolve()
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
// Issue #85: on the normal (timer-fires-first) path, `{once:true}` only
|
|
542
|
+
// stops the listener from firing TWICE — it does not detach it. Every
|
|
543
|
+
// poll of a long-running watch loop reuses the same `config.signal`, so
|
|
544
|
+
// without an explicit removeEventListener here each poll's listener
|
|
545
|
+
// stayed attached for the rest of the process's life — a slow leak over
|
|
546
|
+
// a long continuous watch.
|
|
547
|
+
const onAbort = () => {
|
|
548
|
+
clearTimeout(timer)
|
|
549
|
+
resolve()
|
|
550
|
+
}
|
|
551
|
+
const timer = setTimeout(() => {
|
|
552
|
+
signal?.removeEventListener("abort", onAbort)
|
|
553
|
+
resolve()
|
|
554
|
+
}, ms)
|
|
555
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Re-exported for callers that want the state helpers without importing
|
|
560
|
+
// state.js directly (e.g. tests asserting isProcessed semantics).
|
|
561
|
+
export { isProcessed } from "./state.js"
|
|
562
|
+
export type { WatcherIssueEntry, WatcherState } from "./state.js"
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"allowSyntheticDefaultImports": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"types": ["node"]
|
|
16
|
+
},
|
|
17
|
+
"include": ["src"]
|
|
18
|
+
}
|