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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +299 -0
  3. package/adapters/amazon-q/README.md +23 -0
  4. package/adapters/antigravity/README.md +22 -0
  5. package/adapters/claude-code/README.md +30 -0
  6. package/adapters/cline/README.md +23 -0
  7. package/adapters/codex/README.md +31 -0
  8. package/adapters/copilot/README.md +23 -0
  9. package/adapters/cursor/README.md +29 -0
  10. package/adapters/gemini/README.md +23 -0
  11. package/adapters/generic/README.md +40 -0
  12. package/adapters/hermes/README.md +31 -0
  13. package/adapters/hermes/SKILL.md +89 -0
  14. package/adapters/hermes/scripts/init.mjs +27 -0
  15. package/adapters/hermes/scripts/phase.mjs +27 -0
  16. package/adapters/hermes/scripts/validate.mjs +27 -0
  17. package/adapters/kilo-code/README.md +23 -0
  18. package/adapters/openclaw/README.md +22 -0
  19. package/adapters/pi/README.md +22 -0
  20. package/adapters/roo/README.md +23 -0
  21. package/adapters/windsurf/README.md +23 -0
  22. package/cli/commands/checkpoint.mjs +94 -0
  23. package/cli/commands/config.mjs +268 -0
  24. package/cli/commands/contract.mjs +155 -0
  25. package/cli/commands/detect-tool.mjs +112 -0
  26. package/cli/commands/init.mjs +351 -0
  27. package/cli/commands/learn.mjs +47 -0
  28. package/cli/commands/pause.mjs +34 -0
  29. package/cli/commands/phase.mjs +182 -0
  30. package/cli/commands/resume.mjs +33 -0
  31. package/cli/commands/rollback.mjs +261 -0
  32. package/cli/commands/set-mode.mjs +75 -0
  33. package/cli/commands/status.mjs +168 -0
  34. package/cli/commands/validate.mjs +118 -0
  35. package/cli/commands/worktree.mjs +298 -0
  36. package/cli/harness-dev.mjs +88 -0
  37. package/cli/lib/args.mjs +111 -0
  38. package/cli/lib/command-helpers.mjs +50 -0
  39. package/cli/lib/config-registry.mjs +329 -0
  40. package/cli/lib/constants.mjs +30 -0
  41. package/cli/lib/contract.mjs +306 -0
  42. package/cli/lib/detect-stack.mjs +235 -0
  43. package/cli/lib/errors.mjs +71 -0
  44. package/cli/lib/file-io.mjs +90 -0
  45. package/cli/lib/gates.mjs +492 -0
  46. package/cli/lib/git.mjs +144 -0
  47. package/cli/lib/help.mjs +246 -0
  48. package/cli/lib/modes.mjs +92 -0
  49. package/cli/lib/output.mjs +49 -0
  50. package/cli/lib/paths.mjs +75 -0
  51. package/cli/lib/phases.mjs +58 -0
  52. package/cli/lib/platform.mjs +78 -0
  53. package/cli/lib/progress.mjs +357 -0
  54. package/cli/lib/ralph-inner.mjs +314 -0
  55. package/cli/lib/ralph-outer.mjs +249 -0
  56. package/cli/lib/ralph-output.mjs +178 -0
  57. package/cli/lib/scaffold.mjs +431 -0
  58. package/cli/lib/schemas/stacks.json +477 -0
  59. package/cli/lib/state.mjs +333 -0
  60. package/cli/lib/templates.mjs +264 -0
  61. package/cli/lib/tool-registry.mjs +218 -0
  62. package/cli/lib/validate-schema.mjs +131 -0
  63. package/cli/lib/vars.mjs +114 -0
  64. package/package.json +50 -0
  65. package/schema/harness-config.schema.json +127 -0
  66. package/templates/AGENTS.md +63 -0
  67. package/templates/ci/github-actions.yml +78 -0
  68. package/templates/ci/gitlab-ci.yml +59 -0
  69. package/templates/docs/agents/evaluator.md +14 -0
  70. package/templates/docs/agents/generator.md +13 -0
  71. package/templates/docs/agents/planner.md +13 -0
  72. package/templates/docs/agents/simplifier.md +13 -0
  73. package/templates/docs/phases/build.md +41 -0
  74. package/templates/docs/phases/define.md +51 -0
  75. package/templates/docs/phases/plan.md +36 -0
  76. package/templates/docs/phases/review.md +42 -0
  77. package/templates/docs/phases/ship.md +43 -0
  78. package/templates/docs/phases/simplify.md +40 -0
  79. package/templates/docs/phases/verify.md +38 -0
  80. package/templates/evaluator-rubric.md +28 -0
  81. package/templates/init.ps1 +97 -0
  82. package/templates/init.sh +102 -0
  83. package/templates/sprint-contract.md +31 -0
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: "dev-harness"
3
+ description: "Hermes adapter for the dev-harness CLI — phase-based development pipeline with scaffold, stack detection, gate validation, inner/outer loops, sprint contracts, git worktree management, and rollback/checkpoint. Load this skill when working inside a harness-scaffolded project."
4
+ license: MIT
5
+ category: software-development
6
+ risk: high
7
+ source: self
8
+ date_added: "2026-06-20"
9
+ metadata:
10
+ version: 1.1.0
11
+ paradigm: "Phase-based agentic development pipeline"
12
+ changelog:
13
+ - "1.1.0 — Moved to adapters/hermes/ (tool-agnostic adapter structure)."
14
+ - "1.0.0 — Hermes skill wrapper for dev-harness CLI. Wraps init, phase, and validate commands."
15
+ ---
16
+
17
+ # Dev Harness — Hermes Skill Wrapper
18
+
19
+ Triggers on: "harness-dev", "harness init", "harness scaffold", "new project", "phase pipeline"
20
+
21
+ ## Prerequisites
22
+
23
+ - Node.js >= 18
24
+ - `harness-dev` CLI accessible via `node cli/harness-dev.mjs` from the project root
25
+ - A git repository (for worktree, rollback, and checkpoint commands)
26
+
27
+ ## Actions
28
+
29
+ ### `harness-dev init [--stack <name>] [--target <dir>] [--force]`
30
+
31
+ Scaffold harness in current project. If stack not specified, auto-detects from target directory.
32
+
33
+ ```bash
34
+ node cli/harness-dev.mjs init --stack python --target my-project
35
+ ```
36
+
37
+ ### `harness-dev status [--json] [--target <dir>]`
38
+
39
+ Show current project state — phase, mode, gate status, recent lessons.
40
+
41
+ ### `harness-dev phase <name>`
42
+
43
+ Run a phase: `define` → `plan` → `build` → `verify` → `simplify` → `review` → `ship`
44
+
45
+ ### `harness-dev validate [--json] [--phase <name>] [--feature <id>] [--task <id>]`
46
+
47
+ Run gate checks for current or specified phase.
48
+
49
+ ### `harness-dev set-mode copilot|autopilot`
50
+
51
+ Switch between one-phase-at-a-time (copilot) and auto-advance (autopilot).
52
+
53
+ ### `harness-dev learn <message>`
54
+
55
+ Append a lesson to progress.md.
56
+
57
+ ### `harness-dev config get <key>` / `harness-dev config set <key> <value>`
58
+
59
+ Read/write harness-config.json via dot-notation.
60
+
61
+ ### `harness-dev contract propose|review|status|escalate`
62
+
63
+ Sprint contract negotiation workflow.
64
+
65
+ ### `harness-dev worktree create|list|prune|remove <name>`
66
+
67
+ Git worktree management with harness scaffold.
68
+
69
+ ### `harness-dev rollback list|to|branch`
70
+
71
+ Checkpoint recovery with tag-based state restoration.
72
+
73
+ ### `harness-dev checkpoint create <label>`
74
+
75
+ Manual checkpoint tagging.
76
+
77
+ ## Scripts
78
+
79
+ Thin wrapper scripts are provided under `scripts/` that resolve the CLI relative to the project root:
80
+
81
+ - `scripts/init.mjs` — wrapper for `harness-dev init`
82
+ - `scripts/phase.mjs` — wrapper for `harness-dev phase`
83
+ - `scripts/validate.mjs` — wrapper for `harness-dev validate`
84
+
85
+ Use these when you want to invoke harness commands from other Hermes skill scripts or automation.
86
+
87
+ ## Templates
88
+
89
+ The `templates/` directory is a symlink to `../../../templates` (the project's main templates directory). Template files use `{{VAR}}` substitution for stack-aware scaffolding.
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hermes skill wrapper — harness-dev init
4
+ *
5
+ * Thin wrapper that resolves the CLI relative to this skill's location
6
+ * and forwards arguments to the init command.
7
+ *
8
+ * Usage: node hermes/skill/dev-harness/scripts/init.mjs [--stack <name>] [--target <dir>] [--force] [--no-git] [--json]
9
+ */
10
+
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const projectRoot = resolve(__dirname, '..', '..', '..', '..');
17
+ const cliPath = resolve(projectRoot, 'cli/harness-dev.mjs');
18
+
19
+ const args = process.argv.slice(2);
20
+
21
+ const result = spawnSync('node', [cliPath, 'init', ...args], {
22
+ stdio: 'inherit',
23
+ cwd: projectRoot,
24
+ env: { ...process.env },
25
+ });
26
+
27
+ process.exit(result.status ?? 1);
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hermes skill wrapper — harness-dev phase
4
+ *
5
+ * Thin wrapper that resolves the CLI relative to this skill's location
6
+ * and forwards arguments to the phase command.
7
+ *
8
+ * Usage: node hermes/skill/dev-harness/scripts/phase.mjs <phase-name> [--json] [--target <dir>]
9
+ */
10
+
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const projectRoot = resolve(__dirname, '..', '..', '..', '..');
17
+ const cliPath = resolve(projectRoot, 'cli/harness-dev.mjs');
18
+
19
+ const args = process.argv.slice(2);
20
+
21
+ const result = spawnSync('node', [cliPath, 'phase', ...args], {
22
+ stdio: 'inherit',
23
+ cwd: projectRoot,
24
+ env: { ...process.env },
25
+ });
26
+
27
+ process.exit(result.status ?? 1);
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hermes skill wrapper — harness-dev validate
4
+ *
5
+ * Thin wrapper that resolves the CLI relative to this skill's location
6
+ * and forwards arguments to the validate command.
7
+ *
8
+ * Usage: node hermes/skill/dev-harness/scripts/validate.mjs [--json] [--phase <name>] [--feature <id>] [--task <id>] [--target <dir>]
9
+ */
10
+
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const projectRoot = resolve(__dirname, '..', '..', '..', '..');
17
+ const cliPath = resolve(projectRoot, 'cli/harness-dev.mjs');
18
+
19
+ const args = process.argv.slice(2);
20
+
21
+ const result = spawnSync('node', [cliPath, 'validate', ...args], {
22
+ stdio: 'inherit',
23
+ cwd: projectRoot,
24
+ env: { ...process.env },
25
+ });
26
+
27
+ process.exit(result.status ?? 1);
@@ -0,0 +1,23 @@
1
+ # Kilo Code Adapter
2
+
3
+ Kilo Code reads .kilocoderules.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ harness-dev init --stack node --agent-tool kilo-code --target my-project
9
+ cd my-project
10
+ ```
11
+
12
+ ## Files Generated
13
+
14
+ - `AGENTS.md` — canonical harness conventions (always generated)
15
+ - `.kilocoderules` — Kilo Code-specific rules file (generated from AGENTS.md content)
16
+ - `harness-config.json` — with `agentTool: "kilo-code"`
17
+
18
+ ## How It Works
19
+
20
+ The harness generates AGENTS.md as the canonical conventions file. For tools
21
+ with a specific rules file, the harness copies AGENTS.md content to the
22
+ tool-specific filename (e.g. .kilocoderules) with an optional header. The tool then
23
+ reads its native file and follows the harness phase instructions.
@@ -0,0 +1,22 @@
1
+ # OpenClaw Adapter
2
+
3
+ OpenClaw — assumed to read AGENTS.md.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ harness-dev init --stack node --agent-tool openclaw --target my-project
9
+ cd my-project
10
+ ```
11
+
12
+ ## Files Generated
13
+
14
+ - `AGENTS.md` — canonical harness conventions (always generated)
15
+ - `harness-config.json` — with `agentTool: "openclaw"`
16
+
17
+ ## How It Works
18
+
19
+ The harness generates AGENTS.md as the canonical conventions file. For tools
20
+ with a specific rules file, the harness copies AGENTS.md content to the
21
+ tool-specific filename (e.g. AGENTS.md) with an optional header. The tool then
22
+ reads its native file and follows the harness phase instructions.
@@ -0,0 +1,22 @@
1
+ # Pi Adapter
2
+
3
+ Pi — assumed to read AGENTS.md.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ harness-dev init --stack node --agent-tool pi --target my-project
9
+ cd my-project
10
+ ```
11
+
12
+ ## Files Generated
13
+
14
+ - `AGENTS.md` — canonical harness conventions (always generated)
15
+ - `harness-config.json` — with `agentTool: "pi"`
16
+
17
+ ## How It Works
18
+
19
+ The harness generates AGENTS.md as the canonical conventions file. For tools
20
+ with a specific rules file, the harness copies AGENTS.md content to the
21
+ tool-specific filename (e.g. AGENTS.md) with an optional header. The tool then
22
+ reads its native file and follows the harness phase instructions.
@@ -0,0 +1,23 @@
1
+ # Roo Code Adapter
2
+
3
+ Roo Code reads .roorules.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ harness-dev init --stack node --agent-tool roo --target my-project
9
+ cd my-project
10
+ ```
11
+
12
+ ## Files Generated
13
+
14
+ - `AGENTS.md` — canonical harness conventions (always generated)
15
+ - `.roorules` — Roo Code-specific rules file (generated from AGENTS.md content)
16
+ - `harness-config.json` — with `agentTool: "roo"`
17
+
18
+ ## How It Works
19
+
20
+ The harness generates AGENTS.md as the canonical conventions file. For tools
21
+ with a specific rules file, the harness copies AGENTS.md content to the
22
+ tool-specific filename (e.g. .roorules) with an optional header. The tool then
23
+ reads its native file and follows the harness phase instructions.
@@ -0,0 +1,23 @@
1
+ # Windsurf Adapter
2
+
3
+ Windsurf (Codeium) reads .windsurfrules as system context.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ harness-dev init --stack node --agent-tool windsurf --target my-project
9
+ cd my-project
10
+ ```
11
+
12
+ ## Files Generated
13
+
14
+ - `AGENTS.md` — canonical harness conventions (always generated)
15
+ - `.windsurfrules` — Windsurf-specific rules file (generated from AGENTS.md content)
16
+ - `harness-config.json` — with `agentTool: "windsurf"`
17
+
18
+ ## How It Works
19
+
20
+ The harness generates AGENTS.md as the canonical conventions file. For tools
21
+ with a specific rules file, the harness copies AGENTS.md content to the
22
+ tool-specific filename (e.g. .windsurfrules) with an optional header. The tool then
23
+ reads its native file and follows the harness phase instructions.
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * checkpoint — Manual checkpoint tagging (create).
4
+ *
5
+ * T18 implementation:
6
+ * create <label> — git tag -a manual/<label> -m "checkpoint: <label>"
7
+ *
8
+ * This complements the rollback system. Manual checkpoints let users
9
+ * save named recovery points (e.g. "before-refactor") that appear
10
+ * in `rollback list` and can be used with `rollback to/branch`.
11
+ *
12
+ * Usage: harness-dev checkpoint create <label>
13
+ */
14
+ import { die, CliError, EXIT } from '../lib/errors.mjs';
15
+ import { execGit, getGitRoot, gitTagExists, createGitTag } from '../lib/git.mjs';
16
+ import { parseCommandArgs } from '../lib/command-helpers.mjs';
17
+
18
+ export default async function checkpointCommand(args) {
19
+ const { json, targetDir, force } = parseCommandArgs(args);
20
+ const sub = args.subcommand;
21
+ const label = args.positionals[0];
22
+
23
+ if (sub !== 'create' || !label) {
24
+ die(new CliError('Usage: harness-dev checkpoint create <label> [--force]', EXIT.USAGE_ERROR), json);
25
+ return;
26
+ }
27
+
28
+ const gitRoot = getGitRoot(targetDir);
29
+ if (!gitRoot) {
30
+ const msg = 'Not inside a git repository. Checkpoints require git tags.';
31
+ if (json) {
32
+ process.stdout.write(JSON.stringify({ command: 'checkpoint', subcommand: 'create', label, status: 'error', message: msg }) + '\n');
33
+ } else {
34
+ process.stderr.write(`Error: ${msg}\n`);
35
+ }
36
+ return;
37
+ }
38
+
39
+ // Verify working tree is clean (encourage checkpointing at known states).
40
+ // --force skips this check for users who intentionally checkpoint dirty states.
41
+ const cleanCheck = execGit('git status --porcelain', gitRoot);
42
+ if (!force && cleanCheck.ok && cleanCheck.stdout.length > 0) {
43
+ if (json) {
44
+ process.stdout.write(JSON.stringify({ command: 'checkpoint', subcommand: 'create', label, status: 'error', message: 'Working tree is not clean. Commit or stash changes, or use --force to checkpoint anyway.' }) + '\n');
45
+ } else {
46
+ process.stderr.write('Error: Working tree is not clean. Commit or stash changes, or use --force to checkpoint anyway.\n');
47
+ }
48
+ return;
49
+ }
50
+
51
+ const tagName = `manual/${label}`;
52
+
53
+ // Check tag doesn't already exist
54
+ if (gitTagExists(tagName, gitRoot)) {
55
+ const msg = `Tag "${tagName}" already exists. Use a different label.`;
56
+ if (json) {
57
+ process.stdout.write(JSON.stringify({ command: 'checkpoint', subcommand: 'create', label, tag: tagName, status: 'error', message: msg }) + '\n');
58
+ } else {
59
+ process.stderr.write(`Error: ${msg}\n`);
60
+ }
61
+ return;
62
+ }
63
+
64
+ // Create the annotated tag
65
+ const created = createGitTag(tagName, `checkpoint: ${label}`, gitRoot);
66
+
67
+ if (!created) {
68
+ const msg = `Failed to create checkpoint tag: ${tagName}`;
69
+ if (json) {
70
+ process.stdout.write(JSON.stringify({ command: 'checkpoint', subcommand: 'create', label, tag: tagName, status: 'error', message: msg }) + '\n');
71
+ } else {
72
+ process.stderr.write(`Error: ${msg}\n`);
73
+ }
74
+ return;
75
+ }
76
+
77
+ // Get the hash the tag points to
78
+ const hashR = execGit(`git rev-parse --short "${tagName}"`, gitRoot);
79
+ const hash = hashR.ok ? hashR.stdout : '—';
80
+
81
+ if (json) {
82
+ process.stdout.write(JSON.stringify({
83
+ command: 'checkpoint',
84
+ subcommand: 'create',
85
+ label,
86
+ tag: tagName,
87
+ hash,
88
+ status: 'ok',
89
+ message: `Checkpoint "${tagName}" created (${hash})`,
90
+ }) + '\n');
91
+ } else {
92
+ process.stdout.write(`✓ Checkpoint "${tagName}" created (${hash})\n`);
93
+ }
94
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * config — Get/set/list harness-config.json values.
3
+ *
4
+ * Uses state.mjs for dot-notation read/write with persistence.
5
+ *
6
+ * Usage:
7
+ * harness-dev config list — list all parameters with descriptions
8
+ * harness-dev config get [key] — get a value or all config
9
+ * harness-dev config set <key> <value> — set a value
10
+ *
11
+ * Examples:
12
+ * harness-dev config list
13
+ * harness-dev config list --json
14
+ * harness-dev config get gates.enabled
15
+ * harness-dev config set gates.enabled true
16
+ * harness-dev config set maxRetries 5
17
+ */
18
+ import { resolve } from 'node:path';
19
+ import { die, CliError, EXIT } from '../lib/errors.mjs';
20
+ import { get as stateGet, set as stateSet, loadConfig } from '../lib/state.mjs';
21
+ import { CONFIG_PARAMS, getGroups, getParamsByGroup, getParamMeta } from '../lib/config-registry.mjs';
22
+
23
+ export default async function configCommand(args) {
24
+ const json = !!(args.json || args.flags?.json);
25
+ const sub = args.subcommand; // 'get', 'set', or 'list'
26
+ const pos = args.positionals; // key[, value] for set
27
+ const rawTarget = args.flags?.target;
28
+ const targetDir = (typeof rawTarget === 'string') ? resolve(rawTarget) : process.cwd();
29
+
30
+ if (!sub || (sub !== 'get' && sub !== 'set' && sub !== 'list')) {
31
+ die(new CliError(
32
+ 'Usage: harness-dev config list | config get [key] | config set <key> <value>',
33
+ EXIT.USAGE_ERROR,
34
+ ), json);
35
+ return;
36
+ }
37
+
38
+ // ── list ─────────────────────────────────────────────────────────────────
39
+ if (sub === 'list') {
40
+ const { config, ok } = loadConfig(targetDir);
41
+
42
+ // Helper to resolve current value from config via dot-notation
43
+ function resolveValue(key) {
44
+ if (!ok) { return null; }
45
+ const parts = key.split('.');
46
+ let val = config;
47
+ for (const p of parts) {
48
+ val = val?.[p];
49
+ }
50
+ return val ?? null;
51
+ }
52
+
53
+ if (json) {
54
+ const params = CONFIG_PARAMS.map(p => ({
55
+ key: p.key,
56
+ group: p.group,
57
+ label: p.label,
58
+ type: p.type,
59
+ description: p.description,
60
+ default: p.default,
61
+ options: p.options || null,
62
+ editable: p.editable,
63
+ value: resolveValue(p.key),
64
+ }));
65
+ process.stdout.write(JSON.stringify({
66
+ command: 'config',
67
+ subcommand: 'list',
68
+ status: 'ok',
69
+ message: `${params.length} configuration parameters`,
70
+ params,
71
+ }, null, 2) + '\n');
72
+ return;
73
+ }
74
+
75
+ // Human output — grouped table
76
+ process.stdout.write('═══ Harness Configuration ═══\n\n');
77
+ if (!ok) {
78
+ process.stdout.write(' No harness-config.json found. Run: harness-dev init\n\n');
79
+ return;
80
+ }
81
+
82
+ for (const group of getGroups()) {
83
+ const params = getParamsByGroup(group);
84
+ const readOnly = params[0]?.editable === false;
85
+ process.stdout.write(`── ${group}${readOnly ? ' (read-only)' : ''} ──\n`);
86
+
87
+ // Calculate column widths
88
+ const maxKey = Math.max(...params.map(p => p.key.length), 10);
89
+ const maxVal = Math.max(...params.map(p => {
90
+ const v = resolveValue(p.key);
91
+ return JSON.stringify(v)?.length ?? 4;
92
+ }), 7);
93
+
94
+ for (const p of params) {
95
+ const val = resolveValue(p.key);
96
+ const valStr = JSON.stringify(val) ?? 'null';
97
+ const opts = p.options ? `[${p.options.filter(o => o !== null).slice(0, 4).join('|')}${p.options.length > 4 ? '|...' : ''}]` : `[${p.type}]`;
98
+ const padKey = p.key.padEnd(maxKey);
99
+ const padVal = valStr.padEnd(Math.min(maxVal, 20));
100
+ process.stdout.write(` ${padKey} ${padVal} ${opts.padEnd(16)} ${p.description.slice(0, 60)}\n`);
101
+ }
102
+ process.stdout.write('\n');
103
+ }
104
+
105
+ process.stdout.write('Edit with: harness-dev config set <key> <value>\n');
106
+ process.stdout.write('Full docs: docs/CONFIGURATION.md\n');
107
+ return;
108
+ }
109
+
110
+ // ── get ──────────────────────────────────────────────────────────────────
111
+ if (sub === 'get') {
112
+ const key = pos[0] || null;
113
+ const { value, ok, error } = stateGet(targetDir, key);
114
+
115
+ if (json) {
116
+ process.stdout.write(JSON.stringify({
117
+ command: 'config',
118
+ subcommand: 'get',
119
+ key,
120
+ value: ok ? value : null,
121
+ status: ok ? 'ok' : 'error',
122
+ message: ok ? null : (error || 'Unknown error'),
123
+ }) + '\n');
124
+ return;
125
+ }
126
+
127
+ // Human output
128
+ if (!ok) {
129
+ process.stdout.write(`Config not available: ${error}\n`);
130
+ return;
131
+ }
132
+ if (key === null) {
133
+ process.stdout.write(JSON.stringify(value, null, 2) + '\n');
134
+ } else {
135
+ process.stdout.write(`${key} = ${JSON.stringify(value)}\n`);
136
+ }
137
+ return;
138
+ }
139
+
140
+ // ── set ──────────────────────────────────────────────────────────────────
141
+ if (sub === 'set') {
142
+ if (pos.length < 2) {
143
+ die(new CliError(
144
+ 'Usage: harness-dev config set <key> <value>\n' +
145
+ ' String values: config set mode copilot\n' +
146
+ ' Boolean values: config set gates.enabled true\n' +
147
+ ' Numeric values: config set maxRetries 5',
148
+ EXIT.USAGE_ERROR,
149
+ ), json);
150
+ return;
151
+ }
152
+
153
+ const key = pos[0];
154
+ const rawValue = pos.slice(1).join(' '); // support multi-word values
155
+
156
+ // Get parameter metadata for type-aware coercion
157
+ const meta = getParamMeta(key);
158
+
159
+ // Type coercion: try JSON for arrays/objects, then numbers and booleans
160
+ let parsedValue;
161
+ if (meta && (meta.type === 'array' || meta.type === 'object')) {
162
+ // Array/object types: parse as JSON
163
+ try {
164
+ parsedValue = JSON.parse(rawValue);
165
+ } catch {
166
+ // If JSON parse fails, keep as string (let type check below catch it)
167
+ parsedValue = rawValue;
168
+ }
169
+ } else if (rawValue === 'true') {
170
+ parsedValue = true;
171
+ } else if (rawValue === 'false') {
172
+ parsedValue = false;
173
+ } else if (/^-?\d+(\.\d+)?$/.test(rawValue)) {
174
+ parsedValue = Number(rawValue);
175
+ } else if (rawValue === 'null') {
176
+ parsedValue = null;
177
+ } else {
178
+ parsedValue = rawValue;
179
+ }
180
+
181
+ // Validate against config-registry if parameter is known
182
+ if (meta) {
183
+ // Check editability
184
+ if (!meta.editable) {
185
+ const msg = `"${key}" is read-only (managed by harness). Cannot set manually.`;
186
+ if (json) {
187
+ process.stdout.write(JSON.stringify({ command: 'config', subcommand: 'set', key, status: 'error', message: msg }) + '\n');
188
+ } else {
189
+ process.stderr.write(`✗ ${msg}\n`);
190
+ }
191
+ return;
192
+ }
193
+ // Check enum options
194
+ if (meta.options && !meta.options.includes(parsedValue)) {
195
+ const opts = meta.options.map(o => o === null ? 'null' : `"${o}"`).join(', ');
196
+ const msg = `Invalid value for "${key}". Allowed: ${opts}`;
197
+ if (json) {
198
+ process.stdout.write(JSON.stringify({ command: 'config', subcommand: 'set', key, value: parsedValue, status: 'error', message: msg }) + '\n');
199
+ } else {
200
+ process.stderr.write(`✗ ${msg}\n`);
201
+ }
202
+ return;
203
+ }
204
+ // Check type for integers
205
+ if (meta.type === 'integer' && typeof parsedValue !== 'number') {
206
+ const msg = `"${key}" expects an integer, got ${typeof parsedValue}`;
207
+ if (json) {
208
+ process.stdout.write(JSON.stringify({ command: 'config', subcommand: 'set', key, value: parsedValue, status: 'error', message: msg }) + '\n');
209
+ } else {
210
+ process.stderr.write(`✗ ${msg}\n`);
211
+ }
212
+ return;
213
+ }
214
+ // Check type for booleans
215
+ if (meta.type === 'boolean' && typeof parsedValue !== 'boolean') {
216
+ const msg = `"${key}" expects a boolean (true/false), got ${typeof parsedValue}`;
217
+ if (json) {
218
+ process.stdout.write(JSON.stringify({ command: 'config', subcommand: 'set', key, value: parsedValue, status: 'error', message: msg }) + '\n');
219
+ } else {
220
+ process.stderr.write(`✗ ${msg}\n`);
221
+ }
222
+ return;
223
+ }
224
+ // Check type for arrays
225
+ if (meta.type === 'array' && !Array.isArray(parsedValue)) {
226
+ const msg = `"${key}" expects a JSON array, got ${typeof parsedValue}`;
227
+ if (json) {
228
+ process.stdout.write(JSON.stringify({ command: 'config', subcommand: 'set', key, value: parsedValue, status: 'error', message: msg }) + '\n');
229
+ } else {
230
+ process.stderr.write(`✗ ${msg}\n`);
231
+ }
232
+ return;
233
+ }
234
+ // Check type for objects
235
+ if (meta.type === 'object' && (typeof parsedValue !== 'object' || Array.isArray(parsedValue) || parsedValue === null)) {
236
+ const msg = `"${key}" expects a JSON object, got ${Array.isArray(parsedValue) ? 'array' : typeof parsedValue}`;
237
+ if (json) {
238
+ process.stdout.write(JSON.stringify({ command: 'config', subcommand: 'set', key, value: parsedValue, status: 'error', message: msg }) + '\n');
239
+ } else {
240
+ process.stderr.write(`✗ ${msg}\n`);
241
+ }
242
+ return;
243
+ }
244
+ }
245
+
246
+ const result = stateSet(targetDir, key, parsedValue);
247
+
248
+ if (json) {
249
+ process.stdout.write(JSON.stringify({
250
+ command: 'config',
251
+ subcommand: 'set',
252
+ key,
253
+ value: parsedValue,
254
+ status: result.ok ? 'ok' : 'error',
255
+ message: result.ok
256
+ ? `Set ${key} = ${JSON.stringify(parsedValue)}`
257
+ : (result.error || 'Unknown error'),
258
+ }) + '\n');
259
+ return;
260
+ }
261
+
262
+ if (result.ok) {
263
+ process.stdout.write(`✓ ${key} = ${JSON.stringify(parsedValue)}\n`);
264
+ } else {
265
+ process.stderr.write(`✗ ${result.error}\n`);
266
+ }
267
+ }
268
+ }