@kaiohenricunha/harness 0.2.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +130 -0
  3. package/package.json +68 -0
  4. package/plugins/harness/.claude-plugin/plugin.json +8 -0
  5. package/plugins/harness/README.md +74 -0
  6. package/plugins/harness/bin/harness-check-instruction-drift.mjs +77 -0
  7. package/plugins/harness/bin/harness-check-spec-coverage.mjs +81 -0
  8. package/plugins/harness/bin/harness-detect-drift.mjs +53 -0
  9. package/plugins/harness/bin/harness-doctor.mjs +145 -0
  10. package/plugins/harness/bin/harness-init.mjs +89 -0
  11. package/plugins/harness/bin/harness-validate-skills.mjs +92 -0
  12. package/plugins/harness/bin/harness-validate-specs.mjs +70 -0
  13. package/plugins/harness/bin/harness.mjs +93 -0
  14. package/plugins/harness/hooks/guard-destructive-git.sh +58 -0
  15. package/plugins/harness/scripts/auto-update-manifest.mjs +20 -0
  16. package/plugins/harness/scripts/detect-branch-drift.mjs +81 -0
  17. package/plugins/harness/scripts/lib/output.sh +105 -0
  18. package/plugins/harness/scripts/refresh-worktrees.sh +35 -0
  19. package/plugins/harness/scripts/validate-settings.sh +202 -0
  20. package/plugins/harness/src/check-instruction-drift.mjs +127 -0
  21. package/plugins/harness/src/check-spec-coverage.mjs +95 -0
  22. package/plugins/harness/src/index.mjs +57 -0
  23. package/plugins/harness/src/init-harness-scaffold.mjs +121 -0
  24. package/plugins/harness/src/lib/argv.mjs +108 -0
  25. package/plugins/harness/src/lib/debug.mjs +37 -0
  26. package/plugins/harness/src/lib/errors.mjs +147 -0
  27. package/plugins/harness/src/lib/exit-codes.mjs +18 -0
  28. package/plugins/harness/src/lib/output.mjs +90 -0
  29. package/plugins/harness/src/spec-harness-lib.mjs +359 -0
  30. package/plugins/harness/src/validate-skills-inventory.mjs +148 -0
  31. package/plugins/harness/src/validate-specs.mjs +217 -0
  32. package/plugins/harness/templates/claude/hooks/guard-destructive-git.sh +50 -0
  33. package/plugins/harness/templates/claude/settings.headless.json +24 -0
  34. package/plugins/harness/templates/claude/settings.json +16 -0
  35. package/plugins/harness/templates/claude/skills-manifest.json +6 -0
  36. package/plugins/harness/templates/docs/repo-facts.json +17 -0
  37. package/plugins/harness/templates/docs/specs/README.md +36 -0
  38. package/plugins/harness/templates/githooks/pre-commit +9 -0
  39. package/plugins/harness/templates/workflows/ai-review.yml +28 -0
  40. package/plugins/harness/templates/workflows/detect-drift.yml +15 -0
  41. package/plugins/harness/templates/workflows/validate-skills.yml +36 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tiny argv parser built on Node's `util.parseArgs` (available since Node 18,
