@lannguyensi/harness 0.63.0 → 0.64.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 (76) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -2
  3. package/dist/cli/delegate/index.js +2 -1
  4. package/dist/cli/delegate/index.js.map +1 -1
  5. package/dist/cli/delegate/register.d.ts +5 -0
  6. package/dist/cli/delegate/register.js +90 -0
  7. package/dist/cli/delegate/register.js.map +1 -0
  8. package/dist/cli/doctor/expire-on-tool-match.d.ts +15 -0
  9. package/dist/cli/doctor/expire-on-tool-match.js +58 -0
  10. package/dist/cli/doctor/expire-on-tool-match.js.map +1 -0
  11. package/dist/cli/doctor/format.js +7 -0
  12. package/dist/cli/doctor/format.js.map +1 -1
  13. package/dist/cli/doctor/index.js +8 -0
  14. package/dist/cli/doctor/index.js.map +1 -1
  15. package/dist/cli/doctor/types.d.ts +9 -0
  16. package/dist/cli/doctor/types.js.map +1 -1
  17. package/dist/cli/dry-run.d.ts +12 -0
  18. package/dist/cli/dry-run.js +60 -27
  19. package/dist/cli/dry-run.js.map +1 -1
  20. package/dist/cli/enriched-event.d.ts +35 -0
  21. package/dist/cli/enriched-event.js +60 -0
  22. package/dist/cli/enriched-event.js.map +1 -0
  23. package/dist/cli/explain-action.d.ts +6 -1
  24. package/dist/cli/explain-action.js +31 -1
  25. package/dist/cli/explain-action.js.map +1 -1
  26. package/dist/cli/explain-policy.d.ts +12 -0
  27. package/dist/cli/explain-policy.js +20 -38
  28. package/dist/cli/explain-policy.js.map +1 -1
  29. package/dist/cli/index.js +14 -484
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/cli/init/profiles.js +9 -7
  32. package/dist/cli/init/profiles.js.map +1 -1
  33. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +102 -1
  34. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  35. package/dist/cli/policy/intercept.js +4 -2
  36. package/dist/cli/policy/intercept.js.map +1 -1
  37. package/dist/cli/policy/risk-envelope-enrichment.js +4 -2
  38. package/dist/cli/policy/risk-envelope-enrichment.js.map +1 -1
  39. package/dist/cli/register-audit-group.d.ts +5 -0
  40. package/dist/cli/register-audit-group.js +91 -0
  41. package/dist/cli/register-audit-group.js.map +1 -0
  42. package/dist/cli/register-operator-lifecycle.d.ts +5 -0
  43. package/dist/cli/register-operator-lifecycle.js +117 -0
  44. package/dist/cli/register-operator-lifecycle.js.map +1 -0
  45. package/dist/cli/remove/register.d.ts +5 -0
  46. package/dist/cli/remove/register.js +42 -0
  47. package/dist/cli/remove/register.js.map +1 -0
  48. package/dist/cli/resolve-env.js +12 -13
  49. package/dist/cli/resolve-env.js.map +1 -1
  50. package/dist/cli/test-risk.d.ts +2 -1
  51. package/dist/cli/test-risk.js +5 -1
  52. package/dist/cli/test-risk.js.map +1 -1
  53. package/dist/cli/uninstall/register.d.ts +5 -0
  54. package/dist/cli/uninstall/register.js +169 -0
  55. package/dist/cli/uninstall/register.js.map +1 -0
  56. package/dist/index.d.ts +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js +38 -13
  59. package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js.map +1 -1
  60. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.d.ts +2 -2
  61. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js +26 -13
  62. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -1
  63. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +15 -10
  64. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +51 -22
  65. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -1
  66. package/dist/policy-packs/builtin/understanding-before-execution.js +7 -3
  67. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  68. package/dist/runtime/index.d.ts +1 -1
  69. package/dist/runtime/index.js.map +1 -1
  70. package/dist/runtime/intercept.d.ts +130 -1
  71. package/dist/runtime/intercept.js +85 -14
  72. package/dist/runtime/intercept.js.map +1 -1
  73. package/dist/runtime/task-providers/agent-tasks.d.ts +11 -9
  74. package/dist/runtime/task-providers/agent-tasks.js +11 -9
  75. package/dist/runtime/task-providers/agent-tasks.js.map +1 -1
  76. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import * as fs from "node:fs";
3
2
  import * as os from "node:os";
4
3
  import * as path from "node:path";
5
4
  import { Command } from "commander";
