@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,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * harness-init — scaffold the harness template tree into a target repository.
4
+ *
5
+ * Flags:
6
+ * --project-name <name> defaults to basename(cwd)
7
+ * --project-type <type> defaults to "unknown"
8
+ * --force overwrite an already-initialized repo
9
+ * --target-dir <path> scaffolding destination (defaults to cwd)
10
+ *
11
+ * Exits: 0 scaffold complete, 1 ValidationError (SCAFFOLD_CONFLICT), 2 env
12
+ * error, 64 usage error.
13
+ */
14
+
15
+ import { fileURLToPath } from "node:url";
16
+ import path from "node:path";
17
+ import { parse, helpText } from "../src/lib/argv.mjs";
18
+ import { createOutput } from "../src/lib/output.mjs";
19
+ import { EXIT_CODES } from "../src/lib/exit-codes.mjs";
20
+ import { formatError, ValidationError } from "../src/lib/errors.mjs";
21
+ import { version } from "../src/index.mjs";
22
+ import { scaffoldHarness } from "../src/index.mjs";
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+
26
+ const META = {
27
+ name: "harness-init",
28
+ synopsis: "harness-init [OPTIONS]",
29
+ description: "Scaffold the harness template tree (.claude/, docs/, .github/workflows/, githooks/) into the current repo.",
30
+ flags: {
31
+ "project-name": { type: "string" },
32
+ "project-type": { type: "string" },
33
+ "target-dir": { type: "string" },
34
+ force: { type: "boolean" },
35
+ },
36
+ };
37
+
38
+ let argv;
39
+ try {
40
+ argv = parse(process.argv.slice(2), META.flags);
41
+ } catch (err) {
42
+ process.stderr.write(`${err.message}\n`);
43
+ process.exit(EXIT_CODES.USAGE);
44
+ }
45
+
46
+ if (argv.help) {
47
+ process.stdout.write(`${helpText(META)}\n`);
48
+ process.exit(EXIT_CODES.OK);
49
+ }
50
+ if (argv.version) {
51
+ process.stdout.write(`${version}\n`);
52
+ process.exit(EXIT_CODES.OK);
53
+ }
54
+
55
+ const out = createOutput({ json: argv.json, noColor: argv.noColor });
56
+
57
+ const targetDir = /** @type {string} */ (argv.flags["target-dir"] ?? process.cwd());
58
+ const projectName = /** @type {string} */ (argv.flags["project-name"] ?? path.basename(targetDir));
59
+ const projectType = /** @type {string} */ (argv.flags["project-type"] ?? "unknown");
60
+ const force = Boolean(argv.flags.force);
61
+
62
+ const templatesDir = path.resolve(__dirname, "..", "templates");
63
+ const today = new Date().toISOString().slice(0, 10);
64
+
65
+ try {
66
+ const { filesWritten } = scaffoldHarness(
67
+ {
68
+ templatesDir,
69
+ targetDir,
70
+ placeholders: { project_name: projectName, project_type: projectType, today },
71
+ },
72
+ { force }
73
+ );
74
+ out.pass(`harness initialized in ${targetDir} (${filesWritten.length} files)`);
75
+ if (argv.verbose) {
76
+ for (const f of filesWritten) out.info(` ${f}`);
77
+ }
78
+ out.flush();
79
+ process.exit(EXIT_CODES.OK);
80
+ } catch (err) {
81
+ if (err instanceof ValidationError) {
82
+ out.fail(formatError(err, { verbose: argv.verbose }), err.toJSON());
83
+ out.flush();
84
+ process.exit(EXIT_CODES.VALIDATION);
85
+ }
86
+ out.fail(`scaffold failed: ${err.message}`);
87
+ out.flush();
88
+ process.exit(EXIT_CODES.ENV);
89
+ }
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * harness-validate-skills — validates `.claude/skills-manifest.json` against
4
+ * the sha256 checksums recorded for every indexed skill/command, and flags
5
+ * orphan files on disk + dependency cycles.
6
+ *
7
+ * Exits: 0 manifest valid, 1 one or more violations, 2 env error, 64 usage
8
+ * error.
9
+ */
10
+
11
+ import { parse, helpText } from "../src/lib/argv.mjs";
12
+ import { createOutput } from "../src/lib/output.mjs";
13
+ import { EXIT_CODES } from "../src/lib/exit-codes.mjs";
14
+ import { formatError } from "../src/lib/errors.mjs";
15
+ import { version } from "../src/index.mjs";
16
+ import {
17
+ createHarnessContext,
18
+ validateManifest,
19
+ refreshChecksums,
20
+ } from "../src/index.mjs";
21
+
22
+ const META = {
23
+ name: "harness-validate-skills",
24
+ synopsis: "harness-validate-skills [OPTIONS]",
25
+ description: "Validate .claude/skills-manifest.json checksums, orphans, and DAG. Use --update to rewrite checksums in place.",
26
+ flags: {
27
+ "repo-root": { type: "string" },
28
+ update: { type: "boolean" },
29
+ },
30
+ };
31
+
32
+ let argv;
33
+ try {
34
+ argv = parse(process.argv.slice(2), META.flags);
35
+ } catch (err) {
36
+ process.stderr.write(`${err.message}\n`);
37
+ process.exit(EXIT_CODES.USAGE);
38
+ }
39
+
40
+ if (argv.help) {
41
+ process.stdout.write(`${helpText(META)}\n`);
42
+ process.exit(EXIT_CODES.OK);
43
+ }
44
+ if (argv.version) {
45
+ process.stdout.write(`${version}\n`);
46
+ process.exit(EXIT_CODES.OK);
47
+ }
48
+
49
+ const out = createOutput({ json: argv.json, noColor: argv.noColor });
50
+
51
+ let ctx;
52
+ try {
53
+ ctx = createHarnessContext({ repoRoot: argv.flags["repo-root"] });
54
+ } catch (err) {
55
+ out.fail(`could not resolve repo root: ${err.message}`);
56
+ out.flush();
57
+ process.exit(EXIT_CODES.ENV);
58
+ }
59
+
60
+ if (argv.flags.update) {
61
+ try {
62
+ refreshChecksums(ctx);
63
+ out.pass(`manifest refreshed at ${ctx.manifestPath}`);
64
+ out.flush();
65
+ process.exit(EXIT_CODES.OK);
66
+ } catch (err) {
67
+ out.fail(`refresh failed: ${err.message}`);
68
+ out.flush();
69
+ process.exit(EXIT_CODES.ENV);
70
+ }
71
+ }
72
+
73
+ let result;
74
+ try {
75
+ result = validateManifest(ctx);
76
+ } catch (err) {
77
+ out.fail(err.message);
78
+ out.flush();
79
+ process.exit(EXIT_CODES.ENV);
80
+ }
81
+
82
+ if (result.ok) {
83
+ out.pass(`manifest valid (${result.manifest.skills.length} skills)`);
84
+ out.flush();
85
+ process.exit(EXIT_CODES.OK);
86
+ }
87
+
88
+ for (const err of result.errors) {
89
+ out.fail(formatError(err, { verbose: argv.verbose }), err.toJSON ? err.toJSON() : undefined);
90
+ }
91
+ out.flush();
92
+ process.exit(EXIT_CODES.VALIDATION);
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * harness-validate-specs — validates every `docs/specs/<id>/spec.json` in the
4
+ * repo against the structured-error contract from `validate-specs.mjs`.
5
+ *
6
+ * Exits: 0 all specs valid, 1 one or more validation failures, 2 env error,
7
+ * 64 usage error.
8
+ */
9
+
10
+ import { parse, helpText } from "../src/lib/argv.mjs";
11
+ import { createOutput } from "../src/lib/output.mjs";
12
+ import { EXIT_CODES } from "../src/lib/exit-codes.mjs";
13
+ import { formatError } from "../src/lib/errors.mjs";
14
+ import { version } from "../src/index.mjs";
15
+ import {
16
+ createHarnessContext,
17
+ listSpecDirs,
18
+ validateSpecs,
19
+ } from "../src/index.mjs";
20
+
21
+ const META = {
22
+ name: "harness-validate-specs",
23
+ synopsis: "harness-validate-specs [OPTIONS]",
24
+ description: "Validate every spec.json under docs/specs/ against the StructuredError contract.",
25
+ flags: {
26
+ "repo-root": { type: "string" },
27
+ },
28
+ };
29
+
30
+ let argv;
31
+ try {
32
+ argv = parse(process.argv.slice(2), META.flags);
33
+ } catch (err) {
34
+ process.stderr.write(`${err.message}\n`);
35
+ process.exit(EXIT_CODES.USAGE);
36
+ }
37
+
38
+ if (argv.help) {
39
+ process.stdout.write(`${helpText(META)}\n`);
40
+ process.exit(EXIT_CODES.OK);
41
+ }
42
+ if (argv.version) {
43
+ process.stdout.write(`${version}\n`);
44
+ process.exit(EXIT_CODES.OK);
45
+ }
46
+
47
+ const out = createOutput({ json: argv.json, noColor: argv.noColor });
48
+
49
+ let ctx;
50
+ try {
51
+ ctx = createHarnessContext({ repoRoot: argv.flags["repo-root"] });
52
+ } catch (err) {
53
+ out.fail(`could not resolve repo root: ${err.message}`);
54
+ out.flush();
55
+ process.exit(EXIT_CODES.ENV);
56
+ }
57
+
58
+ const result = validateSpecs(ctx);
59
+ if (result.ok) {
60
+ const count = listSpecDirs(ctx).length;
61
+ out.pass(`${count} spec(s) valid`);
62
+ out.flush();
63
+ process.exit(EXIT_CODES.OK);
64
+ }
65
+
66
+ for (const err of result.errors) {
67
+ out.fail(formatError(err, { verbose: argv.verbose }), err.toJSON ? err.toJSON() : undefined);
68
+ }
69
+ out.flush();
70
+ process.exit(EXIT_CODES.VALIDATION);
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Umbrella dispatcher. Usage:
4
+ *
5
+ * harness --version
6
+ * harness --help
7
+ * harness <subcommand> [args...] # delegates to harness-<subcommand>.mjs
8
+ *
9
+ * Known subcommands mirror the bin/* entries shipped by the package:
10
+ * validate-skills, validate-specs, check-spec-coverage,
11
+ * check-instruction-drift, detect-drift, doctor, init.
12
+ *
13
+ * Exits: 0 ok, 1 delegated failure, 2 env error, 64 usage error.
14
+ */
15
+
16
+ import { spawn } from "node:child_process";
17
+ import { fileURLToPath } from "node:url";
18
+ import { dirname, resolve } from "node:path";
19
+ import { existsSync } from "node:fs";
20
+ import { version } from "../src/index.mjs";
21
+ import { EXIT_CODES } from "../src/lib/exit-codes.mjs";
22
+
23
+ const SUBCOMMANDS = [
24
+ "validate-skills",
25
+ "validate-specs",
26
+ "check-spec-coverage",
27
+ "check-instruction-drift",
28
+ "detect-drift",
29
+ "doctor",
30
+ "init",
31
+ ];
32
+
33
+ function printUsage() {
34
+ process.stdout.write(
35
+ [
36
+ "harness — structured-error-emitting CLI for @kaiohenricunha/harness",
37
+ "",
38
+ "Usage:",
39
+ " harness <subcommand> [options]",
40
+ " harness --version",
41
+ " harness --help",
42
+ "",
43
+ "Subcommands:",
44
+ ...SUBCOMMANDS.map((s) => ` ${s.padEnd(26)}runs harness-${s}`),
45
+ "",
46
+ "Every subcommand also exists as a standalone bin (e.g. `npx harness-doctor`).",
47
+ "Each subcommand supports --help / --version / --json / --verbose / --no-color.",
48
+ "",
49
+ "Exit codes: 0 ok, 1 validation failure, 2 env error, 64 usage error.",
50
+ "",
51
+ ].join("\n")
52
+ );
53
+ }
54
+
55
+ const args = process.argv.slice(2);
56
+
57
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
58
+ printUsage();
59
+ process.exit(EXIT_CODES.OK);
60
+ }
61
+
62
+ if (args[0] === "--version" || args[0] === "-V") {
63
+ process.stdout.write(`${version}\n`);
64
+ process.exit(EXIT_CODES.OK);
65
+ }
66
+
67
+ const sub = args[0];
68
+ if (!SUBCOMMANDS.includes(sub)) {
69
+ process.stderr.write(
70
+ `harness: unknown subcommand '${sub}'. Run 'harness --help' for the list.\n`
71
+ );
72
+ process.exit(EXIT_CODES.USAGE);
73
+ }
74
+
75
+ const __dirname = dirname(fileURLToPath(import.meta.url));
76
+ const binPath = resolve(__dirname, `harness-${sub}.mjs`);
77
+ if (!existsSync(binPath)) {
78
+ process.stderr.write(
79
+ `harness: bin 'harness-${sub}' not found at ${binPath}. Did the package install correctly?\n`
80
+ );
81
+ process.exit(EXIT_CODES.ENV);
82
+ }
83
+
84
+ const child = spawn(process.execPath, [binPath, ...args.slice(1)], {
85
+ stdio: "inherit",
86
+ });
87
+ child.on("exit", (code, signal) => {
88
+ if (signal) {
89
+ process.kill(process.pid, signal);
90
+ return;
91
+ }
92
+ process.exit(code ?? EXIT_CODES.ENV);
93
+ });
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook: block destructive git operations.
3
+ # Reads JSON from stdin (Claude Code hook protocol).
4
+ # Exit 2 = block the tool call (Claude Code hook protocol — NOT the harness
5
+ # validator exit convention). Exit 0 = allow.
6
+ #
7
+ # Bypass: set BYPASS_DESTRUCTIVE_GIT=1 in the command's environment when you
8
+ # genuinely need to run a destructive git invocation. Use sparingly — the
9
+ # block exists because these operations are silently destructive.
10
+
11
+ # Fail open if jq is not installed (don't break all Bash tool calls).
12
+ if ! command -v jq >/dev/null 2>&1; then
13
+ exit 0
14
+ fi
15
+
16
+ if [ "${BYPASS_DESTRUCTIVE_GIT:-0}" = "1" ]; then
17
+ exit 0
18
+ fi
19
+
20
+ INPUT=$(cat)
21
+ TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')
22
+ [ "$TOOL" = "Bash" ] || exit 0
23
+
24
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
25
+ # Normalize whitespace: tabs -> space, collapse runs of whitespace so regex
26
+ # anchors only have to reason about single spaces.
27
+ NORM=$(printf '%s' "$CMD" | tr '\t' ' ' | tr -s ' ')
28
+
29
+ # Boundary before the `git` token is one of: start-of-line, whitespace, or a
30
+ # command-chaining separator (`;`, `&&`, `||`, `|`). This prevents false
31
+ # positives like `echo git` inside a quoted string while still catching
32
+ # `foo && git reset --hard`.
33
+ BOUNDARY='(^|[[:space:];&|])'
34
+ G='git[[:space:]]+'
35
+
36
+ # Destructive verbs. Each alternative is anchored on the right by at least one
37
+ # flag/keyword that makes the call unambiguously destructive.
38
+ PATTERNS=(
39
+ "${BOUNDARY}${G}reset[[:space:]]+--hard(\b|[[:space:]]|$)"
40
+ "${BOUNDARY}${G}push[[:space:]][^&;|]*(-f|--force|--force-with-lease)(\b|=|[[:space:]]|$)"
41
+ "${BOUNDARY}${G}clean[[:space:]][^&;|]*(-[a-zA-Z]*f[a-zA-Z]*|--force)(\b|=|[[:space:]]|$)"
42
+ "${BOUNDARY}${G}checkout[[:space:]]+\.(\b|$)"
43
+ "${BOUNDARY}${G}restore[[:space:]]+\.(\b|$)"
44
+ "${BOUNDARY}${G}branch[[:space:]]+-D\b"
45
+ "${BOUNDARY}${G}worktree[[:space:]]+remove[[:space:]]+--force\b"
46
+ )
47
+
48
+ for rx in "${PATTERNS[@]}"; do
49
+ if printf '%s' "$NORM" | grep -qE "$rx"; then
50
+ {
51
+ echo "BLOCKED: Destructive git operation detected. Get explicit user confirmation first."
52
+ echo " Bypass (only with user confirmation): BYPASS_DESTRUCTIVE_GIT=1 <your command>"
53
+ } >&2
54
+ exit 2
55
+ fi
56
+ done
57
+
58
+ exit 0
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // auto-update-manifest.mjs — thin pre-commit wrapper.
3
+ // Invokes harness-validate-skills --update from the package bin,
4
+ // using the current working directory as repo root.
5
+
6
+ import { execFileSync } from 'child_process';
7
+ import { fileURLToPath } from 'url';
8
+ import { join, dirname } from 'path';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const bin = join(__dirname, '..', 'bin', 'harness-validate-skills.mjs');
12
+
13
+ try {
14
+ execFileSync(process.execPath, [bin, '--update'], {
15
+ cwd: process.cwd(),
16
+ stdio: 'inherit',
17
+ });
18
+ } catch (e) {
19
+ process.exit(e.status ?? 1);
20
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // detect-branch-drift.mjs — walk .claude/commands/*.md on HEAD,
3
+ // diff each against origin/main, report a table. Exit 1 if any file
4
+ // has diverged AND its last-main-commit is >14 days old.
5
+
6
+ import { execFileSync } from 'child_process';
7
+
8
+ const DAY_S = 86400;
9
+ const STALE_DAYS = 14;
10
+
11
+ function exec(cmd, args, cwd) {
12
+ try {
13
+ return execFileSync(cmd, args, { cwd, encoding: 'utf8' }).trim();
14
+ } catch (e) {
15
+ return '';
16
+ }
17
+ }
18
+
19
+ // Parse --repo-root flag
20
+ const args = process.argv.slice(2);
21
+ let repoRoot;
22
+ const flagIdx = args.indexOf('--repo-root');
23
+ if (flagIdx !== -1) {
24
+ repoRoot = args[flagIdx + 1];
25
+ } else {
26
+ repoRoot = exec('git', ['rev-parse', '--show-toplevel'], process.cwd());
27
+ }
28
+
29
+ if (!repoRoot) {
30
+ console.error('Could not determine repo root. Use --repo-root <path>.');
31
+ process.exit(2);
32
+ }
33
+
34
+ // List .claude/commands/*.md files tracked on HEAD
35
+ const lsTree = exec('git', ['ls-tree', '-r', '--name-only', 'HEAD', '.claude/commands/'], repoRoot);
36
+ const files = lsTree.split('\n').filter(f => f.endsWith('.md'));
37
+
38
+ if (files.length === 0) {
39
+ console.log('no drift detected (no .claude/commands/*.md on HEAD)');
40
+ process.exit(0);
41
+ }
42
+
43
+ const now = Math.floor(Date.now() / 1000);
44
+ const rows = [];
45
+ let anyStale = false;
46
+
47
+ for (const file of files) {
48
+ const diff = exec('git', ['diff', 'origin/main', '--', file], repoRoot);
49
+ const diverged = diff.length > 0;
50
+
51
+ let daysBehind = 0;
52
+ if (diverged) {
53
+ const tsStr = exec('git', ['log', '-1', '--format=%ct', 'origin/main', '--', file], repoRoot);
54
+ const ts = parseInt(tsStr, 10);
55
+ if (!isNaN(ts) && ts > 0) {
56
+ daysBehind = Math.floor((now - ts) / DAY_S);
57
+ }
58
+ if (daysBehind > STALE_DAYS) anyStale = true;
59
+ }
60
+
61
+ rows.push({ file, diverged, daysBehind });
62
+ }
63
+
64
+ const anyDiverged = rows.some(r => r.diverged);
65
+ if (!anyDiverged) {
66
+ console.log('no drift detected');
67
+ process.exit(0);
68
+ }
69
+
70
+ // Print table
71
+ const header = `${'FILE'.padEnd(50)} ${'DIVERGED'.padEnd(10)} DAYS-BEHIND-MAIN`;
72
+ console.log(header);
73
+ console.log('-'.repeat(header.length));
74
+ for (const r of rows) {
75
+ const name = r.file.padEnd(50);
76
+ const div = (r.diverged ? 'yes' : 'no').padEnd(10);
77
+ const days = r.diverged ? String(r.daysBehind) : '-';
78
+ console.log(`${name} ${div} ${days}`);
79
+ }
80
+
81
+ process.exit(anyStale ? 1 : 0);
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bash
2
+ # output.sh — shared ✓/✗/⚠ helpers for every harness shell script.
3
+ #
4
+ # Gold-standard originates from plugins/harness/scripts/validate-settings.sh:43-45.
5
+ # Every consumer should:
6
+ #
7
+ # # shellcheck source=plugins/harness/scripts/lib/output.sh
8
+ # source "$(dirname "${BASH_SOURCE[0]}")/lib/output.sh"
9
+ # out_init # sets G/R/Y/N + FAIL/WARN globals, honors --json + NO_COLOR
10
+ # pass "JSON well-formed"
11
+ # fail "SEC-2 skipDangerousModePermissionPrompt is set"
12
+ # warn "projects/ close to budget"
13
+ # out_summary # prints "Summary: N failure(s), N warning(s)"
14
+ #
15
+ # When HARNESS_JSON=1 is set, pass/fail/warn buffer JSON objects with the shape
16
+ # { "check": "...", "category": "...", "status": "pass|fail|warn", "message": "..." }
17
+ # into HARNESS_JSON_BUFFER; callers flush with `out_flush`. The `category`
18
+ # defaults to the CATEGORY env; individual calls can override with the
19
+ # two-argument form: `fail SEC-2 "skipDangerous... is set"`.
20
+
21
+ # Shell-scoped globals are set by out_init. Declaring defaults here keeps
22
+ # callers working even if they forget out_init and lets linters see the
23
+ # assignments.
24
+ FAIL=${FAIL:-0}
25
+ WARN=${WARN:-0}
26
+ G=""; R=""; Y=""; N=""
27
+ HARNESS_JSON=${HARNESS_JSON:-0}
28
+ HARNESS_JSON_BUFFER=""
29
+ # shellcheck disable=SC2034 # CATEGORY is consumed by sourced scripts
30
+ CATEGORY=${CATEGORY:-general}
31
+
32
+ out_init() {
33
+ FAIL=0
34
+ WARN=0
35
+ HARNESS_JSON_BUFFER=""
36
+ if [ "${HARNESS_JSON:-0}" = "1" ] || [ "${NO_COLOR:-}" != "" ] || [ ! -t 1 ]; then
37
+ G=""; R=""; Y=""; N=""
38
+ else
39
+ G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; N=$'\033[0m'
40
+ fi
41
+ }
42
+
43
+ # Internal: buffer one JSON object. Escapes the double-quotes and backslashes in $message.
44
+ _out_json_push() {
45
+ local status="$1" check="$2" message="$3"
46
+ local cat="${4:-$CATEGORY}"
47
+ # Escape backslash then double-quote for safe JSON inclusion.
48
+ message=${message//\\/\\\\}
49
+ message=${message//\"/\\\"}
50
+ check=${check//\\/\\\\}
51
+ check=${check//\"/\\\"}
52
+ local entry
53
+ entry=$(printf '{"check":"%s","category":"%s","status":"%s","message":"%s"}' \
54
+ "$check" "$cat" "$status" "$message")
55
+ if [ -z "$HARNESS_JSON_BUFFER" ]; then
56
+ HARNESS_JSON_BUFFER="$entry"
57
+ else
58
+ HARNESS_JSON_BUFFER="$HARNESS_JSON_BUFFER,$entry"
59
+ fi
60
+ }
61
+
62
+ pass() {
63
+ local msg="$1"
64
+ if [ "${HARNESS_JSON:-0}" = "1" ]; then
65
+ _out_json_push pass "${2:-$msg}" "$msg"
66
+ else
67
+ printf ' %s✓%s %s\n' "$G" "$N" "$msg"
68
+ fi
69
+ }
70
+
71
+ fail() {
72
+ local msg="$1"
73
+ FAIL=$((FAIL+1))
74
+ if [ "${HARNESS_JSON:-0}" = "1" ]; then
75
+ _out_json_push fail "${2:-$msg}" "$msg"
76
+ else
77
+ printf ' %s✗%s %s\n' "$R" "$N" "$msg"
78
+ fi
79
+ }
80
+
81
+ warn() {
82
+ local msg="$1"
83
+ WARN=$((WARN+1))
84
+ if [ "${HARNESS_JSON:-0}" = "1" ]; then
85
+ _out_json_push warn "${2:-$msg}" "$msg"
86
+ else
87
+ printf ' %s⚠%s %s\n' "$Y" "$N" "$msg"
88
+ fi
89
+ }
90
+
91
+ out_flush() {
92
+ if [ "${HARNESS_JSON:-0}" = "1" ]; then
93
+ printf '{"events":[%s],"counts":{"fail":%d,"warn":%d}}\n' \
94
+ "$HARNESS_JSON_BUFFER" "$FAIL" "$WARN"
95
+ fi
96
+ }
97
+
98
+ out_summary() {
99
+ if [ "${HARNESS_JSON:-0}" = "1" ]; then
100
+ out_flush
101
+ else
102
+ echo
103
+ echo "Summary: $FAIL failure(s), $WARN warning(s)"
104
+ fi
105
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ # refresh-worktrees.sh — for each active worktree under .claude/worktrees/,
3
+ # run `git fetch origin main` and `git merge --ff-only origin/main` if the
4
+ # worktree is clean. Report (and skip) any dirty worktree.
5
+
6
+ set -euo pipefail
7
+
8
+ ROOT="${1:-$(git rev-parse --show-toplevel)}"
9
+ cd "$ROOT"
10
+
11
+ WT_BASE="$ROOT/.claude/worktrees"
12
+ [ -d "$WT_BASE" ] || { echo "no worktrees at $WT_BASE"; exit 0; }
13
+
14
+ git fetch origin main
15
+
16
+ for wt in "$WT_BASE"/*/; do
17
+ [ -d "$wt" ] || continue
18
+ name=$(basename "$wt")
19
+ (
20
+ cd "$wt"
21
+ if ! git diff --quiet || ! git diff --cached --quiet; then
22
+ echo "SKIP (dirty): $name"
23
+ exit 0
24
+ fi
25
+ if git merge-base --is-ancestor origin/main HEAD; then
26
+ echo "OK: $name (up to date)"
27
+ exit 0
28
+ fi
29
+ if git merge --ff-only origin/main; then
30
+ echo "FF: $name"
31
+ else
32
+ echo "CONFLICT: $name (manual resolution needed)"
33
+ fi
34
+ )
35
+ done