@nathapp/nax 0.30.0 → 0.31.1
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/bin/nax.ts +25 -2
- package/dist/nax.js +211 -12
- package/package.json +1 -1
- package/src/cli/index.ts +2 -0
- package/src/cli/prompts.ts +185 -1
- package/src/pipeline/stages/execution.ts +40 -0
- package/src/prd/index.ts +4 -0
- package/src/precheck/checks-warnings.ts +21 -7
- package/src/precheck/index.ts +1 -1
- package/src/tdd/session-runner.ts +56 -1
package/bin/nax.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
planCommand,
|
|
55
55
|
pluginsListCommand,
|
|
56
56
|
promptsCommand,
|
|
57
|
+
promptsInitCommand,
|
|
57
58
|
runsListCommand,
|
|
58
59
|
runsShowCommand,
|
|
59
60
|
} from "../src/cli";
|
|
@@ -849,8 +850,10 @@ program
|
|
|
849
850
|
// ── prompts ──────────────────────────────────────────
|
|
850
851
|
program
|
|
851
852
|
.command("prompts")
|
|
852
|
-
.description("Assemble
|
|
853
|
-
.
|
|
853
|
+
.description("Assemble or initialize prompts")
|
|
854
|
+
.option("-f, --feature <name>", "Feature name (required unless using --init)")
|
|
855
|
+
.option("--init", "Initialize default prompt templates", false)
|
|
856
|
+
.option("--force", "Overwrite existing template files", false)
|
|
854
857
|
.option("--story <id>", "Filter to a single story ID (e.g., US-003)")
|
|
855
858
|
.option("--out <dir>", "Output directory for prompt files (default: stdout)")
|
|
856
859
|
.option("-d, --dir <path>", "Project directory", process.cwd())
|
|
@@ -864,6 +867,26 @@ program
|
|
|
864
867
|
process.exit(1);
|
|
865
868
|
}
|
|
866
869
|
|
|
870
|
+
// Handle --init command
|
|
871
|
+
if (options.init) {
|
|
872
|
+
try {
|
|
873
|
+
await promptsInitCommand({
|
|
874
|
+
workdir,
|
|
875
|
+
force: options.force,
|
|
876
|
+
});
|
|
877
|
+
} catch (err) {
|
|
878
|
+
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
|
879
|
+
process.exit(1);
|
|
880
|
+
}
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Handle regular prompts command (requires --feature)
|
|
885
|
+
if (!options.feature) {
|
|
886
|
+
console.error(chalk.red("Error: --feature is required (unless using --init)"));
|
|
887
|
+
process.exit(1);
|
|
888
|
+
}
|
|
889
|
+
|
|
867
890
|
// Load config
|
|
868
891
|
const config = await loadConfig(workdir);
|
|
869
892
|
|
package/dist/nax.js
CHANGED
|
@@ -19505,7 +19505,7 @@ var package_default;
|
|
|
19505
19505
|
var init_package = __esm(() => {
|
|
19506
19506
|
package_default = {
|
|
19507
19507
|
name: "@nathapp/nax",
|
|
19508
|
-
version: "0.
|
|
19508
|
+
version: "0.31.1",
|
|
19509
19509
|
description: "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
19510
19510
|
type: "module",
|
|
19511
19511
|
bin: {
|
|
@@ -19567,8 +19567,8 @@ var init_version = __esm(() => {
|
|
|
19567
19567
|
NAX_VERSION = package_default.version;
|
|
19568
19568
|
NAX_COMMIT = (() => {
|
|
19569
19569
|
try {
|
|
19570
|
-
if (/^[0-9a-f]{6,10}$/.test("
|
|
19571
|
-
return "
|
|
19570
|
+
if (/^[0-9a-f]{6,10}$/.test("ab045bf"))
|
|
19571
|
+
return "ab045bf";
|
|
19572
19572
|
} catch {}
|
|
19573
19573
|
try {
|
|
19574
19574
|
const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
|
|
@@ -19621,6 +19621,11 @@ async function loadPRD(path) {
|
|
|
19621
19621
|
story.escalations = story.escalations ?? [];
|
|
19622
19622
|
story.dependencies = story.dependencies ?? [];
|
|
19623
19623
|
story.tags = story.tags ?? [];
|
|
19624
|
+
const rawStatus = story.status;
|
|
19625
|
+
if (rawStatus === "open")
|
|
19626
|
+
story.status = "pending";
|
|
19627
|
+
if (rawStatus === "done")
|
|
19628
|
+
story.status = "passed";
|
|
19624
19629
|
story.status = story.status ?? "pending";
|
|
19625
19630
|
story.acceptanceCriteria = story.acceptanceCriteria ?? [];
|
|
19626
19631
|
story.storyPoints = story.storyPoints ?? 1;
|
|
@@ -23980,7 +23985,7 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
|
|
|
23980
23985
|
prompt = await PromptBuilder.for("implementer", { variant: lite ? "lite" : "standard" }).withLoader(workdir, config2).story(story).context(contextMarkdown).build();
|
|
23981
23986
|
break;
|
|
23982
23987
|
case "verifier":
|
|
23983
|
-
prompt = await PromptBuilder.for("verifier").withLoader(workdir, config2).story(story).build();
|
|
23988
|
+
prompt = await PromptBuilder.for("verifier").withLoader(workdir, config2).story(story).context(contextMarkdown).build();
|
|
23984
23989
|
break;
|
|
23985
23990
|
}
|
|
23986
23991
|
const logger = getLogger();
|
|
@@ -23996,6 +24001,7 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
|
|
|
23996
24001
|
if (!result.success && result.pid) {
|
|
23997
24002
|
await cleanupProcessTree(result.pid);
|
|
23998
24003
|
}
|
|
24004
|
+
await autoCommitIfDirty(workdir, role, story.id);
|
|
23999
24005
|
let isolation;
|
|
24000
24006
|
if (!skipIsolation) {
|
|
24001
24007
|
if (role === "test-writer") {
|
|
@@ -24042,6 +24048,38 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
|
|
|
24042
24048
|
estimatedCost: result.estimatedCost
|
|
24043
24049
|
};
|
|
24044
24050
|
}
|
|
24051
|
+
async function autoCommitIfDirty(workdir, role, storyId) {
|
|
24052
|
+
const logger = getLogger();
|
|
24053
|
+
try {
|
|
24054
|
+
const statusProc = Bun.spawn(["git", "status", "--porcelain"], {
|
|
24055
|
+
cwd: workdir,
|
|
24056
|
+
stdout: "pipe",
|
|
24057
|
+
stderr: "pipe"
|
|
24058
|
+
});
|
|
24059
|
+
const statusOutput = await new Response(statusProc.stdout).text();
|
|
24060
|
+
await statusProc.exited;
|
|
24061
|
+
if (!statusOutput.trim())
|
|
24062
|
+
return;
|
|
24063
|
+
logger.warn("tdd", `Agent did not commit after ${role} session \u2014 auto-committing`, {
|
|
24064
|
+
role,
|
|
24065
|
+
storyId,
|
|
24066
|
+
dirtyFiles: statusOutput.trim().split(`
|
|
24067
|
+
`).length
|
|
24068
|
+
});
|
|
24069
|
+
const addProc = Bun.spawn(["git", "add", "-A"], {
|
|
24070
|
+
cwd: workdir,
|
|
24071
|
+
stdout: "pipe",
|
|
24072
|
+
stderr: "pipe"
|
|
24073
|
+
});
|
|
24074
|
+
await addProc.exited;
|
|
24075
|
+
const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
|
|
24076
|
+
cwd: workdir,
|
|
24077
|
+
stdout: "pipe",
|
|
24078
|
+
stderr: "pipe"
|
|
24079
|
+
});
|
|
24080
|
+
await commitProc.exited;
|
|
24081
|
+
} catch {}
|
|
24082
|
+
}
|
|
24045
24083
|
var init_session_runner = __esm(() => {
|
|
24046
24084
|
init_config();
|
|
24047
24085
|
init_logger2();
|
|
@@ -24492,6 +24530,34 @@ function routeTddFailure(failureCategory, isLiteMode, ctx, reviewReason) {
|
|
|
24492
24530
|
reason: reviewReason || "Three-session TDD requires review"
|
|
24493
24531
|
};
|
|
24494
24532
|
}
|
|
24533
|
+
async function autoCommitIfDirty2(workdir, role, storyId) {
|
|
24534
|
+
try {
|
|
24535
|
+
const statusProc = Bun.spawn(["git", "status", "--porcelain"], {
|
|
24536
|
+
cwd: workdir,
|
|
24537
|
+
stdout: "pipe",
|
|
24538
|
+
stderr: "pipe"
|
|
24539
|
+
});
|
|
24540
|
+
const statusOutput = await new Response(statusProc.stdout).text();
|
|
24541
|
+
await statusProc.exited;
|
|
24542
|
+
if (!statusOutput.trim())
|
|
24543
|
+
return;
|
|
24544
|
+
const logger = getLogger();
|
|
24545
|
+
logger.warn("execution", `Agent did not commit after ${role} session \u2014 auto-committing`, {
|
|
24546
|
+
role,
|
|
24547
|
+
storyId,
|
|
24548
|
+
dirtyFiles: statusOutput.trim().split(`
|
|
24549
|
+
`).length
|
|
24550
|
+
});
|
|
24551
|
+
const addProc = Bun.spawn(["git", "add", "-A"], { cwd: workdir, stdout: "pipe", stderr: "pipe" });
|
|
24552
|
+
await addProc.exited;
|
|
24553
|
+
const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
|
|
24554
|
+
cwd: workdir,
|
|
24555
|
+
stdout: "pipe",
|
|
24556
|
+
stderr: "pipe"
|
|
24557
|
+
});
|
|
24558
|
+
await commitProc.exited;
|
|
24559
|
+
} catch {}
|
|
24560
|
+
}
|
|
24495
24561
|
var executionStage, _executionDeps;
|
|
24496
24562
|
var init_execution = __esm(() => {
|
|
24497
24563
|
init_agents();
|
|
@@ -24573,6 +24639,7 @@ var init_execution = __esm(() => {
|
|
|
24573
24639
|
dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions
|
|
24574
24640
|
});
|
|
24575
24641
|
ctx.agentResult = result;
|
|
24642
|
+
await autoCommitIfDirty2(ctx.workdir, "single-session", ctx.story.id);
|
|
24576
24643
|
const combinedOutput = (result.output ?? "") + (result.stderr ?? "");
|
|
24577
24644
|
if (_executionDeps.detectMergeConflict(combinedOutput) && ctx.interaction && isTriggerEnabled("merge-conflict", ctx.config)) {
|
|
24578
24645
|
const shouldProceed = await _executionDeps.checkMergeConflict({ featureName: ctx.prd.feature, storyId: ctx.story.id }, ctx.config, ctx.interaction);
|
|
@@ -26889,14 +26956,14 @@ async function checkPendingStories(prd) {
|
|
|
26889
26956
|
message: passed ? `${pendingStories.length} pending stories found` : "no pending stories to execute"
|
|
26890
26957
|
};
|
|
26891
26958
|
}
|
|
26892
|
-
async function checkOptionalCommands(config2) {
|
|
26959
|
+
async function checkOptionalCommands(config2, workdir) {
|
|
26893
26960
|
const missing = [];
|
|
26894
|
-
|
|
26961
|
+
const hasLint = config2.quality?.commands?.lint || config2.execution?.lintCommand || await hasPackageScript(workdir, "lint");
|
|
26962
|
+
const hasTypecheck = config2.quality?.commands?.typecheck || config2.execution?.typecheckCommand || await hasPackageScript(workdir, "typecheck");
|
|
26963
|
+
if (!hasLint)
|
|
26895
26964
|
missing.push("lint");
|
|
26896
|
-
|
|
26897
|
-
if (!config2.execution.typecheckCommand) {
|
|
26965
|
+
if (!hasTypecheck)
|
|
26898
26966
|
missing.push("typecheck");
|
|
26899
|
-
}
|
|
26900
26967
|
const passed = missing.length === 0;
|
|
26901
26968
|
return {
|
|
26902
26969
|
name: "optional-commands-configured",
|
|
@@ -26905,6 +26972,14 @@ async function checkOptionalCommands(config2) {
|
|
|
26905
26972
|
message: passed ? "All optional commands configured" : `Optional commands not configured: ${missing.join(", ")}`
|
|
26906
26973
|
};
|
|
26907
26974
|
}
|
|
26975
|
+
async function hasPackageScript(workdir, name) {
|
|
26976
|
+
try {
|
|
26977
|
+
const pkg = await Bun.file(`${workdir}/package.json`).json();
|
|
26978
|
+
return Boolean(pkg?.scripts?.[name]);
|
|
26979
|
+
} catch {
|
|
26980
|
+
return false;
|
|
26981
|
+
}
|
|
26982
|
+
}
|
|
26908
26983
|
async function checkGitignoreCoversNax(workdir) {
|
|
26909
26984
|
const gitignorePath = `${workdir}/.gitignore`;
|
|
26910
26985
|
const exists = existsSync23(gitignorePath);
|
|
@@ -27096,7 +27171,7 @@ async function runPrecheck(config2, prd, options) {
|
|
|
27096
27171
|
() => checkClaudeMdExists(workdir),
|
|
27097
27172
|
() => checkDiskSpace(),
|
|
27098
27173
|
() => checkPendingStories(prd),
|
|
27099
|
-
() => checkOptionalCommands(config2),
|
|
27174
|
+
() => checkOptionalCommands(config2, workdir),
|
|
27100
27175
|
() => checkGitignoreCoversNax(workdir),
|
|
27101
27176
|
() => checkPromptOverrideFiles(config2, workdir)
|
|
27102
27177
|
];
|
|
@@ -62763,11 +62838,119 @@ function buildFrontmatter(story, ctx, role) {
|
|
|
62763
62838
|
`)}
|
|
62764
62839
|
`;
|
|
62765
62840
|
}
|
|
62841
|
+
var TEMPLATE_ROLES = [
|
|
62842
|
+
{ file: "test-writer.md", role: "test-writer" },
|
|
62843
|
+
{ file: "implementer.md", role: "implementer", variant: "standard" },
|
|
62844
|
+
{ file: "verifier.md", role: "verifier" },
|
|
62845
|
+
{ file: "single-session.md", role: "single-session" }
|
|
62846
|
+
];
|
|
62847
|
+
var TEMPLATE_HEADER = `<!--
|
|
62848
|
+
This file controls the role-body section of the nax prompt for this role.
|
|
62849
|
+
Edit the content below to customize the task instructions given to the agent.
|
|
62850
|
+
|
|
62851
|
+
NON-OVERRIDABLE SECTIONS (always injected by nax, cannot be changed here):
|
|
62852
|
+
- Isolation rules (scope, file access boundaries)
|
|
62853
|
+
- Story context (acceptance criteria, description, dependencies)
|
|
62854
|
+
- Conventions (project coding standards)
|
|
62855
|
+
|
|
62856
|
+
To activate overrides, add to your nax/config.json:
|
|
62857
|
+
{ "prompts": { "overrides": { "<role>": "nax/templates/<role>.md" } } }
|
|
62858
|
+
-->
|
|
62859
|
+
|
|
62860
|
+
`;
|
|
62861
|
+
async function promptsInitCommand(options) {
|
|
62862
|
+
const { workdir, force = false } = options;
|
|
62863
|
+
const templatesDir = join18(workdir, "nax", "templates");
|
|
62864
|
+
mkdirSync3(templatesDir, { recursive: true });
|
|
62865
|
+
const existingFiles = TEMPLATE_ROLES.map((t) => t.file).filter((f) => existsSync15(join18(templatesDir, f)));
|
|
62866
|
+
if (existingFiles.length > 0 && !force) {
|
|
62867
|
+
console.warn(`[WARN] nax/templates/ already contains files: ${existingFiles.join(", ")}. No files overwritten.
|
|
62868
|
+
Pass --force to overwrite existing templates.`);
|
|
62869
|
+
return [];
|
|
62870
|
+
}
|
|
62871
|
+
const written = [];
|
|
62872
|
+
for (const template of TEMPLATE_ROLES) {
|
|
62873
|
+
const filePath = join18(templatesDir, template.file);
|
|
62874
|
+
const roleBody = template.role === "implementer" ? buildRoleTaskSection(template.role, template.variant) : buildRoleTaskSection(template.role);
|
|
62875
|
+
const content = TEMPLATE_HEADER + roleBody;
|
|
62876
|
+
await Bun.write(filePath, content);
|
|
62877
|
+
written.push(filePath);
|
|
62878
|
+
}
|
|
62879
|
+
console.log(`[OK] Written ${written.length} template files to nax/templates/:`);
|
|
62880
|
+
for (const filePath of written) {
|
|
62881
|
+
console.log(` - ${filePath.replace(`${workdir}/`, "")}`);
|
|
62882
|
+
}
|
|
62883
|
+
await autoWirePromptsConfig(workdir);
|
|
62884
|
+
return written;
|
|
62885
|
+
}
|
|
62886
|
+
async function autoWirePromptsConfig(workdir) {
|
|
62887
|
+
const configPath = join18(workdir, "nax.config.json");
|
|
62888
|
+
if (!existsSync15(configPath)) {
|
|
62889
|
+
const exampleConfig = JSON.stringify({
|
|
62890
|
+
prompts: {
|
|
62891
|
+
overrides: {
|
|
62892
|
+
"test-writer": "nax/templates/test-writer.md",
|
|
62893
|
+
implementer: "nax/templates/implementer.md",
|
|
62894
|
+
verifier: "nax/templates/verifier.md",
|
|
62895
|
+
"single-session": "nax/templates/single-session.md"
|
|
62896
|
+
}
|
|
62897
|
+
}
|
|
62898
|
+
}, null, 2);
|
|
62899
|
+
console.log(`
|
|
62900
|
+
No nax.config.json found. To activate overrides, create nax/config.json with:
|
|
62901
|
+
${exampleConfig}`);
|
|
62902
|
+
return;
|
|
62903
|
+
}
|
|
62904
|
+
const configFile = Bun.file(configPath);
|
|
62905
|
+
const configContent = await configFile.text();
|
|
62906
|
+
const config2 = JSON.parse(configContent);
|
|
62907
|
+
if (config2.prompts?.overrides && Object.keys(config2.prompts.overrides).length > 0) {
|
|
62908
|
+
console.log(`[INFO] prompts.overrides already configured in nax.config.json. Skipping auto-wiring.
|
|
62909
|
+
` + " To reset overrides, remove the prompts.overrides section and re-run this command.");
|
|
62910
|
+
return;
|
|
62911
|
+
}
|
|
62912
|
+
const overrides = {
|
|
62913
|
+
"test-writer": "nax/templates/test-writer.md",
|
|
62914
|
+
implementer: "nax/templates/implementer.md",
|
|
62915
|
+
verifier: "nax/templates/verifier.md",
|
|
62916
|
+
"single-session": "nax/templates/single-session.md"
|
|
62917
|
+
};
|
|
62918
|
+
if (!config2.prompts) {
|
|
62919
|
+
config2.prompts = {};
|
|
62920
|
+
}
|
|
62921
|
+
config2.prompts.overrides = overrides;
|
|
62922
|
+
const updatedConfig = formatConfigJson(config2);
|
|
62923
|
+
await Bun.write(configPath, updatedConfig);
|
|
62924
|
+
console.log("[OK] Auto-wired prompts.overrides in nax.config.json");
|
|
62925
|
+
}
|
|
62926
|
+
function formatConfigJson(config2) {
|
|
62927
|
+
const lines = ["{"];
|
|
62928
|
+
const keys = Object.keys(config2);
|
|
62929
|
+
for (let i = 0;i < keys.length; i++) {
|
|
62930
|
+
const key = keys[i];
|
|
62931
|
+
const value = config2[key];
|
|
62932
|
+
const isLast = i === keys.length - 1;
|
|
62933
|
+
if (key === "prompts" && typeof value === "object" && value !== null) {
|
|
62934
|
+
const promptsObj = value;
|
|
62935
|
+
if (promptsObj.overrides) {
|
|
62936
|
+
const overridesJson = JSON.stringify(promptsObj.overrides);
|
|
62937
|
+
lines.push(` "${key}": { "overrides": ${overridesJson} }${isLast ? "" : ","}`);
|
|
62938
|
+
} else {
|
|
62939
|
+
lines.push(` "${key}": ${JSON.stringify(value)}${isLast ? "" : ","}`);
|
|
62940
|
+
}
|
|
62941
|
+
} else {
|
|
62942
|
+
lines.push(` "${key}": ${JSON.stringify(value)}${isLast ? "" : ","}`);
|
|
62943
|
+
}
|
|
62944
|
+
}
|
|
62945
|
+
lines.push("}");
|
|
62946
|
+
return lines.join(`
|
|
62947
|
+
`);
|
|
62948
|
+
}
|
|
62766
62949
|
async function handleThreeSessionTddPrompts(story, ctx, outputDir, logger) {
|
|
62767
62950
|
const [testWriterPrompt, implementerPrompt, verifierPrompt] = await Promise.all([
|
|
62768
62951
|
PromptBuilder.for("test-writer", { isolation: "strict" }).withLoader(ctx.workdir, ctx.config).story(story).context(ctx.contextMarkdown).build(),
|
|
62769
62952
|
PromptBuilder.for("implementer", { variant: "standard" }).withLoader(ctx.workdir, ctx.config).story(story).context(ctx.contextMarkdown).build(),
|
|
62770
|
-
PromptBuilder.for("verifier").withLoader(ctx.workdir, ctx.config).story(story).build()
|
|
62953
|
+
PromptBuilder.for("verifier").withLoader(ctx.workdir, ctx.config).story(story).context(ctx.contextMarkdown).build()
|
|
62771
62954
|
]);
|
|
62772
62955
|
const sessions = [
|
|
62773
62956
|
{ role: "test-writer", prompt: testWriterPrompt },
|
|
@@ -72689,7 +72872,7 @@ program2.command("accept").description("Override failed acceptance criteria").re
|
|
|
72689
72872
|
process.exit(1);
|
|
72690
72873
|
}
|
|
72691
72874
|
});
|
|
72692
|
-
program2.command("prompts").description("Assemble
|
|
72875
|
+
program2.command("prompts").description("Assemble or initialize prompts").option("-f, --feature <name>", "Feature name (required unless using --init)").option("--init", "Initialize default prompt templates", false).option("--force", "Overwrite existing template files", false).option("--story <id>", "Filter to a single story ID (e.g., US-003)").option("--out <dir>", "Output directory for prompt files (default: stdout)").option("-d, --dir <path>", "Project directory", process.cwd()).action(async (options) => {
|
|
72693
72876
|
let workdir;
|
|
72694
72877
|
try {
|
|
72695
72878
|
workdir = validateDirectory(options.dir);
|
|
@@ -72697,6 +72880,22 @@ program2.command("prompts").description("Assemble prompts for stories without ex
|
|
|
72697
72880
|
console.error(source_default.red(`Invalid directory: ${err.message}`));
|
|
72698
72881
|
process.exit(1);
|
|
72699
72882
|
}
|
|
72883
|
+
if (options.init) {
|
|
72884
|
+
try {
|
|
72885
|
+
await promptsInitCommand({
|
|
72886
|
+
workdir,
|
|
72887
|
+
force: options.force
|
|
72888
|
+
});
|
|
72889
|
+
} catch (err) {
|
|
72890
|
+
console.error(source_default.red(`Error: ${err.message}`));
|
|
72891
|
+
process.exit(1);
|
|
72892
|
+
}
|
|
72893
|
+
return;
|
|
72894
|
+
}
|
|
72895
|
+
if (!options.feature) {
|
|
72896
|
+
console.error(source_default.red("Error: --feature is required (unless using --init)"));
|
|
72897
|
+
process.exit(1);
|
|
72898
|
+
}
|
|
72700
72899
|
const config2 = await loadConfig(workdir);
|
|
72701
72900
|
try {
|
|
72702
72901
|
const processedStories = await promptsCommand({
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -20,7 +20,9 @@ export {
|
|
|
20
20
|
} from "./runs";
|
|
21
21
|
export {
|
|
22
22
|
promptsCommand,
|
|
23
|
+
promptsInitCommand,
|
|
23
24
|
type PromptsCommandOptions,
|
|
25
|
+
type PromptsInitCommandOptions,
|
|
24
26
|
} from "./prompts";
|
|
25
27
|
export { initCommand, type InitOptions } from "./init";
|
|
26
28
|
export { pluginsListCommand } from "./plugins";
|
package/src/cli/prompts.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { constitutionStage, contextStage, promptStage, routingStage } from "../p
|
|
|
18
18
|
import type { UserStory } from "../prd";
|
|
19
19
|
import { loadPRD } from "../prd";
|
|
20
20
|
import { PromptBuilder } from "../prompts";
|
|
21
|
+
import { buildRoleTaskSection } from "../prompts/sections/role-task";
|
|
21
22
|
|
|
22
23
|
export interface PromptsCommandOptions {
|
|
23
24
|
/** Feature name */
|
|
@@ -240,6 +241,189 @@ function buildFrontmatter(story: UserStory, ctx: PipelineContext, role?: string)
|
|
|
240
241
|
return `${lines.join("\n")}\n`;
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
export interface PromptsInitCommandOptions {
|
|
245
|
+
/** Working directory (project root) */
|
|
246
|
+
workdir: string;
|
|
247
|
+
/** Overwrite existing files if true */
|
|
248
|
+
force?: boolean;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const TEMPLATE_ROLES = [
|
|
252
|
+
{ file: "test-writer.md", role: "test-writer" as const },
|
|
253
|
+
{ file: "implementer.md", role: "implementer" as const, variant: "standard" as const },
|
|
254
|
+
{ file: "verifier.md", role: "verifier" as const },
|
|
255
|
+
{ file: "single-session.md", role: "single-session" as const },
|
|
256
|
+
] as const;
|
|
257
|
+
|
|
258
|
+
const TEMPLATE_HEADER = `<!--
|
|
259
|
+
This file controls the role-body section of the nax prompt for this role.
|
|
260
|
+
Edit the content below to customize the task instructions given to the agent.
|
|
261
|
+
|
|
262
|
+
NON-OVERRIDABLE SECTIONS (always injected by nax, cannot be changed here):
|
|
263
|
+
- Isolation rules (scope, file access boundaries)
|
|
264
|
+
- Story context (acceptance criteria, description, dependencies)
|
|
265
|
+
- Conventions (project coding standards)
|
|
266
|
+
|
|
267
|
+
To activate overrides, add to your nax/config.json:
|
|
268
|
+
{ "prompts": { "overrides": { "<role>": "nax/templates/<role>.md" } } }
|
|
269
|
+
-->
|
|
270
|
+
|
|
271
|
+
`;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Execute the `nax prompts --init` command.
|
|
275
|
+
*
|
|
276
|
+
* Creates nax/templates/ and writes 4 default role-body template files.
|
|
277
|
+
* Auto-wires prompts.overrides in nax.config.json if the file exists and overrides are not already set.
|
|
278
|
+
* Returns the list of file paths written. Returns empty array if files
|
|
279
|
+
* already exist and force is not set.
|
|
280
|
+
*
|
|
281
|
+
* @param options - Command options
|
|
282
|
+
* @returns Array of file paths written
|
|
283
|
+
*/
|
|
284
|
+
export async function promptsInitCommand(options: PromptsInitCommandOptions): Promise<string[]> {
|
|
285
|
+
const { workdir, force = false } = options;
|
|
286
|
+
const templatesDir = join(workdir, "nax", "templates");
|
|
287
|
+
|
|
288
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
289
|
+
|
|
290
|
+
// Check for existing files
|
|
291
|
+
const existingFiles = TEMPLATE_ROLES.map((t) => t.file).filter((f) => existsSync(join(templatesDir, f)));
|
|
292
|
+
|
|
293
|
+
if (existingFiles.length > 0 && !force) {
|
|
294
|
+
console.warn(
|
|
295
|
+
`[WARN] nax/templates/ already contains files: ${existingFiles.join(", ")}. No files overwritten.\n Pass --force to overwrite existing templates.`,
|
|
296
|
+
);
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const written: string[] = [];
|
|
301
|
+
|
|
302
|
+
for (const template of TEMPLATE_ROLES) {
|
|
303
|
+
const filePath = join(templatesDir, template.file);
|
|
304
|
+
const roleBody =
|
|
305
|
+
template.role === "implementer"
|
|
306
|
+
? buildRoleTaskSection(template.role, template.variant)
|
|
307
|
+
: buildRoleTaskSection(template.role);
|
|
308
|
+
const content = TEMPLATE_HEADER + roleBody;
|
|
309
|
+
await Bun.write(filePath, content);
|
|
310
|
+
written.push(filePath);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
console.log(`[OK] Written ${written.length} template files to nax/templates/:`);
|
|
314
|
+
for (const filePath of written) {
|
|
315
|
+
console.log(` - ${filePath.replace(`${workdir}/`, "")}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Auto-wire prompts.overrides in nax.config.json
|
|
319
|
+
await autoWirePromptsConfig(workdir);
|
|
320
|
+
|
|
321
|
+
return written;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Auto-wire prompts.overrides in nax.config.json after template init.
|
|
326
|
+
*
|
|
327
|
+
* If nax.config.json exists and prompts.overrides is not already set,
|
|
328
|
+
* add the override paths. If overrides are already set, print a note.
|
|
329
|
+
* If nax.config.json doesn't exist, print manual instructions.
|
|
330
|
+
*
|
|
331
|
+
* @param workdir - Project working directory
|
|
332
|
+
*/
|
|
333
|
+
async function autoWirePromptsConfig(workdir: string): Promise<void> {
|
|
334
|
+
const configPath = join(workdir, "nax.config.json");
|
|
335
|
+
|
|
336
|
+
// If config file doesn't exist, print manual instructions
|
|
337
|
+
if (!existsSync(configPath)) {
|
|
338
|
+
const exampleConfig = JSON.stringify(
|
|
339
|
+
{
|
|
340
|
+
prompts: {
|
|
341
|
+
overrides: {
|
|
342
|
+
"test-writer": "nax/templates/test-writer.md",
|
|
343
|
+
implementer: "nax/templates/implementer.md",
|
|
344
|
+
verifier: "nax/templates/verifier.md",
|
|
345
|
+
"single-session": "nax/templates/single-session.md",
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
null,
|
|
350
|
+
2,
|
|
351
|
+
);
|
|
352
|
+
console.log(`\nNo nax.config.json found. To activate overrides, create nax/config.json with:\n${exampleConfig}`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Read existing config
|
|
357
|
+
const configFile = Bun.file(configPath);
|
|
358
|
+
const configContent = await configFile.text();
|
|
359
|
+
const config = JSON.parse(configContent);
|
|
360
|
+
|
|
361
|
+
// Check if prompts.overrides is already set
|
|
362
|
+
if (config.prompts?.overrides && Object.keys(config.prompts.overrides).length > 0) {
|
|
363
|
+
console.log(
|
|
364
|
+
"[INFO] prompts.overrides already configured in nax.config.json. Skipping auto-wiring.\n" +
|
|
365
|
+
" To reset overrides, remove the prompts.overrides section and re-run this command.",
|
|
366
|
+
);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Build the override paths
|
|
371
|
+
const overrides = {
|
|
372
|
+
"test-writer": "nax/templates/test-writer.md",
|
|
373
|
+
implementer: "nax/templates/implementer.md",
|
|
374
|
+
verifier: "nax/templates/verifier.md",
|
|
375
|
+
"single-session": "nax/templates/single-session.md",
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// Add or update prompts section
|
|
379
|
+
if (!config.prompts) {
|
|
380
|
+
config.prompts = {};
|
|
381
|
+
}
|
|
382
|
+
config.prompts.overrides = overrides;
|
|
383
|
+
|
|
384
|
+
// Write config with custom formatting that avoids 4-space indentation
|
|
385
|
+
// by putting the overrides object on a single line
|
|
386
|
+
const updatedConfig = formatConfigJson(config);
|
|
387
|
+
await Bun.write(configPath, updatedConfig);
|
|
388
|
+
|
|
389
|
+
console.log("[OK] Auto-wired prompts.overrides in nax.config.json");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Format config JSON with 2-space indentation, keeping overrides object inline.
|
|
394
|
+
*
|
|
395
|
+
* This avoids 4-space indentation by putting the overrides object on the same line.
|
|
396
|
+
*
|
|
397
|
+
* @param config - Configuration object
|
|
398
|
+
* @returns Formatted JSON string
|
|
399
|
+
*/
|
|
400
|
+
function formatConfigJson(config: Record<string, unknown>): string {
|
|
401
|
+
const lines: string[] = ["{"];
|
|
402
|
+
|
|
403
|
+
const keys = Object.keys(config);
|
|
404
|
+
for (let i = 0; i < keys.length; i++) {
|
|
405
|
+
const key = keys[i];
|
|
406
|
+
const value = config[key];
|
|
407
|
+
const isLast = i === keys.length - 1;
|
|
408
|
+
|
|
409
|
+
if (key === "prompts" && typeof value === "object" && value !== null) {
|
|
410
|
+
// Special handling for prompts object - keep overrides inline
|
|
411
|
+
const promptsObj = value as Record<string, unknown>;
|
|
412
|
+
if (promptsObj.overrides) {
|
|
413
|
+
const overridesJson = JSON.stringify(promptsObj.overrides);
|
|
414
|
+
lines.push(` "${key}": { "overrides": ${overridesJson} }${isLast ? "" : ","}`);
|
|
415
|
+
} else {
|
|
416
|
+
lines.push(` "${key}": ${JSON.stringify(value)}${isLast ? "" : ","}`);
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
lines.push(` "${key}": ${JSON.stringify(value)}${isLast ? "" : ","}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
lines.push("}");
|
|
424
|
+
return lines.join("\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
243
427
|
/**
|
|
244
428
|
* Handle three-session TDD prompts by building separate prompts for each role.
|
|
245
429
|
*
|
|
@@ -266,7 +450,7 @@ async function handleThreeSessionTddPrompts(
|
|
|
266
450
|
.story(story)
|
|
267
451
|
.context(ctx.contextMarkdown)
|
|
268
452
|
.build(),
|
|
269
|
-
PromptBuilder.for("verifier").withLoader(ctx.workdir, ctx.config).story(story).build(),
|
|
453
|
+
PromptBuilder.for("verifier").withLoader(ctx.workdir, ctx.config).story(story).context(ctx.contextMarkdown).build(),
|
|
270
454
|
]);
|
|
271
455
|
|
|
272
456
|
const sessions = [
|
|
@@ -199,6 +199,9 @@ export const executionStage: PipelineStage = {
|
|
|
199
199
|
|
|
200
200
|
ctx.agentResult = result;
|
|
201
201
|
|
|
202
|
+
// BUG-058: Auto-commit if agent left uncommitted changes (single-session/test-after)
|
|
203
|
+
await autoCommitIfDirty(ctx.workdir, "single-session", ctx.story.id);
|
|
204
|
+
|
|
202
205
|
// merge-conflict trigger: detect CONFLICT markers in agent output
|
|
203
206
|
const combinedOutput = (result.output ?? "") + (result.stderr ?? "");
|
|
204
207
|
if (
|
|
@@ -267,3 +270,40 @@ export const _executionDeps = {
|
|
|
267
270
|
isAmbiguousOutput,
|
|
268
271
|
checkStoryAmbiguity,
|
|
269
272
|
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* BUG-058: Auto-commit safety net for single-session/test-after.
|
|
276
|
+
* Mirrors the same function in tdd/session-runner.ts for three-session TDD.
|
|
277
|
+
*/
|
|
278
|
+
async function autoCommitIfDirty(workdir: string, role: string, storyId: string): Promise<void> {
|
|
279
|
+
try {
|
|
280
|
+
const statusProc = Bun.spawn(["git", "status", "--porcelain"], {
|
|
281
|
+
cwd: workdir,
|
|
282
|
+
stdout: "pipe",
|
|
283
|
+
stderr: "pipe",
|
|
284
|
+
});
|
|
285
|
+
const statusOutput = await new Response(statusProc.stdout).text();
|
|
286
|
+
await statusProc.exited;
|
|
287
|
+
|
|
288
|
+
if (!statusOutput.trim()) return;
|
|
289
|
+
|
|
290
|
+
const logger = getLogger();
|
|
291
|
+
logger.warn("execution", `Agent did not commit after ${role} session — auto-committing`, {
|
|
292
|
+
role,
|
|
293
|
+
storyId,
|
|
294
|
+
dirtyFiles: statusOutput.trim().split("\n").length,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const addProc = Bun.spawn(["git", "add", "-A"], { cwd: workdir, stdout: "pipe", stderr: "pipe" });
|
|
298
|
+
await addProc.exited;
|
|
299
|
+
|
|
300
|
+
const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
|
|
301
|
+
cwd: workdir,
|
|
302
|
+
stdout: "pipe",
|
|
303
|
+
stderr: "pipe",
|
|
304
|
+
});
|
|
305
|
+
await commitProc.exited;
|
|
306
|
+
} catch {
|
|
307
|
+
// Silently ignore — auto-commit is best-effort
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/prd/index.ts
CHANGED
|
@@ -49,6 +49,10 @@ export async function loadPRD(path: string): Promise<PRD> {
|
|
|
49
49
|
story.escalations = story.escalations ?? [];
|
|
50
50
|
story.dependencies = story.dependencies ?? [];
|
|
51
51
|
story.tags = story.tags ?? [];
|
|
52
|
+
// Normalize aliases: "open" → "pending", "done" → "passed"
|
|
53
|
+
const rawStatus = story.status as string;
|
|
54
|
+
if (rawStatus === "open") story.status = "pending";
|
|
55
|
+
if (rawStatus === "done") story.status = "passed";
|
|
52
56
|
story.status = story.status ?? "pending";
|
|
53
57
|
story.acceptanceCriteria = story.acceptanceCriteria ?? [];
|
|
54
58
|
story.storyPoints = story.storyPoints ?? 1;
|
|
@@ -90,15 +90,19 @@ export async function checkPendingStories(prd: PRD): Promise<Check> {
|
|
|
90
90
|
/**
|
|
91
91
|
* Check if optional commands are configured.
|
|
92
92
|
*/
|
|
93
|
-
export async function checkOptionalCommands(config: NaxConfig): Promise<Check> {
|
|
93
|
+
export async function checkOptionalCommands(config: NaxConfig, workdir: string): Promise<Check> {
|
|
94
94
|
const missing: string[] = [];
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
// Check quality.commands first, then execution config, then package.json fallback
|
|
97
|
+
const hasLint =
|
|
98
|
+
config.quality?.commands?.lint || config.execution?.lintCommand || (await hasPackageScript(workdir, "lint"));
|
|
99
|
+
const hasTypecheck =
|
|
100
|
+
config.quality?.commands?.typecheck ||
|
|
101
|
+
config.execution?.typecheckCommand ||
|
|
102
|
+
(await hasPackageScript(workdir, "typecheck"));
|
|
103
|
+
|
|
104
|
+
if (!hasLint) missing.push("lint");
|
|
105
|
+
if (!hasTypecheck) missing.push("typecheck");
|
|
102
106
|
|
|
103
107
|
const passed = missing.length === 0;
|
|
104
108
|
|
|
@@ -110,6 +114,16 @@ export async function checkOptionalCommands(config: NaxConfig): Promise<Check> {
|
|
|
110
114
|
};
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
/** Check if package.json has a script by name */
|
|
118
|
+
async function hasPackageScript(workdir: string, name: string): Promise<boolean> {
|
|
119
|
+
try {
|
|
120
|
+
const pkg = await Bun.file(`${workdir}/package.json`).json();
|
|
121
|
+
return Boolean(pkg?.scripts?.[name]);
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
/**
|
|
114
128
|
* Check if .gitignore covers nax runtime files.
|
|
115
129
|
* Patterns: nax.lock, runs/, test/tmp/
|
package/src/precheck/index.ts
CHANGED
|
@@ -141,7 +141,7 @@ export async function runPrecheck(
|
|
|
141
141
|
() => checkClaudeMdExists(workdir),
|
|
142
142
|
() => checkDiskSpace(),
|
|
143
143
|
() => checkPendingStories(prd),
|
|
144
|
-
() => checkOptionalCommands(config),
|
|
144
|
+
() => checkOptionalCommands(config, workdir),
|
|
145
145
|
() => checkGitignoreCoversNax(workdir),
|
|
146
146
|
() => checkPromptOverrideFiles(config, workdir),
|
|
147
147
|
];
|
|
@@ -103,7 +103,11 @@ export async function runTddSession(
|
|
|
103
103
|
.build();
|
|
104
104
|
break;
|
|
105
105
|
case "verifier":
|
|
106
|
-
prompt = await PromptBuilder.for("verifier")
|
|
106
|
+
prompt = await PromptBuilder.for("verifier")
|
|
107
|
+
.withLoader(workdir, config)
|
|
108
|
+
.story(story)
|
|
109
|
+
.context(contextMarkdown)
|
|
110
|
+
.build();
|
|
107
111
|
break;
|
|
108
112
|
}
|
|
109
113
|
|
|
@@ -125,6 +129,9 @@ export async function runTddSession(
|
|
|
125
129
|
await cleanupProcessTree(result.pid);
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
// BUG-058: Auto-commit if agent left uncommitted changes
|
|
133
|
+
await autoCommitIfDirty(workdir, role, story.id);
|
|
134
|
+
|
|
128
135
|
// Check isolation based on role and skipIsolation flag.
|
|
129
136
|
let isolation: IsolationCheck | undefined;
|
|
130
137
|
if (!skipIsolation) {
|
|
@@ -177,3 +184,51 @@ export async function runTddSession(
|
|
|
177
184
|
estimatedCost: result.estimatedCost,
|
|
178
185
|
};
|
|
179
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* BUG-058: Auto-commit safety net.
|
|
190
|
+
*
|
|
191
|
+
* If the agent left uncommitted changes, stage and commit them automatically.
|
|
192
|
+
* This prevents the review stage from failing with "uncommitted changes" errors.
|
|
193
|
+
* Only triggers when the agent forgot — if tree is clean, this is a no-op.
|
|
194
|
+
*/
|
|
195
|
+
async function autoCommitIfDirty(workdir: string, role: string, storyId: string): Promise<void> {
|
|
196
|
+
const logger = getLogger();
|
|
197
|
+
|
|
198
|
+
// Check if working tree is dirty
|
|
199
|
+
try {
|
|
200
|
+
const statusProc = Bun.spawn(["git", "status", "--porcelain"], {
|
|
201
|
+
cwd: workdir,
|
|
202
|
+
stdout: "pipe",
|
|
203
|
+
stderr: "pipe",
|
|
204
|
+
});
|
|
205
|
+
const statusOutput = await new Response(statusProc.stdout).text();
|
|
206
|
+
await statusProc.exited;
|
|
207
|
+
|
|
208
|
+
if (!statusOutput.trim()) return; // Clean tree, nothing to do
|
|
209
|
+
|
|
210
|
+
logger.warn("tdd", `Agent did not commit after ${role} session — auto-committing`, {
|
|
211
|
+
role,
|
|
212
|
+
storyId,
|
|
213
|
+
dirtyFiles: statusOutput.trim().split("\n").length,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Stage all changes
|
|
217
|
+
const addProc = Bun.spawn(["git", "add", "-A"], {
|
|
218
|
+
cwd: workdir,
|
|
219
|
+
stdout: "pipe",
|
|
220
|
+
stderr: "pipe",
|
|
221
|
+
});
|
|
222
|
+
await addProc.exited;
|
|
223
|
+
|
|
224
|
+
// Commit with descriptive message
|
|
225
|
+
const commitProc = Bun.spawn(["git", "commit", "-m", `chore(${storyId}): auto-commit after ${role} session`], {
|
|
226
|
+
cwd: workdir,
|
|
227
|
+
stdout: "pipe",
|
|
228
|
+
stderr: "pipe",
|
|
229
|
+
});
|
|
230
|
+
await commitProc.exited;
|
|
231
|
+
} catch {
|
|
232
|
+
// Silently ignore — auto-commit is best-effort
|
|
233
|
+
}
|
|
234
|
+
}
|