@vibe-commit/capture 0.1.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/agents/claude_code.d.ts +11 -0
  4. package/dist/agents/claude_code.js +162 -0
  5. package/dist/agents/claude_code.js.map +1 -0
  6. package/dist/agents/codex_cli.d.ts +15 -0
  7. package/dist/agents/codex_cli.js +67 -0
  8. package/dist/agents/codex_cli.js.map +1 -0
  9. package/dist/agents/cursor.d.ts +21 -0
  10. package/dist/agents/cursor.js +89 -0
  11. package/dist/agents/cursor.js.map +1 -0
  12. package/dist/agents/registry.d.ts +184 -0
  13. package/dist/agents/registry.js +212 -0
  14. package/dist/agents/registry.js.map +1 -0
  15. package/dist/agents/types.d.ts +279 -0
  16. package/dist/agents/types.js +67 -0
  17. package/dist/agents/types.js.map +1 -0
  18. package/dist/commands/connect.d.ts +36 -0
  19. package/dist/commands/connect.js +499 -0
  20. package/dist/commands/connect.js.map +1 -0
  21. package/dist/commands/context.d.ts +45 -0
  22. package/dist/commands/context.js +14 -0
  23. package/dist/commands/context.js.map +1 -0
  24. package/dist/commands/report.d.ts +105 -0
  25. package/dist/commands/report.js +430 -0
  26. package/dist/commands/report.js.map +1 -0
  27. package/dist/commands/status.d.ts +29 -0
  28. package/dist/commands/status.js +236 -0
  29. package/dist/commands/status.js.map +1 -0
  30. package/dist/commands/why.d.ts +349 -0
  31. package/dist/commands/why.js +937 -0
  32. package/dist/commands/why.js.map +1 -0
  33. package/dist/consent.d.ts +59 -0
  34. package/dist/consent.js +111 -0
  35. package/dist/consent.js.map +1 -0
  36. package/dist/copy/claims.d.ts +79 -0
  37. package/dist/copy/claims.js +343 -0
  38. package/dist/copy/claims.js.map +1 -0
  39. package/dist/copy/grades.d.ts +117 -0
  40. package/dist/copy/grades.js +96 -0
  41. package/dist/copy/grades.js.map +1 -0
  42. package/dist/copy/index.d.ts +12 -0
  43. package/dist/copy/index.js +13 -0
  44. package/dist/copy/index.js.map +1 -0
  45. package/dist/copy/strings.d.ts +1328 -0
  46. package/dist/copy/strings.js +1243 -0
  47. package/dist/copy/strings.js.map +1 -0
  48. package/dist/copy/time.d.ts +27 -0
  49. package/dist/copy/time.js +43 -0
  50. package/dist/copy/time.js.map +1 -0
  51. package/dist/credential.d.ts +211 -0
  52. package/dist/credential.js +298 -0
  53. package/dist/credential.js.map +1 -0
  54. package/dist/exit.d.ts +59 -0
  55. package/dist/exit.js +60 -0
  56. package/dist/exit.js.map +1 -0
  57. package/dist/git.d.ts +299 -0
  58. package/dist/git.js +653 -0
  59. package/dist/git.js.map +1 -0
  60. package/dist/hooks/entry.d.ts +277 -0
  61. package/dist/hooks/entry.js +1092 -0
  62. package/dist/hooks/entry.js.map +1 -0
  63. package/dist/hooks/post_commit.d.ts +59 -0
  64. package/dist/hooks/post_commit.js +160 -0
  65. package/dist/hooks/post_commit.js.map +1 -0
  66. package/dist/hooks/post_rewrite.d.ts +59 -0
  67. package/dist/hooks/post_rewrite.js +161 -0
  68. package/dist/hooks/post_rewrite.js.map +1 -0
  69. package/dist/hooks/session_end.d.ts +101 -0
  70. package/dist/hooks/session_end.js +120 -0
  71. package/dist/hooks/session_end.js.map +1 -0
  72. package/dist/index.d.ts +34 -0
  73. package/dist/index.js +360 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/install.d.ts +153 -0
  76. package/dist/install.js +284 -0
  77. package/dist/install.js.map +1 -0
  78. package/dist/json.d.ts +100 -0
  79. package/dist/json.js +94 -0
  80. package/dist/json.js.map +1 -0
  81. package/dist/oauth/discovery.d.ts +96 -0
  82. package/dist/oauth/discovery.js +163 -0
  83. package/dist/oauth/discovery.js.map +1 -0
  84. package/dist/oauth/lock.d.ts +100 -0
  85. package/dist/oauth/lock.js +287 -0
  86. package/dist/oauth/lock.js.map +1 -0
  87. package/dist/oauth/loopback.d.ts +85 -0
  88. package/dist/oauth/loopback.js +124 -0
  89. package/dist/oauth/loopback.js.map +1 -0
  90. package/dist/oauth/pkce.d.ts +58 -0
  91. package/dist/oauth/pkce.js +77 -0
  92. package/dist/oauth/pkce.js.map +1 -0
  93. package/dist/oauth/read.d.ts +120 -0
  94. package/dist/oauth/read.js +144 -0
  95. package/dist/oauth/read.js.map +1 -0
  96. package/dist/oauth/session.d.ts +80 -0
  97. package/dist/oauth/session.js +112 -0
  98. package/dist/oauth/session.js.map +1 -0
  99. package/dist/oauth/signin.d.ts +178 -0
  100. package/dist/oauth/signin.js +225 -0
  101. package/dist/oauth/signin.js.map +1 -0
  102. package/dist/oauth/token.d.ts +139 -0
  103. package/dist/oauth/token.js +183 -0
  104. package/dist/oauth/token.js.map +1 -0
  105. package/dist/paths.d.ts +366 -0
  106. package/dist/paths.js +460 -0
  107. package/dist/paths.js.map +1 -0
  108. package/dist/policy.d.ts +195 -0
  109. package/dist/policy.js +244 -0
  110. package/dist/policy.js.map +1 -0
  111. package/dist/post.d.ts +302 -0
  112. package/dist/post.js +384 -0
  113. package/dist/post.js.map +1 -0
  114. package/dist/project.d.ts +99 -0
  115. package/dist/project.js +192 -0
  116. package/dist/project.js.map +1 -0
  117. package/dist/redact.d.ts +175 -0
  118. package/dist/redact.js +700 -0
  119. package/dist/redact.js.map +1 -0
  120. package/dist/runtime.d.ts +30 -0
  121. package/dist/runtime.js +37 -0
  122. package/dist/runtime.js.map +1 -0
  123. package/dist/spawn_budget.d.ts +101 -0
  124. package/dist/spawn_budget.js +124 -0
  125. package/dist/spawn_budget.js.map +1 -0
  126. package/dist/spool.d.ts +390 -0
  127. package/dist/spool.js +556 -0
  128. package/dist/spool.js.map +1 -0
  129. package/dist/state.d.ts +109 -0
  130. package/dist/state.js +217 -0
  131. package/dist/state.js.map +1 -0
  132. package/dist/system_message.d.ts +101 -0
  133. package/dist/system_message.js +141 -0
  134. package/dist/system_message.js.map +1 -0
  135. package/dist/term.d.ts +112 -0
  136. package/dist/term.js +134 -0
  137. package/dist/term.js.map +1 -0
  138. package/dist/version.d.ts +16 -0
  139. package/dist/version.js +17 -0
  140. package/dist/version.js.map +1 -0
  141. package/package.json +50 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Is the `vibecommit` a user types OURS? — `CR-025`, D55.