@@ -27,7 +26,6 @@ export function defaultVersionProbe(cmd) {
27
26
  import { add } from "./add/index.js";
28
27
  import { adopt } from "./adopt/index.js";
29
28
  import { apply, CODEX_CONFIG_BASENAME, CodexInstallRefusalError, DRIFT_HINT_MESSAGE, formatNextSteps, formatRuntimeLine, OPENCODE_CONFIG_BASENAME, SETTINGS_BASENAME, } from "./apply/index.js";
30
- import { isRemoveType, KNOWN_REMOVE_TYPES, remove } from "./remove/index.js";
31
29
  import { packAdd, packList, packRemove, packReseed, packUpgrade } from "./pack/index.js";
32
30
  import { runPackHookPreToolUseCli } from "./pack/hook-pre-tool-use.js";
33
31
  import { runPackHookPostToolUseCli } from "./pack/hook-post-tool-use.js";
@@ -44,8 +42,6 @@ import { approveBranchProtection } from "./approve/branch-protection.js";
44
42
  import { approveRisk } from "./approve/risk.js";
45
43
  import { approveUnderstanding } from "./approve/understanding.js";
46
44
  import { readPipedStdin } from "./approve/stdin-report.js";
47
- import { issueDelegation } from "./delegate/index.js";
48
- import { InvalidDurationError, parseDurationSeconds } from "../policies/index.js";
49
45
  import { runRecordDogfood, runRecordReview, runRecordReviewSubagent, } from "./record/index.js";
50
46
  import { describe, isPillar } from "./describe.js";
51
47
  import { diff as diffRun } from "./diff/index.js";
@@ -64,9 +60,6 @@ import { detect as detectInit } from "./init/detect.js";
64
60
  import { init, isTemplate, KNOWN_TEMPLATES } from "./init/index.js";
65
61
  import { runInteractive } from "./init/interactive.js";
66
62
  import { isListCategory, list } from "./list.js";
67
- import { audit } from "./audit.js";
68
- import { sessionExport } from "./session-export/index.js";
69
- import { dryRun } from "./dry-run.js";
70
63
  import { runInterceptCli } from "./policy/intercept.js";
71
64
  import { runSessionStartPreflight } from "./session-start/index.js";
72
65
  import { writePendingApproval } from "../runtime/pending-approval.js";
@@ -83,12 +76,14 @@ import { runPackHookRuntimeRealityCli } from "./pack/hook-runtime-reality.js";
83
76
  import { gateDisable, GateDisableError } from "./gate/disable.js";
84
77
  import { gateEnable, GateEnableError } from "./gate/enable.js";
85
78
  import { DEFAULT_RETENTION_DAYS, gc } from "./gc/index.js";
86
- import { uninstall, UninstallError } from "./uninstall/index.js";
87
- import { migrateHome } from "./migrate-home/index.js";
88
- import { pause as pauseHarness, resume as resumeHarness } from "./pause/index.js";
89
79
  import { formatSmokeReport, runSmoke, splitCommaList, } from "./smoke/index.js";
90
80
  import { formatReport, validate } from "./validate/index.js";
91
81
  import { VERSION } from "../version.js";
82
+ import { registerRemove } from "./remove/register.js";
83
+ import { registerDelegate } from "./delegate/register.js";
84
+ import { registerAuditGroup } from "./register-audit-group.js";
85
+ import { registerUninstall } from "./uninstall/register.js";
86
+ import { registerOperatorLifecycle } from "./register-operator-lifecycle.js";
92
87
  export function buildProgram(opts = {}) {
93
88
  const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
94
89
  const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
@@ -800,42 +795,7 @@ export function buildProgram(opts = {}) {
800
795
  stderr(`restart hint: ${h}\n`);
801
796
  }
802
797
  });
