@nathapp/nax 0.38.0 → 0.38.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.
Files changed (75) hide show
  1. package/dist/nax.js +3294 -2907
  2. package/package.json +2 -2
  3. package/src/agents/claude-complete.ts +72 -0
  4. package/src/agents/claude-execution.ts +189 -0
  5. package/src/agents/claude-interactive.ts +77 -0
  6. package/src/agents/claude-plan.ts +23 -8
  7. package/src/agents/claude.ts +64 -349
  8. package/src/analyze/classifier.ts +2 -1
  9. package/src/cli/config-descriptions.ts +206 -0
  10. package/src/cli/config-diff.ts +103 -0
  11. package/src/cli/config-display.ts +285 -0
  12. package/src/cli/config-get.ts +55 -0
  13. package/src/cli/config.ts +7 -618
  14. package/src/cli/plugins.ts +15 -4
  15. package/src/cli/prompts-export.ts +58 -0
  16. package/src/cli/prompts-init.ts +200 -0
  17. package/src/cli/prompts-main.ts +237 -0
  18. package/src/cli/prompts-tdd.ts +78 -0
  19. package/src/cli/prompts.ts +10 -541
  20. package/src/commands/logs-formatter.ts +201 -0
  21. package/src/commands/logs-reader.ts +171 -0
  22. package/src/commands/logs.ts +11 -362
  23. package/src/config/loader.ts +4 -15
  24. package/src/config/runtime-types.ts +451 -0
  25. package/src/config/schema-types.ts +53 -0
  26. package/src/config/schemas.ts +2 -0
  27. package/src/config/types.ts +49 -486
  28. package/src/context/auto-detect.ts +2 -1
  29. package/src/context/builder.ts +3 -2
  30. package/src/execution/crash-heartbeat.ts +77 -0
  31. package/src/execution/crash-recovery.ts +23 -365
  32. package/src/execution/crash-signals.ts +149 -0
  33. package/src/execution/crash-writer.ts +154 -0
  34. package/src/execution/lifecycle/run-setup.ts +7 -1
  35. package/src/execution/parallel-coordinator.ts +278 -0
  36. package/src/execution/parallel-executor-rectification-pass.ts +117 -0
  37. package/src/execution/parallel-executor-rectify.ts +135 -0
  38. package/src/execution/parallel-executor.ts +19 -211
  39. package/src/execution/parallel-worker.ts +148 -0
  40. package/src/execution/parallel.ts +5 -404
  41. package/src/execution/pid-registry.ts +3 -8
  42. package/src/execution/runner-completion.ts +160 -0
  43. package/src/execution/runner-execution.ts +221 -0
  44. package/src/execution/runner-setup.ts +82 -0
  45. package/src/execution/runner.ts +53 -202
  46. package/src/execution/timeout-handler.ts +100 -0
  47. package/src/hooks/runner.ts +11 -21
  48. package/src/metrics/tracker.ts +7 -30
  49. package/src/pipeline/runner.ts +2 -1
  50. package/src/pipeline/stages/completion.ts +0 -1
  51. package/src/pipeline/stages/context.ts +2 -1
  52. package/src/plugins/extensions.ts +225 -0
  53. package/src/plugins/loader.ts +40 -4
  54. package/src/plugins/types.ts +18 -221
  55. package/src/prd/index.ts +2 -1
  56. package/src/prd/validate.ts +41 -0
  57. package/src/precheck/checks-blockers.ts +15 -419
  58. package/src/precheck/checks-cli.ts +68 -0
  59. package/src/precheck/checks-config.ts +102 -0
  60. package/src/precheck/checks-git.ts +87 -0
  61. package/src/precheck/checks-system.ts +163 -0
  62. package/src/review/orchestrator.ts +19 -6
  63. package/src/review/runner.ts +17 -5
  64. package/src/routing/chain.ts +2 -1
  65. package/src/routing/loader.ts +2 -5
  66. package/src/tdd/orchestrator.ts +2 -1
  67. package/src/tdd/verdict-reader.ts +266 -0
  68. package/src/tdd/verdict.ts +6 -271
  69. package/src/utils/errors.ts +12 -0
  70. package/src/utils/git.ts +12 -5
  71. package/src/utils/json-file.ts +72 -0
  72. package/src/verification/executor.ts +2 -1
  73. package/src/verification/smart-runner.ts +23 -3
  74. package/src/worktree/manager.ts +9 -3
  75. package/src/worktree/merge.ts +3 -2
