@lannguyensi/harness 0.32.0 → 0.34.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 (54) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +8 -5
  3. package/dist/cli/adopt/index.d.ts +7 -0
  4. package/dist/cli/adopt/index.js +10 -0
  5. package/dist/cli/adopt/index.js.map +1 -1
  6. package/dist/cli/apply/apply.d.ts +14 -0
  7. package/dist/cli/apply/apply.js +21 -6
  8. package/dist/cli/apply/apply.js.map +1 -1
  9. package/dist/cli/approve/branch-protection.d.ts +69 -0
  10. package/dist/cli/approve/branch-protection.js +157 -0
  11. package/dist/cli/approve/branch-protection.js.map +1 -0
  12. package/dist/cli/approve/understanding.d.ts +12 -0
  13. package/dist/cli/approve/understanding.js +50 -3
  14. package/dist/cli/approve/understanding.js.map +1 -1
  15. package/dist/cli/gc/index.d.ts +47 -0
  16. package/dist/cli/gc/index.js +148 -0
  17. package/dist/cli/gc/index.js.map +1 -0
  18. package/dist/cli/index.js +174 -14
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/init/composer.js +11 -5
  21. package/dist/cli/init/composer.js.map +1 -1
  22. package/dist/cli/init/profiles.d.ts +2 -2
  23. package/dist/cli/init/profiles.js +2 -2
  24. package/dist/cli/init/templates.d.ts +1 -1
  25. package/dist/cli/init/templates.js +8 -4
  26. package/dist/cli/init/templates.js.map +1 -1
  27. package/dist/cli/pack/hook-branch-protection.d.ts +8 -0
  28. package/dist/cli/pack/hook-branch-protection.js +59 -15
  29. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  30. package/dist/cli/pack/hook-pre-tool-use.js +31 -2
  31. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  32. package/dist/cli/pack/hook-solution-acceptance.d.ts +2 -0
  33. package/dist/cli/pack/hook-solution-acceptance.js +24 -10
  34. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  35. package/dist/cli/pack/read-only-bash.js +127 -4
  36. package/dist/cli/pack/read-only-bash.js.map +1 -1
  37. package/dist/cli/uninstall/index.d.ts +24 -5
  38. package/dist/cli/uninstall/index.js +73 -6
  39. package/dist/cli/uninstall/index.js.map +1 -1
  40. package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +47 -6
  41. package/dist/policy-packs/builtin/branch-protection-runtime.js +53 -6
  42. package/dist/policy-packs/builtin/branch-protection-runtime.js.map +1 -1
  43. package/dist/policy-packs/builtin/branch-protection.js +24 -14
  44. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  45. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +18 -0
  46. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +32 -0
  47. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  48. package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +82 -10
  49. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +88 -22
  50. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +1 -1
  51. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +11 -0
  52. package/dist/policy-packs/builtin/understanding-before-execution.js +15 -0
  53. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  54. package/package.json +3 -3
package/dist/cli/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { Command } from "commander";
4
5
  // Production version probe for `harness doctor`: synchronous --version
@@ -33,6 +34,7 @@ import { runPackHookCodexPreToolUseCli } from "./pack/hook-codex-pre-tool-use.js
33
34
  import { runPackHookCodexStopCli } from "./pack/hook-codex-stop.js";
34
35
  import { runPackHookCodexUserPromptSubmitCli } from "./pack/hook-codex-user-prompt-submit.js";
35
36
  import { isRuntime, KNOWN_RUNTIMES } from "../policy-packs/index.js";
37
+ import { approveBranchProtection } from "./approve/branch-protection.js";
36
38
  import { approveRisk } from "./approve/risk.js";
37
39
  import { approveUnderstanding } from "./approve/understanding.js";
38
40
  import { describe, isPillar } from "./describe.js";
@@ -64,6 +66,7 @@ import { runPackHookSolutionAcceptanceWriteguardCli } from "./pack/hook-solution
64
66
  import { runPackHookRuntimeRealityCli } from "./pack/hook-runtime-reality.js";
65
67
  import { gateDisable, GateDisableError } from "./gate/disable.js";
66
68
  import { gateEnable, GateEnableError } from "./gate/enable.js";
69
+ import { DEFAULT_RETENTION_DAYS, gc } from "./gc/index.js";
67
70
  import { uninstall, UninstallError } from "./uninstall/index.js";
68
71
  import { migrateHome } from "./migrate-home/index.js";