803
- program
804
- .command("remove <type> <name>")
805
- .description(`Remove an entry by name. <type> is one of ${KNOWN_REMOVE_TYPES.join(" | ")}. ` +
806
- "Refuses to remove a hook still referenced by a policy unless --force.")
807
- .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
808
- .option("--dry-run", "print the unified diff and exit without writing")
809
- .option("--force", "remove even if a policy references this entry (dangling policy.hook is then caught by schema)")
810
- .action(async (type, name, options) => {
811
- if (!isRemoveType(type)) {
812
- throw new HarnessExitError(`unknown remove type "${type}"; expected one of ${KNOWN_REMOVE_TYPES.join(", ")}`, EX_USAGE);
813
- }
814
- const result = await remove(type, name, {
815
- configPath: options.config,
816
- dryRun: options.dryRun,
817
- force: options.force,
818
- });
819
- if (result.forcedReferences.length > 0) {
820
- stderr(`(forced removal — referenced by: ${result.forcedReferences.join(", ")})\n`);
821
- }
822
- // F3 (review round 3, 99f47307 Slice 1): printed on the dry-run AND
823
- // the write path. A --force'd removal of an evidence hook silently
824
- // disables the workflows[]-derived merge gate (no schema safety
825
- // net, unlike a dangling policy.hook), so this line is the only
826
- // warning the operator gets.
827
- if (result.derivedGateReferences.length > 0) {
828
- stderr(`(forced removal disables the workflows[]-derived merge gate for: ` +
829
- `${result.derivedGateReferences.join(", ")}; harness policy intercept ` +
830
- `no longer blocks merges for ${result.derivedGateReferences.length === 1 ? "this workflow" : "these workflows"} ` +
831
- `and for any other workflow sharing the same merge surface)\n`);
832
- }
833
- if (options.dryRun) {
834
- stdout(result.diff);
835
- return;
836
- }
837
- stdout(`removed ${result.type} ${JSON.stringify(result.name)} from ${result.path}\n`);
838
- });
798
+ registerRemove(program, { stdout, stderr });
839
799
  // `harness pack` subtree (Phase 6 #3): managed CRUD over policy_packs[].
840
800
  const packCmd = program
841
801
  .command("pack")
@@ -1635,89 +1595,7 @@ export function buildProgram(opts = {}) {
1635
1595
  }
1636
1596
  stdout(`${lines.join("\n")}\n`);
1637
1597
  });