@@ -1,427 +1,23 @@
1
1
  /**
2
2
  * Precheck Tier 1 Blockers
3
3
  *
4
- * Extracted from checks.ts: individual check implementations for Tier 1 blockers.
4
+ * Re-exports check implementations from specialized modules.
5
5
  */
6
- import { existsSync, statSync } from "node:fs";
7
- import type { NaxConfig } from "../config";
8
- import type { PRD } from "../prd/types";
9
- import type { Check } from "./types";
10
6
 
11
- /** Check if directory is a git repository. Uses: git rev-parse --git-dir */
12
- export async function checkGitRepoExists(workdir: string): Promise<Check> {
13
- // First try git rev-parse command
14
- const proc = Bun.spawn(["git", "rev-parse", "--git-dir"], {
15
- cwd: workdir,
16
- stdout: "pipe",
17
- stderr: "pipe",
18
- });
7
+ // Re-export for backward compatibility
8
+ export {
9
+ checkGitRepoExists,
10
+ checkWorkingTreeClean,
11
+ checkGitUserConfigured,
12
+ } from "./checks-git";
19
13
 
20
- const exitCode = await proc.exited;
21
- let passed = exitCode === 0;
14
+ export { checkStaleLock, checkPRDValid } from "./checks-config";
22
15
 
23
- // Fallback: if git command fails, check if .git directory exists
24
- // This handles test scenarios where .git exists but isn't fully initialized
25
- if (!passed) {
26
- const gitDir = `${workdir}/.git`;
27
- if (existsSync(gitDir)) {
28
- const stats = statSync(gitDir);
29
- passed = stats.isDirectory();
30
- }
31
- }
16
+ export { checkClaudeCLI, checkAgentCLI, _deps } from "./checks-cli";
32
17
 
33
- return {
34
- name: "git-repo-exists",
35
- tier: "blocker",
36
- passed,
37
- message: passed ? "git repository detected" : "not a git repository",
38
- };
39
- }
40
-
41
- /** Check if working tree is clean. Uses: git status --porcelain */
42
- export async function checkWorkingTreeClean(workdir: string): Promise<Check> {
43
- const proc = Bun.spawn(["git", "status", "--porcelain"], {
44
- cwd: workdir,
45
- stdout: "pipe",
46
- stderr: "pipe",
47
- });
48
-
49
- const output = await new Response(proc.stdout).text();
50
- const exitCode = await proc.exited;
51
- const passed = exitCode === 0 && output.trim() === "";
52
-
53
- return {
54
- name: "working-tree-clean",
55
- tier: "blocker",
56
- passed,
57
- message: passed ? "Working tree is clean" : "Uncommitted changes detected",
58
- };
59
- }
60
-
61
- /** Check if nax.lock is older than 2 hours. */
62
- export async function checkStaleLock(workdir: string): Promise<Check> {
63
- const lockPath = `${workdir}/nax.lock`;
64
- const exists = existsSync(lockPath);
65
-
66
- if (!exists) {
67
- return {
68
- name: "no-stale-lock",
69
- tier: "blocker",
70
- passed: true,
71
- message: "No lock file present",
72
- };
73
- }
74
-
75
- try {
76
- const file = Bun.file(lockPath);
77
- const content = await file.text();
78
- const lockData = JSON.parse(content);
79
-
80
- // Support both timestamp (ms) and startedAt (ISO string) formats
81
- let lockTimeMs: number;
82
- if (lockData.timestamp) {
83
- lockTimeMs = lockData.timestamp;
84
- } else if (lockData.startedAt) {
85
- lockTimeMs = new Date(lockData.startedAt).getTime();
86
- } else {
87
- // Fallback to file mtime if no timestamp in JSON
88
- const stat = statSync(lockPath);
89
- lockTimeMs = stat.mtimeMs;
90
- }
91
-
92
- const ageMs = Date.now() - lockTimeMs;
93
- const twoHoursMs = 2 * 60 * 60 * 1000;
94
- const passed = ageMs < twoHoursMs;
95
-
96
- const ageMinutes = Math.floor(ageMs / 60000);
97
- const ageHours = Math.floor(ageMinutes / 60);
98
-
99
- return {
100
- name: "no-stale-lock",
101
- tier: "blocker",
102
- passed,
103
- message: passed ? "Lock file is fresh" : "stale lock detected (over 2 hours old)",
104
- };
105
- } catch (error) {
106
- return {
107
- name: "no-stale-lock",
108
- tier: "blocker",
109
- passed: false,
110
- message: "Failed to read lock file",
111
- };
112
- }
113
- }
114
-
115
- /** Validate PRD structure and required fields. Auto-defaults: tags=[], status=pending, storyPoints=1 */
116
- export async function checkPRDValid(prd: PRD): Promise<Check> {
117
- const errors: string[] = [];
118
-
119
- // Validate required PRD fields
120
- if (!prd.project || prd.project.trim() === "") {
121
- errors.push("Missing project field");
122
- }
123
- if (!prd.feature || prd.feature.trim() === "") {
124
- errors.push("Missing feature field");
125
- }
126
- if (!prd.branchName || prd.branchName.trim() === "") {
127
- errors.push("Missing branchName field");
128
- }
129
- if (!Array.isArray(prd.userStories)) {
130
- errors.push("userStories must be an array");
131
- }
132
-
133
- // Validate each story
134
- if (Array.isArray(prd.userStories)) {
135
- for (const story of prd.userStories) {
136
- // Auto-default optional fields in-memory (don't modify the PRD)
137
- story.tags = story.tags ?? [];
138
- story.status = story.status ?? "pending";
139
- story.storyPoints = story.storyPoints ?? 1;
140
- story.acceptanceCriteria = story.acceptanceCriteria ?? [];
141
-
142
- // Validate required fields
143
- if (!story.id || story.id.trim() === "") {
144
- errors.push(`Story missing id: ${JSON.stringify(story).slice(0, 50)}`);
145
- }
146
- if (!story.title || story.title.trim() === "") {
147
- errors.push(`Story ${story.id} missing title`);
148
- }
149
- if (!story.description || story.description.trim() === "") {
150
- errors.push(`Story ${story.id} missing description`);
151
- }
152
- }
153
- }
154
-
155
- const passed = errors.length === 0;
156
-
157
- return {
158
- name: "prd-valid",
159
- tier: "blocker",
160
- passed,
161
- message: passed ? "PRD structure is valid" : errors.join("; "),
162
- };
163
- }
164
-
165
- /** Dependency injection for testability */
166
- export const _deps = {
167
- spawn: Bun.spawn,
168
- };
169
-
170
- /** Check if Claude CLI is available. Uses: claude --version */
171
- export async function checkClaudeCLI(): Promise<Check> {
172
- try {
173
- const proc = _deps.spawn(["claude", "--version"], {
174
- stdout: "pipe",
175
- stderr: "pipe",
176
- });
177
-
178
- const exitCode = await proc.exited;
179
- const passed = exitCode === 0;
180
-
181
- return {
182
- name: "claude-cli-available",
183
- tier: "blocker",
184
- passed,
185
- message: passed ? "Claude CLI is available" : "Claude CLI not found. Install from https://claude.ai/download",
186
- };
187
- } catch {
188
- // Bun.spawn throws ENOENT when the binary is not found in PATH.
189
- // Treat this as a failed check rather than an unhandled exception so the
190
- // rest of the precheck pipeline can continue and report all issues at once.
191
- return {
192
- name: "claude-cli-available",
193
- tier: "blocker",
194
- passed: false,
195
- message: "Claude CLI not found in PATH. Install from https://claude.ai/download",
196
- };
197
- }
198
- }
199
-
200
- /** Check if configured agent binary is available. Reads agent from config, defaults to 'claude'.
201
- * Supports: claude, codex, opencode, gemini, aider */
202
- export async function checkAgentCLI(config: NaxConfig): Promise<Check> {
203
- const agent = config.execution?.agent || "claude";
204
-
205
- try {
206
- const proc = _deps.spawn([agent, "--version"], {
207
- stdout: "pipe",
208
- stderr: "pipe",
209
- });
210
-
211
- const exitCode = await proc.exited;
212
- const passed = exitCode === 0;
213
-
214
- return {
215
- name: "agent-cli-available",
216
- tier: "blocker",
217
- passed,
218
- message: passed ? `${agent} CLI is available` : `${agent} CLI not found. Install the ${agent} binary.`,
219
- };
220
- } catch {
221
- // Bun.spawn throws ENOENT when the binary is not found in PATH.
222
- return {
223
- name: "agent-cli-available",
224
- tier: "blocker",
225
- passed: false,
226
- message: `${agent} CLI not found in PATH. Install the ${agent} binary.`,
227
- };
228
- }
229
- }
230
-
231
- /** Check if dependencies are installed (language-aware). Detects: node_modules, target, venv, vendor */
232
- export async function checkDependenciesInstalled(workdir: string): Promise<Check> {
233
- const depPaths = [
234
- { path: "node_modules" },
235
- { path: "target" },
236
- { path: "venv" },
237
- { path: ".venv" },
238
- { path: "vendor" },
239
- ];
240
-
241
- const found: string[] = [];
242
- for (const { path } of depPaths) {
243
- const fullPath = `${workdir}/${path}`;
244
- // Check if it exists and is a directory
245
- if (existsSync(fullPath)) {
246
- const stats = statSync(fullPath);
247
- if (stats.isDirectory()) {
248
- found.push(path);
249
- }
250
- }
251
- }
252
-
253
- const passed = found.length > 0;
254
-
255
- return {
256
- name: "dependencies-installed",
257
- tier: "blocker",
258
- passed,
259
- message: passed ? `Dependencies found: ${found.join(", ")}` : "No dependency directories detected",
260
- };
261
- }
262
-
263
- /** Check if test command works. Skips silently if command is null/false. */
264
- export async function checkTestCommand(config: NaxConfig): Promise<Check> {
265
- // Try multiple possible locations for testCommand
266
- const testCommand = config.execution.testCommand || (config.quality?.commands?.test as string | undefined);
267
-
268
- // Skip if explicitly disabled or not configured
269
- if (!testCommand || testCommand === null || testCommand === null) {
270
- return {
271
- name: "test-command-works",
272
- tier: "blocker",
273
- passed: true,
274
- message: "Test command not configured (skipped)",
275
- };
276
- }
277
-
278
- // Parse command and args
279
- const parts = testCommand.split(" ");
280
- const [cmd, ...args] = parts;
281
-
282
- try {
283
- const proc = Bun.spawn([cmd, ...args, "--help"], {
284
- stdout: "pipe",
285
- stderr: "pipe",
286
- });
287
-
288
- const exitCode = await proc.exited;
289
- const passed = exitCode === 0;
290
-
291
- return {
292
- name: "test-command-works",
293
- tier: "blocker",
294
- passed,
295
- message: passed ? "Test command is available" : `Test command failed: ${testCommand}`,
296
- };
297
- } catch (error) {
298
- return {
299
- name: "test-command-works",
300
- tier: "blocker",
301
- passed: false,
302
- message: `Test command failed: ${testCommand}`,
303
- };
304
- }
305
- }
306
-
307
- /** Check if lint command works. Skips silently if command is null/false. */
308
- export async function checkLintCommand(config: NaxConfig): Promise<Check> {
309
- const lintCommand = config.execution.lintCommand;
310
-
311
- // Skip if explicitly disabled or not configured
312
- if (!lintCommand || lintCommand === null || lintCommand === null) {
313
- return {
314
- name: "lint-command-works",
315
- tier: "blocker",
316
- passed: true,
317
- message: "Lint command not configured (skipped)",
318
- };
319
- }
320
-
321
- // Parse command and args
322
- const parts = lintCommand.split(" ");
323
- const [cmd, ...args] = parts;
324
-
325
- try {
326
- const proc = Bun.spawn([cmd, ...args, "--help"], {
327
- stdout: "pipe",
328
- stderr: "pipe",
329
- });
330
-
331
- const exitCode = await proc.exited;
332
- const passed = exitCode === 0;
333
-
334
- return {
335
- name: "lint-command-works",
336
- tier: "blocker",
337
- passed,
338
- message: passed ? "Lint command is available" : `Lint command failed: ${lintCommand}`,
339
- };
340
- } catch (error) {
341
- return {
342
- name: "lint-command-works",
343
- tier: "blocker",
344
- passed: false,
345
- message: `Lint command failed: ${lintCommand}`,
346
- };
347
- }
348
- }
349
-
350
- /** Check if typecheck command works. Skips silently if command is null/false. */
351
- export async function checkTypecheckCommand(config: NaxConfig): Promise<Check> {
352
- const typecheckCommand = config.execution.typecheckCommand;
353
-
354
- // Skip if explicitly disabled or not configured
355
- if (!typecheckCommand || typecheckCommand === null || typecheckCommand === null) {
356
- return {
357
- name: "typecheck-command-works",
358
- tier: "blocker",
359
- passed: true,
360
- message: "Typecheck command not configured (skipped)",
361
- };
362
- }
363
-
364
- // Parse command and args
365
- const parts = typecheckCommand.split(" ");
366
- const [cmd, ...args] = parts;
367
-
368
- try {
369
- const proc = Bun.spawn([cmd, ...args, "--help"], {
370
- stdout: "pipe",
371
- stderr: "pipe",
372
- });
373
-
374
- const exitCode = await proc.exited;
375
- const passed = exitCode === 0;
376
-
377
- return {
378
- name: "typecheck-command-works",
379
- tier: "blocker",
380
- passed,
381
- message: passed
382
- ? `Typecheck command is available: ${typecheckCommand}`
383
- : `Typecheck command failed: ${typecheckCommand}`,
384
- };
385
- } catch (error) {
386
- return {
387
- name: "typecheck-command-works",
388
- tier: "blocker",
389
- passed: false,
390
- message: `Typecheck command failed: ${typecheckCommand}`,
391
- };
392
- }
393
- }
394
-
395
- /** Check if git user is configured. */
396
- export async function checkGitUserConfigured(workdir?: string): Promise<Check> {
397
- const spawnOptions = {
398
- stdout: "pipe" as const,
399
- stderr: "pipe" as const,
400
- ...(workdir && { cwd: workdir }),
401
- };
402
-
403
- const nameProc = Bun.spawn(["git", "config", "user.name"], spawnOptions);
404
- const emailProc = Bun.spawn(["git", "config", "user.email"], spawnOptions);
405
-
406
- const nameOutput = await new Response(nameProc.stdout).text();
407
- const emailOutput = await new Response(emailProc.stdout).text();
408
- const nameExitCode = await nameProc.exited;
409
- const emailExitCode = await emailProc.exited;
410
-
411
- const hasName = nameExitCode === 0 && nameOutput.trim() !== "";
412
- const hasEmail = emailExitCode === 0 && emailOutput.trim() !== "";
413
- const passed = hasName && hasEmail;
414
-
415
- return {
416
- name: "git-user-configured",
417
- tier: "blocker",
418
- passed,
419
- message: passed
420
- ? "Git user is configured"
421
- : !hasName && !hasEmail
422
- ? "Git user.name and user.email not configured"
423
- : !hasName
424
- ? "Git user.name not configured"
425
- : "Git user.email not configured",
426
- };
427
- }
18
+ export {
19
+ checkDependenciesInstalled,
20
+ checkTestCommand,
21
+ checkLintCommand,
22
+ checkTypecheckCommand,
23
+ } from "./checks-system";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * CLI availability precheck implementations
3
+ */
4
+
5
+ import type { NaxConfig } from "../config";
6
+ import type { Check } from "./types";
7
+
8
+ /** Dependency injection for testability */
9
+ export const _deps = {
10
+ spawn: Bun.spawn,
11
+ };
12
+
13
+ /** Check if Claude CLI is available. Uses: claude --version */
14
+ export async function checkClaudeCLI(): Promise<Check> {
15
+ try {
16
+ const proc = _deps.spawn(["claude", "--version"], {
17
+ stdout: "pipe",
18
+ stderr: "pipe",
19
+ });
20
+
21
+ const exitCode = await proc.exited;
22
+ const passed = exitCode === 0;
23
+
24
+ return {
25
+ name: "claude-cli-available",
26
+ tier: "blocker",
27
+ passed,
28
+ message: passed ? "Claude CLI is available" : "Claude CLI not found. Install from https://claude.ai/download",
29
+ };
30
+ } catch {
31
+ return {
32
+ name: "claude-cli-available",
33
+ tier: "blocker",
34
+ passed: false,
35
+ message: "Claude CLI not found in PATH. Install from https://claude.ai/download",
36
+ };
37
+ }
38
+ }
39
+
40
+ /** Check if configured agent binary is available. Reads agent from config, defaults to 'claude'.
41
+ * Supports: claude, codex, opencode, gemini, aider */
42
+ export async function checkAgentCLI(config: NaxConfig): Promise<Check> {
43
+ const agent = config.execution?.agent || "claude";
44
+
45
+ try {
46
+ const proc = _deps.spawn([agent, "--version"], {
47
+ stdout: "pipe",
48
+ stderr: "pipe",
49
+ });
50
+
51
+ const exitCode = await proc.exited;
52
+ const passed = exitCode === 0;
53
+
54
+ return {
55
+ name: "agent-cli-available",
56
+ tier: "blocker",
57
+ passed,
58
+ message: passed ? `${agent} CLI is available` : `${agent} CLI not found. Install the ${agent} binary.`,
59
+ };
60
+ } catch {
61
+ return {
62
+ name: "agent-cli-available",
63
+ tier: "blocker",
64
+ passed: false,
65
+ message: `${agent} CLI not found in PATH. Install the ${agent} binary.`,
66
+ };
67
+ }
68
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Configuration-related precheck implementations
3
+ */
4
+
5
+ import { existsSync, statSync } from "node:fs";
6
+ import type { PRD } from "../prd/types";
7
+ import type { Check } from "./types";
8
+
9
+ /** Check if nax.lock is older than 2 hours. */
10
+ export async function checkStaleLock(workdir: string): Promise<Check> {
11
+ const lockPath = `${workdir}/nax.lock`;
12
+ const exists = existsSync(lockPath);
13
+
14
+ if (!exists) {
15
+ return {
16
+ name: "no-stale-lock",
17
+ tier: "blocker",
18
+ passed: true,
19
+ message: "No lock file present",
20
+ };
21
+ }
22
+
23
+ try {
24
+ const file = Bun.file(lockPath);
25
+ const content = await file.text();
26
+ const lockData = JSON.parse(content);
27
+
28
+ let lockTimeMs: number;
29
+ if (lockData.timestamp) {
30
+ lockTimeMs = lockData.timestamp;
31
+ } else if (lockData.startedAt) {
32
+ lockTimeMs = new Date(lockData.startedAt).getTime();
33
+ } else {
34
+ const stat = statSync(lockPath);
35
+ lockTimeMs = stat.mtimeMs;
36
+ }
37
+
38
+ const ageMs = Date.now() - lockTimeMs;
39
+ const twoHoursMs = 2 * 60 * 60 * 1000;
40
+ const passed = ageMs < twoHoursMs;
41
+
42
+ return {
43
+ name: "no-stale-lock",
44
+ tier: "blocker",
45
+ passed,
46
+ message: passed ? "Lock file is fresh" : "stale lock detected (over 2 hours old)",
47
+ };
48
+ } catch {
49
+ return {
50
+ name: "no-stale-lock",
51
+ tier: "blocker",
52
+ passed: false,
53
+ message: "Failed to read lock file",
54
+ };
55
+ }
56
+ }
57
+
58
+ /** Validate PRD structure and required fields. Auto-defaults: tags=[], status=pending, storyPoints=1 */
59
+ export async function checkPRDValid(prd: PRD): Promise<Check> {
60
+ const errors: string[] = [];
61
+
62
+ if (!prd.project || prd.project.trim() === "") {
63
+ errors.push("Missing project field");
64
+ }
65
+ if (!prd.feature || prd.feature.trim() === "") {
66
+ errors.push("Missing feature field");
67
+ }
68
+ if (!prd.branchName || prd.branchName.trim() === "") {
69
+ errors.push("Missing branchName field");
70
+ }
71
+ if (!Array.isArray(prd.userStories)) {
72
+ errors.push("userStories must be an array");
73
+ }
74
+
75
+ if (Array.isArray(prd.userStories)) {
76
+ for (const story of prd.userStories) {
77
+ story.tags = story.tags ?? [];
78
+ story.status = story.status ?? "pending";
79
+ story.storyPoints = story.storyPoints ?? 1;
80
+ story.acceptanceCriteria = story.acceptanceCriteria ?? [];
81
+
82
+ if (!story.id || story.id.trim() === "") {
83
+ errors.push(`Story missing id: ${JSON.stringify(story).slice(0, 50)}`);
84
+ }
85
+ if (!story.title || story.title.trim() === "") {
86
+ errors.push(`Story ${story.id} missing title`);
87
+ }
88
+ if (!story.description || story.description.trim() === "") {
89
+ errors.push(`Story ${story.id} missing description`);
90
+ }
91
+ }
92
+ }
93
+
94
+ const passed = errors.length === 0;
95
+
96
+ return {
97
+ name: "prd-valid",
98
+ tier: "blocker",
99
+ passed,
100
+ message: passed ? "PRD structure is valid" : errors.join("; "),
101
+ };
102
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Git-related precheck implementations
3
+ */
4
+
5
+ import { existsSync, statSync } from "node:fs";
6
+ import type { Check } from "./types";
7
+
8
+ /** Check if directory is a git repository. Uses: git rev-parse --git-dir */
9
+ export async function checkGitRepoExists(workdir: string): Promise<Check> {
10
+ const proc = Bun.spawn(["git", "rev-parse", "--git-dir"], {
11
+ cwd: workdir,
12
+ stdout: "pipe",
13
+ stderr: "pipe",
14
+ });
15
+
16
+ const exitCode = await proc.exited;
17
+ let passed = exitCode === 0;
18
+
19
+ if (!passed) {
20
+ const gitDir = `${workdir}/.git`;
21
+ if (existsSync(gitDir)) {
22
+ const stats = statSync(gitDir);
23
+ passed = stats.isDirectory();
24
+ }
25
+ }
26
+
27
+ return {
28
+ name: "git-repo-exists",
29
+ tier: "blocker",
30
+ passed,
31
+ message: passed ? "git repository detected" : "not a git repository",
32
+ };
33
+ }
34
+
35
+ /** Check if working tree is clean. Uses: git status --porcelain */
36
+ export async function checkWorkingTreeClean(workdir: string): Promise<Check> {
37
+ const proc = Bun.spawn(["git", "status", "--porcelain"], {
38
+ cwd: workdir,
39
+ stdout: "pipe",
40
+ stderr: "pipe",
41
+ });
42
+
43
+ const output = await new Response(proc.stdout).text();
44
+ const exitCode = await proc.exited;
45
+ const passed = exitCode === 0 && output.trim() === "";
46
+
47
+ return {
48
+ name: "working-tree-clean",
49
+ tier: "blocker",
50
+ passed,
51
+ message: passed ? "Working tree is clean" : "Uncommitted changes detected",
52
+ };
53
+ }
54
+
55
+ /** Check if git user is configured. */
56
+ export async function checkGitUserConfigured(workdir?: string): Promise<Check> {
57
+ const spawnOptions = {
58
+ stdout: "pipe" as const,
59
+ stderr: "pipe" as const,
60
+ ...(workdir && { cwd: workdir }),
61
+ };
62
+
63
+ const nameProc = Bun.spawn(["git", "config", "user.name"], spawnOptions);
64
+ const emailProc = Bun.spawn(["git", "config", "user.email"], spawnOptions);
65
+
66
+ const nameOutput = await new Response(nameProc.stdout).text();
67
+ const emailOutput = await new Response(emailProc.stdout).text();
68
+ const nameExitCode = await nameProc.exited;
69
+ const emailExitCode = await emailProc.exited;
70
+
71
+ const hasName = nameExitCode === 0 && nameOutput.trim() !== "";
72
+ const hasEmail = emailExitCode === 0 && emailOutput.trim() !== "";
73
+ const passed = hasName && hasEmail;
74
+
75
+ return {
76
+ name: "git-user-configured",
77
+ tier: "blocker",
78
+ passed,
79
+ message: passed
80
+ ? "Git user is configured"
81
+ : !hasName && !hasEmail
82
+ ? "Git user.name and user.email not configured"
83
+ : !hasName
84
+ ? "Git user.name not configured"
85
+ : "Git user.email not configured",
86
+ };
87
+ }