@nathapp/nax 0.49.3 → 0.50.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/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/dist/nax.js +485 -202
- package/package.json +1 -1
- package/src/acceptance/generator.ts +48 -7
- package/src/agents/acp/adapter.ts +53 -23
- package/src/agents/acp/spawn-client.ts +0 -2
- package/src/agents/claude/execution.ts +14 -0
- package/src/agents/types.ts +7 -0
- package/src/cli/plan.ts +46 -13
- package/src/cli/prompts-main.ts +4 -59
- package/src/cli/prompts-shared.ts +70 -0
- package/src/cli/prompts-tdd.ts +1 -1
- package/src/config/merge.ts +18 -0
- package/src/config/test-strategy.ts +17 -16
- package/src/context/builder.ts +25 -0
- package/src/context/parent-context.ts +39 -0
- package/src/decompose/apply.ts +5 -1
- package/src/execution/escalation/tier-escalation.ts +1 -1
- package/src/execution/escalation/tier-outcome.ts +2 -2
- package/src/execution/lifecycle/run-initialization.ts +47 -13
- package/src/execution/parallel-coordinator.ts +3 -3
- package/src/execution/pipeline-result-handler.ts +30 -1
- package/src/interaction/plugins/webhook.ts +44 -25
- package/src/pipeline/stages/autofix.ts +10 -2
- package/src/prd/index.ts +9 -1
- package/src/prd/types.ts +6 -0
- package/src/routing/router.ts +1 -1
- package/src/tdd/cleanup.ts +15 -6
- package/src/tdd/isolation.ts +9 -2
- package/src/tdd/rectification-gate.ts +41 -10
- package/src/tdd/session-runner.ts +71 -38
- package/src/utils/git.ts +23 -0
- package/src/verification/executor.ts +4 -1
- package/src/verification/strategies/acceptance.ts +4 -1
package/package.json
CHANGED
|
@@ -114,6 +114,18 @@ IMPORTANT: Output raw TypeScript code only. Do NOT use markdown code fences (\`\
|
|
|
114
114
|
});
|
|
115
115
|
const testCode = extractTestCode(rawOutput);
|
|
116
116
|
|
|
117
|
+
if (!testCode) {
|
|
118
|
+
logger.warn("acceptance", "LLM returned non-code output for acceptance tests — falling back to skeleton", {
|
|
119
|
+
outputPreview: rawOutput.slice(0, 200),
|
|
120
|
+
});
|
|
121
|
+
const skeletonCriteria: AcceptanceCriterion[] = refinedCriteria.map((c, i) => ({
|
|
122
|
+
id: `AC-${i + 1}`,
|
|
123
|
+
text: c.refined,
|
|
124
|
+
lineNumber: i + 1,
|
|
125
|
+
}));
|
|
126
|
+
return { testCode: generateSkeletonTests(options.featureName, skeletonCriteria), criteria: skeletonCriteria };
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
const refinedJsonContent = JSON.stringify(
|
|
118
130
|
refinedCriteria.map((c, i) => ({
|
|
119
131
|
acId: `AC-${i + 1}`,
|
|
@@ -306,6 +318,16 @@ export async function generateAcceptanceTests(
|
|
|
306
318
|
// Extract test code from output
|
|
307
319
|
const testCode = extractTestCode(output);
|
|
308
320
|
|
|
321
|
+
if (!testCode) {
|
|
322
|
+
logger.warn("acceptance", "LLM returned non-code output for acceptance tests — falling back to skeleton", {
|
|
323
|
+
outputPreview: output.slice(0, 200),
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
testCode: generateSkeletonTests(options.featureName, criteria),
|
|
327
|
+
criteria,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
309
331
|
return {
|
|
310
332
|
testCode,
|
|
311
333
|
criteria,
|
|
@@ -328,21 +350,40 @@ export async function generateAcceptanceTests(
|
|
|
328
350
|
* @param output - Agent stdout
|
|
329
351
|
* @returns Extracted test code
|
|
330
352
|
*/
|
|
331
|
-
function extractTestCode(output: string): string {
|
|
353
|
+
function extractTestCode(output: string): string | null {
|
|
354
|
+
let code: string | undefined;
|
|
355
|
+
|
|
332
356
|
// Try to extract from markdown code fence
|
|
333
357
|
const fenceMatch = output.match(/```(?:typescript|ts)?\s*([\s\S]*?)\s*```/);
|
|
334
358
|
if (fenceMatch) {
|
|
335
|
-
|
|
359
|
+
code = fenceMatch[1].trim();
|
|
336
360
|
}
|
|
337
361
|
|
|
338
362
|
// If no fence, try to find import statement and take everything from there
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
363
|
+
if (!code) {
|
|
364
|
+
const importMatch = output.match(/import\s+{[\s\S]+/);
|
|
365
|
+
if (importMatch) {
|
|
366
|
+
code = importMatch[0].trim();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// If no fence and no import, try to find describe() block
|
|
371
|
+
if (!code) {
|
|
372
|
+
const describeMatch = output.match(/describe\s*\([\s\S]+/);
|
|
373
|
+
if (describeMatch) {
|
|
374
|
+
code = describeMatch[0].trim();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!code) return null;
|
|
379
|
+
|
|
380
|
+
// Validate: extracted code must contain at least one test-like keyword
|
|
381
|
+
const hasTestKeyword = /\b(?:describe|test|it|expect)\s*\(/.test(code);
|
|
382
|
+
if (!hasTestKeyword) {
|
|
383
|
+
return null;
|
|
342
384
|
}
|
|
343
385
|
|
|
344
|
-
|
|
345
|
-
return output.trim();
|
|
386
|
+
return code;
|
|
346
387
|
}
|
|
347
388
|
|
|
348
389
|
/**
|
|
@@ -261,23 +261,37 @@ function acpSessionsPath(workdir: string, featureName: string): string {
|
|
|
261
261
|
return join(workdir, "nax", "features", featureName, "acp-sessions.json");
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/** Sidecar entry — session name + agent name for correct sweep/close. */
|
|
265
|
+
type SidecarEntry = string | { sessionName: string; agentName: string };
|
|
266
|
+
|
|
267
|
+
/** Extract sessionName from a sidecar entry (handles legacy string format). */
|
|
268
|
+
function sidecarSessionName(entry: SidecarEntry): string {
|
|
269
|
+
return typeof entry === "string" ? entry : entry.sessionName;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Extract agentName from a sidecar entry (defaults to "claude" for legacy entries). */
|
|
273
|
+
function sidecarAgentName(entry: SidecarEntry): string {
|
|
274
|
+
return typeof entry === "string" ? "claude" : entry.agentName;
|
|
275
|
+
}
|
|
276
|
+
|
|
264
277
|
/** Persist a session name to the sidecar file. Best-effort — errors are swallowed. */
|
|
265
278
|
export async function saveAcpSession(
|
|
266
279
|
workdir: string,
|
|
267
280
|
featureName: string,
|
|
268
281
|
storyId: string,
|
|
269
282
|
sessionName: string,
|
|
283
|
+
agentName = "claude",
|
|
270
284
|
): Promise<void> {
|
|
271
285
|
try {
|
|
272
286
|
const path = acpSessionsPath(workdir, featureName);
|
|
273
|
-
let data: Record<string,
|
|
287
|
+
let data: Record<string, SidecarEntry> = {};
|
|
274
288
|
try {
|
|
275
289
|
const existing = await Bun.file(path).text();
|
|
276
290
|
data = JSON.parse(existing);
|
|
277
291
|
} catch {
|
|
278
292
|
// File doesn't exist yet — start fresh
|
|
279
293
|
}
|
|
280
|
-
data[storyId] = sessionName;
|
|
294
|
+
data[storyId] = { sessionName, agentName };
|
|
281
295
|
await Bun.write(path, JSON.stringify(data, null, 2));
|
|
282
296
|
} catch (err) {
|
|
283
297
|
getSafeLogger()?.warn("acp-adapter", "Failed to save session to sidecar", { error: String(err) });
|
|
@@ -307,8 +321,9 @@ export async function readAcpSession(workdir: string, featureName: string, story
|
|
|
307
321
|
try {
|
|
308
322
|
const path = acpSessionsPath(workdir, featureName);
|
|
309
323
|
const existing = await Bun.file(path).text();
|
|
310
|
-
const data: Record<string,
|
|
311
|
-
|
|
324
|
+
const data: Record<string, SidecarEntry> = JSON.parse(existing);
|
|
325
|
+
const entry = data[storyId];
|
|
326
|
+
return entry ? sidecarSessionName(entry) : null;
|
|
312
327
|
} catch {
|
|
313
328
|
return null;
|
|
314
329
|
}
|
|
@@ -326,10 +341,10 @@ const MAX_SESSION_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
|
326
341
|
*/
|
|
327
342
|
export async function sweepFeatureSessions(workdir: string, featureName: string): Promise<void> {
|
|
328
343
|
const path = acpSessionsPath(workdir, featureName);
|
|
329
|
-
let sessions: Record<string,
|
|
344
|
+
let sessions: Record<string, SidecarEntry>;
|
|
330
345
|
try {
|
|
331
346
|
const text = await Bun.file(path).text();
|
|
332
|
-
sessions = JSON.parse(text) as Record<string,
|
|
347
|
+
sessions = JSON.parse(text) as Record<string, SidecarEntry>;
|
|
333
348
|
} catch {
|
|
334
349
|
return; // No sidecar — nothing to sweep
|
|
335
350
|
}
|
|
@@ -340,24 +355,35 @@ export async function sweepFeatureSessions(workdir: string, featureName: string)
|
|
|
340
355
|
const logger = getSafeLogger();
|
|
341
356
|
logger?.info("acp-adapter", `[sweep] Closing ${entries.length} open sessions for feature: ${featureName}`);
|
|
342
357
|
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
358
|
+
// Group sessions by agent name so we create one client per agent
|
|
359
|
+
const byAgent = new Map<string, string[]>();
|
|
360
|
+
for (const [, entry] of entries) {
|
|
361
|
+
const agent = sidecarAgentName(entry);
|
|
362
|
+
const name = sidecarSessionName(entry);
|
|
363
|
+
if (!byAgent.has(agent)) byAgent.set(agent, []);
|
|
364
|
+
byAgent.get(agent)?.push(name);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const [agentName, sessionNames] of byAgent) {
|
|
368
|
+
const cmdStr = `acpx ${agentName}`;
|
|
369
|
+
const client = _acpAdapterDeps.createClient(cmdStr, workdir);
|
|
370
|
+
try {
|
|
371
|
+
await client.start();
|
|
372
|
+
for (const sessionName of sessionNames) {
|
|
373
|
+
try {
|
|
374
|
+
if (client.loadSession) {
|
|
375
|
+
const session = await client.loadSession(sessionName, agentName, "approve-reads");
|
|
376
|
+
if (session) {
|
|
377
|
+
await session.close().catch(() => {});
|
|
378
|
+
}
|
|
353
379
|
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger?.warn("acp-adapter", `[sweep] Failed to close session ${sessionName}`, { error: String(err) });
|
|
354
382
|
}
|
|
355
|
-
} catch (err) {
|
|
356
|
-
logger?.warn("acp-adapter", `[sweep] Failed to close session ${sessionName}`, { error: String(err) });
|
|
357
383
|
}
|
|
384
|
+
} finally {
|
|
385
|
+
await client.close().catch(() => {});
|
|
358
386
|
}
|
|
359
|
-
} finally {
|
|
360
|
-
await client.close().catch(() => {});
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
// Clear sidecar after sweep
|
|
@@ -554,7 +580,7 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
554
580
|
|
|
555
581
|
// 4. Persist for plan→run continuity
|
|
556
582
|
if (options.featureName && options.storyId) {
|
|
557
|
-
await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName);
|
|
583
|
+
await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName, this.name);
|
|
558
584
|
}
|
|
559
585
|
|
|
560
586
|
let lastResponse: AcpSessionResponse | null = null;
|
|
@@ -635,13 +661,17 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
635
661
|
} finally {
|
|
636
662
|
// 6. Cleanup — close session and clear sidecar only on success.
|
|
637
663
|
// On failure, keep session open so retry can resume with full context.
|
|
638
|
-
|
|
664
|
+
// When keepSessionOpen=true (e.g. rectification loop), skip close even on success
|
|
665
|
+
// so all attempts share the same conversation context.
|
|
666
|
+
if (runState.succeeded && !options.keepSessionOpen) {
|
|
639
667
|
await closeAcpSession(session);
|
|
640
668
|
if (options.featureName && options.storyId) {
|
|
641
669
|
await clearAcpSession(options.workdir, options.featureName, options.storyId);
|
|
642
670
|
}
|
|
643
|
-
} else {
|
|
671
|
+
} else if (!runState.succeeded) {
|
|
644
672
|
getSafeLogger()?.info("acp-adapter", "Keeping session open for retry", { sessionName });
|
|
673
|
+
} else {
|
|
674
|
+
getSafeLogger()?.debug("acp-adapter", "Keeping session open (keepSessionOpen=true)", { sessionName });
|
|
645
675
|
}
|
|
646
676
|
await client.close().catch(() => {});
|
|
647
677
|
}
|
|
@@ -272,7 +272,6 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
272
272
|
private readonly model: string;
|
|
273
273
|
private readonly cwd: string;
|
|
274
274
|
private readonly timeoutSeconds: number;
|
|
275
|
-
private readonly permissionMode: string;
|
|
276
275
|
private readonly env: Record<string, string | undefined>;
|
|
277
276
|
private readonly pidRegistry?: PidRegistry;
|
|
278
277
|
|
|
@@ -289,7 +288,6 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
289
288
|
this.agentName = lastToken;
|
|
290
289
|
this.cwd = cwd || process.cwd();
|
|
291
290
|
this.timeoutSeconds = timeoutSeconds || 1800;
|
|
292
|
-
this.permissionMode = "approve-reads";
|
|
293
291
|
this.env = buildAllowedEnv();
|
|
294
292
|
this.pidRegistry = pidRegistry;
|
|
295
293
|
}
|
|
@@ -126,6 +126,20 @@ export async function executeOnce(
|
|
|
126
126
|
const cmd = _runOnceDeps.buildCmd(binary, options);
|
|
127
127
|
const startTime = Date.now();
|
|
128
128
|
|
|
129
|
+
// Log session-related options for traceability. CLI adapter doesn't use sessions,
|
|
130
|
+
// but the pipeline passes these uniformly. Logged so future CLI session support
|
|
131
|
+
// can verify they're threaded correctly.
|
|
132
|
+
if (options.sessionRole || options.acpSessionName || options.keepSessionOpen) {
|
|
133
|
+
const logger = getLogger();
|
|
134
|
+
logger.debug("agent", "CLI mode: session options received (unused)", {
|
|
135
|
+
sessionRole: options.sessionRole,
|
|
136
|
+
acpSessionName: options.acpSessionName,
|
|
137
|
+
keepSessionOpen: options.keepSessionOpen,
|
|
138
|
+
featureName: options.featureName,
|
|
139
|
+
storyId: options.storyId,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
const proc = Bun.spawn(cmd, {
|
|
130
144
|
cwd: options.workdir,
|
|
131
145
|
stdout: "pipe",
|
package/src/agents/types.ts
CHANGED
|
@@ -84,6 +84,13 @@ export interface AgentRunOptions {
|
|
|
84
84
|
pipelineStage?: import("../config/permissions").PipelineStage;
|
|
85
85
|
/** Full nax config — passed through so adapters can call resolvePermissions() */
|
|
86
86
|
config?: NaxConfig;
|
|
87
|
+
/**
|
|
88
|
+
* When true, the adapter will NOT close the session after a successful run.
|
|
89
|
+
* Use this for rectification loops where the same session must persist across
|
|
90
|
+
* multiple attempts so the agent retains full conversation context.
|
|
91
|
+
* The caller is responsible for closing the session when the loop is done.
|
|
92
|
+
*/
|
|
93
|
+
keepSessionOpen?: boolean;
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/**
|
package/src/cli/plan.ts
CHANGED
|
@@ -395,15 +395,18 @@ function buildCodebaseContext(scan: CodebaseScan): string {
|
|
|
395
395
|
/**
|
|
396
396
|
* Build the full planning prompt sent to the LLM.
|
|
397
397
|
*
|
|
398
|
+
* Structured as 3 explicit steps (ENH-006):
|
|
399
|
+
* Step 1: Understand the spec
|
|
400
|
+
* Step 2: Analyze codebase (existing) or architecture decisions (greenfield)
|
|
401
|
+
* Step 3: Generate implementation stories from analysis
|
|
402
|
+
*
|
|
398
403
|
* Includes:
|
|
399
|
-
* - Spec content
|
|
400
|
-
* -
|
|
401
|
-
* -
|
|
402
|
-
* - Complexity classification guide
|
|
403
|
-
* - Test strategy guide
|
|
404
|
+
* - Spec content + codebase context
|
|
405
|
+
* - Output schema with analysis + contextFiles fields
|
|
406
|
+
* - Complexity + test strategy guides
|
|
404
407
|
* - MW-007: Monorepo hint and package list when packages are detected
|
|
405
408
|
*/
|
|
406
|
-
function buildPlanningPrompt(
|
|
409
|
+
export function buildPlanningPrompt(
|
|
407
410
|
specContent: string,
|
|
408
411
|
codebaseContext: string,
|
|
409
412
|
outputFilePath?: string,
|
|
@@ -423,14 +426,48 @@ function buildPlanningPrompt(
|
|
|
423
426
|
|
|
424
427
|
return `You are a senior software architect generating a product requirements document (PRD) as JSON.
|
|
425
428
|
|
|
429
|
+
## Step 1: Understand the Spec
|
|
430
|
+
|
|
431
|
+
Read the spec carefully. Identify the goal, scope, constraints, and what "done" looks like.
|
|
432
|
+
|
|
426
433
|
## Spec
|
|
427
434
|
|
|
428
435
|
${specContent}
|
|
429
436
|
|
|
437
|
+
## Step 2: Analyze
|
|
438
|
+
|
|
439
|
+
Examine the codebase context below.
|
|
440
|
+
|
|
441
|
+
If the codebase has existing code (refactoring, enhancement, bug fix):
|
|
442
|
+
- Which existing files need modification?
|
|
443
|
+
- Which files import from or depend on them?
|
|
444
|
+
- What tests cover the affected code?
|
|
445
|
+
- What are the risks (breaking changes, backward compatibility)?
|
|
446
|
+
- What is the migration path?
|
|
447
|
+
|
|
448
|
+
If this is a greenfield project (empty or minimal codebase):
|
|
449
|
+
- What is the target architecture?
|
|
450
|
+
- What are the key technical decisions (framework, patterns, conventions)?
|
|
451
|
+
- What should be built first (dependency order)?
|
|
452
|
+
|
|
453
|
+
Record ALL findings in the "analysis" field of the output JSON. This analysis is provided to every implementation agent as context — be thorough.
|
|
454
|
+
|
|
430
455
|
## Codebase Context
|
|
431
456
|
|
|
432
457
|
${codebaseContext}${monorepoHint}
|
|
433
458
|
|
|
459
|
+
## Step 3: Generate Implementation Stories
|
|
460
|
+
|
|
461
|
+
Based on your Step 2 analysis, create stories that produce CODE CHANGES.
|
|
462
|
+
|
|
463
|
+
${GROUPING_RULES}
|
|
464
|
+
|
|
465
|
+
For each story, set "contextFiles" to the key source files the agent should read before implementing (max 5 per story). Use your Step 2 analysis to identify the most relevant files. Leave empty for greenfield stories with no existing files to reference.
|
|
466
|
+
|
|
467
|
+
${COMPLEXITY_GUIDE}
|
|
468
|
+
|
|
469
|
+
${TEST_STRATEGY_GUIDE}
|
|
470
|
+
|
|
434
471
|
## Output Schema
|
|
435
472
|
|
|
436
473
|
Generate a JSON object with this exact structure (no markdown, no explanation — JSON only):
|
|
@@ -438,6 +475,7 @@ Generate a JSON object with this exact structure (no markdown, no explanation
|
|
|
438
475
|
{
|
|
439
476
|
"project": "string — project name",
|
|
440
477
|
"feature": "string — feature name",
|
|
478
|
+
"analysis": "string — your Step 2 analysis: key files, impact areas, risks, architecture decisions, migration notes. All implementation agents will receive this.",
|
|
441
479
|
"branchName": "string — git branch (e.g. feat/my-feature)",
|
|
442
480
|
"createdAt": "ISO 8601 timestamp",
|
|
443
481
|
"updatedAt": "ISO 8601 timestamp",
|
|
@@ -447,13 +485,14 @@ Generate a JSON object with this exact structure (no markdown, no explanation
|
|
|
447
485
|
"title": "string — concise story title",
|
|
448
486
|
"description": "string — detailed description of the story",
|
|
449
487
|
"acceptanceCriteria": ["string — each AC line"],
|
|
488
|
+
"contextFiles": ["string — key source files the agent should read (max 5, relative paths)"],
|
|
450
489
|
"tags": ["string — routing tags, e.g. feature, security, api"],
|
|
451
490
|
"dependencies": ["string — story IDs this story depends on"],${workdirField}
|
|
452
491
|
"status": "pending",
|
|
453
492
|
"passes": false,
|
|
454
493
|
"routing": {
|
|
455
494
|
"complexity": "simple | medium | complex | expert",
|
|
456
|
-
"testStrategy": "
|
|
495
|
+
"testStrategy": "tdd-simple | three-session-tdd-lite | three-session-tdd | test-after",
|
|
457
496
|
"reasoning": "string — brief classification rationale"
|
|
458
497
|
},
|
|
459
498
|
"escalations": [],
|
|
@@ -462,12 +501,6 @@ Generate a JSON object with this exact structure (no markdown, no explanation
|
|
|
462
501
|
]
|
|
463
502
|
}
|
|
464
503
|
|
|
465
|
-
${COMPLEXITY_GUIDE}
|
|
466
|
-
|
|
467
|
-
${TEST_STRATEGY_GUIDE}
|
|
468
|
-
|
|
469
|
-
${GROUPING_RULES}
|
|
470
|
-
|
|
471
504
|
${
|
|
472
505
|
outputFilePath
|
|
473
506
|
? `Write the PRD JSON directly to this file path: ${outputFilePath}\nDo NOT output the JSON to the conversation. Write the file, then reply with a brief confirmation.`
|
package/src/cli/prompts-main.ts
CHANGED
|
@@ -13,7 +13,11 @@ import type { PipelineContext } from "../pipeline";
|
|
|
13
13
|
import { constitutionStage, contextStage, promptStage, routingStage } from "../pipeline/stages";
|
|
14
14
|
import type { UserStory } from "../prd";
|
|
15
15
|
import { loadPRD } from "../prd";
|
|
16
|
+
// buildFrontmatter lives in prompts-shared to avoid circular import with prompts-tdd.
|
|
17
|
+
// Import for local use + re-export to preserve the public API via prompts.ts.
|
|
18
|
+
import { buildFrontmatter } from "./prompts-shared";
|
|
16
19
|
import { handleThreeSessionTddPrompts } from "./prompts-tdd";
|
|
20
|
+
export { buildFrontmatter };
|
|
17
21
|
|
|
18
22
|
export interface PromptsCommandOptions {
|
|
19
23
|
/** Feature name */
|
|
@@ -177,62 +181,3 @@ export async function promptsCommand(options: PromptsCommandOptions): Promise<st
|
|
|
177
181
|
|
|
178
182
|
return processedStories;
|
|
179
183
|
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Build YAML frontmatter for a story prompt.
|
|
183
|
-
*
|
|
184
|
-
* Uses actual token counts from BuiltContext elements (computed by context builder
|
|
185
|
-
* using CHARS_PER_TOKEN=3) rather than re-estimating independently.
|
|
186
|
-
*
|
|
187
|
-
* @param story - User story
|
|
188
|
-
* @param ctx - Pipeline context after running prompt assembly
|
|
189
|
-
* @param role - Optional role for three-session TDD (test-writer, implementer, verifier)
|
|
190
|
-
* @returns YAML frontmatter string (without delimiters)
|
|
191
|
-
*/
|
|
192
|
-
export function buildFrontmatter(story: UserStory, ctx: PipelineContext, role?: string): string {
|
|
193
|
-
const lines: string[] = [];
|
|
194
|
-
|
|
195
|
-
lines.push(`storyId: ${story.id}`);
|
|
196
|
-
lines.push(`title: "${story.title}"`);
|
|
197
|
-
lines.push(`testStrategy: ${ctx.routing.testStrategy}`);
|
|
198
|
-
lines.push(`modelTier: ${ctx.routing.modelTier}`);
|
|
199
|
-
|
|
200
|
-
if (role) {
|
|
201
|
-
lines.push(`role: ${role}`);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Use actual token counts from BuiltContext if available
|
|
205
|
-
const builtContext = ctx.builtContext;
|
|
206
|
-
const contextTokens = builtContext?.totalTokens ?? 0;
|
|
207
|
-
const promptTokens = ctx.prompt ? Math.ceil(ctx.prompt.length / 3) : 0;
|
|
208
|
-
|
|
209
|
-
lines.push(`contextTokens: ${contextTokens}`);
|
|
210
|
-
lines.push(`promptTokens: ${promptTokens}`);
|
|
211
|
-
|
|
212
|
-
// Dependencies
|
|
213
|
-
if (story.dependencies && story.dependencies.length > 0) {
|
|
214
|
-
lines.push(`dependencies: [${story.dependencies.join(", ")}]`);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Context elements breakdown from actual BuiltContext
|
|
218
|
-
lines.push("contextElements:");
|
|
219
|
-
|
|
220
|
-
if (builtContext) {
|
|
221
|
-
for (const element of builtContext.elements) {
|
|
222
|
-
lines.push(` - type: ${element.type}`);
|
|
223
|
-
if (element.storyId) {
|
|
224
|
-
lines.push(` storyId: ${element.storyId}`);
|
|
225
|
-
}
|
|
226
|
-
if (element.filePath) {
|
|
227
|
-
lines.push(` filePath: ${element.filePath}`);
|
|
228
|
-
}
|
|
229
|
-
lines.push(` tokens: ${element.tokens}`);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (builtContext?.truncated) {
|
|
234
|
-
lines.push("truncated: true");
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return `${lines.join("\n")}\n`;
|
|
238
|
-
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Prompts Utilities
|
|
3
|
+
*
|
|
4
|
+
* Functions shared between prompts-main and prompts-tdd to avoid circular imports.
|
|
5
|
+
* Both modules need buildFrontmatter; keeping it here breaks the cycle:
|
|
6
|
+
* prompts-main → prompts-tdd (was circular)
|
|
7
|
+
* now both → prompts-shared
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PipelineContext } from "../pipeline";
|
|
11
|
+
import type { UserStory } from "../prd";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build YAML frontmatter for a prompt file.
|
|
15
|
+
*
|
|
16
|
+
* Token counts use actual BuiltContext values (computed during pipeline execution,
|
|
17
|
+
* using CHARS_PER_TOKEN=3) rather than re-estimating independently.
|
|
18
|
+
*
|
|
19
|
+
* @param story - User story
|
|
20
|
+
* @param ctx - Pipeline context after running prompt assembly
|
|
21
|
+
* @param role - Optional role for three-session TDD (test-writer, implementer, verifier)
|
|
22
|
+
* @returns YAML frontmatter string (without delimiters)
|
|
23
|
+
*/
|
|
24
|
+
export function buildFrontmatter(story: UserStory, ctx: PipelineContext, role?: string): string {
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
|
|
27
|
+
lines.push(`storyId: ${story.id}`);
|
|
28
|
+
lines.push(`title: "${story.title}"`);
|
|
29
|
+
lines.push(`testStrategy: ${ctx.routing.testStrategy}`);
|
|
30
|
+
lines.push(`modelTier: ${ctx.routing.modelTier}`);
|
|
31
|
+
|
|
32
|
+
if (role) {
|
|
33
|
+
lines.push(`role: ${role}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Use actual token counts from BuiltContext if available
|
|
37
|
+
const builtContext = ctx.builtContext;
|
|
38
|
+
const contextTokens = builtContext?.totalTokens ?? 0;
|
|
39
|
+
const promptTokens = ctx.prompt ? Math.ceil(ctx.prompt.length / 3) : 0;
|
|
40
|
+
|
|
41
|
+
lines.push(`contextTokens: ${contextTokens}`);
|
|
42
|
+
lines.push(`promptTokens: ${promptTokens}`);
|
|
43
|
+
|
|
44
|
+
// Dependencies
|
|
45
|
+
if (story.dependencies && story.dependencies.length > 0) {
|
|
46
|
+
lines.push(`dependencies: [${story.dependencies.join(", ")}]`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Context elements breakdown from actual BuiltContext
|
|
50
|
+
lines.push("contextElements:");
|
|
51
|
+
|
|
52
|
+
if (builtContext) {
|
|
53
|
+
for (const element of builtContext.elements) {
|
|
54
|
+
lines.push(` - type: ${element.type}`);
|
|
55
|
+
if (element.storyId) {
|
|
56
|
+
lines.push(` storyId: ${element.storyId}`);
|
|
57
|
+
}
|
|
58
|
+
if (element.filePath) {
|
|
59
|
+
lines.push(` filePath: ${element.filePath}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push(` tokens: ${element.tokens}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (builtContext?.truncated) {
|
|
66
|
+
lines.push("truncated: true");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return `${lines.join("\n")}\n`;
|
|
70
|
+
}
|
package/src/cli/prompts-tdd.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { getLogger } from "../logger";
|
|
|
9
9
|
import type { PipelineContext } from "../pipeline";
|
|
10
10
|
import type { UserStory } from "../prd";
|
|
11
11
|
import { PromptBuilder } from "../prompts";
|
|
12
|
-
import { buildFrontmatter } from "./prompts-
|
|
12
|
+
import { buildFrontmatter } from "./prompts-shared";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Handle three-session TDD prompts by building separate prompts for each role.
|
package/src/config/merge.ts
CHANGED
|
@@ -55,6 +55,24 @@ export function mergePackageConfig(root: NaxConfig, packageOverride: Partial<Nax
|
|
|
55
55
|
...packageOverride.review,
|
|
56
56
|
commands: {
|
|
57
57
|
...root.review.commands,
|
|
58
|
+
// PKG-006: Bridge quality.commands → review.commands for per-package overrides.
|
|
59
|
+
// Users naturally put per-package commands in quality.commands (the intuitive
|
|
60
|
+
// place), but the review runner reads review.commands. Bridge them here so
|
|
61
|
+
// packages don't need to define the same commands in two places.
|
|
62
|
+
// Explicit review.commands still take precedence (applied after).
|
|
63
|
+
...(packageOverride.quality?.commands?.lint !== undefined && {
|
|
64
|
+
lint: packageOverride.quality.commands.lint,
|
|
65
|
+
}),
|
|
66
|
+
...(packageOverride.quality?.commands?.lintFix !== undefined && {
|
|
67
|
+
lintFix: packageOverride.quality.commands.lintFix,
|
|
68
|
+
}),
|
|
69
|
+
...(packageOverride.quality?.commands?.typecheck !== undefined && {
|
|
70
|
+
typecheck: packageOverride.quality.commands.typecheck,
|
|
71
|
+
}),
|
|
72
|
+
...(packageOverride.quality?.commands?.test !== undefined && {
|
|
73
|
+
test: packageOverride.quality.commands.test,
|
|
74
|
+
}),
|
|
75
|
+
// Explicit review.commands override bridged quality values
|
|
58
76
|
...packageOverride.review?.commands,
|
|
59
77
|
},
|
|
60
78
|
},
|
|
@@ -40,31 +40,32 @@ export function resolveTestStrategy(raw: string | undefined): TestStrategy {
|
|
|
40
40
|
|
|
41
41
|
export const COMPLEXITY_GUIDE = `## Complexity Classification Guide
|
|
42
42
|
|
|
43
|
-
- simple: ≤50 LOC, single-file change, purely additive, no new dependencies →
|
|
44
|
-
- medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → tdd-
|
|
43
|
+
- simple: ≤50 LOC, single-file change, purely additive, no new dependencies → tdd-simple
|
|
44
|
+
- medium: 50–200 LOC, 2–5 files, standard patterns, clear requirements → three-session-tdd-lite
|
|
45
45
|
- complex: 200–500 LOC, multiple modules, new abstractions or integrations → three-session-tdd
|
|
46
|
-
- expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd
|
|
46
|
+
- expert: 500+ LOC, architectural changes, cross-cutting concerns, high risk → three-session-tdd
|
|
47
47
|
|
|
48
48
|
### Security Override
|
|
49
49
|
|
|
50
50
|
Security-critical functions (authentication, cryptography, tokens, sessions, credentials,
|
|
51
|
-
password hashing, access control) must
|
|
52
|
-
regardless of LOC count. These require at minimum "tdd-simple" test strategy.`;
|
|
51
|
+
password hashing, access control) must use three-session-tdd regardless of complexity.`;
|
|
53
52
|
|
|
54
53
|
export const TEST_STRATEGY_GUIDE = `## Test Strategy Guide
|
|
55
54
|
|
|
56
|
-
-
|
|
57
|
-
- tdd-
|
|
58
|
-
- three-session-tdd: Complex stories. 3 sessions: (1) test-writer writes failing tests — no src/ changes allowed, (2) implementer makes them pass without modifying test files, (3) verifier confirms correctness.
|
|
59
|
-
-
|
|
55
|
+
- tdd-simple: Simple stories (≤50 LOC). Write failing tests first, then implement to pass them — all in one session.
|
|
56
|
+
- three-session-tdd-lite: Medium stories, or complex stories involving UI/CLI/integration. 3 sessions: (1) test-writer writes failing tests and may create minimal src/ stubs for imports, (2) implementer makes tests pass and may replace stubs, (3) verifier confirms correctness.
|
|
57
|
+
- three-session-tdd: Complex/expert stories or security-critical code. 3 sessions with strict isolation: (1) test-writer writes failing tests — no src/ changes allowed, (2) implementer makes them pass without modifying test files, (3) verifier confirms correctness.
|
|
58
|
+
- test-after: Only when explicitly configured (tddStrategy: "off"). Write tests after implementation. Not auto-assigned.`;
|
|
60
59
|
|
|
61
|
-
export const GROUPING_RULES = `##
|
|
60
|
+
export const GROUPING_RULES = `## Story Rules
|
|
62
61
|
|
|
62
|
+
- Every story must produce code changes verifiable by tests or review.
|
|
63
|
+
- NEVER create stories for analysis, planning, documentation, or migration plans.
|
|
64
|
+
Your analysis belongs in the "analysis" field, not in a story.
|
|
65
|
+
- NEVER create stories whose primary purpose is writing tests, achieving coverage
|
|
66
|
+
targets, or running validation/regression suites. Each story's testStrategy
|
|
67
|
+
handles test creation as part of implementation. Testing is a built-in pipeline
|
|
68
|
+
stage, not a user story. No exceptions.
|
|
63
69
|
- Combine small, related tasks into a single "simple" or "medium" story.
|
|
64
|
-
|
|
65
|
-
- Do NOT create standalone stories purely for test coverage or testing.
|
|
66
|
-
Each story's testStrategy already handles testing (tdd-simple writes tests first,
|
|
67
|
-
three-session-tdd uses separate test-writer session, test-after writes tests after).
|
|
68
|
-
Only create a dedicated test story for unique integration/E2E test logic that spans
|
|
69
|
-
multiple stories and cannot be covered by individual story test strategies.
|
|
70
|
+
Do NOT create separate stories for every single file or function unless complex.
|
|
70
71
|
- Aim for coherent units of value. Maximum recommended stories: 10-15 per feature.`;
|