3
+ *
4
+ * D55 named the npm package `@vibe-commit/capture` because the bare
5
+ * `vibecommit` is taken — by `vibecommit@1.0.6`, which is **itself a commit
6
+ * CLI**. The BIN name stayed `vibecommit`, and D55 accepted the residual
7
+ * collision as unlikely. The register corrects that to closer to certain: the
8
+ * overlap population is exactly developers who install commit tooling.
9
+ *
10
+ * **This detects the collision. It does not reopen the decision** — the bin
11
+ * name is settled and D57 §DX5's verb surface is locked.
12
+ *
13
+ * What the clash costs is narrow and worth stating precisely, because the error
14
+ * copy has to: hook capture is UNAFFECTED, because the plugin invokes the binary
15
+ * by path. What breaks is the verbs a human types — `status`, `why`, `report`
16
+ * would run the other program.
17
+ *
18
+ * ## Resolved in-process, not by shelling out
19
+ *
20
+ * PATH lookup is a `readdir`-free stat of one candidate per PATH entry, so doing
21
+ * it here costs nothing and avoids a subprocess entirely — no argument array to
22
+ * get wrong, no shell to quote for, and no dependency on `which`/`command -v`
23
+ * being present or behaving the same across platforms. It also makes the whole
24
+ * thing testable by handing in an `env`, which a subprocess-based version could
25
+ * only fake by building a directory of shims.
26
+ */
27
+ export type InstallVerdict =
28
+ /** The `vibecommit` on PATH resolves to this same program. */
29
+ {
30
+ readonly kind: "ours";
31
+ }
32
+ /** Something else owns the name. `resolved` is the real path it points at. */
33
+ | {
34
+ readonly kind: "foreign";
35
+ readonly resolved: string;
36
+ }
37
+ /** Nothing on PATH answers to `vibecommit`. */
38
+ | {
39
+ readonly kind: "absent";
40
+ };
41
+ /**
42
+ * Classify the `vibecommit` on PATH against the program actually running.
43
+ *
44
+ * `selfPath` is this process's entry point. Both sides are `realpath`'d before
45
+ * comparison, because the normal install IS a symlink — npm puts a link in
46
+ * `.../bin/vibecommit` pointing at the package's `dist/index.js`, so a string
47
+ * compare would call our own correct install foreign.
48
+ *
49
+ * Unresolvable either side is treated as NOT ours. That is the conservative
50
+ * direction here: the cost of a false "foreign" is one printed block a user can
51
+ * read and dismiss; the cost of a false "ours" is silence about a real clash,
52
+ * which is the failure this exists to catch.
53
+ */
54
+ export declare function classifyInstall(env: NodeJS.ProcessEnv, selfPath: string): InstallVerdict;
55
+ /** The first executable `name` on PATH, or null. */
56
+ export declare function resolveOnPath(env: NodeJS.ProcessEnv, name: string): string | null;
57
+ /** Marks a `post-commit` as ours. Also the idempotency check. */
58
+ export declare const HOOK_MARKER = "vibecommit-capture-hook v1";
59
+ /**
60
+ * Where a pre-existing hook is moved so the chain can still run it.
61
+ *
62
+ * ⚠ **THE SPELLING IS LOAD-BEARING, AND THE FIRST TWO ATTEMPTS FAILED A GUARD.**
63
+ * `test/provenance.test.ts` is a wall over RAW TEXT with two separate tripwires,
64
+ * and ordinary explanatory prose sets off both:
65
+ *
66
+ * 1. it scans for the product name followed by a hyphen and a word, and reads
67
+ * every hit as a sibling REPOSITORY that must be declared. A filename
68
+ * suffix of that shape is indistinguishable from a repo name to a regex,
69
+ * so `.<product>-original` failed for a reason unrelated to provenance;
70
+ * 2. it counts bare marker tags and requires each to parse as a full
71
+ * three-part declaration, so merely NAMING the tag in a sentence registers
72
+ * a malformed one.
73
+ *
74
+ * Both were hit while writing this comment, in that order. The suffix is
75
+ * renamed and the prose avoids both literals — the guards are not weakened.
76
+ * `git.ts` carries the same warning about its linkage regex, for the same reason.
77
+ */
78
+ export declare const CHAINED_SUFFIX = ".chained-by-vibecommit";
79
+ export type HookInstall =
80
+ /** Written into an empty slot. */
81
+ {
82
+ readonly kind: "installed";
83
+ readonly path: string;
84
+ }
85
+ /** A foreign `post-commit` was moved aside and is invoked FIRST by ours. */
86
+ | {
87
+ readonly kind: "chained";
88
+ readonly path: string;
89
+ readonly original: string;
90
+ }
91
+ /** Ours is already there; the binary path was refreshed. */
92
+ | {
93
+ readonly kind: "already";
94
+ readonly path: string;
95
+ }
96
+ /** ⛔ `core.hooksPath` is set, so `.git/hooks` is DEAD. Nothing was written. */
97
+ | {
98
+ readonly kind: "hooks-path";
99
+ readonly configured: string;
100
+ }
101
+ /** Not a work tree, or the write failed. */
102
+ | {
103
+ readonly kind: "failed";
104
+ readonly why: string;
105
+ };
106
+ /**
107
+ * Install (or re-point) the `post-commit` hook for the work tree at `toplevel`.
108
+ *
109
+ * ⛔ **`core.hooksPath` IS CHECKED FIRST AND IS A REFUSAL, NOT A WARNING.** When
110
+ * it is set — husky sets it, and so do many monorepos — git does not look in
111
+ * `.git/hooks` at all. Writing there would succeed at the filesystem level and
112
+ * report success, while capture silently observed nothing for the life of the
113
+ * clone. We do not write into the configured directory either: that directory is
114
+ * owned by whatever tool configured it, and a file we drop in gets regenerated
115
+ * away without warning. The honest move is to say so.
116
+ *
117
+ * ⚠ **VERIFIED that our own repos set NEITHER `core.hooksPath` NOR husky**, so
118
+ * dogfooding cannot exercise this path — it is covered by deliberate tests.
119
+ */
120
+ export declare function installPostCommitHook(toplevel: string, binPath: string): HookInstall;
121
+ /** One verdict per hook. Both are installed, or neither is. */
122
+ export interface CaptureHooks {
123
+ readonly commit: HookInstall;
124
+ readonly rewrite: HookInstall;
125
+ }
126
+ /**
127
+ * Install BOTH capture hooks for the work tree at `toplevel`.
128
+ *
129
+ * ⛔ **`post-rewrite` IS NOT AN EXTRA, IT IS THE OTHER HALF OF `post-commit`.**
130
+ * `--amend` and `rebase` fire `post-commit` for a sha they then DESTROY (`M5`),
131
+ * so a clone with only the commit hook records observations that name commits
132
+ * unreachable from every branch and gone after `gc --prune=now`. The two go in
133
+ * together, behind the same `core.hooksPath` refusal and the same chaining.
134
+ *
135
+ * ⚠ **`installPostCommitHook` RETURNS ONLY THE COMMIT VERDICT, and the rewrite
136
+ * verdict is currently PRINTED BY NOTHING.** `commands/connect.ts` reports one
137
+ * install and `src/copy/` has one string for it, both outside this lane's scope.
138
+ * The gap is narrow — `core.hooksPath` and a `not a git work tree` failure are
139
+ * shared, so they are reported by the commit verdict — and is limited to a clone
140
+ * that has a pre-existing `post-rewrite` and no pre-existing `post-commit`: that
141
+ * chaining happens correctly and is not announced. ⛔ Recorded here rather than
142
+ * left to be discovered, because an unreported install outcome is precisely what
143
+ * the block at the top of this section calls the third silent no-op.
144
+ */
145
+ export declare function installCaptureHooks(toplevel: string, binPath: string): CaptureHooks;
146
+ /**
147
+ * The hooks directory git itself would use.
148
+ *
149
+ * `rev-parse --git-path hooks` rather than `<toplevel>/.git/hooks`, because the
150
+ * two differ for a linked work tree and for a repository with a separate git
151
+ * dir — and it answers RELATIVE to the work tree, so it is resolved here.
152
+ */
153
+ export declare function resolveHooksDir(toplevel: string): string | null;
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Is the `vibecommit` a user types OURS? — `CR-025`, D55.
3
+ *
4
+ * D55 named the npm package `@vibe-commit/capture` because the bare
5
+ * `vibecommit` is taken — by `vibecommit@1.0.6`, which is **itself a commit
6
+ * CLI**. The BIN name stayed `vibecommit`, and D55 accepted the residual
7
+ * collision as unlikely. The register corrects that to closer to certain: the
8
+ * overlap population is exactly developers who install commit tooling.
9
+ *
10
+ * **This detects the collision. It does not reopen the decision** — the bin
11
+ * name is settled and D57 §DX5's verb surface is locked.
12
+ *
13
+ * What the clash costs is narrow and worth stating precisely, because the error
14
+ * copy has to: hook capture is UNAFFECTED, because the plugin invokes the binary
15
+ * by path. What breaks is the verbs a human types — `status`, `why`, `report`
16
+ * would run the other program.
17
+ *
18
+ * ## Resolved in-process, not by shelling out
19
+ *
20
+ * PATH lookup is a `readdir`-free stat of one candidate per PATH entry, so doing
21
+ * it here costs nothing and avoids a subprocess entirely — no argument array to
22
+ * get wrong, no shell to quote for, and no dependency on `which`/`command -v`
23
+ * being present or behaving the same across platforms. It also makes the whole
24
+ * thing testable by handing in an `env`, which a subprocess-based version could
25
+ * only fake by building a directory of shims.
26
+ */
27
+ import { accessSync, chmodSync, constants, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
28
+ import { delimiter, isAbsolute, join } from "node:path";
29
+ import { gitProbe } from "./git.js";
30
+ /** The command name D55 kept. */
31
+ const BIN = "vibecommit";
32
+ /**
33
+ * Classify the `vibecommit` on PATH against the program actually running.
34
+ *
35
+ * `selfPath` is this process's entry point. Both sides are `realpath`'d before
36
+ * comparison, because the normal install IS a symlink — npm puts a link in
37
+ * `.../bin/vibecommit` pointing at the package's `dist/index.js`, so a string
38
+ * compare would call our own correct install foreign.
39
+ *
40
+ * Unresolvable either side is treated as NOT ours. That is the conservative
41
+ * direction here: the cost of a false "foreign" is one printed block a user can
42
+ * read and dismiss; the cost of a false "ours" is silence about a real clash,
43
+ * which is the failure this exists to catch.
44
+ */
45
+ export function classifyInstall(env, selfPath) {
46
+ const found = resolveOnPath(env, BIN);
47
+ if (found === null)
48
+ return { kind: "absent" };
49
+ const self = realpathOrNull(selfPath);
50
+ const other = realpathOrNull(found);
51
+ if (self !== null && other !== null && self === other)
52
+ return { kind: "ours" };
53
+ return { kind: "foreign", resolved: other ?? found };
54
+ }
55
+ /** The first executable `name` on PATH, or null. */
56
+ export function resolveOnPath(env, name) {
57
+ const raw = env.PATH;
58
+ if (raw === undefined || raw === "")
59
+ return null;
60
+ for (const dir of raw.split(delimiter)) {
61
+ if (dir === "")
62
+ continue;
63
+ const candidate = join(dir, name);
64
+ try {
65
+ // A directory named `vibecommit` on PATH is not a command.
66
+ if (!statSync(candidate).isFile())
67
+ continue;
68
+ accessSync(candidate, constants.X_OK);
69
+ return candidate;
70
+ }
71
+ catch {
72
+ // Not there, not readable, not executable — keep looking.
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+ function realpathOrNull(path) {
78
+ if (path === "")
79
+ return null;
80
+ try {
81
+ return realpathSync(path);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // The `post-commit` hook install — `CR-170`, D154.
89
+ //
90
+ // ⛔ THREE OF THIS PATH'S FAILURE MODES ARE SILENT NO-OPS (D98's class), and a
91
+ // silent no-op here means capture quietly observes nothing forever:
92
+ //
93
+ // 1. `core.hooksPath` set -> `.git/hooks` is ignored ENTIRELY by git, so
94
+ // writing there succeeds, reports success, and does nothing at all.
95
+ // 2. an existing `post-commit` -> overwriting it breaks the user's own tooling.
96
+ // 3. no report -> the user cannot tell (1) or (2) happened.
97
+ //
98
+ // All three are LOUD below: each is its own verdict, and `connect` prints it.
99
+ // ---------------------------------------------------------------------------
100
+ /** Marks a `post-commit` as ours. Also the idempotency check. */
101
+ export const HOOK_MARKER = "vibecommit-capture-hook v1";
102
+ /**
103
+ * Where a pre-existing hook is moved so the chain can still run it.
104
+ *
105
+ * ⚠ **THE SPELLING IS LOAD-BEARING, AND THE FIRST TWO ATTEMPTS FAILED A GUARD.**
106
+ * `test/provenance.test.ts` is a wall over RAW TEXT with two separate tripwires,
107
+ * and ordinary explanatory prose sets off both:
108
+ *
109
+ * 1. it scans for the product name followed by a hyphen and a word, and reads
110
+ * every hit as a sibling REPOSITORY that must be declared. A filename
111
+ * suffix of that shape is indistinguishable from a repo name to a regex,
112
+ * so `.<product>-original` failed for a reason unrelated to provenance;
113
+ * 2. it counts bare marker tags and requires each to parse as a full
114
+ * three-part declaration, so merely NAMING the tag in a sentence registers
115
+ * a malformed one.
116
+ *
117
+ * Both were hit while writing this comment, in that order. The suffix is
118
+ * renamed and the prose avoids both literals — the guards are not weakened.
119
+ * `git.ts` carries the same warning about its linkage regex, for the same reason.
120
+ */
121
+ export const CHAINED_SUFFIX = ".chained-by-vibecommit";
122
+ /**
123
+ * The hook body.
124
+ *
125
+ * ⚠ **The original runs FIRST and its exit status is preserved**; ours runs
126
+ * after and can never change the outcome. MEASURED 2026-08-17: git IGNORES
127
+ * `post-commit`'s exit code entirely (a hook exiting 7 still leaves
128
+ * `git commit` at 0), so this cannot break a commit either way — but preserving
129
+ * the status keeps the chained hook's own contract with anything that reads it.
130
+ *
131
+ * `>/dev/null 2>&1` on our side: hook output lands in the user's own terminal,
132
+ * and a capture tool has no business writing there.
133
+ *
134
+ * ## ⛔ `post-rewrite` TAKES ITS INPUT ON STDIN, AND A STREAM IS READ ONCE
135
+ *
136
+ * `post-commit` is handed nothing. `post-rewrite` is handed the ancestor/successor
137
+ * pairs on stdin (`M6`) — and **a chained hook that reads stdin leaves ours with
138
+ * an empty stream, and ours would leave theirs with one.** Copying the
139
+ * `post-commit` body would therefore have silently broken every pre-existing
140
+ * `post-rewrite` hook the moment we chained it: their tooling would run, read
141
+ * nothing, and conclude nothing was rewritten.
142
+ *
143
+ * So the stdin variant buffers the stream ONCE and feeds a copy to each. ⚠
144
+ * `$(cat)` strips trailing newlines and cannot carry a NUL, which is fine and is
145
+ * checked rather than assumed: this stream is `<40-hex> <40-hex>` lines, ASCII,
146
+ * one per rewrite. `printf '%s\n'` restores the single trailing newline.
147
+ */
148
+ function hookScript(binPath, hookName, readsStdin) {
149
+ const chained = `"$d/${hookName}${CHAINED_SUFFIX}"`;
150
+ const ours = `${shellQuote(binPath)} ${hookName}`;
151
+ return [
152
+ "#!/bin/sh",
153
+ `# ${HOOK_MARKER}`,
154
+ "# Installed by `vibecommit connect`. Safe to delete; capture then observes",
155
+ "# no commits for this clone.",
156
+ 'd=$(dirname "$0")',
157
+ "status=0",
158
+ ...(readsStdin ? ["input=$(cat)"] : []),
159
+ `if [ -x "$d/${hookName}${CHAINED_SUFFIX}" ]; then`,
160
+ readsStdin
161
+ ? ` printf '%s\\n' "$input" | ${chained} "$@" || status=$?`
162
+ : ` ${chained} "$@" || status=$?`,
163
+ "fi",
164
+ readsStdin
165
+ ? `printf '%s\\n' "$input" | ${ours} "$@" >/dev/null 2>&1 || true`
166
+ : `${ours} >/dev/null 2>&1 || true`,
167
+ "exit $status",
168
+ "",
169
+ ].join("\n");
170
+ }
171
+ /** Single-quote for `sh`. A repository path containing a space is ordinary. */
172
+ function shellQuote(value) {
173
+ return `'${value.replace(/'/g, `'\\''`)}'`;
174
+ }
175
+ /**
176
+ * Install (or re-point) the `post-commit` hook for the work tree at `toplevel`.
177
+ *
178
+ * ⛔ **`core.hooksPath` IS CHECKED FIRST AND IS A REFUSAL, NOT A WARNING.** When
179
+ * it is set — husky sets it, and so do many monorepos — git does not look in
180
+ * `.git/hooks` at all. Writing there would succeed at the filesystem level and
181
+ * report success, while capture silently observed nothing for the life of the
182
+ * clone. We do not write into the configured directory either: that directory is
183
+ * owned by whatever tool configured it, and a file we drop in gets regenerated
184
+ * away without warning. The honest move is to say so.
185
+ *
186
+ * ⚠ **VERIFIED that our own repos set NEITHER `core.hooksPath` NOR husky**, so
187
+ * dogfooding cannot exercise this path — it is covered by deliberate tests.
188
+ */
189
+ export function installPostCommitHook(toplevel, binPath) {
190
+ return installCaptureHooks(toplevel, binPath).commit;
191
+ }
192
+ /**
193
+ * Install BOTH capture hooks for the work tree at `toplevel`.
194
+ *
195
+ * ⛔ **`post-rewrite` IS NOT AN EXTRA, IT IS THE OTHER HALF OF `post-commit`.**
196
+ * `--amend` and `rebase` fire `post-commit` for a sha they then DESTROY (`M5`),
197
+ * so a clone with only the commit hook records observations that name commits
198
+ * unreachable from every branch and gone after `gc --prune=now`. The two go in
199
+ * together, behind the same `core.hooksPath` refusal and the same chaining.
200
+ *
201
+ * ⚠ **`installPostCommitHook` RETURNS ONLY THE COMMIT VERDICT, and the rewrite
202
+ * verdict is currently PRINTED BY NOTHING.** `commands/connect.ts` reports one
203
+ * install and `src/copy/` has one string for it, both outside this lane's scope.
204
+ * The gap is narrow — `core.hooksPath` and a `not a git work tree` failure are
205
+ * shared, so they are reported by the commit verdict — and is limited to a clone
206
+ * that has a pre-existing `post-rewrite` and no pre-existing `post-commit`: that
207
+ * chaining happens correctly and is not announced. ⛔ Recorded here rather than
208
+ * left to be discovered, because an unreported install outcome is precisely what
209
+ * the block at the top of this section calls the third silent no-op.
210
+ */
211
+ export function installCaptureHooks(toplevel, binPath) {
212
+ const configured = gitProbe(toplevel, ["config", "--get", "core.hooksPath"]);
213
+ // `--get` exits 1 when unset, which `gitProbe` reports as null. A SET value
214
+ // is the failure case here, which is the opposite of the usual reading.
215
+ if (configured !== null && configured.trim() !== "") {
216
+ // ⛔ BOTH refuse. `.git/hooks` is dead for every hook in it, not just ours.
217
+ const verdict = { kind: "hooks-path", configured: configured.trim() };
218
+ return { commit: verdict, rewrite: verdict };
219
+ }
220
+ const hooksDir = resolveHooksDir(toplevel);
221
+ if (hooksDir === null) {
222
+ const verdict = { kind: "failed", why: "not a git work tree" };
223
+ return { commit: verdict, rewrite: verdict };
224
+ }
225
+ return {
226
+ commit: writeHook(hooksDir, "post-commit", hookScript(binPath, "post-commit", false)),
227
+ rewrite: writeHook(hooksDir, "post-rewrite", hookScript(binPath, "post-rewrite", true)),
228
+ };
229
+ }
230
+ /** Write one hook into an empty slot, over ours, or in front of theirs. */
231
+ function writeHook(hooksDir, hookName, script) {
232
+ const hookPath = join(hooksDir, hookName);
233
+ const existing = readIfPresent(hookPath);
234
+ try {
235
+ mkdirSync(hooksDir, { recursive: true });
236
+ if (existing !== null && existing.includes(HOOK_MARKER)) {
237
+ // Ours already. Rewrite anyway — the binary may have moved since.
238
+ writeFileSync(hookPath, script, { mode: 0o755 });
239
+ chmodSync(hookPath, 0o755);
240
+ return { kind: "already", path: hookPath };
241
+ }
242
+ if (existing !== null) {
243
+ // ⛔ CHAIN, NEVER CLOBBER. Their hook keeps running, from a path ours
244
+ // invokes explicitly, and it goes first.
245
+ const original = `${hookPath}${CHAINED_SUFFIX}`;
246
+ renameSync(hookPath, original);
247
+ chmodSync(original, 0o755);
248
+ writeFileSync(hookPath, script, { mode: 0o755 });
249
+ chmodSync(hookPath, 0o755);
250
+ return { kind: "chained", path: hookPath, original };
251
+ }
252
+ writeFileSync(hookPath, script, { mode: 0o755 });
253
+ chmodSync(hookPath, 0o755);
254
+ return { kind: "installed", path: hookPath };
255
+ }
256
+ catch (error) {
257
+ return { kind: "failed", why: error instanceof Error ? error.message : "write failed" };
258
+ }
259
+ }
260
+ /**
261
+ * The hooks directory git itself would use.
262
+ *
263
+ * `rev-parse --git-path hooks` rather than `<toplevel>/.git/hooks`, because the
264
+ * two differ for a linked work tree and for a repository with a separate git
265
+ * dir — and it answers RELATIVE to the work tree, so it is resolved here.
266
+ */
267
+ export function resolveHooksDir(toplevel) {
268
+ const out = gitProbe(toplevel, ["rev-parse", "--git-path", "hooks"]);
269
+ if (out === null)
270
+ return null;
271
+ const path = out.trim();
272
+ if (path === "")
273
+ return null;
274
+ return isAbsolute(path) ? path : join(toplevel, path);
275
+ }
276
+ function readIfPresent(path) {
277
+ try {
278
+ return readFileSync(path, "utf8");
279
+ }
280
+ catch {
281
+ return null;
282
+ }
283
+ }
284
+ //# sourceMappingURL=install.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"install.js","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"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"}
package/dist/json.d.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The machine-readable surface of the read verbs — DESIGN.md §13.3, D122 §2.
3
+ *
4
+ * §13.3 is the authority and it says three things this module exists to make
5
+ * true:
6
+ *
7
+ * 1. `--json` **implies the whole no-colour detection order** and emits
8
+ * **NOTHING BUT** the JSON document on stdout. "Nothing but" is literal:
9
+ * no banner, no progress line, no prose. `resolveColour` already returns
10
+ * `false` for `json: true`, so half of this was true before; the other half
11
+ * is `emitJson` being the ONLY thing a verb writes to stdout under the flag.
12
+ * 2. **Grade words appear in `--json` ONLY.** Prose never prints a bare grade
13
+ * (claims-audit §R3, D65 §DR4), so this is the one place the three-way
14
+ * `derived` / `observed` / `declared` distinction is machine-readable.
15
+ * 3. The three status states stay **distinguishable**. §13.2's `GLYPH` rule
16
+ * ("a dropped glyph turns three distinguishable states into three identical
17
+ * ones") has no glyph here, and `outcome` carries the distinction instead.
18
+ *
19
+ * ## ⛔ THE RULE THAT GOVERNS EVERY FIELD: A ZERO IS A MEASUREMENT CLAIM
20
+ *
21
+ * `CR-129a`'s title, and it is the single most likely way to get this surface
22
+ * wrong. **`null` means NOT KNOWN. `0` and `[]` mean MEASURED NONE.** They are
23
+ * different facts and this document keeps them different, because the two
24
+ * absences it actually has to express today are both of the first kind:
25
+ *
26
+ * - `commit_file_attribution` **has no writer** (D104 §2b), so mcp hardcodes
27
+ * `files: []` on every turn. That is *we do not know which files*, which is
28
+ * why `turnDocument` renders it as `null` and NEVER as `[]`.
29
+ * - `observed` **has no producer in any repo** (D104 §2a). So no field here is
30
+ * ever populated with a grade the wire did not send: `grades` and `by_grade`
31
+ * are ECHOED, never widened to the full enum with zeros. A key the payload
32
+ * did not carry stays absent rather than becoming `0`.
33
+ *
34
+ * ⚠ The corollary is that `[]` and `0` are legal in this schema and simply do
35
+ * not occur on the paths that have no producer. That is deliberate: the document
36
+ * must be ABLE to say *none* distinctly from *not known*, or the day a producer
37
+ * lands there is no way to say it.
38
+ *
39
+ * ⚠ **THE WORD `from` IS NEVER FOLLOWED BY A DOUBLE-QUOTED PHRASE IN THIS
40
+ * FILE**, and that is a constraint rather than a style. `test/provenance.test.ts`
41
+ * runs a linkage wall as a REGEX over raw text — `(?:from|import)` then optional
42
+ * space then a quoted specifier — so an ordinary sentence that quotes a phrase
43
+ * right after that word reads as an import escaping the package and turns the
44
+ * wall RED against correct prose. It did, on the first run of this file, and
45
+ * then again on the first wording of this very warning. `src/commands/why.ts`
46
+ * and `src/oauth/signin.ts` already carry the same note; a false RED is the more
47
+ * dangerous direction, because it justifies "fixing" correct content and nothing
48
+ * later flags it. Quote such phrases in *italics* here instead.
49
+ *
50
+ * ## What is NOT here, deliberately
51
+ *
52
+ * **A failure taxonomy.** A verb that could not do its job renders §13.6's
53
+ * what/why/fix block to **stderr** and writes **nothing** to stdout, exactly as
54
+ * it does without the flag — clause 1 holds trivially, the exit code already
55
+ * carries the branch a wrapper needs (§13.7 gives five distinct codes), and
56
+ * minting a parallel error vocabulary would be a contract nobody asked for.
57
+ * **Absence is not a failure** and DOES emit a document: `why` on a commit with
58
+ * no edge and `report` on an empty window are ANSWERS (exit 4 says so), and a
59
+ * machine surface that went silent on them would lose the state that matters.
60
+ *
61
+ * ⚠ §13.3 does not settle that split in either direction. It is reported as an
62
+ * open question rather than smoothed over — `DESIGN.md` is orchestrator-owned
63
+ * (D66 §3) and this module's reading of it is a builder's, not a ruling.
64
+ */
65
+ import type { Writable } from "node:stream";
66
+ /**
67
+ * The document format version, and it is the FIRST key so a wrapper can branch
68
+ * before it parses anything else. Bumped only when a field changes meaning —
69
+ * adding a key is not a bump.
70
+ */
71
+ export declare const JSON_SCHEMA_VERSION = 1;
72
+ /** The read verbs. `connect` is an install moment and `off` is a mutation. */
73
+ export type JsonVerb = "status" | "why" | "report";
74
+ /**
75
+ * The document, minus the two keys every document carries.
76
+ *
77
+ * `unknown` rather than a union of the three shapes: each verb owns its own
78
+ * body, and a shared discriminated union here would put three verbs' fields in
79
+ * one place for no reader's benefit.
80
+ */
81
+ export type JsonBody = Readonly<Record<string, unknown>>;
82
+ /**
83
+ * The exact bytes of one document.
84
+ *
85
+ * ⚠ **ONE LINE, NO INDENTATION, ONE TRAILING NEWLINE.** Compact is what makes
86
+ * "nothing before or after the document" checkable on the RAW BYTES rather than
87
+ * by trusting a lenient parser — a leading banner on its own line still parses
88
+ * under most readers, and this shape makes that impossible to miss. §13.4's
89
+ * 80-column rule does not apply: it governs terminal prose, and this is not
90
+ * prose. The trailing newline is the POSIX line-termination convention, not
91
+ * copy; `JSON.parse` ignores trailing whitespace, so it cannot break a consumer.
92
+ */
93
+ export declare function renderJson(verb: JsonVerb, outcome: string, body: JsonBody): string;
94
+ /**
95
+ * Write one document to stdout, and nothing else.
96
+ *
97
+ * Takes the stream rather than the whole `CommandContext` so the same helper
98
+ * serves all three verbs without importing a command module into a leaf.
99
+ */
100
+ export declare function emitJson(stdout: Writable, verb: JsonVerb, outcome: string, body: JsonBody): void;
package/dist/json.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The machine-readable surface of the read verbs — DESIGN.md §13.3, D122 §2.
3
+ *
4
+ * §13.3 is the authority and it says three things this module exists to make
5
+ * true:
6
+ *
7
+ * 1. `--json` **implies the whole no-colour detection order** and emits
8
+ * **NOTHING BUT** the JSON document on stdout. "Nothing but" is literal:
9
+ * no banner, no progress line, no prose. `resolveColour` already returns
10
+ * `false` for `json: true`, so half of this was true before; the other half
11
+ * is `emitJson` being the ONLY thing a verb writes to stdout under the flag.
12
+ * 2. **Grade words appear in `--json` ONLY.** Prose never prints a bare grade
13
+ * (claims-audit §R3, D65 §DR4), so this is the one place the three-way
14
+ * `derived` / `observed` / `declared` distinction is machine-readable.
15
+ * 3. The three status states stay **distinguishable**. §13.2's `GLYPH` rule
16
+ * ("a dropped glyph turns three distinguishable states into three identical
17
+ * ones") has no glyph here, and `outcome` carries the distinction instead.
18
+ *
19
+ * ## ⛔ THE RULE THAT GOVERNS EVERY FIELD: A ZERO IS A MEASUREMENT CLAIM
20
+ *
21
+ * `CR-129a`'s title, and it is the single most likely way to get this surface
22
+ * wrong. **`null` means NOT KNOWN. `0` and `[]` mean MEASURED NONE.** They are
23
+ * different facts and this document keeps them different, because the two
24
+ * absences it actually has to express today are both of the first kind:
25
+ *
26
+ * - `commit_file_attribution` **has no writer** (D104 §2b), so mcp hardcodes
27
+ * `files: []` on every turn. That is *we do not know which files*, which is
28
+ * why `turnDocument` renders it as `null` and NEVER as `[]`.
29
+ * - `observed` **has no producer in any repo** (D104 §2a). So no field here is
30
+ * ever populated with a grade the wire did not send: `grades` and `by_grade`
31
+ * are ECHOED, never widened to the full enum with zeros. A key the payload
32
+ * did not carry stays absent rather than becoming `0`.
33
+ *
34
+ * ⚠ The corollary is that `[]` and `0` are legal in this schema and simply do
35
+ * not occur on the paths that have no producer. That is deliberate: the document
36
+ * must be ABLE to say *none* distinctly from *not known*, or the day a producer
37
+ * lands there is no way to say it.
38
+ *
39
+ * ⚠ **THE WORD `from` IS NEVER FOLLOWED BY A DOUBLE-QUOTED PHRASE IN THIS
40
+ * FILE**, and that is a constraint rather than a style. `test/provenance.test.ts`
41
+ * runs a linkage wall as a REGEX over raw text — `(?:from|import)` then optional
42
+ * space then a quoted specifier — so an ordinary sentence that quotes a phrase
43
+ * right after that word reads as an import escaping the package and turns the
44
+ * wall RED against correct prose. It did, on the first run of this file, and
45
+ * then again on the first wording of this very warning. `src/commands/why.ts`
46
+ * and `src/oauth/signin.ts` already carry the same note; a false RED is the more
47
+ * dangerous direction, because it justifies "fixing" correct content and nothing
48
+ * later flags it. Quote such phrases in *italics* here instead.
49
+ *
50
+ * ## What is NOT here, deliberately
51
+ *
52
+ * **A failure taxonomy.** A verb that could not do its job renders §13.6's
53
+ * what/why/fix block to **stderr** and writes **nothing** to stdout, exactly as
54
+ * it does without the flag — clause 1 holds trivially, the exit code already
55
+ * carries the branch a wrapper needs (§13.7 gives five distinct codes), and
56
+ * minting a parallel error vocabulary would be a contract nobody asked for.
57
+ * **Absence is not a failure** and DOES emit a document: `why` on a commit with
58
+ * no edge and `report` on an empty window are ANSWERS (exit 4 says so), and a
59
+ * machine surface that went silent on them would lose the state that matters.
60
+ *
61
+ * ⚠ §13.3 does not settle that split in either direction. It is reported as an
62
+ * open question rather than smoothed over — `DESIGN.md` is orchestrator-owned
63
+ * (D66 §3) and this module's reading of it is a builder's, not a ruling.
64
+ */
65
+ /**
66
+ * The document format version, and it is the FIRST key so a wrapper can branch
67
+ * before it parses anything else. Bumped only when a field changes meaning —
68
+ * adding a key is not a bump.
69
+ */
70
+ export const JSON_SCHEMA_VERSION = 1;
71
+ /**
72
+ * The exact bytes of one document.
73
+ *
74
+ * ⚠ **ONE LINE, NO INDENTATION, ONE TRAILING NEWLINE.** Compact is what makes
75
+ * "nothing before or after the document" checkable on the RAW BYTES rather than
76
+ * by trusting a lenient parser — a leading banner on its own line still parses
77
+ * under most readers, and this shape makes that impossible to miss. §13.4's
78
+ * 80-column rule does not apply: it governs terminal prose, and this is not
79
+ * prose. The trailing newline is the POSIX line-termination convention, not
80
+ * copy; `JSON.parse` ignores trailing whitespace, so it cannot break a consumer.
81
+ */
82
+ export function renderJson(verb, outcome, body) {
83
+ return `${JSON.stringify({ schema: JSON_SCHEMA_VERSION, verb, outcome, ...body })}\n`;
84
+ }
85
+ /**
86
+ * Write one document to stdout, and nothing else.
87
+ *
88
+ * Takes the stream rather than the whole `CommandContext` so the same helper
89
+ * serves all three verbs without importing a command module into a leaf.
90
+ */
91
+ export function emitJson(stdout, verb, outcome, body) {
92
+ stdout.write(renderJson(verb, outcome, body));
93
+ }
94
+ //# sourceMappingURL=json.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json.js","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AAIH;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC;AAcrC;;;;;;;;;;GAUG;AACH,MAAM,UAAU,UAAU,CAAC,IAAc,EAAE,OAAe,EAAE,IAAc;IACxE,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,mBAAmB,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,EAAE,CAAC,IAAI,CAAC;AACxF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,QAAQ,CACtB,MAAgB,EAChB,IAAc,EACd,OAAe,EACf,IAAc;IAEd,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;AAChD,CAAC"}