pi-project-gate 1.0.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.
package/AGENTS.md ADDED
@@ -0,0 +1,94 @@
1
+ # Project Gate — Agent Usage Guide
2
+
3
+ > You are an AI agent. Use project-gate tools to validate, start, and track work. Never start work on an issue without checking it first.
4
+
5
+ ## Golden Rule
6
+
7
+ > **⚠️ DO NOT start work on an issue without calling `project_check()` first.**
8
+ > This ensures the issue has required sections, no blockers, and you haven't exceeded WIP limits.
9
+
10
+ ## Workflow
11
+
12
+ ```
13
+ project_check(issue_id="42") ← validate: template? deps resolved? not taken?
14
+
15
+
16
+ project_start(issue_id="42") ← WIP check, dep check, mark started
17
+
18
+
19
+ contrib_start_work(issue_id="42") ← create branch
20
+
21
+
22
+ [implement + test]
23
+
24
+
25
+ contrib_propose(message="...") ← validate + commit
26
+
27
+
28
+ contrib_submit(title="...") ← push + PR
29
+
30
+
31
+ [CI + review gates pass → merged]
32
+
33
+
34
+ project_status() ← verify WIP freed, blockers clear
35
+ ```
36
+
37
+ ## Issue Template Requirements
38
+
39
+ Issues MUST have these sections (configurable in `.projectrc.yml`):
40
+
41
+ ```markdown
42
+ ## Problem
43
+ What is the problem we're solving?
44
+
45
+ ## Proposed Solution
46
+ How should we solve it?
47
+
48
+ ## Acceptance Criteria
49
+ - [ ] Criterion 1
50
+ - [ ] Criterion 2
51
+
52
+ Complexity: medium
53
+ Area: backend
54
+ Depends on #41
55
+ ```
56
+
57
+ The `project_check()` tool validates these sections exist. If missing, it blocks work start.
58
+
59
+ ## WIP Limits
60
+
61
+ Default: max 3 open PRs per agent. Configured via `maxWip` in `.projectrc.yml`.
62
+
63
+ If you have 3 open PRs, `project_start()` will refuse to start new work.
64
+
65
+ ## Dependency Blocking
66
+
67
+ Issues can declare dependencies using:
68
+
69
+ ```
70
+ Depends on #123
71
+ Blocked by #456
72
+ Requires #789
73
+ ```
74
+
75
+ If any dependency is still open, `project_start()` blocks work.
76
+
77
+ ## Release Notes
78
+
79
+ Generate release notes from conventional commits:
80
+
81
+ ```
82
+ project_release_notes() ← latest tag to HEAD
83
+ project_release_notes(from="v1.0.0") ← specific tag to HEAD
84
+ project_release_notes(from="v1.0.0", to="v1.1.0") ← between two tags
85
+ ```
86
+
87
+ ## When Things Go Wrong
88
+
89
+ | Problem | Solution |
90
+ |---|---|
91
+ | Issue missing sections | Add required sections to the issue body |
92
+ | WIP limit reached | Close or merge an existing PR first |
93
+ | Blocked by dependency | Help resolve the blocking issue or wait for it to be merged |
94
+ | No complexity label | Add `Complexity: small/medium/large/epic` to issue body |
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nandal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # Project Gate for Pi
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-project-gate)](https://www.npmjs.com/package/pi-project-gate)
4
+ [![license](https://img.shields.io/npm/l/pi-project-gate)](./LICENSE)
5
+
6
+ > Project orchestration gate for AI agents — structured issues, WIP limits, dependency blocking, and auto-generated release notes. **Agents work from issues, not vibes.**
7
+
8
+ ## Philosophy
9
+
10
+ `pi-contrib-gate` handles the *contribution* side (branch → commit → PR).
11
+ `pi-review-gate` handles the *review* side (check → approve → merge).
12
+ `pi-project-gate` handles the *project* side (issue → plan → release).
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pi install npm:pi-project-gate
18
+ ```
19
+
20
+ ## Tools
21
+
22
+ | Tool | What it does |
23
+ |---|---|
24
+ | `project_check(issue_id)` | Validate issue readiness — template, complexity, dependencies |
25
+ | `project_start(issue_id)` | Start work on an issue — checks WIP, dependencies, template |
26
+ | `project_status()` | Project board — active issues, WIP counts, blockers |
27
+ | `project_release_notes(from, to)` | Generate release notes from conventional commits |
28
+
29
+ ## Gates
30
+
31
+ | Gate | Config | Description |
32
+ |---|---|---|
33
+ | Issue template | `requiredSections` | Block work start if issue is missing required sections |
34
+ | WIP limit | `maxWip` | Max concurrent open PRs per agent (default: 3) |
35
+ | Dependency blocking | `dependencyPattern` | Prevent starting issues with unresolved dependencies |
36
+ | Complexity tracking | `complexityLevels` | Ensure issues are scoped with complexity labels |
37
+ | Release notes | `releaseNoteGroups` | Auto-generate grouped release notes from conventional commits |
38
+
39
+ ## Configuration
40
+
41
+ Create `.projectrc.yml`:
42
+
43
+ ```yaml
44
+ # Max open PRs per agent
45
+ maxWip: 3
46
+
47
+ # Required sections in issue body (comma-separated)
48
+ requiredSections: "## Problem,## Proposed Solution,## Acceptance Criteria"
49
+
50
+ # Complexity levels
51
+ complexityLevels: trivial,small,medium,large,epic
52
+
53
+ # Area labels for categorization
54
+ areas: backend,frontend,infra,docs,tooling
55
+
56
+ # Grouping order for release notes
57
+ releaseNoteGroups: feat,fix,perf,refactor,chore,docs,test,ci,build
58
+
59
+ # Include commit hashes in release notes
60
+ releaseNoteIncludeHashes: false
61
+
62
+ # Regex pattern for issue dependencies
63
+ dependencyPattern: "(?:Depends on|Blocked by|Requires)\\s+#(\\d+)"
64
+ ```
65
+
66
+ ## Workflow
67
+
68
+ ```
69
+ project_check(#42) ← validate issue readiness
70
+
71
+
72
+ project_start(#42) ← start work (WIP + deps checked)
73
+
74
+
75
+ contrib_start_work(#42) ← create branch
76
+
77
+
78
+ [implement]
79
+
80
+
81
+ contrib_propose(...) ← validate + commit
82
+
83
+
84
+ contrib_submit(...) ← push + create PR
85
+
86
+
87
+ [CI + review gates pass]
88
+
89
+
90
+ MERGED → project_status() ← WIP freed, blockers resolved
91
+ ```
92
+
93
+ ## Release Notes
94
+
95
+ ```bash
96
+ # Generate notes from last tag to HEAD
97
+ project_release_notes()
98
+
99
+ # Generate notes between two tags
100
+ project_release_notes(from="v1.0.0", to="v1.1.0")
101
+ ```
102
+
103
+ Example output:
104
+
105
+ ```
106
+ # Release v1.1.0 (2026-05-14)
107
+
108
+ ### 🚀 Features
109
+ - **auth**: add OAuth2 support
110
+ - **api**: new user endpoint
111
+
112
+ ### 🐛 Bug Fixes
113
+ - fix null pointer in login flow
114
+ - fix race condition in task queue
115
+
116
+ ### 🔧 Chores
117
+ - update dependencies
118
+ ```
119
+
120
+ ## Integration
121
+
122
+ Install all three gates for full agent governance:
123
+
124
+ ```bash
125
+ pi install npm:pi-contrib-gate
126
+ pi install npm:pi-review-gate
127
+ pi install npm:pi-project-gate
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT © [nandal](https://github.com/nandal)
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "pi-project-gate",
3
+ "version": "1.0.0",
4
+ "description": "Project orchestration gate for AI agents — structured issues, WIP limits, dependency blocking, and auto-generated release notes.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "governance",
9
+ "project-management",
10
+ "release-notes",
11
+ "issue-tracking"
12
+ ],
13
+ "author": "nandal <nandal@users.noreply.github.com>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/nandal/pi-ext",
17
+ "directory": "project-gate"
18
+ },
19
+ "homepage": "https://github.com/nandal/pi-ext/tree/main/project-gate",
20
+ "bugs": {
21
+ "url": "https://github.com/nandal/pi-ext/issues"
22
+ },
23
+ "license": "MIT",
24
+ "main": "./src/index.ts",
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "src/",
30
+ "README.md",
31
+ "AGENTS.md",
32
+ "LICENSE"
33
+ ],
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "typebox": "*"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./src/index.ts"
41
+ ]
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,687 @@
1
+ /**
2
+ * pi-project-gate — Project Orchestration Gate
3
+ *
4
+ * Ensures agents work from structured issues, not vibes.
5
+ * Enforces WIP limits, dependency blocking, issue templates,
6
+ * and auto-generates release notes from conventional commits.
7
+ *
8
+ * Tools:
9
+ * project_check(issue_id) → validate issue readiness
10
+ * project_start(issue_id) → start work (WIP + dependency checks)
11
+ * project_status() → project board — active work, blockers
12
+ * project_release_notes(from, to) → generate release notes from commits
13
+ *
14
+ * Config: .projectrc.yml (WIP limits, required template sections, release note format)
15
+ */
16
+
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { Type } from "typebox";
19
+ import * as cp from "node:child_process";
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+
23
+ // ── Types ──
24
+
25
+ interface ProjectConfig {
26
+ /** Maximum concurrent open PRs per agent */
27
+ maxWip: number;
28
+ /** Required sections in issue body */
29
+ requiredSections: string[];
30
+ /** Complexity levels and their labels */
31
+ complexityLevels: string[];
32
+ /** Area labels for categorization */
33
+ areas: string[];
34
+ /** Release note grouping order */
35
+ releaseNoteGroups: string[];
36
+ /** Whether to include commit hashes in release notes */
37
+ releaseNoteIncludeHashes: boolean;
38
+ /** Issue dependency marker pattern (e.g., "Depends on #123") */
39
+ dependencyPattern: string;
40
+ }
41
+
42
+ const DEFAULT_CONFIG: ProjectConfig = {
43
+ maxWip: 3,
44
+ requiredSections: ["## Problem", "## Proposed Solution", "## Acceptance Criteria"],
45
+ complexityLevels: ["trivial", "small", "medium", "large", "epic"],
46
+ areas: [],
47
+ releaseNoteGroups: ["feat", "fix", "perf", "refactor", "chore", "docs", "test", "ci", "build"],
48
+ releaseNoteIncludeHashes: false,
49
+ dependencyPattern: "(?:Depends on|Blocked by|Requires)\\s+#(\\d+)",
50
+ };
51
+
52
+ // ── Session state ──
53
+
54
+ let activeIssueId: string | null = null;
55
+
56
+ // ── Config ──
57
+
58
+ function loadConfig(cwd: string): ProjectConfig {
59
+ const configPath = path.join(cwd, ".projectrc.yml");
60
+ if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
61
+ try {
62
+ const content = fs.readFileSync(configPath, "utf-8");
63
+ const result: Record<string, unknown> = {};
64
+ for (const line of content.split("\n")) {
65
+ const m = line.match(/^\s*([\w][\w.]*):\s*(.+)$/);
66
+ if (m) result[m[1]] = m[2].trim();
67
+ }
68
+ return {
69
+ maxWip: parseInt(result["maxWip"] as string) || DEFAULT_CONFIG.maxWip,
70
+ requiredSections: (result["requiredSections"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.requiredSections,
71
+ complexityLevels: (result["complexityLevels"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.complexityLevels,
72
+ areas: (result["areas"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || [],
73
+ releaseNoteGroups: (result["releaseNoteGroups"] as string)?.split(",").map(s => s.trim()).filter(Boolean) || DEFAULT_CONFIG.releaseNoteGroups,
74
+ releaseNoteIncludeHashes: result["releaseNoteIncludeHashes"] === "true",
75
+ dependencyPattern: (result["dependencyPattern"] as string) || DEFAULT_CONFIG.dependencyPattern,
76
+ };
77
+ } catch {
78
+ return { ...DEFAULT_CONFIG };
79
+ }
80
+ }
81
+
82
+ // ── Git helpers ──
83
+
84
+ function exec(cmd: string, cwd?: string): { ok: boolean; stdout: string; stderr: string } {
85
+ try {
86
+ const r = cp.execSync(cmd, { cwd, encoding: "utf-8", timeout: 30000 });
87
+ return { ok: true, stdout: r.trim(), stderr: "" };
88
+ } catch (e: any) {
89
+ return { ok: false, stdout: e.stdout?.trim() || "", stderr: e.stderr?.trim() || e.message };
90
+ }
91
+ }
92
+
93
+ function currentBranch(cwd: string): string {
94
+ return exec("git branch --show-current", cwd).stdout;
95
+ }
96
+
97
+ // ── Gitea API ──
98
+
99
+ interface GiteaApiOpts {
100
+ repo: string;
101
+ token?: string;
102
+ }
103
+
104
+ function resolveGitea(cwd: string): GiteaApiOpts {
105
+ const remote = exec("git remote get-url gitea 2>/dev/null || git remote get-url origin", cwd);
106
+ const url = remote.stdout || "";
107
+ const match = url.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
108
+ const repo = match ? `${match[1]}/${match[2]}` : "factory/wrok.in";
109
+ const credMatch = url.match(/:\/\/([^:]+):([^@]+)@/);
110
+ const token = credMatch ? credMatch[2] : "";
111
+ return { repo, token };
112
+ }
113
+
114
+ function giteaApi(
115
+ path: string,
116
+ method: string,
117
+ body: Record<string, unknown> | null,
118
+ opts: GiteaApiOpts,
119
+ cwd: string,
120
+ ): { ok: boolean; data: unknown; error?: string } {
121
+ const base = `http://127.0.0.1:3001/api/v1/repos/${opts.repo}`;
122
+ const headers = [
123
+ opts.token ? `-H "Authorization: token ${opts.token}"` : "",
124
+ `-H "Content-Type: application/json"`,
125
+ `-H "Accept: application/json"`,
126
+ ].filter(Boolean).join(" ");
127
+
128
+ const dataFlag = body ? `-d '${JSON.stringify(body).replace(/'/g, "'\\''")}'` : "";
129
+ const cmd = `curl -sf -w "\\n%{http_code}" -X ${method} "${base}${path}" ${headers} ${dataFlag}`;
130
+ const r = exec(cmd, cwd);
131
+
132
+ if (!r.ok) {
133
+ const lines = r.stdout.split("\n");
134
+ const bodyText = lines.slice(0, -1).join("\n");
135
+ return { ok: false, data: null, error: r.stderr || bodyText || "API error" };
136
+ }
137
+
138
+ const lines = r.stdout.split("\n");
139
+ const bodyText = lines.slice(0, -1).join("\n");
140
+ try {
141
+ return { ok: true, data: JSON.parse(bodyText) };
142
+ } catch {
143
+ return { ok: true, data: bodyText };
144
+ }
145
+ }
146
+
147
+ // ── Issue template validation ──
148
+
149
+ function validateIssueTemplate(
150
+ issueBody: string,
151
+ config: ProjectConfig,
152
+ ): { ok: true } | { ok: false; missingSections: string[] } {
153
+ const missing = config.requiredSections.filter(section =>
154
+ !issueBody.includes(section)
155
+ );
156
+ if (missing.length > 0) {
157
+ return { ok: false, missingSections: missing };
158
+ }
159
+ return { ok: true };
160
+ }
161
+
162
+ // ── Dependency parsing ──
163
+
164
+ function parseDependencies(body: string, config: ProjectConfig): string[] {
165
+ const pattern = new RegExp(config.dependencyPattern, "gi");
166
+ const deps = new Set<string>();
167
+ let match;
168
+ while ((match = pattern.exec(body)) !== null) {
169
+ deps.add(match[1]);
170
+ }
171
+ return [...deps];
172
+ }
173
+
174
+ // ── WIP counting ──
175
+
176
+ function countOpenPRs(opts: GiteaApiOpts, cwd: string): { total: number; byAuthor: Record<string, number> } {
177
+ const r = giteaApi("/pulls?state=open&limit=100", "GET", null, opts, cwd);
178
+ if (!r.ok || !r.data) return { total: 0, byAuthor: {} };
179
+
180
+ const prs = Array.isArray(r.data) ? r.data : [];
181
+ const byAuthor: Record<string, number> = {};
182
+ for (const pr of prs) {
183
+ const author = (pr as any).user?.login || "unknown";
184
+ byAuthor[author] = (byAuthor[author] || 0) + 1;
185
+ }
186
+
187
+ return { total: prs.length, byAuthor };
188
+ }
189
+
190
+ // ── Release notes generation ──
191
+
192
+ interface CommitEntry {
193
+ hash: string;
194
+ type: string;
195
+ scope: string;
196
+ subject: string;
197
+ body: string;
198
+ }
199
+
200
+ function parseConventionalCommits(log: string): CommitEntry[] {
201
+ const entries: CommitEntry[] = [];
202
+ // Parse each commit from `git log --format` output
203
+ const commits = log.split(/\n(?=commit )/);
204
+ for (const block of commits) {
205
+ const hashMatch = block.match(/^commit (\S+)/m);
206
+ if (!hashMatch) continue;
207
+ const hash = hashMatch[1].slice(0, 8);
208
+
209
+ // Extract subject line
210
+ const subjectLine = block.split("\n").find(l => l.trim() && !l.startsWith("commit ") && !l.startsWith("Author:") && !l.startsWith("Date:"));
211
+ if (!subjectLine) continue;
212
+
213
+ const convMatch = subjectLine.trim().match(/^(feat|fix|perf|refactor|chore|docs|style|test|ci|build|revert)(?:\(([^)]+)\))?:\s(.+)$/);
214
+ if (!convMatch) continue;
215
+
216
+ entries.push({
217
+ hash,
218
+ type: convMatch[1],
219
+ scope: convMatch[2] || "",
220
+ subject: convMatch[3].trim(),
221
+ body: "",
222
+ });
223
+ }
224
+ return entries;
225
+ }
226
+
227
+ function generateReleaseNotes(
228
+ from: string,
229
+ to: string,
230
+ config: ProjectConfig,
231
+ cwd: string,
232
+ ): { version: string; date: string; sections: Record<string, string[]> } {
233
+ const range = from ? `${from}..${to}` : to;
234
+ const log = exec(`git log ${range} --format="commit %H%n%B%n---" --no-merges`, cwd);
235
+ const commits = parseConventionalCommits(log.stdout || "");
236
+
237
+ const sections: Record<string, string[]> = {};
238
+ for (const group of config.releaseNoteGroups) {
239
+ sections[group] = [];
240
+ }
241
+ sections["other"] = [];
242
+
243
+ for (const commit of commits) {
244
+ const prefix = config.releaseNoteIncludeHashes ? `- ${commit.hash} ` : "- ";
245
+ const scope = commit.scope ? `**${commit.scope}**: ` : "";
246
+ const line = `${prefix}${scope}${commit.subject}`;
247
+
248
+ if (sections[commit.type]) {
249
+ sections[commit.type].push(line);
250
+ } else {
251
+ sections["other"].push(line);
252
+ }
253
+ }
254
+
255
+ return {
256
+ version: to || "HEAD",
257
+ date: new Date().toISOString().split("T")[0],
258
+ sections,
259
+ };
260
+ }
261
+
262
+ function formatReleaseNotes(release: { version: string; date: string; sections: Record<string, string[]> }): string {
263
+ const lines: string[] = [];
264
+ lines.push(`# Release ${release.version} (${release.date})`);
265
+ lines.push("");
266
+
267
+ const labels: Record<string, string> = {
268
+ feat: "🚀 Features",
269
+ fix: "🐛 Bug Fixes",
270
+ perf: "⚡ Performance",
271
+ refactor: "♻️ Refactoring",
272
+ chore: "🔧 Chores",
273
+ docs: "📝 Documentation",
274
+ test: "✅ Tests",
275
+ ci: "👷 CI/CD",
276
+ build: "📦 Build",
277
+ other: "📌 Other",
278
+ };
279
+
280
+ for (const [group, entries] of Object.entries(release.sections)) {
281
+ if (entries.length === 0) continue;
282
+ const label = labels[group] || group;
283
+ lines.push(`### ${label}`);
284
+ lines.push("");
285
+ for (const entry of entries) {
286
+ lines.push(entry);
287
+ }
288
+ lines.push("");
289
+ }
290
+
291
+ return lines.join("\n");
292
+ }
293
+
294
+ // ── Extension ──
295
+
296
+ export default function (pi: ExtensionAPI) {
297
+ // ═══════════════════════════════════════
298
+ // Tool: project_check
299
+ // ═══════════════════════════════════════
300
+ pi.registerTool({
301
+ name: "project_check",
302
+ label: "Check Issue Readiness",
303
+ description: "Validate that an issue is ready to be worked on — has required sections, no blockers, not already taken.",
304
+ parameters: Type.Object({
305
+ issue_id: Type.String({ description: "Issue number to check" }),
306
+ }),
307
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
308
+ const config = loadConfig(ctx.cwd);
309
+ const opts = resolveGitea(ctx.cwd);
310
+ const issueId = params.issue_id.replace(/^#/, "");
311
+
312
+ // Fetch issue from Gitea
313
+ const r = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
314
+ if (!r.ok || !r.data) {
315
+ return {
316
+ content: [{ type: "text", text: `Issue #${issueId} not found: ${r.error || "API error"}` }],
317
+ isError: true,
318
+ details: {},
319
+ };
320
+ }
321
+
322
+ const issue = r.data as Record<string, unknown>;
323
+ const lines: string[] = [];
324
+ const issues: string[] = [];
325
+
326
+ lines.push(`📋 Issue #${issueId}: ${issue.title || "untitled"}`);
327
+ lines.push(` State: ${issue.state}`);
328
+ lines.push(` Assignee: ${(issue.assignee as any)?.login || "unassigned"}`);
329
+
330
+ // 1. Template validation
331
+ const body = (issue.body as string) || "";
332
+ const templateCheck = validateIssueTemplate(body, config);
333
+ if (!templateCheck.ok) {
334
+ issues.push(`❌ Missing required sections: ${templateCheck.missingSections.join(", ")}`);
335
+ issues.push(` Add to issue: ${config.requiredSections.join(", ")}`);
336
+ } else {
337
+ lines.push(` Template: ✅ complete`);
338
+ }
339
+
340
+ // 2. Complexity check
341
+ const complexity = config.complexityLevels.find(l => body.toLowerCase().includes(l.toLowerCase()));
342
+ if (complexity) {
343
+ lines.push(` Complexity: ${complexity}`);
344
+ } else {
345
+ issues.push(`⚠️ No complexity label found. Consider adding: ${config.complexityLevels.join(", ")}`);
346
+ }
347
+
348
+ // 3. Area check
349
+ const area = config.areas.find(a => body.includes(a));
350
+ if (area) {
351
+ lines.push(` Area: ${area}`);
352
+ } else if (config.areas.length > 0) {
353
+ issues.push(`⚠️ No area tag found. Available: ${config.areas.join(", ")}`);
354
+ }
355
+
356
+ // 4. Dependency check
357
+ const dependencies = parseDependencies(body, config);
358
+ if (dependencies.length > 0) {
359
+ lines.push(` Dependencies: #${dependencies.join(", #")}`);
360
+
361
+ // Check if dependencies are resolved
362
+ const blocked: string[] = [];
363
+ for (const dep of dependencies) {
364
+ const dr = giteaApi(`/issues/${dep}`, "GET", null, opts, ctx.cwd);
365
+ if (dr.ok && dr.data) {
366
+ const depIssue = dr.data as Record<string, unknown>;
367
+ if (depIssue.state === "open") {
368
+ blocked.push(dep);
369
+ }
370
+ }
371
+ }
372
+ if (blocked.length > 0) {
373
+ issues.push(`🔒 Blocked by unresolved dependencies: #${blocked.join(", #")}`);
374
+ } else {
375
+ lines.push(` Dependencies resolved: ✅`);
376
+ }
377
+ }
378
+
379
+ // 5. Already assigned?
380
+ if (issue.assignee) {
381
+ issues.push(`⚠️ Already assigned to ${(issue.assignee as any)?.login}`);
382
+ }
383
+
384
+ // Summary
385
+ const hasBlockers = issues.some(i => i.startsWith("❌") || i.startsWith("🔒"));
386
+ if (issues.length > 0) {
387
+ lines.push("");
388
+ for (const i of issues) lines.push(` ${i}`);
389
+ }
390
+
391
+ if (hasBlockers) {
392
+ lines.push("", "❌ Issue is not ready to start. Resolve blockers first.");
393
+ } else if (issues.length === 0) {
394
+ lines.push("", "✅ Issue is ready! Use project_start() to begin work.");
395
+ } else {
396
+ lines.push("", "⚠️ Issue has warnings but can be started.");
397
+ }
398
+
399
+ return {
400
+ content: [{ type: "text", text: lines.join("\n") }],
401
+ details: {
402
+ issueId,
403
+ title: issue.title,
404
+ state: issue.state,
405
+ dependencies,
406
+ ready: !hasBlockers,
407
+ },
408
+ };
409
+ },
410
+ });
411
+
412
+ // ═══════════════════════════════════════
413
+ // Tool: project_start
414
+ // ═══════════════════════════════════════
415
+ pi.registerTool({
416
+ name: "project_start",
417
+ label: "Start Work on Issue",
418
+ description: "Mark an issue as in-progress. Checks WIP limits, dependency blocking, and template completeness.",
419
+ parameters: Type.Object({
420
+ issue_id: Type.String({ description: "Issue number to start working on" }),
421
+ }),
422
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
423
+ const config = loadConfig(ctx.cwd);
424
+ const opts = resolveGitea(ctx.cwd);
425
+ const issueId = params.issue_id.replace(/^#/, "");
426
+
427
+ // Fetch issue
428
+ const r = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
429
+ if (!r.ok || !r.data) {
430
+ return {
431
+ content: [{ type: "text", text: `Issue #${issueId} not found.` }],
432
+ isError: true,
433
+ details: {},
434
+ };
435
+ }
436
+
437
+ const issue = r.data as Record<string, unknown>;
438
+
439
+ // 1. Template validation
440
+ const body = (issue.body as string) || "";
441
+ const templateCheck = validateIssueTemplate(body, config);
442
+ if (!templateCheck.ok) {
443
+ return {
444
+ content: [{
445
+ type: "text",
446
+ text: [
447
+ `❌ Issue #${issueId} is missing required sections:`,
448
+ ...templateCheck.missingSections.map(s => ` - ${s}`),
449
+ "",
450
+ `Required: ${config.requiredSections.join(", ")}`,
451
+ "Add the missing sections to the issue body before starting work.",
452
+ ].join("\n"),
453
+ }],
454
+ isError: true,
455
+ details: { missingSections: templateCheck.missingSections },
456
+ };
457
+ }
458
+
459
+ // 2. Dependency check
460
+ const dependencies = parseDependencies(body, config);
461
+ if (dependencies.length > 0) {
462
+ const blocked: string[] = [];
463
+ for (const dep of dependencies) {
464
+ const dr = giteaApi(`/issues/${dep}`, "GET", null, opts, ctx.cwd);
465
+ if (dr.ok && dr.data) {
466
+ const depIssue = dr.data as Record<string, unknown>;
467
+ if (depIssue.state === "open") {
468
+ blocked.push(dep);
469
+ }
470
+ }
471
+ }
472
+ if (blocked.length > 0) {
473
+ return {
474
+ content: [{
475
+ type: "text",
476
+ text: [
477
+ `🔒 Cannot start — blocked by unresolved dependencies:`,
478
+ ...blocked.map(b => ` - #${b} (still open)`),
479
+ "",
480
+ "Close or merge the blocking issues first.",
481
+ ].join("\n"),
482
+ }],
483
+ isError: true,
484
+ details: { blockedBy: blocked },
485
+ };
486
+ }
487
+ }
488
+
489
+ // 3. WIP limit check
490
+ const wip = countOpenPRs(opts, ctx.cwd);
491
+ const author = issue.user?.login || "factory";
492
+ const currentWip = wip.byAuthor[author as string] || 0;
493
+
494
+ if (currentWip >= config.maxWip) {
495
+ return {
496
+ content: [{
497
+ type: "text",
498
+ text: [
499
+ `⚠️ WIP limit reached (${currentWip}/${config.maxWip} open PRs).`,
500
+ "",
501
+ `Your open PRs:`,
502
+ ` (check with project_status())`,
503
+ "",
504
+ `Complete or close existing PRs before starting new work.`,
505
+ `WIP limit: ${config.maxWip} — configured in .projectrc.yml`,
506
+ ].join("\n"),
507
+ }],
508
+ isError: true,
509
+ details: { currentWip, maxWip: config.maxWip },
510
+ };
511
+ }
512
+
513
+ // 4. All checks passed — mark as started
514
+ activeIssueId = issueId;
515
+
516
+ return {
517
+ content: [{
518
+ type: "text",
519
+ text: [
520
+ `✅ Work started on #${issueId}: "${issue.title || "untitled"}"`,
521
+ ` WIP: ${currentWip + 1}/${config.maxWip}`,
522
+ "",
523
+ `Next: Use contrib_start_work(#${issueId}) to create your branch,`,
524
+ `then contrib_propose() → contrib_submit() to ship.`,
525
+ ].join("\n"),
526
+ }],
527
+ details: { issueId, title: issue.title, wip: currentWip + 1, maxWip: config.maxWip },
528
+ };
529
+ },
530
+ });
531
+
532
+ // ═══════════════════════════════════════
533
+ // Tool: project_status
534
+ // ═══════════════════════════════════════
535
+ pi.registerTool({
536
+ name: "project_status",
537
+ label: "Project Status",
538
+ description: "Show project board — active issues, WIP counts, blockers, and open PRs.",
539
+ parameters: Type.Object({}),
540
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
541
+ const config = loadConfig(ctx.cwd);
542
+ const opts = resolveGitea(ctx.cwd);
543
+
544
+ const lines: string[] = [];
545
+ lines.push("📊 Project Status");
546
+ lines.push("");
547
+
548
+ // WIP summary
549
+ const wip = countOpenPRs(opts, ctx.cwd);
550
+ lines.push(`🏗 WIP: ${wip.total} open PRs (limit: ${config.maxWip} per agent)`);
551
+ if (Object.keys(wip.byAuthor).length > 0) {
552
+ lines.push("");
553
+ for (const [author, count] of Object.entries(wip.byAuthor).sort(([, a], [, b]) => b - a)) {
554
+ const status = count >= config.maxWip ? "⚠️ AT LIMIT" : "✅";
555
+ lines.push(` ${author}: ${count}/${config.maxWip} ${status}`);
556
+ }
557
+ }
558
+
559
+ // Open issues (recent)
560
+ const issues = giteaApi("/issues?state=open&limit=10", "GET", null, opts, ctx.cwd);
561
+ if (issues.ok && Array.isArray(issues.data)) {
562
+ const openIssues = issues.data as Record<string, unknown>[];
563
+ const assigned = openIssues.filter(i => i.assignee);
564
+ const unassigned = openIssues.filter(i => !i.assignee);
565
+
566
+ lines.push("");
567
+ lines.push(`📝 ${assigned.length} assigned, ${unassigned.length} unassigned open issues`);
568
+
569
+ if (assigned.length > 0) {
570
+ lines.push("");
571
+ lines.push(" In Progress:");
572
+ for (const i of assigned.slice(0, 10)) {
573
+ const labels = (i.labels as any[])?.map((l: any) => l.name).join(", ") || "";
574
+ const assignee = (i.assignee as any)?.login || "?";
575
+ lines.push(` - #${i.number} [${assignee}] ${i.title}${labels ? ` (${labels})` : ""}`);
576
+ }
577
+ }
578
+
579
+ // Flag blocked issues
580
+ const blocked = openIssues.filter(i => {
581
+ const b = (i.body as string) || "";
582
+ const deps = parseDependencies(b, config);
583
+ return deps.length > 0;
584
+ });
585
+
586
+ if (blocked.length > 0) {
587
+ lines.push("");
588
+ lines.push(`🔒 ${blocked.length} blocked issues (unresolved dependencies):`);
589
+ for (const i of blocked.slice(0, 5)) {
590
+ const deps = parseDependencies((i.body as string) || "", config);
591
+ lines.push(` - #${i.number} ${i.title} → depends on #${deps.join(", #")}`);
592
+ }
593
+ }
594
+ }
595
+
596
+ // Active issue
597
+ if (activeIssueId) {
598
+ lines.push("");
599
+ lines.push(`🎯 Currently working on: #${activeIssueId}`);
600
+ }
601
+
602
+ // Release info
603
+ const tags = exec("git tag --sort=-creatordate | head -3", ctx.cwd);
604
+ if (tags.ok && tags.stdout) {
605
+ lines.push("");
606
+ lines.push("🏷 Recent tags:");
607
+ for (const tag of tags.stdout.split("\n").filter(Boolean)) {
608
+ lines.push(` ${tag}`);
609
+ }
610
+ }
611
+
612
+ return {
613
+ content: [{ type: "text", text: lines.join("\n") }],
614
+ details: { wip, activeIssueId },
615
+ };
616
+ },
617
+ });
618
+
619
+ // ═══════════════════════════════════════
620
+ // Tool: project_release_notes
621
+ // ═══════════════════════════════════════
622
+ pi.registerTool({
623
+ name: "project_release_notes",
624
+ label: "Generate Release Notes",
625
+ description: "Generate release notes from conventional commits between two tags or from the latest tag to HEAD.",
626
+ parameters: Type.Object({
627
+ from: Type.Optional(Type.String({ description: "Starting tag/ref (default: latest tag)" })),
628
+ to: Type.Optional(Type.String({ description: "Ending tag/ref (default: HEAD)" })),
629
+ }),
630
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
631
+ const config = loadConfig(ctx.cwd);
632
+
633
+ // Determine range
634
+ let from = params.from || "";
635
+ const to = params.to || "HEAD";
636
+
637
+ if (!from) {
638
+ // Use latest tag
639
+ const latestTag = exec("git describe --tags --abbrev=0 2>/dev/null || echo ''", ctx.cwd);
640
+ from = latestTag.stdout || "";
641
+ }
642
+
643
+ if (!from) {
644
+ // No tags found — use all commits
645
+ const firstCommit = exec("git rev-list --max-parents=0 HEAD", ctx.cwd);
646
+ from = firstCommit.stdout || "";
647
+ }
648
+
649
+ if (!from && !to) {
650
+ return {
651
+ content: [{ type: "text", text: "No commits or tags found to generate release notes from." }],
652
+ isError: true,
653
+ details: {},
654
+ };
655
+ }
656
+
657
+ const release = generateReleaseNotes(from, to, config, ctx.cwd);
658
+
659
+ // Check if there's anything
660
+ const totalEntries = Object.values(release.sections).reduce((sum, arr) => sum + arr.length, 0);
661
+ if (totalEntries === 0) {
662
+ return {
663
+ content: [{
664
+ type: "text",
665
+ text: `No conventional commits found between ${from} and ${to}.`,
666
+ }],
667
+ isError: true,
668
+ details: {},
669
+ };
670
+ }
671
+
672
+ const notes = formatReleaseNotes(release);
673
+
674
+ return {
675
+ content: [{ type: "text", text: notes }],
676
+ details: { from, to, totalEntries, sections: release.sections },
677
+ };
678
+ },
679
+ });
680
+
681
+ // ═══════════════════════════════════════
682
+ // Session cleanup
683
+ // ═══════════════════════════════════════
684
+ pi.on("session_shutdown", async () => {
685
+ activeIssueId = null;
686
+ });
687
+ }