69
72
  import { pause as pauseHarness, resume as resumeHarness } from "./pause/index.js";
@@ -127,6 +130,7 @@ export function buildProgram(opts = {}) {
127
130
  .option("--project <name>", "apply per-project overrides for this project name")
128
131
  .option("--strict", "promote warnings to errors")
129
132
  .option("--check-lock", "surface harness.lock asset-content drift as warnings (or errors with --strict)")
133
+ .option("--json", "emit a structured JSON report ({ diagnostics, errorCount, warningCount }) instead of prose")
130
134
  .action((options) => {
131
135
  const result = validate({
132
136
  configPath: options.config,
@@ -134,11 +138,22 @@ export function buildProgram(opts = {}) {
134
138
  strict: options.strict,
135
139
  checkLock: options.checkLock,
136
140
  });
137
- const report = formatReport(result);
138
- if (result.diagnostics.length > 0)
139
- stderr(report);
140
- else
141
- stdout(report);
141
+ if (options.json) {
142
+ // JSON goes to stdout regardless of outcome so pipelines can
143
+ // always parse it; the exit code still carries pass/fail.
144
+ stdout(`${JSON.stringify({
145
+ diagnostics: result.diagnostics,
146
+ errorCount: result.errorCount,
147
+ warningCount: result.warningCount,
148
+ }, null, 2)}\n`);
149
+ }
150
+ else {
151
+ const report = formatReport(result);
152
+ if (result.diagnostics.length > 0)
153
+ stderr(report);
154
+ else
155
+ stdout(report);
156
+ }
142
157
  if (result.errorCount > 0) {
143
158
  throw new HarnessExitError("", EX_FAIL);
144
159
  }
@@ -463,7 +478,8 @@ export function buildProgram(opts = {}) {
463
478
  .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
464
479
  .option("--project <name>", "apply per-project overrides for this project name")
465
480
  .option("--dry-run", "print the would-be diff + restart hints; do not write")
466
- .option("--overwrite-drift", "discard any on-disk hand-edits to harness.generated/ files (prompts for `yes`)")
481
+ .option("--overwrite-drift", "discard any on-disk hand-edits to harness.generated/ files (prompts for `yes`; pair with --yes for non-interactive runs)")
482
+ .option("--yes", "with --overwrite-drift, skip the confirmation prompt (for non-interactive use)")
467
483
  .option("--strict-lock", "refuse with exit 1 (no write) when harness.lock asset drift is detected; dry-run wins")
468
484
  .option("--target <path>", "additionally write the generated settings.json to <path> (e.g. .claude/settings.local.json)")
469
485
  .option("--merge", "with --target, 3-way merge into an existing target file (replace owned keys, preserve others)")
@@ -494,6 +510,7 @@ export function buildProgram(opts = {}) {
494
510
  ...(options.project !== undefined ? { project: options.project } : {}),
495
511
  ...(options.dryRun ? { dryRun: true } : {}),
496
512
  ...(options.overwriteDrift ? { overwriteDrift: true } : {}),
513
+ ...(options.yes ? { yes: true } : {}),
497
514
  ...(options.strictLock ? { strictLock: true } : {}),
498
515
  ...(options.target !== undefined ? { target: options.target } : {}),
499
516
  ...(options.merge ? { merge: true } : {}),
@@ -835,7 +852,8 @@ export function buildProgram(opts = {}) {
835
852
  .command("branch-protection")
836
853
  .description("PreToolUse blocker for the branch-protection pack: read tool-event JSON from stdin, consult the " +
837
854
  "evidence ledger, emit a deny envelope on protected branches unless either a fresh " +
838
- "`branch:non-protected` tag (within 5m) or a `branch-protection-ack` override is present.")
855
+ "`branch:non-protected` tag (within 5m) or the operator-only override marker " +
856
+ "(written by `harness approve branch-protection`) is present.")
839
857
  .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
840
858
  .option("--project <name>", "apply per-project overrides")
841
859
  .option("--ledger-timeout <ms>", "per-call ledger timeout in milliseconds")
@@ -1064,6 +1082,11 @@ export function buildProgram(opts = {}) {
1064
1082
  ? "; stamped sessionId"
1065
1083
  : "";
1066
1084
  lines.push(`report: ✓ ${result.persistedReport.filePath} (approvalStatus: ${prev} → approved${stampNote})`);
1085
+ const fb = result.persistedReport.fallbackAdopted;
1086
+ if (fb) {
1087
+ lines.push(` ⚠ adopted via sessionId-less fallback: created ${fb.createdAt ?? "<unknown>"} (${fb.ageMinutes}m ago).`);
1088
+ lines.push(" The live session's report was never persisted, or an older producer", " wrote it without a sessionId. Verify this is the report you just read", " before trusting the approval.");
1089
+ }
1067
1090
  }
1068
1091
  else {
1069
1092
  lines.push(`report: ⚠ skipped (${result.persistedReport.reason})`);
@@ -1170,6 +1193,58 @@ export function buildProgram(opts = {}) {
1170
1193
  }
1171
1194
  stdout(`${lines.join("\n")}\n`);
1172
1195
  });
1196
+ approveCmd
1197
+ .command("branch-protection")
1198
+ .description("Bless a deliberate protected-branch edit for one session. Writes the canonical " +
1199
+ "operator-only approval marker under harness.generated/.approvals/ that the " +
1200
+ "branch-protection blocker consults, plus a best-effort branch-protection-ack " +
1201
+ "ledger row for audit. Operator action: the marker (not the ledger tag) is the " +
1202
+ "trusted override, because the ledger is agent-writable.")
1203
+ .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
1204
+ .option("--project <name>", "apply per-project overrides")
1205
+ .option("--session <id>", "explicit session id (default: $CLAUDE_CODE_SESSION_ID, then $CLAUDE_SESSION_ID, then $CODEX_SESSION_ID, then staged .pending-approval)")
1206
+ .option("--reason <text>", "free-form note recorded in the audit ledger tag (why the override fired)")
1207
+ .option("--approved-by <actor>", "actor to record on the marker (default: harness-approve-cli)")
1208
+ .action(async (options) => {
1209
+ const cliOpts = {};
1210
+ if (options.config)
1211
+ cliOpts.configPath = options.config;
1212
+ if (options.project)
1213
+ cliOpts.project = options.project;
1214
+ if (options.session)
1215
+ cliOpts.session = options.session;
1216
+ if (options.reason)
1217
+ cliOpts.reason = options.reason;
1218
+ if (options.approvedBy)
1219
+ cliOpts.approvedBy = options.approvedBy;
1220
+ const result = await approveBranchProtection(cliOpts);
1221
+ const lines = [];
1222
+ const sourceNote = result.sessionSource === "pending-approval"
1223
+ ? " (resolved from .pending-approval staged by the gate hook)"
1224
+ : result.sessionSource === "env-claude-code"
1225
+ ? " (from $CLAUDE_CODE_SESSION_ID)"
1226
+ : result.sessionSource === "env-claude"
1227
+ ? " (from $CLAUDE_SESSION_ID)"
1228
+ : result.sessionSource === "env-codex"
1229
+ ? " (from $CODEX_SESSION_ID)"
1230
+ : "";
1231
+ lines.push(`session: ${result.sessionId}${sourceNote}`);
1232
+ if (result.marker.ok) {
1233
+ lines.push(`marker: ✓ ${result.marker.filePath} (canonical gate signal)`);
1234
+ lines.push(" the branch-protection gate now allows protected-branch edits for this session.");
1235
+ }
1236
+ else {
1237
+ lines.push(`marker: ✗ FAILED (${result.marker.reason})`);
1238
+ lines.push(" the gate WILL keep blocking the next tool call until the marker exists.");
1239
+ }
1240
+ if (result.ledger.ok) {
1241
+ lines.push(`ledger: ✓ wrote ${result.ledger.tag} (audit only)`);
1242
+ }
1243
+ else {
1244
+ lines.push(`ledger: ⚠ skipped (${result.ledger.reason ?? "unknown"}) (audit only)`);
1245
+ }
1246
+ stdout(`${lines.join("\n")}\n`);
1247
+ });
1173
1248
  const VALID_DECISION_FILTERS = [
1174
1249
  "allow",
1175
1250
  "warn",
@@ -1635,18 +1710,77 @@ export function buildProgram(opts = {}) {
1635
1710
  throw err;
1636
1711
  }
1637
1712
  });
1713
+ program
1714
+ .command("gc")
1715
+ .description("Retention-based cleanup of harness-owned gate state: terminal " +
1716
+ "(approved/expired) understanding-gate reports, parse-error logs, and " +
1717
+ "approval markers older than the retention window. Pending reports and " +
1718
+ "anything outside the enumerated harness-owned dirs are never touched " +
1719
+ "(the evidence ledger and solution-acceptance verdict dirs are owned by " +
1720
+ "their producers). Dry-run by default; pass --apply to delete.")
1721
+ .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
1722
+ .option("--retention-days <n>", `delete artifacts older than this many days (default: ${DEFAULT_RETENTION_DAYS})`)
1723
+ .option("--apply", "delete the listed artifacts (default: dry-run listing only)")
1724
+ .action((options) => {
1725
+ const cliOpts = {};
1726
+ if (options.config)
1727
+ cliOpts.configPath = options.config;
1728
+ if (options.apply)
1729
+ cliOpts.apply = true;
1730
+ if (options.retentionDays !== undefined) {
1731
+ const parsed = Number(options.retentionDays);
1732
+ if (!Number.isFinite(parsed) || parsed < 1) {
1733
+ stderr(`--retention-days must be a positive number, got ${JSON.stringify(options.retentionDays)}\n`);
1734
+ throw new HarnessExitError("", EX_USAGE);
1735
+ }
1736
+ cliOpts.retentionDays = parsed;
1737
+ }
1738
+ const result = gc(cliOpts);
1739
+ const sweptDirs = [
1740
+ result.reportsDir,
1741
+ ...(result.parseErrorsDir !== null ? [result.parseErrorsDir] : []),
1742
+ result.approvalsDir,
1743
+ ];
1744
+ if (result.parseErrorsDir === null) {
1745
+ stderr("gc: skipping the parse-errors sweep (reports dir does not have the conventional .understanding-gate/reports shape)\n");
1746
+ }
1747
+ if (result.candidates.length === 0) {
1748
+ stdout(`gc: nothing older than ${result.retentionDays}d (cutoff ${result.cutoffIso}) under\n` +
1749
+ sweptDirs.map((d) => ` ${d}\n`).join("") +
1750
+ `${result.keptCount} artifact(s) inspected and kept.\n`);
1751
+ return;
1752
+ }
1753
+ const verb = result.applied ? "removing" : "would remove";
1754
+ stdout(`gc: ${verb} ${result.candidates.length} artifact(s) older than ${result.retentionDays}d (cutoff ${result.cutoffIso}); keeping ${result.keptCount}:\n`);
1755
+ for (const c of result.candidates) {
1756
+ stdout(` [${c.category}] ${c.filePath} (${c.reason})\n`);
1757
+ }
1758
+ if (!result.applied) {
1759
+ stdout(`\nDry-run; pass --apply to delete.\n`);
1760
+ return;
1761
+ }
1762
+ stdout(`removed ${result.removed.length} file(s).\n`);
1763
+ if (result.failures.length > 0) {
1764
+ for (const f of result.failures) {
1765
+ stderr(`gc: failed to remove ${f.filePath}: ${f.reason}\n`);
1766
+ }
1767
+ throw new HarnessExitError(`${result.failures.length} deletion(s) failed`, EX_FAIL);
1768
+ }
1769
+ });
1638
1770
  program
1639
1771
  .command("uninstall")
1640
1772
  .description("Clean teardown of a harness installation. Inventories harness-owned " +
1641
- "entries in ~/.claude/ (manifest, lock, harness.generated/, hook groups " +
1642
- "and mcpServers in settings.json) and prints them. With --apply, removes " +
1643
- "them after writing a reversible settings.json backup + snapshot. " +
1773
+ "state (manifest, lock, harness.generated/, .understanding-gate/ under " +
1774
+ "the state root; hook groups and mcpServers in ~/.claude/settings.json) " +
1775
+ "and prints it. With --apply, removes it after writing a reversible " +
1776
+ "settings.json backup + snapshot. " +
1644
1777
  "settings.json.pre-harness-<TS> backups are listed but never deleted, " +
1645
1778
  "so the operator can hand them to --restore-from <path> (atomic restore " +
1646
1779
  "from that file) or `rm` them manually.")
1647
1780
  .option("--apply", "execute the teardown (default: dry-run listing only)")
1648
1781
  .option("--restore-from <path>", "atomic restore: copy this file over settings.json instead of selective removal (implies --apply)")
1649
- .option("--home <path>", "override ~/.claude/ root (for tests / non-default installs)")
1782
+ .option("--home <path>", "override ~/.claude/ (settings home; without --state it also overrides the state root, for tests / non-default installs)")
1783
+ .option("--state <path>", "override the harness state root (default: ~/.harness/, legacy fallback ~/.claude/)")
1650
1784
  .option("--settings <path>", "override ~/.claude/settings.json")
1651
1785
  .action(async (options) => {
1652
1786
  const cliOpts = {};
@@ -1656,6 +1790,8 @@ export function buildProgram(opts = {}) {
1656
1790
  cliOpts.restoreFrom = options.restoreFrom;
1657
1791
  if (options.home)
1658
1792
  cliOpts.homeDir = options.home;
1793
+ if (options.state)
1794
+ cliOpts.stateDir = options.state;
1659
1795
  if (options.settings)
1660
1796
  cliOpts.settingsPath = options.settings;
1661
1797
  try {
@@ -1665,22 +1801,28 @@ export function buildProgram(opts = {}) {
1665
1801
  const nothing = inv.manifestPath === null &&
1666
1802
  inv.lockPath === null &&
1667
1803
  inv.generatedDir === null &&
1804
+ inv.gateStateDir === null &&
1668
1805
  inv.hookGroups.length === 0 &&
1669
1806
  inv.mcpServers.length === 0 &&
1670
1807
  inv.preHarnessBackups.length === 0;
1808
+ const rootsLabel = inv.stateDir === inv.homeDir
1809
+ ? inv.homeDir
1810
+ : `${inv.stateDir} (state) + ${inv.homeDir} (settings)`;
1671
1811
  if (nothing) {
1672
- stdout(`no harness install found under ${inv.homeDir}; nothing to do.\n`);
1812
+ stdout(`no harness install found under ${rootsLabel}; nothing to do.\n`);
1673
1813
  for (const w of inv.warnings)
1674
1814
  stderr(`warning: ${w}\n`);
1675
1815
  return;
1676
1816
  }
1677
- stdout(`harness install under ${inv.homeDir}:\n`);
1817
+ stdout(`harness install under ${rootsLabel}:\n`);
1678
1818
  if (inv.manifestPath)
1679
1819
  stdout(` manifest: ${inv.manifestPath}\n`);
1680
1820
  if (inv.lockPath)
1681
1821
  stdout(` lock: ${inv.lockPath}\n`);
1682
1822
  if (inv.generatedDir)
1683
1823
  stdout(` generated: ${inv.generatedDir}/\n`);
1824
+ if (inv.gateStateDir)
1825
+ stdout(` gate: ${inv.gateStateDir}/ (understanding-gate state)\n`);
1684
1826
  if (inv.hookGroups.length > 0) {
1685
1827
  stdout(` hook groups in ${inv.settingsPath}:\n`);
1686
1828
  for (const g of inv.hookGroups) {
@@ -1734,11 +1876,29 @@ export function buildProgram(opts = {}) {
1734
1876
  stdout(`removed from disk:\n`);
1735
1877
  for (const f of result.removedFiles)
1736
1878
  stdout(` ${f}\n`);
1879
+ // Explicit kept-list: name whatever survives under the state
1880
+ // root (machines/ + projects/ override layers are
1881
+ // operator-authored; foreign files are not ours to judge) so
1882
+ // the operator never has to discover residue by accident.
1883
+ try {
1884
+ const residue = fs
1885
+ .readdirSync(inv.stateDir)
1886
+ .filter((name) => !name.startsWith("settings.json"));
1887
+ if (residue.length > 0) {
1888
+ stdout(`kept under ${inv.stateDir}: ${residue.join(", ")} (not removed; operator-authored or out of scope)\n`);
1889
+ }
1890
+ }
1891
+ catch {
1892
+ /* state root itself may be gone or unreadable; nothing to report */
1893
+ }
1737
1894
  }
1738
1895
  if (result.backupPath === null &&
1739
1896
  result.snapshotPath === null &&
1740
1897
  result.removedFiles.length === 0) {
1741
- stdout(`no harness install found under ${inv.homeDir}; nothing to remove.\n`);
1898
+ const rootsLabel = inv.stateDir === inv.homeDir
1899
+ ? inv.homeDir
1900
+ : `${inv.stateDir} (state) + ${inv.homeDir} (settings)`;
1901
+ stdout(`no harness install found under ${rootsLabel}; nothing to remove.\n`);
1742
1902
  }
1743
1903
  else {
1744
1904
  stdout(`\nTo finish: \`npm uninstall -g @lannguyensi/harness\` (uninstall does not touch the npm install).\n`);