harnery 0.3.2 → 0.5.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 (68) hide show
  1. package/README.md +20 -7
  2. package/dist/commander.js +2 -2
  3. package/dist/commands/completion.d.ts.map +1 -1
  4. package/dist/commands/completion.js +48 -10
  5. package/dist/commands/deinit.d.ts +51 -0
  6. package/dist/commands/deinit.d.ts.map +1 -0
  7. package/dist/commands/{uninstall.js → deinit.js} +83 -14
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +47 -9
  10. package/dist/commands/init.d.ts +2 -21
  11. package/dist/commands/init.d.ts.map +1 -1
  12. package/dist/commands/init.js +3 -15
  13. package/dist/core/agents/render/session-context.d.ts +12 -2
  14. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  15. package/dist/core/agents/render/session-context.js +75 -36
  16. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  17. package/dist/core/agents/rules/claim-conflict.js +38 -12
  18. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  19. package/dist/core/agents/state/heartbeat-writer.js +7 -1
  20. package/dist/core/config.d.ts +7 -0
  21. package/dist/core/config.d.ts.map +1 -1
  22. package/dist/core/config.js +13 -0
  23. package/dist/core/hooks/cli.js +4 -10
  24. package/dist/core/hooks/guard-path.d.ts +29 -0
  25. package/dist/core/hooks/guard-path.d.ts.map +1 -0
  26. package/dist/core/hooks/guard-path.js +38 -0
  27. package/dist/core/hooks/harness/wiring.d.ts +85 -0
  28. package/dist/core/hooks/harness/wiring.d.ts.map +1 -0
  29. package/dist/core/hooks/harness/wiring.js +137 -0
  30. package/dist/lib/completion/bash.d.ts +15 -0
  31. package/dist/lib/completion/bash.d.ts.map +1 -1
  32. package/dist/lib/completion/bash.js +35 -0
  33. package/dist/lib/completion/fish.d.ts +10 -0
  34. package/dist/lib/completion/fish.d.ts.map +1 -1
  35. package/dist/lib/completion/fish.js +21 -0
  36. package/dist/lib/completion/index.d.ts +4 -3
  37. package/dist/lib/completion/index.d.ts.map +1 -1
  38. package/dist/lib/completion/index.js +4 -3
  39. package/dist/lib/completion/resolve.d.ts +52 -0
  40. package/dist/lib/completion/resolve.d.ts.map +1 -0
  41. package/dist/lib/completion/resolve.js +171 -0
  42. package/dist/lib/completion/zsh.d.ts +8 -0
  43. package/dist/lib/completion/zsh.d.ts.map +1 -1
  44. package/dist/lib/completion/zsh.js +33 -0
  45. package/dist/lib/docs-lint.d.ts.map +1 -1
  46. package/dist/lib/docs-lint.js +6 -0
  47. package/package.json +1 -1
  48. package/schemas/config.schema.json +4 -0
  49. package/src/commander.ts +2 -2
  50. package/src/commands/completion.ts +62 -9
  51. package/src/commands/{uninstall.ts → deinit.ts} +107 -15
  52. package/src/commands/doctor.ts +47 -9
  53. package/src/commands/init.ts +12 -39
  54. package/src/core/agents/render/session-context.ts +74 -34
  55. package/src/core/agents/rules/claim-conflict.ts +37 -12
  56. package/src/core/agents/state/heartbeat-writer.ts +8 -1
  57. package/src/core/config.ts +21 -0
  58. package/src/core/hooks/cli.ts +4 -8
  59. package/src/core/hooks/guard-path.ts +34 -0
  60. package/src/core/hooks/harness/wiring.ts +185 -0
  61. package/src/lib/completion/bash.ts +36 -0
  62. package/src/lib/completion/fish.ts +22 -0
  63. package/src/lib/completion/index.ts +12 -3
  64. package/src/lib/completion/resolve.ts +210 -0
  65. package/src/lib/completion/zsh.ts +34 -0
  66. package/src/lib/docs-lint.ts +5 -0
  67. package/dist/commands/uninstall.d.ts +0 -22
  68. package/dist/commands/uninstall.d.ts.map +0 -1
package/README.md CHANGED
@@ -27,23 +27,36 @@
27
27
  ## Install
28
28
 
29
29
  ```bash
30
- npm install -g harnery
30
+ curl -fsSL https://harnery.com/install.sh | bash
31
31
  ```
32
32
 
33
- Or as a per-project dep:
33
+ One line, no clone: it installs the `harn` CLI globally (npm preferred, Bun fallback), puts it on your `PATH`, and verifies it. Or drive your package manager directly — `npm install -g harnery` / `bun add -g harnery` (or `npm install harnery` for a project dep). Then wire a project:
34
34
 
35
35
  ```bash
36
- npm install harnery
36
+ harn init # creates .harnery/ + registers the harness hooks
37
+ harn doctor # optional: one-time runtime + dependency check
37
38
  ```
