@lannguyensi/harness 0.33.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.
@@ -0,0 +1,148 @@
1
+ // `harness gc` — retention-based cleanup of harness-owned gate state.
2
+ //
3
+ // Nothing else ever deletes terminal understanding-gate reports,
4
+ // parse-error logs, or approval markers of long-dead sessions, so the
5
+ // state dirs grow unbounded (harness-discovery M3; 103 report files
6
+ // accumulated in under a month on the originating install). Stale
7
+ // pending reports were also the raw material of the C1 stale-adoption
8
+ // bug, but pending state is deliberately NOT touched here: only
9
+ // artifacts in a terminal status age out.
10
+ //
11
+ // Safety posture (mirrors `uninstall` / `migrate-home`):
12
+ // - Dry-run by default; `--apply` commits.
13
+ // - Only enumerated, harness-owned locations are considered:
14
+ // <reportsDir> terminal-status reports (approved / expired)
15
+ // <reportsDir>/../parse-errors parse-error logs
16
+ // <generatedDir>/.approvals session / task / branch-protection markers
17
+ // The evidence ledger (grounding-mcp) and solution-acceptance
18
+ // verdict dirs (producer-owned) are out of scope by design.
19
+ // - Deletion failures are surfaced loudly per file, never swallowed.
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { APPROVAL_MARKER_DIRNAME, defaultReportsDir, listPersistedReports, } from "../../policy-packs/builtin/understanding-before-execution-runtime.js";
23
+ import { resolveGeneratedDir } from "../../runtime/pending-approval.js";
24
+ import { resolvePaths } from "../loader.js";
25
+ export const DEFAULT_RETENTION_DAYS = 30;
26
+ // Same literal `harness approve understanding` uses for its parse-error
27
+ // diagnostics lookup (approve/understanding.ts).
28
+ const PARSE_ERRORS_DIRNAME = "parse-errors";
29
+ function ageDays(nowMs, thenMs) {
30
+ return Math.round((nowMs - thenMs) / 86_400_000);
31
+ }
32
+ /** Plain files in `dir` whose mtime is older than `cutoffMs`. */
33
+ function staleFilesByMtime(dir, cutoffMs, nowMs, category) {
34
+ let names;
35
+ try {
36
+ names = fs.readdirSync(dir);
37
+ }
38
+ catch {
39
+ return { candidates: [], kept: 0 };
40
+ }
41
+ const candidates = [];
42
+ let kept = 0;
43
+ for (const name of names) {
44
+ const full = path.join(dir, name);
45
+ let stat;
46
+ try {
47
+ stat = fs.statSync(full);
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ if (!stat.isFile()) {
53
+ kept += 1;
54
+ continue;
55
+ }
56
+ if (stat.mtimeMs < cutoffMs) {
57
+ candidates.push({
58
+ filePath: full,
59
+ category,
60
+ reason: `${ageDays(nowMs, stat.mtimeMs)}d old (mtime)`,
61
+ });
62
+ }
63
+ else {
64
+ kept += 1;
65
+ }
66
+ }
67
+ return { candidates, kept };
68
+ }
69
+ export function gc(opts = {}) {
70
+ const retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
71
+ if (!Number.isFinite(retentionDays) || retentionDays < 1) {
72
+ throw new Error(`retention must be a positive number of days, got ${retentionDays}`);
73
+ }
74
+ const now = opts.now ?? new Date();
75
+ const nowMs = now.getTime();
76
+ const cutoffMs = nowMs - retentionDays * 86_400_000;
77
+ // Path resolution mirrors `approve understanding`: explicit opts win
78
+ // (test injection), then env / manifest-anchored defaults. resolvePaths
79
+ // is evaluated lazily (and once) so injected dirs don't drag the
80
+ // loader in.
81
+ let resolvedBase;
82
+ const manifestBase = () => (resolvedBase ??= resolvePaths(opts).base);
83
+ const reportsDir = opts.reportsDir ?? defaultReportsDir(path.dirname(manifestBase()));
84
+ const conventionalLayout = path.basename(reportsDir) === "reports" &&
85
+ path.basename(path.dirname(reportsDir)) === ".understanding-gate";
86
+ const parseErrorsDir = conventionalLayout
87
+ ? path.join(path.dirname(reportsDir), PARSE_ERRORS_DIRNAME)
88
+ : null;
89
+ const generatedDir = opts.generatedDir ??
90
+ resolveGeneratedDir({
91
+ ...(opts.homeDir !== undefined ? { homeDir: opts.homeDir } : {}),
92
+ manifestPath: manifestBase(),
93
+ });
94
+ const approvalsDir = path.join(generatedDir, APPROVAL_MARKER_DIRNAME);
95
+ const candidates = [];
96
+ let keptCount = 0;
97
+ // Reports: only terminal statuses age out. A pending report is never
98
+ // deleted regardless of age; since the C1 fix, stale pending leftovers
99
+ // can no longer satisfy `approve understanding`, and keeping them
100
+ // preserves the forensic trail for the producer-side investigation.
101
+ for (const report of listPersistedReports(reportsDir)) {
102
+ const terminal = report.approvalStatus === "approved" || report.approvalStatus === "expired";
103
+ if (terminal && report.createdAtMs < cutoffMs) {
104
+ candidates.push({
105
+ filePath: report.filePath,
106
+ category: "report",
107
+ reason: `${report.approvalStatus}, created ${ageDays(nowMs, report.createdAtMs)}d ago`,
108
+ });
109
+ }
110
+ else {
111
+ keptCount += 1;
112
+ }
113
+ }
114
+ if (parseErrorsDir !== null) {
115
+ const parseErrors = staleFilesByMtime(parseErrorsDir, cutoffMs, nowMs, "parse-error");
116
+ candidates.push(...parseErrors.candidates);
117
+ keptCount += parseErrors.kept;
118
+ }
119
+ const markers = staleFilesByMtime(approvalsDir, cutoffMs, nowMs, "approval-marker");
120
+ candidates.push(...markers.candidates);
121
+ keptCount += markers.kept;
122
+ const removed = [];
123
+ const failures = [];
124
+ if (opts.apply === true) {
125
+ for (const c of candidates) {
126
+ try {
127
+ fs.unlinkSync(c.filePath);
128
+ removed.push(c.filePath);
129
+ }
130
+ catch (err) {
131
+ failures.push({ filePath: c.filePath, reason: err.message });
132
+ }
133
+ }
134
+ }
135
+ return {
136
+ retentionDays,
137
+ cutoffIso: new Date(cutoffMs).toISOString(),
138
+ reportsDir,
139
+ parseErrorsDir,
140
+ approvalsDir,
141
+ candidates,
142
+ removed,
143
+ failures,
144
+ keptCount,
145
+ applied: opts.apply === true,
146
+ };
147
+ }
148
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/cli/gc/index.ts"],"names":[],"mappings":"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"}
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
@@ -65,6 +66,7 @@ import { runPackHookSolutionAcceptanceWriteguardCli } from "./pack/hook-solution
65
66
  import { runPackHookRuntimeRealityCli } from "./pack/hook-runtime-reality.js";
66
67
  import { gateDisable, GateDisableError } from "./gate/disable.js";
67
68
  import { gateEnable, GateEnableError } from "./gate/enable.js";
69
+ import { DEFAULT_RETENTION_DAYS, gc } from "./gc/index.js";
68
70
  import { uninstall, UninstallError } from "./uninstall/index.js";
69
71
  import { migrateHome } from "./migrate-home/index.js";
70
72
  import { pause as pauseHarness, resume as resumeHarness } from "./pause/index.js";
@@ -128,6 +130,7 @@ export function buildProgram(opts = {}) {
128
130
  .option("--project <name>", "apply per-project overrides for this project name")
129
131
  .option("--strict", "promote warnings to errors")
130
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")
131
134
  .action((options) => {
132
135
  const result = validate({
133
136
  configPath: options.config,
@@ -135,11 +138,22 @@ export function buildProgram(opts = {}) {
135
138
  strict: options.strict,
136
139
  checkLock: options.checkLock,
137
140
  });
138
- const report = formatReport(result);
139
- if (result.diagnostics.length > 0)
140
- stderr(report);
141
- else
142
- 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
+ }
143
157
  if (result.errorCount > 0) {
144
158
  throw new HarnessExitError("", EX_FAIL);
145
159
  }
@@ -464,7 +478,8 @@ export function buildProgram(opts = {}) {
464
478
  .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
465
479
  .option("--project <name>", "apply per-project overrides for this project name")
466
480
  .option("--dry-run", "print the would-be diff + restart hints; do not write")
467
- .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)")
468
483
  .option("--strict-lock", "refuse with exit 1 (no write) when harness.lock asset drift is detected; dry-run wins")
469
484
  .option("--target <path>", "additionally write the generated settings.json to <path> (e.g. .claude/settings.local.json)")
470
485
  .option("--merge", "with --target, 3-way merge into an existing target file (replace owned keys, preserve others)")
@@ -495,6 +510,7 @@ export function buildProgram(opts = {}) {
495
510
  ...(options.project !== undefined ? { project: options.project } : {}),
496
511
  ...(options.dryRun ? { dryRun: true } : {}),
497
512
  ...(options.overwriteDrift ? { overwriteDrift: true } : {}),
513
+ ...(options.yes ? { yes: true } : {}),
498
514
  ...(options.strictLock ? { strictLock: true } : {}),
499
515
  ...(options.target !== undefined ? { target: options.target } : {}),
500
516
  ...(options.merge ? { merge: true } : {}),
@@ -1066,6 +1082,11 @@ export function buildProgram(opts = {}) {
1066
1082
  ? "; stamped sessionId"
1067
1083
  : "";
1068
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
+ }
1069
1090
  }
1070
1091
  else {
1071
1092
  lines.push(`report: ⚠ skipped (${result.persistedReport.reason})`);
@@ -1689,18 +1710,77 @@ export function buildProgram(opts = {}) {
1689
1710
  throw err;
1690
1711
  }
1691
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
+ });
1692
1770
  program
1693
1771
  .command("uninstall")
1694
1772
  .description("Clean teardown of a harness installation. Inventories harness-owned " +
1695
- "entries in ~/.claude/ (manifest, lock, harness.generated/, hook groups " +
1696
- "and mcpServers in settings.json) and prints them. With --apply, removes " +
1697
- "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. " +
1698
1777
  "settings.json.pre-harness-<TS> backups are listed but never deleted, " +
1699
1778
  "so the operator can hand them to --restore-from <path> (atomic restore " +
1700
1779
  "from that file) or `rm` them manually.")
1701
1780
  .option("--apply", "execute the teardown (default: dry-run listing only)")
1702
1781
  .option("--restore-from <path>", "atomic restore: copy this file over settings.json instead of selective removal (implies --apply)")
1703
- .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/)")
1704
1784
  .option("--settings <path>", "override ~/.claude/settings.json")
1705
1785
  .action(async (options) => {
1706
1786
  const cliOpts = {};
@@ -1710,6 +1790,8 @@ export function buildProgram(opts = {}) {
1710
1790
  cliOpts.restoreFrom = options.restoreFrom;
1711
1791
  if (options.home)
1712
1792
  cliOpts.homeDir = options.home;
1793
+ if (options.state)
1794
+ cliOpts.stateDir = options.state;
1713
1795
  if (options.settings)
1714
1796
  cliOpts.settingsPath = options.settings;
1715
1797
  try {
@@ -1719,22 +1801,28 @@ export function buildProgram(opts = {}) {
1719
1801
  const nothing = inv.manifestPath === null &&
1720
1802
  inv.lockPath === null &&
1721
1803
  inv.generatedDir === null &&
1804
+ inv.gateStateDir === null &&
1722
1805
  inv.hookGroups.length === 0 &&
1723
1806
  inv.mcpServers.length === 0 &&
1724
1807
  inv.preHarnessBackups.length === 0;
1808
+ const rootsLabel = inv.stateDir === inv.homeDir
1809
+ ? inv.homeDir
1810
+ : `${inv.stateDir} (state) + ${inv.homeDir} (settings)`;
1725
1811
  if (nothing) {
1726
- stdout(`no harness install found under ${inv.homeDir}; nothing to do.\n`);
1812
+ stdout(`no harness install found under ${rootsLabel}; nothing to do.\n`);
1727
1813
  for (const w of inv.warnings)
1728
1814
  stderr(`warning: ${w}\n`);
1729
1815
  return;
1730
1816
  }
1731
- stdout(`harness install under ${inv.homeDir}:\n`);
1817
+ stdout(`harness install under ${rootsLabel}:\n`);
1732
1818
  if (inv.manifestPath)
1733
1819
  stdout(` manifest: ${inv.manifestPath}\n`);
1734
1820
  if (inv.lockPath)
1735
1821
  stdout(` lock: ${inv.lockPath}\n`);
1736
1822
  if (inv.generatedDir)
1737
1823
  stdout(` generated: ${inv.generatedDir}/\n`);
1824
+ if (inv.gateStateDir)
1825
+ stdout(` gate: ${inv.gateStateDir}/ (understanding-gate state)\n`);
1738
1826
  if (inv.hookGroups.length > 0) {
1739
1827
  stdout(` hook groups in ${inv.settingsPath}:\n`);
1740
1828
  for (const g of inv.hookGroups) {
@@ -1788,11 +1876,29 @@ export function buildProgram(opts = {}) {
1788
1876
  stdout(`removed from disk:\n`);
1789
1877
  for (const f of result.removedFiles)
1790
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
+ }
1791
1894
  }
1792
1895
  if (result.backupPath === null &&
1793
1896
  result.snapshotPath === null &&
1794
1897
  result.removedFiles.length === 0) {
1795
- 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`);
1796
1902
  }
1797
1903
  else {
1798
1904
  stdout(`\nTo finish: \`npm uninstall -g @lannguyensi/harness\` (uninstall does not touch the npm install).\n`);