3
+ * engines.node `>=20`).
4
+ *
5
+ * Recognizes the harness-wide flag set:
6
+ * --help / -h bool
7
+ * --version / -V bool
8
+ * --json bool
9
+ * --verbose / -v bool
10
+ * --no-color bool
11
+ *
12
+ * Callers add their own flags via the `spec` parameter; the harness-wide set
13
+ * is merged in automatically.
14
+ *
15
+ * @typedef {object} FlagSpec
16
+ * @property {'boolean'|'string'} type
17
+ * @property {string} [short] single-letter alias
18
+ * @property {string|boolean} [default]
19
+ * @property {boolean} [multiple] allow repetition (array of values)
20
+ *
21
+ * @typedef {{ [name: string]: FlagSpec }} FlagsSpec
22
+ *
23
+ * @typedef {object} ParsedArgs
24
+ * @property {{ [name: string]: boolean|string|string[]|undefined }} flags
25
+ * @property {string[]} positional
26
+ * @property {boolean} help
27
+ * @property {boolean} version
28
+ * @property {boolean} json
29
+ * @property {boolean} verbose
30
+ * @property {boolean} noColor
31
+ */
32
+
33
+ import { parseArgs } from 'node:util';
34
+
35
+ /** Harness-wide flag set auto-included in every `parse()` call. */
36
+ export const HARNESS_FLAGS = Object.freeze({
37
+ help: { type: 'boolean', short: 'h' },
38
+ version: { type: 'boolean', short: 'V' },
39
+ json: { type: 'boolean' },
40
+ verbose: { type: 'boolean', short: 'v' },
41
+ 'no-color': { type: 'boolean' },
42
+ });
43
+
44
+ /**
45
+ * Parse `argv` (typically `process.argv.slice(2)`) against `spec`.
46
+ * Throws `Error` with a `.code = 'USAGE_UNKNOWN_FLAG'` property on unknown
47
+ * flags so callers can map to `EXIT_CODES.USAGE` without string matching.
48
+ *
49
+ * @param {string[]} argv
50
+ * @param {FlagsSpec} [spec]
51
+ * @returns {ParsedArgs}
52
+ */
53
+ export function parse(argv, spec = {}) {
54
+ const merged = { ...HARNESS_FLAGS, ...spec };
55
+ let parsed;
56
+ try {
57
+ parsed = parseArgs({
58
+ args: argv,
59
+ options: /** @type {any} */ (merged),
60
+ allowPositionals: true,
61
+ strict: true,
62
+ });
63
+ } catch (err) {
64
+ const wrapped = new Error(err instanceof Error ? err.message : String(err));
65
+ /** @type {any} */ (wrapped).code = 'USAGE_UNKNOWN_FLAG';
66
+ throw wrapped;
67
+ }
68
+ const values = /** @type {Record<string, any>} */ (parsed.values);
69
+ return {
70
+ flags: values,
71
+ positional: parsed.positionals,
72
+ help: Boolean(values.help),
73
+ version: Boolean(values.version),
74
+ json: Boolean(values.json),
75
+ verbose: Boolean(values.verbose),
76
+ noColor: Boolean(values['no-color']),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Render a conventional `--help` message.
82
+ *
83
+ * @param {object} meta
84
+ * @param {string} meta.name e.g. "harness-validate-specs"
85
+ * @param {string} meta.synopsis e.g. "harness-validate-specs [OPTIONS]"
86
+ * @param {string} meta.description 1-2 sentence summary.
87
+ * @param {FlagsSpec} [meta.flags] Bin-specific flags to document (harness-wide flags are always appended).
88
+ * @returns {string}
89
+ */
90
+ export function helpText(meta) {
91
+ const lines = [
92
+ meta.synopsis,
93
+ '',
94
+ meta.description,
95
+ '',
96
+ 'Options:',
97
+ ];
98
+ const all = { ...(meta.flags ?? {}), ...HARNESS_FLAGS };
99
+ const longest = Math.max(...Object.keys(all).map((k) => k.length + 2));
100
+ for (const [name, def] of Object.entries(all)) {
101
+ const long = `--${name}`;
102
+ const short = /** @type {any} */ (def).short ? `, -${/** @type {any} */ (def).short}` : '';
103
+ lines.push(` ${long}${short}`.padEnd(longest + 6) + (def.type === 'string' ? '<value>' : ''));
104
+ }
105
+ lines.push('');
106
+ lines.push('Exit codes: 0 ok, 1 validation failure, 2 env error, 64 usage error.');
107
+ return lines.join('\n');
108
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Debug logger gated on `HARNESS_DEBUG=1`.
3
+ *
4
+ * Replaces silent `catch` blocks in `spec-harness-lib.mjs:28-29` and `:184-186`
5
+ * (legacy behavior) with opt-in diagnostic output. When the env flag is unset,
6
+ * `debug()` is a no-op — zero runtime cost in normal operation.
7
+ *
8
+ * Usage:
9
+ * } catch (err) {
10
+ * debug('git:rev-parse', err.message);
11
+ * return null;
12
+ * }
13
+ *
14
+ * @param {string} tag
15
+ * @param {...unknown} args
16
+ * @returns {void}
17
+ */
18
+ export function debug(tag, ...args) {
19
+ if (process.env.HARNESS_DEBUG !== '1') return;
20
+ process.stderr.write(`[harness:${tag}] ${args.map(stringify).join(' ')}\n`);
21
+ }
22
+
23
+ /** @returns {boolean} */
24
+ export function isDebug() {
25
+ return process.env.HARNESS_DEBUG === '1';
26
+ }
27
+
28
+ /** @param {unknown} v */
29
+ function stringify(v) {
30
+ if (v instanceof Error) return v.stack ?? v.message;
31
+ if (typeof v === 'string') return v;
32
+ try {
33
+ return JSON.stringify(v);
34
+ } catch {
35
+ return String(v);
36
+ }
37
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Structured error taxonomy for every harness validator.
3
+ *
4
+ * Every validator emits instances of `ValidationError` rather than raw strings.
5
+ * This gives consumers a machine-parseable `.code`, `.file`, and `.pointer`
6
+ * while keeping `.toString()` readable for human-first CLI output.
7
+ *
8
+ * @typedef {object} StructuredError
9
+ * @property {string} code Stable enum value (see `ERROR_CODES`). Never rename.
10
+ * @property {string} message Human-readable one-liner. May contain identifier names.
11
+ * @property {string} [file] Repo-relative path where the error was detected.
12
+ * @property {string} [pointer] JSON Pointer or dotted key when the error is inside a structured file.
13
+ * @property {number} [line] 1-indexed line number when available.
14
+ * @property {string} [expected] Text describing what the validator expected.
15
+ * @property {string} [got] Text describing what was observed.
16
+ * @property {string} [hint] Actionable remediation suggestion.
17
+ * @property {'spec'|'skill'|'manifest'|'coverage'|'drift'|'scaffold'|'settings'|'env'|'usage'} [category]
18
+ */
19
+
20
+ /**
21
+ * Stable error codes. Consumers may match on these; renames are breaking changes.
22
+ */
23
+ export const ERROR_CODES = Object.freeze({
24
+ // spec
25
+ SPEC_JSON_INVALID: 'SPEC_JSON_INVALID',
26
+ SPEC_STATUS_INVALID: 'SPEC_STATUS_INVALID',
27
+ SPEC_MISSING_REQUIRED_FIELD: 'SPEC_MISSING_REQUIRED_FIELD',
28
+ SPEC_ID_MISMATCH: 'SPEC_ID_MISMATCH',
29
+ SPEC_LINKED_PATH_MISSING: 'SPEC_LINKED_PATH_MISSING',
30
+ SPEC_ACCEPTANCE_EMPTY: 'SPEC_ACCEPTANCE_EMPTY',
31
+ SPEC_DEPENDENCY_UNKNOWN: 'SPEC_DEPENDENCY_UNKNOWN',
32
+ // skill
33
+ SKILL_FRONTMATTER_MISSING: 'SKILL_FRONTMATTER_MISSING',
34
+ SKILL_NAME_MISMATCH: 'SKILL_NAME_MISMATCH',
35
+ // manifest
36
+ MANIFEST_CHECKSUM_MISMATCH: 'MANIFEST_CHECKSUM_MISMATCH',
37
+ MANIFEST_ENTRY_MISSING: 'MANIFEST_ENTRY_MISSING',
38
+ MANIFEST_ORPHAN_FILE: 'MANIFEST_ORPHAN_FILE',
39
+ MANIFEST_DEPENDENCY_CYCLE: 'MANIFEST_DEPENDENCY_CYCLE',
40
+ // coverage
41
+ COVERAGE_UNCOVERED: 'COVERAGE_UNCOVERED',
42
+ COVERAGE_NO_SPEC_RATIONALE: 'COVERAGE_NO_SPEC_RATIONALE',
43
+ COVERAGE_UNKNOWN_SPEC_ID: 'COVERAGE_UNKNOWN_SPEC_ID',
44
+ // drift
45
+ DRIFT_TEAM_COUNT: 'DRIFT_TEAM_COUNT',
46
+ DRIFT_PROTECTED_PATH: 'DRIFT_PROTECTED_PATH',
47
+ DRIFT_INSTRUCTION_FILES: 'DRIFT_INSTRUCTION_FILES',
48
+ DRIFT_INSTRUCTION_FILE_MISSING: 'DRIFT_INSTRUCTION_FILE_MISSING',
49
+ // scaffold
50
+ SCAFFOLD_CONFLICT: 'SCAFFOLD_CONFLICT',
51
+ SCAFFOLD_USAGE: 'SCAFFOLD_USAGE',
52
+ // settings / hooks (sh validator parity)
53
+ SETTINGS_SEC_1: 'SETTINGS_SEC_1',
54
+ SETTINGS_SEC_2: 'SETTINGS_SEC_2',
55
+ SETTINGS_SEC_3: 'SETTINGS_SEC_3',
56
+ SETTINGS_SEC_4: 'SETTINGS_SEC_4',
57
+ SETTINGS_OPS_1: 'SETTINGS_OPS_1',
58
+ SETTINGS_OPS_2: 'SETTINGS_OPS_2',
59
+ // env / usage
60
+ ENV_REPO_ROOT_UNKNOWN: 'ENV_REPO_ROOT_UNKNOWN',
61
+ ENV_FACTS_MISSING: 'ENV_FACTS_MISSING',
62
+ USAGE_UNKNOWN_FLAG: 'USAGE_UNKNOWN_FLAG',
63
+ USAGE_MISSING_POSITIONAL: 'USAGE_MISSING_POSITIONAL',
64
+ });
65
+
66
+ /**
67
+ * Structured validator error. Extends `Error` so existing `throw`/`catch`
68
+ * paths continue to work; extra properties give consumers a machine-parseable
69
+ * view.
70
+ */
71
+ export class ValidationError extends Error {
72
+ /**
73
+ * @param {StructuredError} details
74
+ */
75
+ constructor(details) {
76
+ if (!details || typeof details !== 'object') {
77
+ throw new TypeError('ValidationError requires a StructuredError details object');
78
+ }
79
+ if (!details.code || typeof details.code !== 'string') {
80
+ throw new TypeError('ValidationError requires a non-empty `code`');
81
+ }
82
+ if (!details.message || typeof details.message !== 'string') {
83
+ throw new TypeError('ValidationError requires a non-empty `message`');
84
+ }
85
+ super(details.message);
86
+ this.name = 'ValidationError';
87
+ this.code = details.code;
88
+ if (details.file !== undefined) this.file = details.file;
89
+ if (details.pointer !== undefined) this.pointer = details.pointer;
90
+ if (details.line !== undefined) this.line = details.line;
91
+ if (details.expected !== undefined) this.expected = details.expected;
92
+ if (details.got !== undefined) this.got = details.got;
93
+ if (details.hint !== undefined) this.hint = details.hint;
94
+ if (details.category !== undefined) this.category = details.category;
95
+ }
96
+
97
+ /**
98
+ * Legacy string format: `"<file>: <message>"` so existing tests and CI
99
+ * pipelines that regex on stderr continue to work unchanged.
100
+ * @returns {string}
101
+ */
102
+ toString() {
103
+ return this.file ? `${this.file}: ${this.message}` : this.message;
104
+ }
105
+
106
+ /**
107
+ * JSON-safe representation for `--json` output.
108
+ * @returns {StructuredError}
109
+ */
110
+ toJSON() {
111
+ /** @type {StructuredError} */
112
+ const out = { code: this.code, message: this.message };
113
+ if (this.file !== undefined) out.file = this.file;
114
+ if (this.pointer !== undefined) out.pointer = this.pointer;
115
+ if (this.line !== undefined) out.line = this.line;
116
+ if (this.expected !== undefined) out.expected = this.expected;
117
+ if (this.got !== undefined) out.got = this.got;
118
+ if (this.hint !== undefined) out.hint = this.hint;
119
+ if (this.category !== undefined) out.category = this.category;
120
+ return out;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Render a single error for human-readable CLI output. Mirrors the
126
+ * `✗ <message>` prefix style used by `plugins/harness/scripts/validate-settings.sh:43-45`.
127
+ *
128
+ * @param {ValidationError | StructuredError | Error} err
129
+ * @param {{ verbose?: boolean }} [opts]
130
+ * @returns {string}
131
+ */
132
+ export function formatError(err, opts = {}) {
133
+ const verbose = Boolean(opts.verbose);
134
+ const prefix = err.file ? `${err.file}: ` : '';
135
+ const head = `${prefix}${err.message ?? String(err)}`;
136
+ if (!verbose) return head;
137
+
138
+ const tail = [];
139
+ if (err.code) tail.push(` code: ${err.code}`);
140
+ if (err.pointer !== undefined) tail.push(` pointer: ${err.pointer}`);
141
+ if (err.line !== undefined) tail.push(` line: ${err.line}`);
142
+ if (err.expected !== undefined) tail.push(` expected: ${err.expected}`);
143
+ if (err.got !== undefined) tail.push(` got: ${err.got}`);
144
+ if (err.hint !== undefined) tail.push(` hint: ${err.hint}`);
145
+ if (err.category !== undefined) tail.push(` category: ${err.category}`);
146
+ return tail.length > 0 ? `${head}\n${tail.join('\n')}` : head;
147
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Named exit code enum for every harness bin.
3
+ *
4
+ * Convention:
5
+ * - 0 OK — success
6
+ * - 1 VALIDATION — one or more validation rules failed (the expected failure mode)
7
+ * - 2 ENV — misconfigured environment (missing file, bad git repo, unreadable facts)
8
+ * - 64 USAGE — bad CLI invocation (unknown flag, missing required positional)
9
+ *
10
+ * 64 mirrors BSD sysexits.h EX_USAGE so operators can distinguish user error
11
+ * from a real validation failure in CI pipelines.
12
+ */
13
+ export const EXIT_CODES = Object.freeze({
14
+ OK: 0,
15
+ VALIDATION: 1,
16
+ ENV: 2,
17
+ USAGE: 64,
18
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared CLI output primitives — the JS parity of
3
+ * `plugins/harness/scripts/validate-settings.sh:43-45`.
4
+ *
5
+ * Provides the same ✓/✗/⚠ format with ANSI coloring when the stream is a TTY,
6
+ * plus a `--json` buffer-and-flush mode so CI pipelines can consume a single
7
+ * JSON array of events instead of regexing stderr.
8
+ *
9
+ * @typedef {'pass'|'fail'|'warn'|'info'} OutputKind
10
+ *
11
+ * @typedef {object} OutputEvent
12
+ * @property {OutputKind} kind
13
+ * @property {string} message
14
+ * @property {object} [details] Optional structured payload (e.g. `ValidationError.toJSON()`).
15
+ *
16
+ * @typedef {object} Output
17
+ * @property {(msg: string) => void} pass
18
+ * @property {(msg: string, details?: object) => void} fail
19
+ * @property {(msg: string, details?: object) => void} warn
20
+ * @property {(msg: string) => void} info
21
+ * @property {() => void} flush Emit buffered JSON when in json mode. No-op otherwise.
22
+ * @property {() => { fail: number, warn: number, pass: number }} counts
23
+ *
24
+ * @typedef {object} OutputOptions
25
+ * @property {boolean} [json] When true, buffer events and emit a JSON array on flush().
26
+ * @property {boolean} [noColor] When true, suppress ANSI escapes regardless of TTY.
27
+ * @property {NodeJS.WritableStream} [stream] Defaults to process.stdout.
28
+ * @property {NodeJS.ProcessEnv} [env] Defaults to process.env.
29
+ */
30
+
31
+ const GREEN = '\x1b[32m';
32
+ const RED = '\x1b[31m';
33
+ const YELLOW = '\x1b[33m';
34
+ const RESET = '\x1b[0m';
35
+
36
+ /**
37
+ * Construct an `Output` with the given behavior flags.
38
+ *
39
+ * @param {OutputOptions} [opts]
40
+ * @returns {Output}
41
+ */
42
+ export function createOutput(opts = {}) {
43
+ const env = opts.env ?? process.env;
44
+ const stream = opts.stream ?? process.stdout;
45
+ const json = Boolean(opts.json);
46
+ const noColor = Boolean(opts.noColor) || 'NO_COLOR' in env;
47
+ const useAnsi = !json && !noColor && Boolean(stream.isTTY);
48
+
49
+ const counts = { pass: 0, fail: 0, warn: 0 };
50
+ /** @type {OutputEvent[]} */
51
+ const buffer = [];
52
+
53
+ const color = (code, text) => (useAnsi ? `${code}${text}${RESET}` : text);
54
+
55
+ /**
56
+ * @param {OutputKind} kind
57
+ * @param {string} message
58
+ * @param {object} [details]
59
+ */
60
+ const emit = (kind, message, details) => {
61
+ if (kind === 'pass') counts.pass++;
62
+ else if (kind === 'fail') counts.fail++;
63
+ else if (kind === 'warn') counts.warn++;
64
+ if (json) {
65
+ /** @type {OutputEvent} */
66
+ const event = { kind, message };
67
+ if (details !== undefined) event.details = details;
68
+ buffer.push(event);
69
+ return;
70
+ }
71
+ let glyph;
72
+ if (kind === 'pass') glyph = color(GREEN, '✓');
73
+ else if (kind === 'fail') glyph = color(RED, '✗');
74
+ else if (kind === 'warn') glyph = color(YELLOW, '⚠');
75
+ else glyph = ' ';
76
+ stream.write(` ${glyph} ${message}\n`);
77
+ };
78
+
79
+ return {
80
+ pass: (msg) => emit('pass', msg),
81
+ fail: (msg, details) => emit('fail', msg, details),
82
+ warn: (msg, details) => emit('warn', msg, details),
83
+ info: (msg) => emit('info', msg),
84
+ flush: () => {
85
+ if (!json) return;
86
+ stream.write(JSON.stringify({ events: buffer, counts }, null, 2) + '\n');
87
+ },
88
+ counts: () => ({ ...counts }),
89
+ };
90
+ }