38
39
 
39
- Then:
40
+ > **From a git clone?** Cloning for the `web/` dashboard or to contribute? `./scripts/setup.sh` does the clone setup in one shot: installs deps, builds `dist/` on a Bun-free host, runs `harn init`, and links the bins onto your `PATH`.
41
+
42
+ > **npm gives you the engine + CLI.** The `web/` dashboard and the `docs/` site live in the git repo, not the npm package (which is the CLI + coord engine: `bin`, `dist`, `src`, `schemas`). To run the dashboard, `git clone` the repo, `bun install`, and `harn web up` from there, pointing it at your project with `--coord-root <dir>` (or just run it from inside the project). `harn web up` prints these exact steps if you invoke it without the clone present.
43
+
44
+ ## Uninstall
45
+
46
+ Two layers. **Unwire a project** (keeps `.harnery/` history by default; on a terminal it asks before deleting it):
40
47
 
41
48
  ```bash
42
- harn --help
43
- harn doctor # one-time runtime + dep check
49
+ harn deinit # unwire the harness hooks
50
+ harn deinit --purge-state # also delete .harnery/ (destructive)
44
51
  ```
45
52
 
46
- > **npm gives you the engine + CLI.** The `web/` dashboard and the `docs/` site live in the git repo, not the npm package (which is the CLI + coord engine: `bin`, `dist`, `src`, `schemas`). To run the dashboard, `git clone` the repo, `bun install`, and `harn web up` from there, pointing it at your project with `--coord-root <dir>` (or just run it from inside the project). `harn web up` prints these exact steps if you invoke it without the clone present.
53
+ **Remove the CLI** with the hosted one-liner (`npm rm -g harnery` / `bun remove -g harnery` work too):
54
+
55
+ ```bash
56
+ curl -fsSL https://harnery.com/uninstall.sh | bash
57
+ ```
58
+
59
+ From a git clone, `./scripts/teardown.sh` is the mirror of `./scripts/setup.sh`: it runs `harn deinit`, removes the `PATH` symlinks, and — on a terminal — asks whether to also delete this project's `.harnery/` history and the clone itself. Both default to no; pre-answer with `--purge-state` and `--remove-clone` for unattended runs.
47
60
 
48
61
  ## Use as a CLI library
49
62
 
package/dist/commander.js CHANGED
@@ -26,6 +26,7 @@ import { registerCompletionCommand } from "./commands/completion.js";
26
26
  import { registerConfigGetCommand } from "./commands/config-get.js";
27
27
  import { registerContextCommand } from "./commands/context.js";
28
28
  import { registerCookiesCommand } from "./commands/cookies.js";
29
+ import { registerDeinitCommand } from "./commands/deinit.js";
29
30
  import { registerDocsCommand } from "./commands/docs.js";
30
31
  import { registerDoctorCommand } from "./commands/doctor.js";
31
32
  import { registerEditBatchCommand } from "./commands/edit-batch.js";
@@ -44,7 +45,6 @@ import { registerSyncCommand } from "./commands/sync.js";
44
45
  import { registerSectionCommand, registerTocCommand } from "./commands/toc.js";
45
46
  import { registerTokensCommand } from "./commands/tokens.js";
46
47
  import { registerTunnelCommand } from "./commands/tunnel.js";
47
- import { registerUninstallCommand } from "./commands/uninstall.js";
48
48
  import { registerWebCommand } from "./commands/web.js";