1638
- // `harness delegate` (Slice 3 of docs/decisions/2026-08-27-ug-auto-mode-approval.md,
1639
- // agent-tasks 37ad0b05): issue a signed pre-authorization for a
1640
- // headless `claude -p` child session, bound to an already-approved
1641
- // PARENT session. NOT an approval: the child still writes and gets its
1642
- // own Understanding Report checked by its own PreToolUse hook before
1643
- // its auto-marker is minted. Writes harness.generated/.delegations/,
1644
- // never .approvals/.
1645
- program
1646
- .command("delegate")
1647
- .description("Issue a signed delegation for a headless `claude -p` child session, bound to " +
1648
- "an already-approved parent session (pre-authorization, not an approval: the " +
1649
- "child still writes and gets its own Understanding Report checked). Writes " +
1650
- "harness.generated/.delegations/<child-sid>, never .approvals/. Consumption is " +
1651
- "Claude Code only: the Codex adapter has no delegation consumer, so a Codex " +
1652
- "session cannot be the delegated child. A Codex session CAN be the delegating " +
1653
- "parent today (parent resolution reads $CODEX_SESSION_ID and accepts any " +
1654
- "validly signed marker). See docs/decisions/2026-08-27-ug-auto-mode-approval.md.")
1655
- .requiredOption("--child-session <uuid>", "session id of the claude -p child this delegation authorizes (UUID)")
1656
- .option("--cwd <path>", "bind the delegation to this working directory; at least one of --cwd/--task is required")
1657
- .option("--task <id>", "bind the delegation to this agent-tasks task id; at least one of --cwd/--task is required")
1658
- .option("--ttl <duration>", "delegation lifetime (e.g. 30m, 1h); default: the pack's approval_lifecycle.max_age when set, else 1h")
1659
- .option("--report <path>", "fallback shape: copy the child's Understanding Report to harness.generated/.delegation-reports/<child-sid>.md and bind it by content + that conventional path's hash at spawn time; the child's PreToolUse hook reads it back from there, see the pack doc")
1660
- .option("--session-id <id>", "explicit parent session id (default: $CLAUDE_CODE_SESSION_ID, then $CLAUDE_SESSION_ID, then $CODEX_SESSION_ID, then staged .pending-approval)")
1661
- .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
1662
- .option("--project <name>", "apply per-project overrides")
1663
- .action(async (options) => {
1664
- // Usage-level check ahead of every deeper resolution, with the
1665
- // exact message the ADR slice 3 acceptance criterion pins.
1666
- // `issueDelegation` carries the identical check (reason
1667
- // "no-binding") for callers that invoke it directly (the smoke
1668
- // runner, tests) without going through commander's flag parsing.
1669
- if (options.cwd === undefined && options.task === undefined) {
1670
- throw new HarnessExitError("a delegation must bind a cwd or a task", EX_FAIL);
1671
- }
1672
- let ttlSeconds;
1673
- if (options.ttl) {
1674
- try {
1675
- ttlSeconds = parseDurationSeconds(options.ttl);
1676
- }
1677
- catch (err) {
1678
- if (err instanceof InvalidDurationError) {
1679
- throw new HarnessExitError(`--ttl: ${err.message}`, EX_USAGE);
1680
- }
1681
- throw err;
1682
- }
1683
- }
1684
- const cliOpts = {
1685
- childSessionId: options.childSession,
1686
- };
1687
- // `!== undefined` (not truthy), agreeing with the usage check
1688
- // above: a truthy check would silently drop an explicit `--cwd
1689
- // ''` / `--task ''` instead of letting `issueDelegation` refuse
1690
- // it with `invalid-cwd` / `invalid-task` (L3 fix, agent-tasks
1691
- // 37ad0b05).
1692
- if (options.cwd !== undefined)
1693
- cliOpts.cwd = options.cwd;
1694
- if (options.task !== undefined)
1695
- cliOpts.taskId = options.task;
1696
- if (ttlSeconds !== undefined)
1697
- cliOpts.ttlSeconds = ttlSeconds;
1698
- if (options.report)
1699
- cliOpts.reportPath = options.report;
1700
- if (options.sessionId)
1701
- cliOpts.parentSessionId = options.sessionId;
1702
- if (options.config)
1703
- cliOpts.configPath = options.config;
1704
- if (options.project)
1705
- cliOpts.project = options.project;
1706
- const result = await issueDelegation(cliOpts);
1707
- if (!result.ok) {
1708
- throw new HarnessExitError(`delegate refused (${result.reason}): ${result.detail}`, EX_FAIL);
1709
- }
1710
- const lines = [
1711
- `delegation: ✓ ${result.filePath} (child ${result.childSessionId}, parent ${result.parentSessionId}, expires ${result.expiresAt})`,
1712
- ];
1713
- if (result.ledgerFact.written) {
1714
- lines.push(`ledger: ✓ wrote understanding-delegated:${result.childSessionId}:${result.parentSessionId} (audit only)`);
1715
- }
1716
- else {
1717
- lines.push(`ledger: ⚠ skipped (${result.ledgerFact.reason ?? "unknown"}) (audit only)`);
1718
- }
1719
- stdout(`${lines.join("\n")}\n`);
1720
- });
1598
+ registerDelegate(program, { stdout, stderr });
1721
1599
  const VALID_DECISION_FILTERS = [
1722
1600
  "allow",
1723
1601
  "warn",
@@ -1771,10 +1649,14 @@ export function buildProgram(opts = {}) {
1771
1649
  .description("Risk Gate debug verb (Phase 7): read a tool-event JSON file (the Claude Code PreToolUse hook payload shape: " +
1772
1650
  "{ hook_event_name, tool_name, tool_input, session_id, cwd }) and print the normalized Action Envelope. " +
1773
1651
  "Inspection surface for the envelope that downstream Risk Gate stages consume; does not evaluate policies.")
1652
+ .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml); read only for a leading `git switch|checkout`")
1653
+ .option("--project <name>", "apply per-project overrides")
1774
1654
  .option("--json", "emit the envelope as JSON instead of YAML")
1775
1655
  .action((eventPath, options) => {
1776
1656
  const result = explainAction({
1777
1657
  eventPath,
1658
+ ...(options.config !== undefined && { configPath: options.config }),
1659
+ ...(options.project !== undefined && { project: options.project }),
1778
1660
  ...(options.json === true && { json: true }),
1779
1661
  });
1780
1662
  stdout(result.output);
@@ -1841,89 +1723,7 @@ export function buildProgram(opts = {}) {
1841
1723
  if (!result.output.endsWith("\n"))
1842
1724
  stdout("\n");
1843
1725
  });
1844
- program
1845
- .command("audit")
1846
- .description("Replay policy decisions from the evidence ledger for a time window, plus an approvals section listing raw understanding-gate approval facts")
1847
- .option("--since <duration>", "time window (default: 24h)")
1848
- .option("--policy <name>", "filter to a single policy by name")
1849
- .option("--outcome <outcome>", "filter by decision outcome (allow / warn / require_approval / deny / warn-degraded / deny-degraded)")
1850
- .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
1851
- .option("--project <name>", "apply per-project overrides")
1852
- .option("--session <id>", "grounding session whose audit log to read (default: $CLAUDE_SESSION_ID, then 'default')")
1853
- .option("--json", "emit JSON instead of a table")
1854
- .action(async (options) => {
1855
- const auditOpts = {};
1856
- if (options.since)
1857
- auditOpts.since = options.since;
1858
- if (options.policy)
1859
- auditOpts.policy = options.policy;
1860
- if (options.outcome)
1861
- auditOpts.outcome = options.outcome;
1862
- if (options.config)
1863
- auditOpts.configPath = options.config;
1864
- if (options.project)
1865
- auditOpts.project = options.project;
1866
- if (options.session)
1867
- auditOpts.sessionId = options.session;
1868
- if (options.json)
1869
- auditOpts.json = options.json;
1870
- const result = await audit(auditOpts);
1871
- stdout(result.output);
1872
- });
1873
- program
1874
- .command("session-export [sessionId]")
1875
- .description("Export a chronological audit artifact joining the on-disk transcript JSONL and the evidence ledger for a session")
1876
- .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
1877
- .option("--project <name>", "apply per-project overrides")
1878
- .option("--format <fmt>", "output format: json (default) or jsonl", "json")
1879
- .option("-o, --out <file>", "write the export to <file> instead of stdout")
1880
- .action(async (sessionIdArg, options) => {
1881
- const fmt = options.format ?? "json";
1882
- if (fmt !== "json" && fmt !== "jsonl") {
1883
- throw new HarnessExitError(`unknown --format "${fmt}"; expected json or jsonl`, EX_USAGE);
1884
- }
1885
- const exportOpts = {
1886
- format: fmt,
1887
- };
1888
- if (sessionIdArg)
1889
- exportOpts.sessionId = sessionIdArg;
1890
- if (options.config)
1891
- exportOpts.configPath = options.config;
1892
- if (options.project)
1893
- exportOpts.project = options.project;
1894
- if (options.out)
1895
- exportOpts.outFile = options.out;
1896
- const result = await sessionExport(exportOpts);
1897
- if (!options.out) {
1898
- stdout(result.output);
1899
- }
1900
- else {
1901
- stdout(`session-export wrote ${result.events.length} events to ${options.out}\n`);
1902
- }
1903
- });
1904
- program
1905
- .command("dry-run <prompt>")
1906
- .description("Statically predict which hooks fire / policies match / memories route for a prompt")
1907
- .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
1908
- .option("--project <name>", "apply per-project overrides")
1909
- .option("--tool <name>", "simulate a PreToolUse event for this tool name")
1910
- .option("--tool-args <json>", "JSON for tool_input (default: {})")
1911
- .option("--json", "emit JSON instead of YAML")
1912
- .action((prompt, options) => {
1913
- const dryRunOpts = {};
1914
- if (options.config)
1915
- dryRunOpts.configPath = options.config;
1916
- if (options.project)
1917
- dryRunOpts.project = options.project;
1918
- if (options.tool)
1919
- dryRunOpts.tool = options.tool;
1920
- if (options.toolArgs)
1921
- dryRunOpts.toolArgs = options.toolArgs;
1922
- if (options.json)
1923
- dryRunOpts.json = options.json;
1924
- const result = dryRun(prompt, dryRunOpts);
1925
- stdout(result.output);
1926
- });
1726
+ registerAuditGroup(program, { stdout, stderr });
1927
1727
  program
1928
1728
  .command("smoke")
1929
1729
  .description("Drive `claude -p` end-to-end against the apply'd manifest and assert per --expect-* flags. " +
@@ -2352,278 +2152,8 @@ export function buildProgram(opts = {}) {
2352
2152
  throw new HarnessExitError(`${result.failures.length} deletion(s) failed`, EX_FAIL);
2353
2153
  }
2354
2154
  });
2355
- program
2356
- .command("uninstall")
2357
- .description("Clean teardown of a harness installation. Inventories harness-owned " +
2358
- "state (manifest, lock, harness.generated/, .understanding-gate/ under " +
2359
- "the state root; hook groups and mcpServers in ~/.claude/settings.json) " +
2360
- "and prints it. With --apply, removes it after writing a reversible " +
2361
- "settings.json backup + snapshot. " +
2362
- "settings.json.pre-harness-<TS> backups are listed but never deleted, " +
2363
- "so the operator can hand them to --restore-from <path> (atomic restore " +
2364
- "from that file) or `rm` them manually.")
2365
- .option("--apply", "execute the teardown (default: dry-run listing only)")
2366
- .option("--restore-from <path>", "atomic restore: copy this file over settings.json instead of selective removal (implies --apply)")
2367
- .option("--home <path>", "override ~/.claude/ (settings home; without --state it also overrides the state root, for tests / non-default installs)")
2368
- .option("--state <path>", "override the harness state root (default: ~/.harness/, legacy fallback ~/.claude/)")
2369
- .option("--settings <path>", "override ~/.claude/settings.json")
2370
- .action(async (options) => {
2371
- const cliOpts = {};
2372
- if (options.apply)
2373
- cliOpts.apply = true;
2374
- if (options.restoreFrom)
2375
- cliOpts.restoreFrom = options.restoreFrom;
2376
- if (options.home)
2377
- cliOpts.homeDir = options.home;
2378
- if (options.state)
2379
- cliOpts.stateDir = options.state;
2380
- if (options.settings)
2381
- cliOpts.settingsPath = options.settings;
2382
- try {
2383
- const result = await uninstall(cliOpts);
2384
- const inv = result.inventory;
2385
- if (result.mode === "list") {
2386
- const nothing = inv.manifestPath === null &&
2387
- inv.lockPath === null &&
2388
- inv.generatedDir === null &&
2389
- inv.gateStateDir === null &&
2390
- inv.hookGroups.length === 0 &&
2391
- inv.mcpServers.length === 0 &&
2392
- inv.mcpRegistryServers.length === 0 &&
2393
- inv.preHarnessBackups.length === 0;
2394
- const rootsLabel = inv.stateDir === inv.homeDir
2395
- ? inv.homeDir
2396
- : `${inv.stateDir} (state) + ${inv.homeDir} (settings)`;
2397
- if (nothing) {
2398
- stdout(`no harness install found under ${rootsLabel}; nothing to do.\n`);
2399
- for (const w of inv.warnings)
2400
- stderr(`warning: ${w}\n`);
2401
- return;
2402
- }
2403
- stdout(`harness install under ${rootsLabel}:\n`);
2404
- if (inv.manifestPath)
2405
- stdout(` manifest: ${inv.manifestPath}\n`);
2406
- if (inv.lockPath)
2407
- stdout(` lock: ${inv.lockPath}\n`);
2408
- if (inv.generatedDir)
2409
- stdout(` generated: ${inv.generatedDir}/\n`);
2410
- if (inv.gateStateDir)
2411
- stdout(` gate: ${inv.gateStateDir}/ (understanding-gate state)\n`);
2412
- if (inv.hookGroups.length > 0) {
2413
- stdout(` hook groups in ${inv.settingsPath}:\n`);
2414
- for (const g of inv.hookGroups) {
2415
- const matcherLabel = g.matcher === null ? "(no matcher)" : JSON.stringify(g.matcher);
2416
- stdout(` ${g.event}[${g.index}] matcher=${matcherLabel}: ${g.description}\n`);
2417
- }
2418
- }
2419
- if (inv.mcpServers.length > 0) {
2420
- stdout(` mcpServers in ${inv.settingsPath}: ${inv.mcpServers.join(", ")}\n`);
2421
- }
2422
- if (inv.mcpRegistryServers.length > 0) {
2423
- stdout(` mcpServers registered in the claude CLI user-scope registry (${inv.mcpRegistryPath}): ` +
2424
- `${inv.mcpRegistryServers.join(", ")}\n`);
2425
- }
2426
- if (inv.preHarnessBackups.length > 0) {
2427
- stdout(` pre-harness backups:\n`);
2428
- for (const b of inv.preHarnessBackups)
2429
- stdout(` ${b}\n`);
2430
- stdout(`\n Restore from one of these with: harness uninstall --restore-from <path>\n`);
2431
- }
2432
- stdout(`\nPass --apply to remove the above. This is a dry-run.\n`);
2433
- for (const w of inv.warnings)
2434
- stderr(`warning: ${w}\n`);
2435
- return;
2436
- }
2437
- if (result.mode === "restore") {
2438
- stdout(`restored ${inv.settingsPath} from ${result.restoredFrom}.\n`);
2439
- stdout(`backup: ${result.backupPath}\n`);
2440
- stdout(`snapshot: ${result.snapshotPath}\n`);
2441
- if (result.removedFiles.length > 0) {
2442
- stdout(`removed:\n`);
2443
- for (const f of result.removedFiles)
2444
- stdout(` ${f}\n`);
2445
- }
2446
- if (result.mcpRegistryRemovals.length > 0) {
2447
- stdout(`claude mcp remove (user scope, ${inv.mcpRegistryPath}):\n`);
2448
- for (const r of result.mcpRegistryRemovals)
2449
- stdout(` ${r.name}: ${r.status}\n`);
2450
- }
2451
- stdout(`\nTo finish: \`npm uninstall -g @lannguyensi/harness\` (uninstall does not touch the npm install).\n`);
2452
- for (const w of inv.warnings)
2453
- stderr(`warning: ${w}\n`);
2454
- return;
2455
- }
2456
- // apply
2457
- if (result.backupPath !== null && result.snapshotPath !== null) {
2458
- stdout(`mutated ${inv.settingsPath}:\n`);
2459
- if (inv.hookGroups.length > 0) {
2460
- stdout(` removed ${inv.hookGroups.length} hook group(s): `);
2461
- stdout(inv.hookGroups.map((g) => `${g.event}[${g.index}]`).join(", "));
2462
- stdout(`\n`);
2463
- }
2464
- if (inv.mcpServers.length > 0) {
2465
- stdout(` removed mcpServers: ${inv.mcpServers.join(", ")}\n`);
2466
- }
2467
- stdout(`backup: ${result.backupPath}\n`);
2468
- stdout(`snapshot: ${result.snapshotPath}\n`);
2469
- }
2470
- if (result.removedFiles.length > 0) {
2471
- stdout(`removed from disk:\n`);
2472
- for (const f of result.removedFiles)
2473
- stdout(` ${f}\n`);
2474
- // Explicit kept-list: name whatever survives under the state
2475
- // root (machines/ + projects/ override layers are
2476
- // operator-authored; foreign files are not ours to judge) so
2477
- // the operator never has to discover residue by accident.
2478
- try {
2479
- const residue = fs
2480
- .readdirSync(inv.stateDir)
2481
- .filter((name) => !name.startsWith("settings.json"));
2482
- if (residue.length > 0) {
2483
- stdout(`kept under ${inv.stateDir}: ${residue.join(", ")} (not removed; operator-authored or out of scope)\n`);
2484
- }
2485
- }
2486
- catch {
2487
- /* state root itself may be gone or unreadable; nothing to report */
2488
- }
2489
- }
2490
- if (result.mcpRegistryRemovals.length > 0) {
2491
- stdout(`claude mcp remove (user scope, ${inv.mcpRegistryPath}):\n`);
2492
- for (const r of result.mcpRegistryRemovals)
2493
- stdout(` ${r.name}: ${r.status}\n`);
2494
- }
2495
- if (result.backupPath === null &&
2496
- result.snapshotPath === null &&
2497
- result.removedFiles.length === 0 &&
2498
- result.mcpRegistryRemovals.length === 0) {
2499
- const rootsLabel = inv.stateDir === inv.homeDir
2500
- ? inv.homeDir
2501
- : `${inv.stateDir} (state) + ${inv.homeDir} (settings)`;
2502
- stdout(`no harness install found under ${rootsLabel}; nothing to remove.\n`);
2503
- }
2504
- else {
2505
- stdout(`\nTo finish: \`npm uninstall -g @lannguyensi/harness\` (uninstall does not touch the npm install).\n`);
2506
- }
2507
- for (const w of inv.warnings)
2508
- stderr(`warning: ${w}\n`);
2509
- }
2510
- catch (err) {
2511
- if (err instanceof UninstallError) {
2512
- throw new HarnessExitError(err.message, EX_FAIL);
2513
- }
2514
- throw err;
2515
- }
2516
- });
2517
- program
2518
- .command("migrate-home")
2519
- .description("Move harness operator-state from the legacy ~/.claude/ root to the runtime-neutral " +
2520
- "~/.harness/ root introduced in v0.24.0. Dry-run by default; pass --apply to perform " +
2521
- "the move. Re-running on already-migrated state is a no-op. Moves: harness.yaml, " +
2522
- "harness.generated/, .understanding-gate/, harness.lock. Does NOT touch settings.json " +
2523
- "or any other ~/.claude/ contents.")
2524
- .option("--apply", "perform the move (default: dry-run report only)")
2525
- .action(async (options) => {
2526
- const result = migrateHome({ ...(options.apply ? { apply: true } : {}) });
2527
- if (result.outcome === "target-conflict") {
2528
- throw new HarnessExitError("", EX_FAIL);
2529
- }
2530
- });
2531
- // `harness pause` / `harness resume`: operator-only kill switch.
2532
- // Sibling top-level commands: `pause` must start its own
2533
- // `program.command(...)` statement. It was previously chained onto the
2534
- // `migrate-home` block above, which (Commander's `.command()` returns
2535
- // the new subcommand) registered it as `harness migrate-home pause`
2536
- // and dropped it from top-level `harness --help`.
2537
- program
2538
- .command("pause")
2539
- .description("Temporarily make all harness hooks dormant by writing a sentinel under harness.generated/. " +
2540
- "Operator-only (refuses when $CLAUDE_CODE_SESSION_ID, $CLAUDE_SESSION_ID, or $CODEX_SESSION_ID " +
2541
- "is set, or stdin is non-TTY). Intended for lockout recovery, debug A/B-tests, and incident " +
2542
- "hotfixes. NOT for routine gate bypass: for permanent per-policy disable, edit " +
2543
- "`policies[].enabled` in the manifest.")
2544
- .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
2545
- .option("--project <name>", "apply per-project overrides")
2546
- .option("--for <duration>", "auto-resume after this duration (e.g. 5m, 1h, PT30S; default: 15m)")
2547
- .option("--indefinite", "skip auto-expiry (requires --i-am-the-operator-and-accept-no-auto-resume)")
2548
- .option("--i-am-the-operator-and-accept-no-auto-resume", "acknowledge that --indefinite leaves harness dormant until you remember to resume")
2549
- .option("--reason <text>", "free-form reason recorded in the sentinel + announced on each hook fire")
2550
- .option("--i-am-the-operator", "acknowledge a scripted / non-TTY invocation (otherwise refused)")
2551
- .action(async (options) => {
2552
- const cliOpts = {};
2553
- if (options.config)
2554
- cliOpts.configPath = options.config;
2555
- if (options.project)
2556
- cliOpts.project = options.project;
2557
- if (options.for)
2558
- cliOpts.forDuration = options.for;
2559
- if (options.indefinite)
2560
- cliOpts.indefinite = true;
2561
- if (options.iAmTheOperatorAndAcceptNoAutoResume)
2562
- cliOpts.acceptNoAutoResume = true;
2563
- if (options.reason)
2564
- cliOpts.reason = options.reason;
2565
- if (options.iAmTheOperator)
2566
- cliOpts.iAmTheOperator = true;
2567
- const result = await pauseHarness(cliOpts);
2568
- const lines = [];
2569
- if (result.alreadyPaused) {
2570
- lines.push("note: harness was already paused; sentinel overwritten with new expiry");
2571
- }
2572
- const expiry = result.sentinel.expiresAt === null
2573
- ? "indefinite (no auto-resume)"
2574
- : `auto-resumes at ${result.sentinel.expiresAt}`;
2575
- lines.push(`paused: ✓ ${result.sentinelPath}`);
2576
- lines.push(`expiry: ${expiry}`);
2577
- if (result.sentinel.reason !== null) {
2578
- lines.push(`reason: ${result.sentinel.reason}`);
2579
- }
2580
- if (result.ledger.ok) {
2581
- lines.push(`ledger: ✓ wrote ${result.ledger.tag} (audit)`);
2582
- }
2583
- else {
2584
- lines.push(`ledger: ⚠ skipped (${result.ledger.reason ?? "unknown"}) (audit)`);
2585
- }
2586
- lines.push("");
2587
- lines.push("Hooks will allow + emit a stderr notice while paused. Run `harness resume` to re-enable.");
2588
- stdout(`${lines.join("\n")}\n`);
2589
- });
2590
- program
2591
- .command("resume")
2592
- .description("Delete the pause sentinel and re-enable harness hooks. Operator-only. Idempotent: " +
2593
- "running against an un-paused install exits 0 with a notice.")
2594
- .option("--config <path>", "manifest path (default: ~/.harness/harness.yaml; legacy fallback ~/.claude/harness.yaml)")
2595
- .option("--project <name>", "apply per-project overrides")
2596
- .option("--i-am-the-operator", "acknowledge a scripted / non-TTY invocation (otherwise refused)")
2597
- .action(async (options) => {
2598
- const cliOpts = {};
2599
- if (options.config)
2600
- cliOpts.configPath = options.config;
2601
- if (options.project)
2602
- cliOpts.project = options.project;
2603
- if (options.iAmTheOperator)
2604
- cliOpts.iAmTheOperator = true;
2605
- const result = await resumeHarness(cliOpts);
2606
- const lines = [];
2607
- if (!result.wasPaused) {
2608
- lines.push(`resume: · harness was not paused (${result.sentinelPath} did not exist)`);
2609
- }
2610
- else {
2611
- lines.push(`resume: ✓ deleted ${result.sentinelPath}`);
2612
- if (result.previousSentinel) {
2613
- const prev = result.previousSentinel;
2614
- lines.push(`prev: pausedAt=${prev.pausedAt} expiresAt=${prev.expiresAt ?? "indefinite"}`);
2615
- if (prev.reason !== null)
2616
- lines.push(`reason: ${prev.reason}`);
2617
- }
2618
- if (result.ledger.ok) {
2619
- lines.push(`ledger: ✓ wrote ${result.ledger.tag} (audit)`);
2620
- }
2621
- else {
2622
- lines.push(`ledger: ⚠ skipped (${result.ledger.reason ?? "unknown"}) (audit)`);
2623
- }
2624
- }
2625
- stdout(`${lines.join("\n")}\n`);
2626
- });
2155
+ registerUninstall(program, { stdout, stderr });
2156
+ registerOperatorLifecycle(program, { stdout, stderr });
2627
2157
  const policy = program.command("policy").description("Policy runtime verbs");
2628
2158
  policy
2629
2159
  .command("intercept")