dev-harness-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +299 -0
- package/adapters/amazon-q/README.md +23 -0
- package/adapters/antigravity/README.md +22 -0
- package/adapters/claude-code/README.md +30 -0
- package/adapters/cline/README.md +23 -0
- package/adapters/codex/README.md +31 -0
- package/adapters/copilot/README.md +23 -0
- package/adapters/cursor/README.md +29 -0
- package/adapters/gemini/README.md +23 -0
- package/adapters/generic/README.md +40 -0
- package/adapters/hermes/README.md +31 -0
- package/adapters/hermes/SKILL.md +89 -0
- package/adapters/hermes/scripts/init.mjs +27 -0
- package/adapters/hermes/scripts/phase.mjs +27 -0
- package/adapters/hermes/scripts/validate.mjs +27 -0
- package/adapters/kilo-code/README.md +23 -0
- package/adapters/openclaw/README.md +22 -0
- package/adapters/pi/README.md +22 -0
- package/adapters/roo/README.md +23 -0
- package/adapters/windsurf/README.md +23 -0
- package/cli/commands/checkpoint.mjs +94 -0
- package/cli/commands/config.mjs +268 -0
- package/cli/commands/contract.mjs +155 -0
- package/cli/commands/detect-tool.mjs +112 -0
- package/cli/commands/init.mjs +351 -0
- package/cli/commands/learn.mjs +47 -0
- package/cli/commands/pause.mjs +34 -0
- package/cli/commands/phase.mjs +182 -0
- package/cli/commands/resume.mjs +33 -0
- package/cli/commands/rollback.mjs +261 -0
- package/cli/commands/set-mode.mjs +75 -0
- package/cli/commands/status.mjs +168 -0
- package/cli/commands/validate.mjs +118 -0
- package/cli/commands/worktree.mjs +298 -0
- package/cli/harness-dev.mjs +88 -0
- package/cli/lib/args.mjs +111 -0
- package/cli/lib/command-helpers.mjs +50 -0
- package/cli/lib/config-registry.mjs +329 -0
- package/cli/lib/constants.mjs +30 -0
- package/cli/lib/contract.mjs +306 -0
- package/cli/lib/detect-stack.mjs +235 -0
- package/cli/lib/errors.mjs +71 -0
- package/cli/lib/file-io.mjs +90 -0
- package/cli/lib/gates.mjs +492 -0
- package/cli/lib/git.mjs +144 -0
- package/cli/lib/help.mjs +246 -0
- package/cli/lib/modes.mjs +92 -0
- package/cli/lib/output.mjs +49 -0
- package/cli/lib/paths.mjs +75 -0
- package/cli/lib/phases.mjs +58 -0
- package/cli/lib/platform.mjs +78 -0
- package/cli/lib/progress.mjs +357 -0
- package/cli/lib/ralph-inner.mjs +314 -0
- package/cli/lib/ralph-outer.mjs +249 -0
- package/cli/lib/ralph-output.mjs +178 -0
- package/cli/lib/scaffold.mjs +431 -0
- package/cli/lib/schemas/stacks.json +477 -0
- package/cli/lib/state.mjs +333 -0
- package/cli/lib/templates.mjs +264 -0
- package/cli/lib/tool-registry.mjs +218 -0
- package/cli/lib/validate-schema.mjs +131 -0
- package/cli/lib/vars.mjs +114 -0
- package/package.json +50 -0
- package/schema/harness-config.schema.json +127 -0
- package/templates/AGENTS.md +63 -0
- package/templates/ci/github-actions.yml +78 -0
- package/templates/ci/gitlab-ci.yml +59 -0
- package/templates/docs/agents/evaluator.md +14 -0
- package/templates/docs/agents/generator.md +13 -0
- package/templates/docs/agents/planner.md +13 -0
- package/templates/docs/agents/simplifier.md +13 -0
- package/templates/docs/phases/build.md +41 -0
- package/templates/docs/phases/define.md +51 -0
- package/templates/docs/phases/plan.md +36 -0
- package/templates/docs/phases/review.md +42 -0
- package/templates/docs/phases/ship.md +43 -0
- package/templates/docs/phases/simplify.md +40 -0
- package/templates/docs/phases/verify.md +38 -0
- package/templates/evaluator-rubric.md +28 -0
- package/templates/init.ps1 +97 -0
- package/templates/init.sh +102 -0
- package/templates/sprint-contract.md +31 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contract — Sprint Contract negotiation (propose/review/status/escalate).
|
|
3
|
+
*
|
|
4
|
+
* Manages the generator-evaluator agreement loop.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* harness-dev contract propose [--scope "msg"] [--exclusions "msg"]
|
|
8
|
+
* harness-dev contract review [--agreed|--needs-revision]
|
|
9
|
+
* harness-dev contract status
|
|
10
|
+
* harness-dev contract escalate [--reason "msg"]
|
|
11
|
+
*/
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { die, CliError, EXIT } from '../lib/errors.mjs';
|
|
14
|
+
import { proposeContract, reviewContract, getContractStatus, escalateContract } from '../lib/contract.mjs';
|
|
15
|
+
|
|
16
|
+
const SUBCOMMANDS = ['propose', 'review', 'status', 'escalate'];
|
|
17
|
+
|
|
18
|
+
export default async function contractCommand(args) {
|
|
19
|
+
const json = !!(args.json || args.flags?.json);
|
|
20
|
+
const rawTarget = args.flags?.target;
|
|
21
|
+
const targetDir = (typeof rawTarget === 'string') ? resolve(rawTarget) : process.cwd();
|
|
22
|
+
const sub = args.subcommand;
|
|
23
|
+
|
|
24
|
+
if (!sub || !SUBCOMMANDS.includes(sub)) {
|
|
25
|
+
die(new CliError(`Usage: harness-dev contract ${SUBCOMMANDS.join('|')}`, EXIT.USAGE_ERROR), json);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── propose ──────────────────────────────────────────────────────────────
|
|
30
|
+
if (sub === 'propose') {
|
|
31
|
+
const scope = args.flags?.scope || args.positionals.join(' ');
|
|
32
|
+
const exclusions = args.flags?.exclusions || null;
|
|
33
|
+
const criteria = args.flags?.criteria ? args.flags.criteria.split('|') : null;
|
|
34
|
+
|
|
35
|
+
if (!scope) {
|
|
36
|
+
die(new CliError(
|
|
37
|
+
'Usage: harness-dev contract propose --scope "I will build X" [--exclusions "W"] [--criteria "test1|test2"]',
|
|
38
|
+
EXIT.USAGE_ERROR,
|
|
39
|
+
), json);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = proposeContract(targetDir, { scope, exclusions, criteria });
|
|
44
|
+
|
|
45
|
+
if (json) {
|
|
46
|
+
process.stdout.write(JSON.stringify({
|
|
47
|
+
command: 'contract',
|
|
48
|
+
subcommand: 'propose',
|
|
49
|
+
status: result.ok ? 'ok' : 'error',
|
|
50
|
+
message: result.ok ? 'Contract proposed. Evaluator review needed.' : result.error,
|
|
51
|
+
}) + '\n');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (result.ok) {
|
|
56
|
+
process.stdout.write('✓ Contract proposed. Run: harness-dev contract review\n');
|
|
57
|
+
} else {
|
|
58
|
+
process.stderr.write(`✗ ${result.error}\n`);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── review ───────────────────────────────────────────────────────────────
|
|
64
|
+
if (sub === 'review') {
|
|
65
|
+
const agreed = args.flags?.agreed === true || args.flags?.agreed === 'true';
|
|
66
|
+
const needsRevision = args.flags?.['needs-revision'] === true || args.flags?.['needs-revision'] === 'true';
|
|
67
|
+
const notes = args.flags?.notes || null;
|
|
68
|
+
|
|
69
|
+
if (!agreed && !needsRevision) {
|
|
70
|
+
die(new CliError(
|
|
71
|
+
'Usage: harness-dev contract review --agreed [--notes "msg"] OR --needs-revision [--notes "msg"]',
|
|
72
|
+
EXIT.USAGE_ERROR,
|
|
73
|
+
), json);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const decision = agreed ? 'agreed' : 'needs-revision';
|
|
78
|
+
const result = reviewContract(targetDir, decision, notes);
|
|
79
|
+
|
|
80
|
+
if (json) {
|
|
81
|
+
process.stdout.write(JSON.stringify({
|
|
82
|
+
command: 'contract',
|
|
83
|
+
subcommand: 'review',
|
|
84
|
+
status: result.ok ? 'ok' : 'error',
|
|
85
|
+
message: result.ok
|
|
86
|
+
? result.escalated
|
|
87
|
+
? 'Max negotiation rounds reached. Contract escalated to human.'
|
|
88
|
+
: `Contract ${decision}.`
|
|
89
|
+
: result.error,
|
|
90
|
+
escalated: result.escalated,
|
|
91
|
+
}) + '\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (result.ok) {
|
|
96
|
+
const msg = result.escalated
|
|
97
|
+
? '✓ Max negotiation rounds reached. Contract escalated to human.'
|
|
98
|
+
: `✓ Contract marked as "${decision}"`;
|
|
99
|
+
process.stdout.write(msg + '\n');
|
|
100
|
+
} else {
|
|
101
|
+
process.stderr.write(`✗ ${result.error}\n`);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── status ───────────────────────────────────────────────────────────────
|
|
107
|
+
if (sub === 'status') {
|
|
108
|
+
const { status, rounds } = getContractStatus(targetDir);
|
|
109
|
+
|
|
110
|
+
if (json) {
|
|
111
|
+
process.stdout.write(JSON.stringify({
|
|
112
|
+
command: 'contract',
|
|
113
|
+
subcommand: 'status',
|
|
114
|
+
status: status ? 'ok' : 'error',
|
|
115
|
+
contractStatus: status || 'not_found',
|
|
116
|
+
rounds,
|
|
117
|
+
message: status
|
|
118
|
+
? `Contract ${status} (round ${rounds}/5)`
|
|
119
|
+
: 'No sprint-contract.md found. Run: harness-dev contract propose',
|
|
120
|
+
}) + '\n');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (status) {
|
|
125
|
+
process.stdout.write(`Contract status: ${status} (round ${rounds}/5)\n`);
|
|
126
|
+
} else {
|
|
127
|
+
process.stdout.write('No sprint-contract.md found. Run: harness-dev contract propose\n');
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── escalate ─────────────────────────────────────────────────────────────
|
|
133
|
+
if (sub === 'escalate') {
|
|
134
|
+
const reason = args.flags?.reason || args.positionals.join(' ') || null;
|
|
135
|
+
|
|
136
|
+
const result = escalateContract(targetDir, reason);
|
|
137
|
+
|
|
138
|
+
if (json) {
|
|
139
|
+
process.stdout.write(JSON.stringify({
|
|
140
|
+
command: 'contract',
|
|
141
|
+
subcommand: 'escalate',
|
|
142
|
+
status: result.ok ? 'ok' : 'error',
|
|
143
|
+
message: result.ok ? 'Contract escalated to human.' : result.error,
|
|
144
|
+
}) + '\n');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (result.ok) {
|
|
149
|
+
process.stdout.write('✓ Contract escalated to human.\n');
|
|
150
|
+
} else {
|
|
151
|
+
process.stderr.write(`✗ ${result.error}\n`);
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detect-tool — Detect which agent coding tools are configured in a project.
|
|
3
|
+
*
|
|
4
|
+
* Scans the project directory for tool-specific files:
|
|
5
|
+
* - CLAUDE.md → claude-code
|
|
6
|
+
* - .cursorrules → cursor
|
|
7
|
+
* - AGENTS.md → generic (read by Codex, Aider, Continue, OpenCode)
|
|
8
|
+
* - .aider.conf* → aider
|
|
9
|
+
* - continue.json → continue
|
|
10
|
+
* - hermes/ skill → hermes
|
|
11
|
+
*
|
|
12
|
+
* Also reads harness-config.json agentTool field if present.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* harness-dev detect-tool [--target <dir>] [--json]
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
import { parseCommandArgs } from '../lib/command-helpers.mjs';
|
|
20
|
+
import { emitJson, emitHuman } from '../lib/output.mjs';
|
|
21
|
+
import { loadConfig } from '../lib/state.mjs';
|
|
22
|
+
import { getAllDetectionSignatures, AGENTS_MD_TOOLS, TOOL_REGISTRY } from '../lib/tool-registry.mjs';
|
|
23
|
+
|
|
24
|
+
export default async function detectToolCommand(args) {
|
|
25
|
+
const { json, targetDir } = parseCommandArgs(args);
|
|
26
|
+
|
|
27
|
+
const detected = [];
|
|
28
|
+
const hasAgentsMd = existsSync(resolve(targetDir, 'AGENTS.md'));
|
|
29
|
+
|
|
30
|
+
// 1. Scan for tool-specific detection files (from registry)
|
|
31
|
+
for (const { tool, file } of getAllDetectionSignatures()) {
|
|
32
|
+
if (existsSync(resolve(targetDir, file))) {
|
|
33
|
+
if (!detected.includes(tool)) {
|
|
34
|
+
detected.push(tool);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. AGENTS.md present → tools that read it natively are "available"
|
|
40
|
+
if (hasAgentsMd) {
|
|
41
|
+
for (const tool of AGENTS_MD_TOOLS) {
|
|
42
|
+
if (!detected.includes(tool)) {
|
|
43
|
+
detected.push(tool);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Read config.agentTool if present
|
|
49
|
+
const { config, ok } = loadConfig(targetDir);
|
|
50
|
+
const configuredTool = ok && config.agentTool ? config.agentTool : null;
|
|
51
|
+
|
|
52
|
+
// 4. Recommend: configured tool > first detected tool-specific > 'generic'
|
|
53
|
+
let recommended = configuredTool;
|
|
54
|
+
if (!recommended && detected.length > 0) {
|
|
55
|
+
// Prefer tool-specific (has a file) over AGENTS.md-native
|
|
56
|
+
const specific = detected.find(t => {
|
|
57
|
+
const entry = TOOL_REGISTRY[t];
|
|
58
|
+
return entry && entry.file !== null;
|
|
59
|
+
});
|
|
60
|
+
recommended = specific || detected[0];
|
|
61
|
+
}
|
|
62
|
+
if (!recommended) {
|
|
63
|
+
recommended = 'generic';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 5. Build per-tool details for richer output
|
|
67
|
+
const toolDetails = detected.map(t => {
|
|
68
|
+
const entry = TOOL_REGISTRY[t];
|
|
69
|
+
return {
|
|
70
|
+
tool: t,
|
|
71
|
+
label: entry?.label || t,
|
|
72
|
+
file: entry?.file || null,
|
|
73
|
+
notes: entry?.notes || '',
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const result = {
|
|
78
|
+
command: 'detect-tool',
|
|
79
|
+
status: 'ok',
|
|
80
|
+
message: detected.length > 0
|
|
81
|
+
? `Detected ${detected.length} agent tool(s): ${detected.join(', ')}`
|
|
82
|
+
: 'No agent tools detected. Run: harness-dev init',
|
|
83
|
+
available: detected,
|
|
84
|
+
configured: configuredTool,
|
|
85
|
+
recommended,
|
|
86
|
+
hasAgentsMd,
|
|
87
|
+
tools: toolDetails,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (json) {
|
|
91
|
+
emitJson(result);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
emitHuman(`═══ Agent Tool Detection ═══\n\n`);
|
|
96
|
+
if (detected.length > 0) {
|
|
97
|
+
emitHuman(`Available tools:\n`);
|
|
98
|
+
for (const t of toolDetails) {
|
|
99
|
+
const marker = t.tool === recommended ? ' ← recommended' : '';
|
|
100
|
+
const source = t.tool === configuredTool ? ' (from config)' : '';
|
|
101
|
+
const fileInfo = t.file ? ` [${t.file}]` : ' [AGENTS.md]';
|
|
102
|
+
emitHuman(` • ${t.label}${source}${marker}${fileInfo}\n`);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
emitHuman(` No agent tools detected.\n`);
|
|
106
|
+
emitHuman(` Run: harness-dev init to scaffold a project.\n`);
|
|
107
|
+
}
|
|
108
|
+
if (hasAgentsMd) {
|
|
109
|
+
emitHuman(`\n AGENTS.md present — tools that read it natively are available.\n`);
|
|
110
|
+
}
|
|
111
|
+
emitHuman(`\n Recommended: ${TOOL_REGISTRY[recommended]?.label || recommended}\n`);
|
|
112
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* init — Scaffold full harness in target directory.
|
|
4
|
+
*
|
|
5
|
+
* Creates all harness files: template-based (AGENTS.md, harness-config.json,
|
|
6
|
+
* init.sh, progress.md, sprint-contract.md) plus project files (feature_list.json,
|
|
7
|
+
* feature-list.schema.json, session-handoff.md, etc.), git init, .gitignore.
|
|
8
|
+
*
|
|
9
|
+
* Usage: harness-dev init [--stack <name>] [--target <dir>] [--agent-tool <name>] [--force] [--no-git] [--json]
|
|
10
|
+
*/
|
|
11
|
+
import { resolve, join } from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
existsSync, writeFileSync, mkdirSync, readFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { generateTemplates } from '../lib/templates.mjs';
|
|
16
|
+
import { detectStack, getStackMeta } from '../lib/detect-stack.mjs';
|
|
17
|
+
import { listStacks } from '../lib/vars.mjs';
|
|
18
|
+
import { CliError, EXIT, die } from '../lib/errors.mjs';
|
|
19
|
+
import { execGit } from '../lib/git.mjs';
|
|
20
|
+
import {
|
|
21
|
+
getExtraFiles,
|
|
22
|
+
getConfigFileContent,
|
|
23
|
+
getVersionFileContent,
|
|
24
|
+
getGitignoreContent,
|
|
25
|
+
KNOWN_AGENT_TOOLS,
|
|
26
|
+
} from '../lib/scaffold.mjs';
|
|
27
|
+
import { getToolEntry } from '../lib/tool-registry.mjs';
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
// ── Git helpers ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if targetDir is inside a git repository.
|
|
34
|
+
*/
|
|
35
|
+
function isInsideGitRepo(targetDir) {
|
|
36
|
+
return execGit('git rev-parse --git-dir 2>/dev/null', targetDir).ok;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if git repo is empty (no commits yet).
|
|
41
|
+
*/
|
|
42
|
+
function isGitRepoEmpty(targetDir) {
|
|
43
|
+
const r = execGit('git rev-list -n 1 HEAD 2>/dev/null', targetDir);
|
|
44
|
+
return !r.ok || r.stdout === '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Init git repo and create initial commit.
|
|
49
|
+
*/
|
|
50
|
+
function initGit(targetDir) {
|
|
51
|
+
const messages = [];
|
|
52
|
+
|
|
53
|
+
if (!isInsideGitRepo(targetDir)) {
|
|
54
|
+
execGit('git init', targetDir);
|
|
55
|
+
messages.push('Initialized empty git repo');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isGitRepoEmpty(targetDir)) {
|
|
59
|
+
// Stage everything and commit
|
|
60
|
+
execGit('git add -A', targetDir);
|
|
61
|
+
execGit('git commit -m "harness: initial scaffold" --allow-empty', targetDir);
|
|
62
|
+
messages.push('Created initial commit: harness: initial scaffold');
|
|
63
|
+
} else {
|
|
64
|
+
messages.push('Git repo already has commits — skipped initial commit');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return messages;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Command handler ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export default async function initCommand(args) {
|
|
73
|
+
const json = !!(args.json || args.flags?.json);
|
|
74
|
+
const force = args.flags?.force === true || args.flags?.force === 'true';
|
|
75
|
+
const noGit = args.flags?.['no-git'] === true || args.flags?.['no-git'] === 'true';
|
|
76
|
+
const agentTool = args.flags?.['agent-tool'] || null;
|
|
77
|
+
|
|
78
|
+
// Validate agent-tool if specified
|
|
79
|
+
if (agentTool && !KNOWN_AGENT_TOOLS.includes(agentTool)) {
|
|
80
|
+
die(
|
|
81
|
+
new CliError(
|
|
82
|
+
`Unknown agent tool "${agentTool}". Valid: ${KNOWN_AGENT_TOOLS.join(', ')}`,
|
|
83
|
+
EXIT.USAGE_ERROR,
|
|
84
|
+
),
|
|
85
|
+
json,
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Guard: --target without value passes boolean true — fall back to cwd
|
|
90
|
+
const rawTarget = args.flags?.target;
|
|
91
|
+
const targetDir = resolve((typeof rawTarget === 'string') ? rawTarget : process.cwd());
|
|
92
|
+
|
|
93
|
+
// Resolve stack — explicit or auto-detect
|
|
94
|
+
let stack;
|
|
95
|
+
const explicitStack = args.flags?.stack;
|
|
96
|
+
const validStacks = listStacks();
|
|
97
|
+
|
|
98
|
+
if (explicitStack) {
|
|
99
|
+
if (!validStacks.includes(explicitStack)) {
|
|
100
|
+
// Allow unknown stacks — user/agent will fill stackMeta in harness-config.json
|
|
101
|
+
// during DEFINE phase (testCmd, lintCmd, buildCmd, installCmd, etc.)
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
`Note: stack "${explicitStack}" is not built-in. ` +
|
|
104
|
+
`Fill stackMeta in harness-config.json during DEFINE phase ` +
|
|
105
|
+
`(testCmd, lintCmd, buildCmd, installCmd, coverageCmd).\n`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
stack = explicitStack;
|
|
109
|
+
} else {
|
|
110
|
+
// Auto-detect
|
|
111
|
+
const detected = detectStack(targetDir);
|
|
112
|
+
if (detected.name === 'generic') {
|
|
113
|
+
die(
|
|
114
|
+
new CliError(
|
|
115
|
+
'Could not auto-detect project stack. Specify with --stack <name>. ' +
|
|
116
|
+
`Valid: ${validStacks.join(', ')}, or any custom stack name ` +
|
|
117
|
+
`(fill stackMeta during DEFINE phase).`,
|
|
118
|
+
EXIT.USAGE_ERROR,
|
|
119
|
+
),
|
|
120
|
+
json,
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
stack = detected.name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Build the full file manifest
|
|
128
|
+
const extraFiles = getExtraFiles(stack);
|
|
129
|
+
|
|
130
|
+
// Config file and version file from stacks.json metadata
|
|
131
|
+
const meta = getStackMeta(stack);
|
|
132
|
+
let configFileRel = null;
|
|
133
|
+
let versionFileRel = null;
|
|
134
|
+
if (meta && meta.configFile) {
|
|
135
|
+
configFileRel = meta.configFile; // e.g. "pyproject.toml"
|
|
136
|
+
}
|
|
137
|
+
if (meta && meta.versionFile) {
|
|
138
|
+
versionFileRel = meta.versionFile; // e.g. ".python-version"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Collate all output paths for existence check
|
|
142
|
+
// We separate "harness files" (our scaffold — conflict = abort) from
|
|
143
|
+
// "project files" (user's own — skip silently if they exist).
|
|
144
|
+
const harnessPaths = [];
|
|
145
|
+
const projectPaths = [];
|
|
146
|
+
|
|
147
|
+
// Template files — known template names
|
|
148
|
+
const templateNames = [
|
|
149
|
+
'AGENTS.md', 'harness-config.json', 'init.sh',
|
|
150
|
+
'progress.md', 'sprint-contract.md', 'evaluator-rubric.md',
|
|
151
|
+
];
|
|
152
|
+
for (const name of templateNames) {
|
|
153
|
+
harnessPaths.push(join(targetDir, name));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Extra scaffold files
|
|
157
|
+
for (const relPath of Object.keys(extraFiles)) {
|
|
158
|
+
harnessPaths.push(join(targetDir, relPath));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// .gitignore
|
|
162
|
+
harnessPaths.push(join(targetDir, '.gitignore'));
|
|
163
|
+
|
|
164
|
+
// Stack config file and version file — these are the user's own project files.
|
|
165
|
+
// Skip if they exist (don't abort scaffold for them).
|
|
166
|
+
if (configFileRel) {
|
|
167
|
+
projectPaths.push({ rel: configFileRel, abs: join(targetDir, configFileRel) });
|
|
168
|
+
}
|
|
169
|
+
if (versionFileRel) {
|
|
170
|
+
projectPaths.push({ rel: versionFileRel, abs: join(targetDir, versionFileRel) });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check for existing harness files (unless --force)
|
|
174
|
+
if (!force) {
|
|
175
|
+
const conflicts = harnessPaths.filter(p => existsSync(p));
|
|
176
|
+
if (conflicts.length > 0) {
|
|
177
|
+
const msg = conflicts.length === 1
|
|
178
|
+
? `File already exists: ${conflicts[0]}` +
|
|
179
|
+
'\nUse --force to overwrite existing files.'
|
|
180
|
+
: `${conflicts.length} harness file(s) already exist in ${targetDir}` +
|
|
181
|
+
`\nFirst conflict: ${conflicts[0]}` +
|
|
182
|
+
'\nUse --force to overwrite existing files.';
|
|
183
|
+
die(new CliError(msg, EXIT.VALIDATION_FAILURE), json);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Write phase ──────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
// Ensure target directory exists
|
|
191
|
+
mkdirSync(targetDir, { recursive: true });
|
|
192
|
+
|
|
193
|
+
// Ensure docs/ directory exists
|
|
194
|
+
mkdirSync(join(targetDir, 'docs'), { recursive: true });
|
|
195
|
+
|
|
196
|
+
const created = [];
|
|
197
|
+
const errors = [];
|
|
198
|
+
|
|
199
|
+
// 1. Template files
|
|
200
|
+
try {
|
|
201
|
+
const tmplResult = generateTemplates({ stack, target: targetDir });
|
|
202
|
+
for (const f of tmplResult.files) {
|
|
203
|
+
created.push(f);
|
|
204
|
+
}
|
|
205
|
+
for (const e of tmplResult.errors) {
|
|
206
|
+
errors.push(e);
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
errors.push(`Template generation: ${err.message}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2. Extra files (inline content)
|
|
213
|
+
for (const [relPath, content] of Object.entries(extraFiles)) {
|
|
214
|
+
const absPath = join(targetDir, relPath);
|
|
215
|
+
// Ensure subdirectories exist
|
|
216
|
+
mkdirSync(resolve(absPath, '..'), { recursive: true });
|
|
217
|
+
try {
|
|
218
|
+
writeFileSync(absPath, content, 'utf-8');
|
|
219
|
+
created.push(absPath);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
errors.push(`${relPath}: ${err.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 3. Stack config file — skip if it already exists (user's project file)
|
|
226
|
+
if (configFileRel) {
|
|
227
|
+
const cfPath = join(targetDir, configFileRel);
|
|
228
|
+
if (force || !existsSync(cfPath)) {
|
|
229
|
+
const cfContent = getConfigFileContent(stack);
|
|
230
|
+
if (cfContent !== null) {
|
|
231
|
+
try {
|
|
232
|
+
writeFileSync(cfPath, cfContent, 'utf-8');
|
|
233
|
+
created.push(cfPath);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
errors.push(`${configFileRel}: ${err.message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
// Skipped — already exists, will report in output
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 4. Stack version file — skip if it already exists
|
|
244
|
+
if (versionFileRel) {
|
|
245
|
+
const vfPath = join(targetDir, versionFileRel);
|
|
246
|
+
if (force || !existsSync(vfPath)) {
|
|
247
|
+
const vfContent = getVersionFileContent(stack);
|
|
248
|
+
if (vfContent !== null && vfContent !== '') {
|
|
249
|
+
try {
|
|
250
|
+
writeFileSync(vfPath, vfContent, 'utf-8');
|
|
251
|
+
created.push(vfPath);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
errors.push(`${versionFileRel}: ${err.message}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
// Skipped — already exists
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 5. .gitignore
|
|
262
|
+
const gitignorePath = join(targetDir, '.gitignore');
|
|
263
|
+
try {
|
|
264
|
+
writeFileSync(gitignorePath, getGitignoreContent(stack), 'utf-8');
|
|
265
|
+
created.push(gitignorePath);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
errors.push(`.gitignore: ${err.message}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 5b. Agent-tool file (e.g. CLAUDE.md, .cursorrules, .windsurfrules)
|
|
271
|
+
// Generated from the already-rendered AGENTS.md content + optional header.
|
|
272
|
+
// No separate templates needed — AGENTS.md is the canonical source.
|
|
273
|
+
if (agentTool) {
|
|
274
|
+
const toolEntry = getToolEntry(agentTool);
|
|
275
|
+
if (toolEntry && toolEntry.file) {
|
|
276
|
+
const agentsMdPath = join(targetDir, 'AGENTS.md');
|
|
277
|
+
if (existsSync(agentsMdPath)) {
|
|
278
|
+
try {
|
|
279
|
+
const agentsContent = readFileSync(agentsMdPath, 'utf-8');
|
|
280
|
+
const header = toolEntry.header || '';
|
|
281
|
+
const outPath = join(targetDir, toolEntry.file);
|
|
282
|
+
// Ensure subdirectory exists (e.g. .github/copilot-instructions.md)
|
|
283
|
+
mkdirSync(resolve(outPath, '..'), { recursive: true });
|
|
284
|
+
writeFileSync(outPath, header + '\n' + agentsContent, 'utf-8');
|
|
285
|
+
created.push(outPath);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
errors.push(`${toolEntry.file}: ${err.message}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Set agentTool in the generated harness-config.json
|
|
293
|
+
const configPath = join(targetDir, 'harness-config.json');
|
|
294
|
+
if (existsSync(configPath)) {
|
|
295
|
+
try {
|
|
296
|
+
const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
297
|
+
cfg.agentTool = agentTool;
|
|
298
|
+
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
|
|
299
|
+
} catch (err) {
|
|
300
|
+
errors.push(`harness-config.json agentTool: ${err.message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 6. Git init (unless --no-git)
|
|
306
|
+
const gitMessages = [];
|
|
307
|
+
if (!noGit) {
|
|
308
|
+
try {
|
|
309
|
+
const msgs = initGit(targetDir);
|
|
310
|
+
gitMessages.push(...msgs);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
errors.push(`Git init: ${err.message}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Output ───────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
if (json) {
|
|
319
|
+
const status = errors.length > 0 ? 'partial' : 'ok';
|
|
320
|
+
const message = errors.length > 0
|
|
321
|
+
? `Created ${created.length} file(s) with ${errors.length} error(s)`
|
|
322
|
+
: `Created ${created.length} file(s) for stack "${stack}"`;
|
|
323
|
+
process.stdout.write(JSON.stringify({
|
|
324
|
+
command: 'init',
|
|
325
|
+
status,
|
|
326
|
+
message,
|
|
327
|
+
stack,
|
|
328
|
+
target: targetDir,
|
|
329
|
+
filesCreated: created.length,
|
|
330
|
+
files: created,
|
|
331
|
+
git: gitMessages,
|
|
332
|
+
errors,
|
|
333
|
+
}) + '\n');
|
|
334
|
+
if (errors.length > 0) {
|
|
335
|
+
process.exit(EXIT.VALIDATION_FAILURE);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Human output
|
|
341
|
+
for (const f of created) {
|
|
342
|
+
process.stdout.write(` ✓ ${f}\n`);
|
|
343
|
+
}
|
|
344
|
+
for (const e of errors) {
|
|
345
|
+
process.stderr.write(` ✗ ${e}\n`);
|
|
346
|
+
}
|
|
347
|
+
for (const g of gitMessages) {
|
|
348
|
+
process.stdout.write(` ● ${g}\n`);
|
|
349
|
+
}
|
|
350
|
+
process.stdout.write(`\nCreated ${created.length} file(s) for stack "${stack}" in ${targetDir}\n`);
|
|
351
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* learn — Append a lesson to progress.md.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* harness-dev learn "Lesson text here"
|
|
6
|
+
* harness-dev learn "Lesson text" --json
|
|
7
|
+
*/
|
|
8
|
+
import { CliError, EXIT, die } from '../lib/errors.mjs';
|
|
9
|
+
import { appendLesson } from '../lib/progress.mjs';
|
|
10
|
+
import { parseCommandArgs } from '../lib/command-helpers.mjs';
|
|
11
|
+
import { emitJson, emitHuman, emitHumanError } from '../lib/output.mjs';
|
|
12
|
+
|
|
13
|
+
export default async function learnCommand(args) {
|
|
14
|
+
const { json, targetDir, subcommand, positionals } = parseCommandArgs(args);
|
|
15
|
+
const message = subcommand || positionals.join(' ');
|
|
16
|
+
|
|
17
|
+
if (!message) {
|
|
18
|
+
die(
|
|
19
|
+
new CliError(
|
|
20
|
+
'Lesson message required.\n Example: harness-dev learn "Token refresh gotcha — accepts access_token in body"',
|
|
21
|
+
EXIT.USAGE_ERROR,
|
|
22
|
+
),
|
|
23
|
+
json,
|
|
24
|
+
);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = appendLesson(targetDir, message);
|
|
29
|
+
|
|
30
|
+
if (json) {
|
|
31
|
+
emitJson({
|
|
32
|
+
command: 'learn',
|
|
33
|
+
lesson: message,
|
|
34
|
+
status: result.ok ? 'ok' : 'error',
|
|
35
|
+
message: result.ok
|
|
36
|
+
? `Lesson saved: "${message}"`
|
|
37
|
+
: (result.error || 'Failed to save lesson'),
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (result.ok) {
|
|
43
|
+
emitHuman(`✓ Lesson saved\n "${message}"\n`);
|
|
44
|
+
} else {
|
|
45
|
+
emitHumanError(`✗ ${result.error}\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pause — Pause autopilot execution.
|
|
3
|
+
*
|
|
4
|
+
* Sets config.paused = true. The outer loop checks this
|
|
5
|
+
* before starting a new phase in autopilot mode.
|
|
6
|
+
*
|
|
7
|
+
* Usage: harness-dev pause [--json]
|
|
8
|
+
*/
|
|
9
|
+
import { set } from '../lib/state.mjs';
|
|
10
|
+
import { parseCommandArgs } from '../lib/command-helpers.mjs';
|
|
11
|
+
import { emitJson, emitHuman, emitHumanError } from '../lib/output.mjs';
|
|
12
|
+
|
|
13
|
+
export default async function pauseCommand(args) {
|
|
14
|
+
const { json, targetDir } = parseCommandArgs(args);
|
|
15
|
+
|
|
16
|
+
const result = set(targetDir, 'paused', true);
|
|
17
|
+
|
|
18
|
+
if (json) {
|
|
19
|
+
emitJson({
|
|
20
|
+
command: 'pause',
|
|
21
|
+
status: result.ok ? 'ok' : 'error',
|
|
22
|
+
message: result.ok
|
|
23
|
+
? 'Pipeline paused. Autopilot will stop after current phase gate.'
|
|
24
|
+
: (result.error || 'Failed to pause'),
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (result.ok) {
|
|
30
|
+
emitHuman('✓ Pipeline paused. Autopilot will stop after current phase gate.\n');
|
|
31
|
+
} else {
|
|
32
|
+
emitHumanError(`✗ ${result.error}\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|