49
49
  export const defaultEmit = {
50
50
  config() {
@@ -115,7 +115,7 @@ export function createHarneryProgram(opts = {}) {
115
115
  registerAgentsCommand(program, emit);
116
116
  registerDoctorCommand(program, emit);
117
117
  registerInitCommand(program, emit, opts.binName);
118
- registerUninstallCommand(program, emit);
118
+ registerDeinitCommand(program, emit, opts.binName);
119
119
  registerBackupCommand(program, emit);
120
120
  registerSyncCommand(program, emit);
121
121
  if (include("web"))
@@ -1 +1 @@
1
- {"version":3,"file":"completion.d.ts","sourceRoot":"","sources":["../../src/commands/completion.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAY1E;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,WAAW,EAClB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,IAAI,CA8DN"}
1
+ {"version":3,"file":"completion.d.ts","sourceRoot":"","sources":["../../src/commands/completion.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAkB1E;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,WAAW,EAClB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,IAAI,CAqGN"}
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
- import { generateBash, generateFish, generateZsh, walkProgram, } from "../lib/completion/index.js";
3
+ import { encodeResult, generateBash, generateBashDynamic, generateFish, generateFishDynamic, generateZsh, generateZshDynamic, resolveCompletions, walkProgram, } from "../lib/completion/index.js";
4
4
  const noopProviderRunner = async () => [];
5
5
  const noopLookup = () => undefined;
6
6
  /**
@@ -22,25 +22,35 @@ export function registerCompletionCommand(program, _emit, context) {
22
22
  const root = program
23
23
  .command("completion")
24
24
  .description("Shell tab-completion. Emit a script per shell or install to the standard location.");
25
+ const dynamicHint = "Emit a thin shim that calls the binary at tab-time (never goes stale; install once)";
25
26
  root
26
27
  .command("bash")
27
28
  .description("Emit bash completion script to stdout")
28
- .action(() => {
29
- const out = generateBash(walkProgram(program, lookup), program.name());
29
+ .option("--dynamic", dynamicHint)
30
+ .action((opts) => {
31
+ const out = opts.dynamic
32
+ ? generateBashDynamic(program.name())
33
+ : generateBash(walkProgram(program, lookup), program.name());
30
34
  process.stdout.write(out); // raw bytes: shell completion scripts must be unframed (consumer evals stdout).
31
35
  });
32
36
  root
33
37
  .command("zsh")
34
38
  .description("Emit zsh completion script to stdout")
35
- .action(() => {
36
- const out = generateZsh(walkProgram(program, lookup), program.name());
39
+ .option("--dynamic", dynamicHint)
40
+ .action((opts) => {
41
+ const out = opts.dynamic
42
+ ? generateZshDynamic(program.name())
43
+ : generateZsh(walkProgram(program, lookup), program.name());
37
44
  process.stdout.write(out); // raw bytes: shell completion scripts must be unframed (consumer evals stdout).
38
45
  });
39
46
  root
40
47
  .command("fish")
41
48
  .description("Emit fish completion script to stdout")
42
- .action(() => {
43
- const out = generateFish(walkProgram(program, lookup), program.name());
49
+ .option("--dynamic", dynamicHint)
50
+ .action((opts) => {
51
+ const out = opts.dynamic
52
+ ? generateFishDynamic(program.name())
53
+ : generateFish(walkProgram(program, lookup), program.name());
44
54
  process.stdout.write(out); // raw bytes: shell completion scripts must be unframed (consumer evals stdout).
45
55
  });
46
56
  root
@@ -49,6 +59,7 @@ export function registerCompletionCommand(program, _emit, context) {
49
59
  .option("--shell <name>", "bash | zsh | fish (default: auto-detect from $SHELL)")
50
60
  .option("--path <file>", "Override destination path")
51
61
  .option("--print-path", "Print the destination path and exit (no write)")
62
+ .option("--dynamic", `${dynamicHint} (recommended)`)
52
63
  .action(async (opts) => {
53
64
  await installCompletion(program, opts, lookup);
54
65
  });
@@ -72,6 +83,26 @@ export function registerCompletionCommand(program, _emit, context) {
72
83
  });
73
84
  // Keep TS happy that the variable is used.
74
85
  void hidden;
86
+ // Hidden internal entry for DYNAMIC completion: the thin shim passes the live
87
+ // command line (cursor index + all words after `--`) and we compute the full
88
+ // candidate set from the live command tree. `--` stops option parsing so
89
+ // words like `-h` reach the variadic instead of being read as our flags.
90
+ const hiddenLine = program
91
+ .command("__complete-line <cword> [words...]", { hidden: true })
92
+ .description("Internal: full-line completion callback for the dynamic shell shim")
93
+ .allowUnknownOption(true)
94
+ .allowExcessArguments(true)
95
+ .action(async (cword, words) => {
96
+ try {
97
+ const result = await resolveCompletions(program, words ?? [], Number.parseInt(cword, 10) || 0, lookup, runProvider);
98
+ process.stdout.write(encodeResult(result)); // lint-ok-emission: shell callback; the encoded candidate/directive stream is the contract with the shim.
99
+ }
100
+ catch {
101
+ // Never break the user's tab: emit just the file-fallback directive.
102
+ process.stdout.write("\x1f:1\n"); // lint-ok-emission: shell callback fallback directive.
103
+ }
104
+ });
105
+ void hiddenLine;
75
106
  }
76
107
  async function installCompletion(program, opts, lookup) {
77
108
  const shell = opts.shell ?? detectShell();
@@ -84,16 +115,23 @@ async function installCompletion(program, opts, lookup) {
84
115
  process.stdout.write(`${destination}\n`); // lint-ok-emission: --print-path is meant to be piped (e.g., dest=$(harn completion install --print-path)).
85
116
  return;
86
117
  }
118
+ const name = program.name();
87
119
  let content;
88
120
  switch (shell) {
89
121
  case "bash":
90
- content = generateBash(walkProgram(program, lookup), program.name());
122
+ content = opts.dynamic
123
+ ? generateBashDynamic(name)
124
+ : generateBash(walkProgram(program, lookup), name);
91
125
  break;
92
126
  case "zsh":
93
- content = generateZsh(walkProgram(program, lookup), program.name());
127
+ content = opts.dynamic
128
+ ? generateZshDynamic(name)
129
+ : generateZsh(walkProgram(program, lookup), name);
94
130
  break;
95
131
  case "fish":
96
- content = generateFish(walkProgram(program, lookup), program.name());
132
+ content = opts.dynamic
133
+ ? generateFishDynamic(name)
134
+ : generateFish(walkProgram(program, lookup), name);
97
135
  break;
98
136
  default:
99
137
  process.stderr.write(`Unknown shell: ${shell}\n`); // lint-ok-emission: install-time error, see above.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `harn deinit`: reverse what `harn init` wired into a project.
3
+ *
4
+ * `init` makes two kinds of change outside the harnery package:
5
+ * 1. Merges `agent-hook` entries into the harness settings file
6
+ * (Claude Code `.claude/settings.json`, Cursor `.cursor/hooks.json`, or
7
+ * Codex `.codex/hooks.json`).
8
+ * 2. Creates the `.harnery/` coord root (runtime state: events, councils,
9
+ * identities, scratch) and stamps the host bin name into
10
+ * `.harnery/config.jsonc`.
11
+ *
12
+ * `deinit` undoes (1) by default: it removes only harnery's hook entries from
13
+ * the settings file, preserving any other hooks the consumer added, and deletes
14
+ * the settings file outright when it's left harnery-only. It does NOT touch the
15
+ * `.harnery/` coord root unless `--purge-state` is passed, because that directory
16
+ * holds session history a consumer may want to keep. Idempotent + `--dry-run`,
17
+ * mirroring `init`.
18
+ *
19
+ * For standalone `harn` on a terminal it also handles the destructive extra
20
+ * conversationally: when `--purge-state` wasn't passed and `.harnery/` exists, it
21
+ * asks before deleting it, and afterward it prints how to remove the harnery CLI
22
+ * itself (which a running process can't do to its own package). Both are gated to
23
+ * standalone harn — an embedding host routes output through its own emit and owns
24
+ * its install lifecycle — and the prompt never fires off a TTY, so scripted / CI
25
+ * runs keep the flag-driven behavior untouched. The shell wrapper `scripts/teardown.sh`
26
+ * is the fuller mirror (it also unlinks the PATH bins and can delete the clone).
27
+ */
28
+ import type { Command } from "commander";
29
+ import type { EmitContext } from "../commander.js";
30
+ export declare function registerDeinitCommand(program: Command, emit: EmitContext, binName?: string): void;
31
+ /**
32
+ * Whether to interactively ask before deleting `.harnery/`. True only for
33
+ * standalone harn on a TTY, when the user didn't already answer with
34
+ * --purge-state, it isn't a dry run, and there's actually a coord root to
35
+ * delete. Pure so the gating is unit-testable without a terminal.
36
+ */
37
+ export declare function shouldPromptForState(o: {
38
+ standalone: boolean;
39
+ interactive: boolean;
40
+ dryRun: boolean;
41
+ purgeState: boolean;
42
+ coordExists: boolean;
43
+ }): boolean;
44
+ /**
45
+ * The "harnery itself is still installed" line shown to standalone-harn users
46
+ * after a real deinit. `harn deinit` can't remove the package it's running
47
+ * from, so it points at the two ways to finish the job. Pure + exported for the
48
+ * test.
49
+ */
50
+ export declare function engineRemovalHint(): string;
51
+ //# sourceMappingURL=deinit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deinit.d.ts","sourceRoot":"","sources":["../../src/commands/deinit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAYnD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CA2GjG;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EAAE;IACtC,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAEV;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAM1C"}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `harn uninstall`: reverse what `harn init` wired into a project.
2
+ * `harn deinit`: reverse what `harn init` wired into a project.
3
3
  *
4
4
  * `init` makes two kinds of change outside the harnery package:
5
5
  * 1. Merges `agent-hook` entries into the harness settings file
@@ -9,29 +9,46 @@
9
9
  * identities, scratch) and stamps the host bin name into
10
10
  * `.harnery/config.jsonc`.
11
11
  *
12
- * `uninstall` undoes (1) by default: it removes only harnery's hook entries from
12
+ * `deinit` undoes (1) by default: it removes only harnery's hook entries from
13
13
  * the settings file, preserving any other hooks the consumer added, and deletes
14
14
  * the settings file outright when it's left harnery-only. It does NOT touch the
15
15
  * `.harnery/` coord root unless `--purge-state` is passed, because that directory
16
16
  * holds session history a consumer may want to keep. Idempotent + `--dry-run`,
17
17
  * mirroring `init`.
18
+ *
19
+ * For standalone `harn` on a terminal it also handles the destructive extra
20
+ * conversationally: when `--purge-state` wasn't passed and `.harnery/` exists, it
21
+ * asks before deleting it, and afterward it prints how to remove the harnery CLI
22
+ * itself (which a running process can't do to its own package). Both are gated to
23
+ * standalone harn — an embedding host routes output through its own emit and owns
24
+ * its install lifecycle — and the prompt never fires off a TTY, so scripted / CI
25
+ * runs keep the flag-driven behavior untouched. The shell wrapper `scripts/teardown.sh`
26
+ * is the fuller mirror (it also unlinks the PATH bins and can delete the clone).
18
27
  */
19
28
  import { spawnSync } from "node:child_process";
20
29
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
30
  import { relative, resolve } from "node:path";
31
+ import { createInterface } from "node:readline";
32
+ import { DEFAULT_BIN_NAME } from "../core/config.js";
22
33
  import { HARNESS_SPECS } from "../core/hooks/harness/events.js";
23
34
  import { unwireHooks } from "./init.js";
24
- export function registerUninstallCommand(program, emit) {
35
+ export function registerDeinitCommand(program, emit, binName) {
36
+ // The interactive prompt + engine-removal hint are standalone-harn niceties.
37
+ // An embedding host routes output through its own emit and owns its install
38
+ // lifecycle, so for a host (binName set + non-default) deinit stays strictly
39
+ // flag-driven and says nothing about removing "the package".
40
+ const standalone = !binName || binName === DEFAULT_BIN_NAME;
25
41
  program
26
- .command("uninstall")
42
+ .command("deinit")
27
43
  .description("Reverse `harn init`: remove harnery's hook entries from the harness " +
28
44
  "settings file (keeps any others). Pass --purge-state to also delete the " +
29
- ".harnery/ coord root. Idempotent; use --dry-run to preview.")
45
+ ".harnery/ coord root (on a terminal it asks first). Idempotent; use " +
46
+ "--dry-run to preview.")
30
47
  .option("--harness <id>", "claude-code | cursor | codex", "claude-code")
31
48
  .option("--dry-run", "Show what would change without writing")
32
49
  .option("--project-root <path>", "Project root (default: git toplevel, else cwd)")
33
50
  .option("--purge-state", "Also delete the .harnery/ coord root (runtime state, destructive)")
34
- .action((opts) => {
51
+ .action(async (opts) => {
35
52
  const harness = opts.harness;
36
53
  const spec = HARNESS_SPECS[harness];
37
54
  if (!spec) {
@@ -41,7 +58,20 @@ export function registerUninstallCommand(program, emit) {
41
58
  }
42
59
  const projectRoot = resolve(opts.projectRoot ?? gitTopLevel() ?? process.cwd());
43
60
  const dryRun = opts.dryRun === true;
61
+ const coordDir = resolve(projectRoot, ".harnery");
62
+ const coordExists = existsSync(coordDir);
63
+ let purgeState = opts.purgeState === true;
44
64
  const actions = [];
65
+ // ── 0. interactive: offer to delete .harnery/ (standalone harn, TTY) ───
66
+ if (shouldPromptForState({
67
+ standalone,
68
+ interactive: process.stdin.isTTY === true,
69
+ dryRun,
70
+ purgeState,
71
+ coordExists,
72
+ })) {
73
+ purgeState = await confirmDeleteState(coordDir);
74
+ }
45
75
  // ── 1. unwire harness hooks ────────────────────────────────────────────
46
76
  const settingsPath = resolve(projectRoot, spec.settingsFile);
47
77
  if (!existsSync(settingsPath)) {
@@ -84,9 +114,8 @@ export function registerUninstallCommand(program, emit) {
84
114
  }
85
115
  }
86
116
  // ── 2. coord root (opt-in; destructive) ────────────────────────────────
87
- const coordDir = resolve(projectRoot, ".harnery");
88
- if (opts.purgeState) {
89
- if (!existsSync(coordDir)) {
117
+ if (purgeState) {
118
+ if (!coordExists) {
90
119
  actions.push("· .harnery/ doesn't exist; nothing to purge");
91
120
  }
92
121
  else if (dryRun) {
@@ -97,10 +126,49 @@ export function registerUninstallCommand(program, emit) {
97
126
  actions.push("+ deleted .harnery/ and all coord state");
98
127
  }
99
128
  }
100
- else if (existsSync(coordDir)) {
129
+ else if (coordExists) {
101
130
  actions.push("· left .harnery/ coord root in place (pass --purge-state to delete it)");
102
131
  }
103
- emit.text(render(projectRoot, dryRun, actions));
132
+ const hint = standalone && !dryRun ? engineRemovalHint() : null;
133
+ emit.text(render(projectRoot, dryRun, actions, hint));
134
+ });
135
+ }
136
+ /**
137
+ * Whether to interactively ask before deleting `.harnery/`. True only for
138
+ * standalone harn on a TTY, when the user didn't already answer with
139
+ * --purge-state, it isn't a dry run, and there's actually a coord root to
140
+ * delete. Pure so the gating is unit-testable without a terminal.
141
+ */
142
+ export function shouldPromptForState(o) {
143
+ return o.standalone && o.interactive && !o.dryRun && !o.purgeState && o.coordExists;
144
+ }
145
+ /**
146
+ * The "harnery itself is still installed" line shown to standalone-harn users
147
+ * after a real deinit. `harn deinit` can't remove the package it's running
148
+ * from, so it points at the two ways to finish the job. Pure + exported for the
149
+ * test.
150
+ */
151
+ export function engineRemovalHint() {
152
+ return ("harnery itself is still installed. To remove the CLI too: `npm rm -g harnery` " +
153
+ "(if you installed it with npm/bun); from a git clone, " +
154
+ "`scripts/teardown.sh --remove-clone` removes the checkout.");
155
+ }
156
+ /** Mirror scripts/teardown.sh's wording: explain what .harnery/ holds, then ask. */
157
+ function confirmDeleteState(coordDir) {
158
+ process.stdout.write("\nharnery saved this project's coordination history in .harnery/\n" +
159
+ "(its event log, councils, agent identities, and scratchpads):\n" +
160
+ ` ${coordDir}\n` +
161
+ "Unwiring leaves that in place. Deleting it can't be undone.\n");
162
+ return confirm("Delete this project's harnery history too? [y/N]");
163
+ }
164
+ /** Read one y/N answer from the TTY. Yes only on an explicit y / yes. */
165
+ function confirm(question) {
166
+ return new Promise((resolveAnswer) => {
167
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
168
+ rl.question(`${question} `, (answer) => {
169
+ rl.close();
170
+ resolveAnswer(/^y(es)?$/i.test(answer.trim()));
171
+ });
104
172
  });
105
173
  }
106
174
  /** True when an unwired settings object holds nothing but (optionally) `version`. */
@@ -108,12 +176,13 @@ function harnessOnly(settings) {
108
176
  const keys = Object.keys(settings);
109
177
  return keys.length === 0 || (keys.length === 1 && keys[0] === "version");
110
178
  }
111
- function render(projectRoot, dryRun, actions) {
112
- const head = dryRun ? "harn uninstall (dry run): no changes written" : "harn uninstall";
179
+ function render(projectRoot, dryRun, actions, hint) {
180
+ const head = dryRun ? "harn deinit (dry run): no changes written" : "harn deinit";
113
181
  const tail = dryRun
114
182
  ? "\nRe-run without --dry-run to apply."
115
183
  : "\nDone. harnery hooks are unwired; restart your harness session to drop them.";
116
- return `${head}\n root: ${projectRoot}\n${actions.map((a) => ` ${a}`).join("\n")}${tail}`;
184
+ const hintBlock = hint ? `\n\n${hint}` : "";
185
+ return `${head}\n root: ${projectRoot}\n${actions.map((a) => ` ${a}`).join("\n")}${tail}${hintBlock}`;
117
186
  }
118
187
  function gitTopLevel() {
119
188
  const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,KAAK,QAAQ,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvC,UAAU,KAAK;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAMD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAwC/E;AAED,wBAAgB,SAAS,IAAI,KAAK,EAAE,CAWnC"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAInD,KAAK,QAAQ,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvC,UAAU,KAAK;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAMD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAwC/E;AAED,wBAAgB,SAAS,IAAI,KAAK,EAAE,CAYnC"}
@@ -14,6 +14,8 @@ import { spawnSync } from "node:child_process";
14
14
  import { existsSync } from "node:fs";
15
15
  import os from "node:os";
16
16
  import path from "node:path";
17
+ import { resolveBinName } from "../core/config.js";
18
+ import { loadHarnessWiring } from "../core/hooks/harness/wiring.js";
17
19
  export function registerDoctorCommand(program, emit) {
18
20
  program
19
21
  .command("doctor")
@@ -55,6 +57,7 @@ export function runChecks() {
55
57
  checkGit(),
56
58
  checkBun(),
57
59
  checkHarneryDir(),
60
+ checkHarnessHooks(),
58
61
  checkRestic(),
59
62
  checkRclone(),
60
63
  checkPlaywright(),
@@ -172,22 +175,24 @@ function checkPython() {
172
175
  }
173
176
  return { name: "python3", severity: "ok", detail: r.out };
174
177
  }
175
- function checkHarneryDir() {
176
- // Walk up from cwd looking for .harnery/.
178
+ /** Walk up from cwd to the nearest dir containing `.harnery/`; null if none. */
179
+ function findCoordProjectRoot() {
177
180
  let dir = process.cwd();
178
181
  for (let i = 0; i < 8; i++) {
179
- if (existsSync(path.join(dir, ".harnery"))) {
180
- return {
181
- name: ".harnery/",
182
- severity: "ok",
183
- detail: path.join(dir, ".harnery"),
184
- };
185
- }
182
+ if (existsSync(path.join(dir, ".harnery")))
183
+ return dir;
186
184
  const parent = path.dirname(dir);
187
185
  if (parent === dir)
188
186
  break;
189
187
  dir = parent;
190
188
  }
189
+ return null;
190
+ }
191
+ function checkHarneryDir() {
192
+ const root = findCoordProjectRoot();
193
+ if (root) {
194
+ return { name: ".harnery/", severity: "ok", detail: path.join(root, ".harnery") };
195
+ }
191
196
  return {
192
197
  name: ".harnery/",
193
198
  severity: "warn",
@@ -195,6 +200,39 @@ function checkHarneryDir() {
195
200
  hint: "create one with `mkdir -p .harnery/active` from your monorepo root",
196
201
  };
197
202
  }
203
+ /**
204
+ * Compare the project's wired harness hooks against HARNESS_SPECS. Catches the
205
+ * post-upgrade case where a harnery release added (or renamed) a hook event but
206
+ * the consumer's settings file hasn't been re-wired. Only fires for a harness
207
+ * the project has opted into (≥1 harnery hook already wired) — see
208
+ * loadHarnessWiring — so a bare settings file never false-warns. The remedy is
209
+ * always the same: re-run `<bin> init` (idempotent, additive).
210
+ */
211
+ function checkHarnessHooks() {
212
+ const root = findCoordProjectRoot();
213
+ if (!root) {
214
+ return { name: "harness hooks", severity: "ok", detail: "n/a (no .harnery/ above cwd)" };
215
+ }
216
+ const drift = loadHarnessWiring(root);
217
+ if (drift.length === 0) {
218
+ return { name: "harness hooks", severity: "ok", detail: "wired + current" };
219
+ }
220
+ const bin = resolveBinName(root);
221
+ const parts = drift.map((d) => {
222
+ const bits = [];
223
+ if (d.missing.length > 0) {
224
+ bits.push(`${d.missing.length} missing (${d.missing.map((m) => m.subcommand).join(", ")})`);
225
+ }
226
+ if (d.orphans.length > 0)
227
+ bits.push(`${d.orphans.length} orphaned (${d.orphans.join(", ")})`);
228
+ return `${d.settingsFile}: ${bits.join("; ")}`;
229
+ });
230
+ const hasOrphans = drift.some((d) => d.orphans.length > 0);
231
+ const hint = hasOrphans
232
+ ? `run \`${bin} init\` to wire missing hooks; remove orphaned entries (renamed/dropped events) with \`${bin} deinit\` then \`${bin} init\``
233
+ : `run \`${bin} init\` to wire the new hook(s) (idempotent)`;
234
+ return { name: "harness hooks", severity: "warn", detail: parts.join(" | "), hint };
235
+ }
198
236
  function macOrLinux(mac, linux) {
199
237
  return os.platform() === "darwin" ? mac : linux;
200
238
  }
@@ -18,26 +18,8 @@
18
18
  import type { Command } from "commander";
19
19
  import type { EmitContext } from "../commander.js";
20
20
  import { type HarnessId, type HarnessSpec } from "../core/hooks/harness/events.js";
21
- /** Claude Code + Codex entry: `{ hooks: [{ type, command }] }`. */
22
- interface ClaudeHookGroup {
23
- matcher?: string;
24
- hooks: {
25
- type: string;
26
- command: string;
27
- }[];
28
- }
29
- /** Cursor entry: a flat `{ command }`. */
30
- interface CursorHookGroup {
31
- command: string;
32
- type?: string;
33
- matcher?: string;
34
- }
35
- type HookGroup = ClaudeHookGroup | CursorHookGroup;
36
- export interface SettingsFile {
37
- version?: number;
38
- hooks?: Record<string, HookGroup[]>;
39
- [k: string]: unknown;
40
- }
21
+ import { type HookGroup, type SettingsFile } from "../core/hooks/harness/wiring.js";
22
+ export type { HookGroup, SettingsFile };
41
23
  export declare function registerInitCommand(program: Command, emit: EmitContext, binName?: string): void;
42
24
  /**
43
25
  * Merge agent-hook entries into a harness settings object in place, idempotently.
@@ -78,5 +60,4 @@ export declare function unwireHooks(settings: SettingsFile): {
78
60
  * - key absent → splice it in as the first key (comment-safe).
79
61
  */
80
62
  export declare function stampBinName(configPath: string, binName: string, dryRun: boolean): string | null;
81
- export {};
82
63
  //# sourceMappingURL=init.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,WAAW,EAEjB,MAAM,iCAAiC,CAAC;AAWzC,mEAAmE;AACnE,UAAU,eAAe;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC5C;AACD,0CAA0C;AAC1C,UAAU,eAAe;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AACD,KAAK,SAAS,GAAG,eAAe,GAAG,eAAe,CAAC;AAEnD,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;IACpC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAsF/F;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,WAAW,EACjB,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,SAAS,GACjB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAsBpC;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAmB1F;AAgBD;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAmDhG"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,OAAO,EAAiB,KAAK,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAClG,OAAO,EAGL,KAAK,SAAS,EAEd,KAAK,YAAY,EAClB,MAAM,iCAAiC,CAAC;AAIzC,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;AAWxC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAsF/F;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,WAAW,EACjB,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,SAAS,GACjB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAsBpC;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAmB1F;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAmDhG"}
@@ -20,7 +20,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
20
  import { dirname, relative, resolve } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { DEFAULT_BIN_NAME, stripJsonComments } from "../core/config.js";
23
- import { HARNESS_SPECS, } from "../core/hooks/harness/events.js";
23
+ import { HARNESS_SPECS } from "../core/hooks/harness/events.js";
24
+ import { commandWiresSubcommand, groupCommands, makeEntry, } from "../core/hooks/harness/wiring.js";
24
25
  // This file is src/commands/init.ts → harnery package root is two levels up.
25
26
  const HARNERY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
26
27
  export function registerInitCommand(program, emit, binName) {
@@ -122,7 +123,7 @@ export function wireHooks(settings, spec, agentHookPath, harness) {
122
123
  for (const { settingsKey, subcommand } of spec.events) {
123
124
  const command = `bash ${agentHookPath} ${subcommand} --harness ${harness}`;
124
125
  const groups = settings.hooks[settingsKey] ?? [];
125
- const present = groups.some((g) => groupCommands(g).some((c) => c.includes(`agent-hook ${subcommand} `)));
126
+ const present = groups.some((g) => groupCommands(g).some((c) => commandWiresSubcommand(c, subcommand)));
126
127
  if (present) {
127
128
  already++;
128
129
  continue;
@@ -169,19 +170,6 @@ export function unwireHooks(settings) {
169
170
  delete settings.hooks;
170
171
  return { removed, remaining };
171
172
  }
172
- /** Build a hook entry in the harness's shape. */
173
- function makeEntry(shape, command) {
174
- return shape === "cursor" ? { command } : { hooks: [{ type: "command", command }] };
175
- }
176
- /** Pull every command string out of a hook entry, regardless of shape. */
177
- function groupCommands(group) {
178
- if ("command" in group && typeof group.command === "string")
179
- return [group.command];
180
- if ("hooks" in group && Array.isArray(group.hooks)) {
181
- return group.hooks.map((h) => h.command).filter((c) => typeof c === "string");
182
- }
183
- return [];
184
- }
185
173
  /**
186
174
  * Idempotently record `binName` in `.harnery/config.jsonc`, preserving any
187
175
  * existing JSONC comments and the `files` section. Returns an action line, or
@@ -22,8 +22,18 @@ export interface RenderOpts {
22
22
  */
23
23
  export declare function renderSessionContext(opts: RenderOpts): string;
24
24
  /**
25
- * Returns a list of wiring issues (empty when everything's wired). Checks
26
- * parent core.hooksPath + one representative submodule.
25
+ * Returns a list of commit-guard wiring issues (empty when wired). Portable
26
+ * across host projects: it asserts the FUNCTIONAL property ("does this repo's
27
+ * pre-commit invoke harnery's guard?") rather than any path convention. For
28
+ * each repo it resolves the EFFECTIVE git-hooks dir via
29
+ * `git rev-parse --git-path hooks` (which already honors `core.hooksPath`,
30
+ * linked worktrees, and submodule gitdirs) and checks whether the `pre-commit`
31
+ * there calls `agent-coord` / `agent-hook`. Checks the parent repo + one
32
+ * representative submodule (others almost always share the same setup).
33
+ *
34
+ * harnery does not install git hooks itself — each host wires its own
35
+ * pre-commit to invoke the guard — so the remediation command is host-specific
36
+ * and supplied via `hooksSetupHint` in `.harnery/config.jsonc` (see the caller).
27
37
  */
28
38
  export declare function checkWiring(coordRoot: string): string[];
29
39
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"session-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/session-context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAyC,QAAQ,EAAE,MAAM,SAAS,CAAC;AAmB1E,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;+BAC2B;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAoC7D;AAwHD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAiCvD;AA6CD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAiDlF;AAED;;;GAGG;AACH,eAAO,MAAM,uBAAuB,iBAAW,CAAC"}
1
+ {"version":3,"file":"session-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/session-context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAyC,QAAQ,EAAE,MAAM,SAAS,CAAC;AAoB1E,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;+BAC2B;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CA+D7D;AAwHD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAwBvD;AAwDD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAiDlF;AAED;;;GAGG;AACH,eAAO,MAAM,uBAAuB,iBAAW,CAAC"}