cohorte 1.2.1 → 1.2.3

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,40 @@
3
3
  Entries are shown by `/update-pipeline` ("What's new") after a core refresh. Keep them short,
4
4
  user-facing, most recent first. One `## <version> — <YYYY-MM-DD>` section per release.
5
5
 
6
+ ## 1.2.3 — 2026-07-29
7
+
8
+ - **Telemetry now covers the whole funnel.** Only `/build` was actually pinging; `/smoke`,
9
+ `/review` and `/fix` wrote their metrics line but never sent one, so consenting installs
10
+ reported a quarter of their pipeline. Those three are fixed, and `/brainstorm`, `/spec`
11
+ (on a landed freeze) and `/ship` join them — the seven stages of `idea → PR` now report,
12
+ so it's finally possible to see *where* features stall. Setup and maintenance commands
13
+ (`/doctor`, `/init-pipeline`, `/update-pipeline`, `/audit`, `/refactor`, `/align-ds`)
14
+ deliberately never ping: the collected set stays inside what the consent text describes.
15
+ Same data categories as before, same purpose — nothing new about you is sent, so your
16
+ existing consent stands and nothing re-asks. The full table is in SCHEMA.md §Telemetry.
17
+ - `telemetry-send.sh` now allowlists the phase name client-side — a typo in a command file
18
+ used to sail through and land a phantom phase in the dataset.
19
+ - `/fix` never defined a wall-clock start, so the `seconds` in its metrics line was
20
+ undefined. It now notes the epoch like `/build` and `/review` do.
21
+ - **`/doctor` catches a half-copied core.** New check: `pipeline/scripts/` must hold every
22
+ shipped script, and `VERSION` must not be newer than its siblings. Callers chain these
23
+ scripts with `|| true`, so a missing one was invisible — no kanban move, no telemetry
24
+ ping, no error. If you saw either go quiet, this is why: re-run the installer.
25
+ - CI now fails if an installer forgets to copy a `scripts/*.sh`, and the dry-run install
26
+ asserts the scripts land executable — the root cause above, caught before release
27
+ rather than on someone's machine.
28
+ - The npm tarball no longer ships `scripts/new-feature.sh` + `scripts/remove-feature.sh`
29
+ — cohorte's *own* rendered isolation scripts, with this repo's ports and paths baked
30
+ in. They claimed in their header to be excluded but never were (an explicit `files`
31
+ whitelist wins over `.npmignore`). Only the `*.sh.template` files ship, as intended.
32
+ - Fixed `validate-core.mjs` crashing on Windows (`C:\C:\…` path), so the guard above
33
+ actually runs locally too.
34
+
35
+ ## 1.2.2 — 2026-07-29
36
+
37
+ - The reference collector moved to its own (private) deployment repo; the public repo keeps
38
+ the collector API contract in SCHEMA.md §Telemetry. No behavior change for users.
39
+
6
40
  ## 1.2.1 — 2026-07-29
7
41
 
8
42
  - Telemetry collector URL shipped as the config-template default
package/README.md CHANGED
@@ -277,7 +277,7 @@ result counts, and a *hash* of the feature id — never repo names, paths, code,
277
277
  recorded so you're never re-asked. Withdraw anytime (`telemetry.enabled: false` in
278
278
  `~/.claude/cohorte.config.yaml`); erase your history anytime (`/doctor` prints your `install_id`,
279
279
  the collector's `DELETE /v1/install/<id>` drops it). Full spec + GDPR details:
280
- `profile/SCHEMA.md` §Telemetry; reference collector in `telemetry/`.
280
+ `profile/SCHEMA.md` §Telemetry (including the collector API contract).
281
281
 
282
282
  ## License
283
283
 
@@ -49,3 +49,8 @@ when invoked with no paste). In chat print only a 3-line summary + the path. Tel
49
49
  downstream. If a board is configured, **move** the card into the **Brainstorm** column tagged
50
50
  `#<feature_id>` (per §Kanban): the picked Ideas card if the human chose one, else a new card. No-op if
51
51
  no board.
52
+
53
+ **Telemetry:** chain the opt-in usage ping onto that same Bash call — `/build` §4's shared form,
54
+ `<phase>` = `brainstorm`, `<seconds>` = `0` (this phase is human thinking time, not pipeline
55
+ wall-clock — the funnel signal is the event, not its duration), `<results>` = `""`. No board ⇒ still
56
+ ping, in its own `|| true` call. Silent no-op without consent; never ask about consent here.
@@ -101,10 +101,13 @@ Compute the elapsed time in the same Bash call
101
101
  (`echo "{...\"seconds\":$(($(date +%s)-<start epoch from §2>)),...}" >> …`):
102
102
  `{"ts":"<ISO date>","feature":"$ARGUMENTS","phase":"build","seconds":<wall-clock>,"surfaces":{"<key>":"ok|error",…}}`
103
103
  — this is the evidence SCHEMA.md §Specialization asks for before splitting a surface. In the same
104
- Bash call, chain the opt-in usage ping:
105
- `<core>/pipeline/scripts/telemetry-send.sh build "$ARGUMENTS" <seconds> "<ok,ok|error,…>" || true`
106
- (`<core>` = `~/.claude` global / `.claude` bundled) a silent no-op unless the human explicitly
107
- consented (SCHEMA.md §Telemetry); never ask about consent here.
104
+ Bash call, chain the opt-in usage ping — **the shared form every phase command reuses**:
105
+ `<core>/pipeline/scripts/telemetry-send.sh <phase> "$ARGUMENTS" <seconds> "<results>" || true`
106
+ (`<core>` = `~/.claude` global / `.claude` bundled; here `<phase>` = `build`, `<results>` =
107
+ `<ok,ok|error,…>`) — a silent no-op unless the human explicitly consented (SCHEMA.md §Telemetry);
108
+ never ask about consent here. `/review`, `/fix` and `/smoke` chain the same line with their own
109
+ phase + results. The `|| true` swallows a **missing** script too, so a half-copied core goes
110
+ silent rather than loud — `/doctor` check 1 is what catches that.
108
111
  Then tell the human: run `/smoke $ARGUMENTS` to exercise the feature end-to-end (or test by hand),
109
112
  then `/review $ARGUMENTS`. Do not run the app or migrations yourself here — `/smoke` is the
110
113
  sanctioned path for that. **Recommend a `/clear` now** — the spec, contract and diff are all on
@@ -18,7 +18,12 @@ fix only with the human's go-ahead (or hand them the command).
18
18
  suggest `/update-pipeline`. Read `pipeline/CHANGELOG.md` for what they're missing. The router
19
19
  commands' step files are present — `templates/steps/init-pipeline/` non-empty (a router whose
20
20
  `templates/steps/<cmd>/` dir is missing is a partial/stale install ⇒
21
- re-run install/update).
21
+ re-run install/update). **Shipped scripts present and executable** in `<core>/pipeline/scripts/`:
22
+ `kanban-move.sh`, `telemetry-send.sh`, `new-feature.sh.template`, `remove-feature.sh.template`
23
+ — ❌ any missing one. Every caller chains these with `|| true`, so an absent script is a **silent**
24
+ no-op (no kanban card moves, no telemetry ping, no error anywhere) — this check is the only thing
25
+ that sees it. Also flag ❌ a `VERSION` **newer than** the other `pipeline/` files (compare mtimes):
26
+ a version bumped without a full re-copy is a half-done update ⇒ re-run install/update.
22
27
  2. **Profile.** `PIPELINE.md` exists and its `yaml pipeline-profile` block parses. Every
23
28
  `surfaces[].agent` has its `.claude/agents/<agent>.md` and every agent file has its `surfaces[]`
24
29
  entry — **no orphans either way** (SCHEMA.md rule). Each rendered agent's frontmatter `tools`
@@ -29,6 +29,8 @@ that change the *contract*; `/fix` is for everything else.
29
29
  re-author the contract file yourself now (lead-only, per `/build` §2) — agents never edit it. If
30
30
  the contract change ripples into surfaces *without* findings, fall back to full `/build` instead
31
31
  and say so.
32
+ - **Note the epoch** (`date +%s`) in the first Bash call you make here — §3's metrics line and usage
33
+ ping both carry `seconds`, and there is no separate timing call.
32
34
 
33
35
  ## 2. Scope the re-dispatch — only surfaces with findings
34
36
 
@@ -61,7 +63,8 @@ When the agents return:
61
63
  needs those checkboxes).
62
64
  - Print one status line per surface (`<key> · items fixed <n>/<m> · tests pass/fail`) — do not restate
63
65
  handoff content — and append ONE metrics line for the batch to `pipeline-metrics.jsonl`
64
- (see `/build` §4, `phase: "fix"`).
66
+ (see `/build` §4, `phase: "fix"`), chaining the opt-in usage ping in the same Bash call
67
+ (results = items fixed over items found across surfaces, e.g. `"5/6"`).
65
68
  - Tell the human: re-run `/smoke` if the failures were runtime ones, and `/review $ARGUMENTS` for the
66
69
  re-verdict — the re-review is what *verifies* the ticked items actually hold (a regression simply
67
70
  reappears as a new finding in the next round). **Recommend a `/clear`** — all state (spec,
@@ -58,6 +58,8 @@ Merge the returned reports into **one** REVIEW REPORT (same template): findings
58
58
  re-ordered by severity, counts summed, duplicates collapsed, verdict = the worst returned
59
59
  (`BLOCK` > `REVISE` > `SHIP`). Append ONE metrics line for the batch to `pipeline-metrics.jsonl`
60
60
  (main-checkout path + rules in `/build` §4): `{"ts":"<ISO>","feature":"$ARGUMENTS","phase":"review","seconds":<wall-clock>,"surfaces":{"<key>":"<verdict>:<finding count>",…}}`.
61
+ In the same Bash call, chain the opt-in usage ping (`/build` §4, `phase: "review"`, results = the
62
+ merged verdict + total finding count, e.g. `"REVISE:3"`).
61
63
  **Stage the full report to `specs/reports/$ARGUMENTS.md`** (overwrite) — a gitignored buffer so a
62
64
  `/fix` after a `/clear` can still read the findings; the `specs/reports/` subfolder is skipped by the
63
65
  non-recursive `specs/*.md` glob, so it's never mistaken for a spec (no phantom card, no bogus stage).
@@ -60,6 +60,13 @@ compare URL was emitted (no PR yet), move the card without a number. Then verify
60
60
  or an offset-limited Read around the match): exactly one card, under the `shipped` heading — never
61
61
  re-read the whole board into context. No board ⇒ skip silently.
62
62
 
63
+ **Telemetry — the usage ping that closes the funnel.** Chain it onto the verify call above
64
+ (`/build` §4's shared form, `<phase>` = `ship`, `<seconds>` = `0` — the release agent's duration is
65
+ not the pipeline's, `<results>` = `pr` when a PR was created / `compare` when only a compare URL was
66
+ emitted). Fire it **after** the release agent reports success, never on an aborted ship — a `ship`
67
+ event must mean the feature actually left the pipeline. No board ⇒ still ping, in its own `|| true`
68
+ call. Silent no-op without consent; never ask about consent here.
69
+
63
70
  ## 5. After the PR — CI gate + teardown
64
71
 
65
72
  - If `host: github` and `gh` is available, watch the PR's checks (`gh pr checks <url> --watch`) and
@@ -34,7 +34,8 @@ human; that's expected.
34
34
 
35
35
  - Print the agent's return as-is (verdict + ❌ lines + report path) — it is already minimal.
36
36
  - Append ONE metrics line to `pipeline-metrics.jsonl` (main-checkout path + rules in `/build` §4,
37
- `phase: "smoke"`).
37
+ `phase: "smoke"`), chaining the opt-in usage ping in the same Bash call (results = `PASS` or
38
+ `FAIL:<n>` failing flows).
38
39
  - **PASS** → tell the human to run `/review $ARGUMENTS`. **FAIL** → the failures are findings: feed
39
40
  them to `/fix $ARGUMENTS`, re-run `/smoke` after. Either way the report is on disk —
40
41
  **recommend a `/clear`** before the next command.
@@ -47,7 +47,12 @@ Detect the mode from the pasted content:
47
47
  it. This is just so the human isn't surprised when `/build` proposes a new agent.
48
48
  5. When the human validates, **freeze**: write `specs/<id>.md` (`status: frozen`, front-matter filled).
49
49
  Create the file — do not ask the human to. **Postcondition:** `grep -q '^status: frozen' specs/<id>.md`
50
- — if it fails the freeze didn't land; fix it before pointing the human at `/build`.
50
+ — if it fails the freeze didn't land; fix it before pointing the human at `/build`. Chain the
51
+ opt-in usage ping onto the postcondition's Bash call (`/build` §4's shared form, `<phase>` =
52
+ `spec`, `<seconds>` = `0` — interactive time, not pipeline wall-clock, `<results>` = `frozen`).
53
+ Ping only on a **landed** freeze, so the funnel counts specs that exist, not attempts. Mode B does
54
+ not ping — it re-enters an already-counted spec, and `/fix` covers that loop. Silent no-op without
55
+ consent; never ask about consent here.
51
56
  6. Author the **design brief** — `specs/design/<id>.md`, rendered via
52
57
  `.claude/templates/design-brief.md` (resolves to `~/.claude/templates/…` on a global install).
53
58
  _Only if `design.enabled` / the feature has UI; skip entirely for a backend-only feature._
@@ -43,8 +43,9 @@ Prefer sensible defaults from Phase 1 as the first (Recommended) option in each
43
43
  - **Telemetry** (optional, machine-scoped — SKIP entirely if `~/.claude/cohorte.config.yaml` already
44
44
  has a `telemetry:` block with a `consent_date`, i.e. the human already answered on this machine).
45
45
  Ask ONE opt-in question, stating exactly: _"Share anonymous usage stats with the cohorte project?
46
- Sent per pipeline phase: core version, OS, phase name, duration, per-surface result counts, and a
47
- hash of the feature id — never repo names, paths, code, or IPs. Off by default; withdraw anytime
46
+ One ping per pipeline phase, `/brainstorm` through `/ship`: core version, OS, phase name, duration,
47
+ per-surface result counts, and a hash of the feature id — never repo names, paths, code, or IPs.
48
+ Setup and maintenance commands never ping. Off by default; withdraw anytime
48
49
  (`telemetry.enabled: false`); erase your history anytime (SCHEMA.md §Telemetry). Default: No."_
49
50
  On **yes**: in the global config set `telemetry.enabled: true`, mint `install_id` (`uuidgen`,
50
51
  lowercase), set `consent_date` (ISO date). On **no**: set `enabled: false` + `consent_date` (so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code — install the core, run /init-pipeline, and it adapts to your project's stack.",
5
5
  "bin": {
6
6
  "cohorte": "bin/cli.js"
@@ -13,10 +13,11 @@
13
13
  "core",
14
14
  "profile",
15
15
  "scripts",
16
+ "!scripts/new-feature.sh",
17
+ "!scripts/remove-feature.sh",
16
18
  "dashboard/server",
17
19
  "dashboard/dist",
18
20
  "dashboard/README.md",
19
- "telemetry",
20
21
  "install.sh",
21
22
  "install.ps1",
22
23
  "CHANGELOG.md"
package/profile/SCHEMA.md CHANGED
@@ -325,9 +325,28 @@ Cohorte can send the maintainers anonymous usage pings so the pipeline improves
325
325
  slow. **Nothing is ever sent without explicit consent**: `/init-pipeline` (and `/update-pipeline` on
326
326
  pre-telemetry installs) ask ONE question, once per machine, default **No**, and record the answer in
327
327
  `~/.claude/cohorte.config.yaml` §`telemetry` (`enabled`, `install_id`, `consent_date`). The sender —
328
- `pipeline/scripts/telemetry-send.sh`, chained by each phase after its metrics line is a silent
329
- no-op unless `enabled: true` AND `install_id` AND `endpoint` are all set, times out at 2s, and never
330
- fails the pipeline.
328
+ `pipeline/scripts/telemetry-send.sh` is a silent no-op unless `enabled: true` AND `install_id` AND
329
+ `endpoint` are all set, times out at 2s, and never fails the pipeline. Callers chain it with
330
+ `|| true`, so a **missing** script is equally silent: `/doctor` check 1 verifies `pipeline/scripts/`
331
+ is fully populated.
332
+
333
+ **Which commands ping** — the seven that make up the feature funnel, and only those. The point is to
334
+ see where features stall, so every stage of `idea → PR` reports and nothing else does:
335
+
336
+ | phase | fired when | `seconds` | `results` |
337
+ | --- | --- | --- | --- |
338
+ | `brainstorm` | the return is staged | `0` | — |
339
+ | `spec` | a freeze lands (Mode A only) | `0` | `frozen` |
340
+ | `build` | after the batch metrics line | wall-clock | `ok,ok` / `error` |
341
+ | `smoke` | after the verdict | wall-clock | `PASS` / `FAIL:<n>` |
342
+ | `review` | after the merged verdict | wall-clock | `<verdict>:<count>` |
343
+ | `fix` | after the batch metrics line | wall-clock | `<fixed>/<found>` |
344
+ | `ship` | the release agent succeeded | `0` | `pr` / `compare` |
345
+
346
+ `seconds: 0` marks a phase whose duration is human thinking time, not pipeline wall-clock — the
347
+ funnel signal there is the event, not how long it took. `/doctor`, `/audit`, `/refactor`,
348
+ `/align-ds`, `/init-pipeline` and `/update-pipeline` **never** ping: they sit outside the funnel, and
349
+ keeping them out is what holds the collected set to what the consent text describes.
331
350
 
332
351
  **What one event contains** (strict allowlist, ~200 bytes):
333
352
 
@@ -348,9 +367,9 @@ work without revealing what is being built.
348
367
  the next phase, no restart.
349
368
  - **Erasure** — `/doctor` prints your `install_id`; send
350
369
  `curl -X DELETE <endpoint-origin>/v1/install/<install_id>` and the collector drops every event
351
- for that id (the reference collector in `telemetry/` implements this and stores no IPs).
370
+ for that id (the deployed collector implements this and stores no IPs).
352
371
  - **Access/portability** — events are keyed by your `install_id`; ask the operator for an export.
353
372
 
354
- **Collector contract** (implement your own, or deploy `telemetry/collector.mjs`):
373
+ **Collector contract** (any implementation must honor it):
355
374
  `POST /v1/events` (one JSON event, allowlisted fields) · `DELETE /v1/install/<id>` (erasure) ·
356
375
  `GET /healthz`. Operators must not retain IP-bearing access logs for the ingest vhost.
@@ -15,7 +15,9 @@ enabled: true # cfg:enabled — master switch; false disables ev
15
15
  # (/init-pipeline or /update-pipeline ask once per machine; they record your answer here).
16
16
  # What is sent when enabled: core version, OS, phase name, wall-clock seconds, per-surface
17
17
  # result counts, and a SHA-256 HASH of the feature id — never repo names, paths, code, spec
18
- # content, emails, or IPs. Sent fire-and-forget (2s timeout, silent on failure) by
18
+ # content, emails, or IPs. One ping per pipeline phase, /brainstorm through /ship; setup and
19
+ # maintenance commands (/doctor, /init-pipeline, /update-pipeline, /audit, /refactor,
20
+ # /align-ds) never ping. Sent fire-and-forget (2s timeout, silent on failure) by
19
21
  # pipeline/scripts/telemetry-send.sh. Withdraw anytime: set enabled: false. Erase your history:
20
22
  # see SCHEMA.md §Telemetry (DELETE by install_id).
21
23
  telemetry:
@@ -2,7 +2,8 @@
2
2
  # telemetry-send.sh — fire-and-forget anonymous usage ping (SCHEMA.md §Telemetry).
3
3
  #
4
4
  # telemetry-send.sh <phase> <feature_id> <seconds> [results]
5
- # phase build|review|fix|smoke
5
+ # phase brainstorm|spec|build|smoke|review|fix|ship — the feature funnel, and only it.
6
+ # Setup/maintenance commands never ping (SCHEMA.md §Telemetry).
6
7
  # feature the feature id — NEVER sent raw; SHA-256-hashed to 12 hex chars
7
8
  # seconds batch wall-clock
8
9
  # results optional compact summary, e.g. "ok,ok" or "REVISE:3"
@@ -35,6 +36,13 @@ install_id="$(tval install_id)"; [ -n "$install_id" ] || exit 0
35
36
  phase="${1:-}"; feature="${2:-}"; seconds="${3:-0}"; results="${4:-}"
36
37
  [ -n "$phase" ] || exit 0
37
38
 
39
+ # Allowlist the phase here — the collector accepts any string, so a typo in a command
40
+ # file would silently pollute the dataset with a phantom phase nobody notices.
41
+ case "$phase" in
42
+ brainstorm|spec|build|smoke|review|fix|ship) ;;
43
+ *) exit 0 ;;
44
+ esac
45
+
38
46
  if command -v shasum >/dev/null 2>&1; then
39
47
  fhash=$(printf '%s' "$feature" | shasum -a 256 | cut -c1-12)
40
48
  else
@@ -4,8 +4,11 @@
4
4
  // locally with `node scripts/validate-core.mjs`.
5
5
  import { readFileSync, readdirSync, existsSync } from "node:fs";
6
6
  import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
 
8
- const root = new URL("..", import.meta.url).pathname;
9
+ // fileURLToPath, not .pathname — on Windows the latter yields "/C:/…", which
10
+ // join() then resolves against the cwd drive ("C:\C:\…") and every read ENOENTs.
11
+ const root = fileURLToPath(new URL("..", import.meta.url));
9
12
  const errors = [];
10
13
  const fail = (file, msg) => errors.push(`${file}: ${msg}`);
11
14
 
@@ -109,6 +112,36 @@ const steps = join(root, "core/templates/steps/init-pipeline");
109
112
  if (!existsSync(steps) || readdirSync(steps).length === 0)
110
113
  fail("core/templates/steps/init-pipeline", "router step files missing/empty");
111
114
 
115
+ // ── telemetry coverage ──────────────────────────────────────────────────────
116
+ // The funnel is only readable if every one of its stages pings — a single missing
117
+ // one silently truncates it (that is how /smoke, /review and /fix went unreported
118
+ // until 1.2.3). The phase list here must match SCHEMA.md §Telemetry's table.
119
+ const FUNNEL = ["brainstorm", "spec", "build", "smoke", "review", "fix", "ship"];
120
+ for (const c of FUNNEL)
121
+ if (!/usage ping/i.test(read(`core/commands/${c}.md`)))
122
+ fail(`core/commands/${c}.md`, "funnel command with no usage ping — breaks the telemetry funnel");
123
+ // …and nothing outside the funnel may ping (consent text scopes it to the funnel).
124
+ for (const f of readdirSync(join(root, "core/commands"))) {
125
+ const c = f.replace(/\.md$/, "");
126
+ // `telemetry-send.sh` + an argument = a call site; the bare filename (e.g. /doctor
127
+ // listing the scripts it checks for) is a mention, not a ping.
128
+ if (!FUNNEL.includes(c) && /telemetry-send\.sh +\S|usage ping/i.test(read(`core/commands/${f}`)))
129
+ fail(`core/commands/${f}`, "non-funnel command pings telemetry — outside the consented scope");
130
+ }
131
+
132
+ // ── shipped scripts ─────────────────────────────────────────────────────────
133
+ // Every scripts/*.sh must be copied by BOTH installers. Callers chain these with
134
+ // `|| true`, so one the installer forgets is a silent no-op forever — no kanban
135
+ // card moves, no telemetry ping, no error. CI is the only place this is loud.
136
+ // A `<name>.sh` with a `<name>.sh.template` sibling is a locally-rendered artifact
137
+ // (this repo dogfoods its own /init-pipeline), not a core asset — skip those.
138
+ const installers = { "install.sh": read("install.sh"), "install.ps1": read("install.ps1") };
139
+ const shipped = readdirSync(join(root, "scripts"));
140
+ for (const f of shipped.filter((f) => f.endsWith(".sh") && !shipped.includes(`${f}.template`)))
141
+ for (const [name, src] of Object.entries(installers))
142
+ if (!src.includes(`scripts/${f}`) && !src.includes(`scripts\\${f}`))
143
+ fail(name, `never copies scripts/${f} into pipeline/scripts/ (silent no-op at runtime)`);
144
+
112
145
  // ── report ──────────────────────────────────────────────────────────────────
113
146
  if (errors.length) {
114
147
  console.error(`validate-core: ${errors.length} error(s)\n`);
@@ -1,49 +0,0 @@
1
- # cohorte telemetry collector (reference)
2
-
3
- Zero-dependency ingest endpoint for cohorte's **opt-in** anonymous usage pings
4
- (see the repo README §Privacy and `profile/SCHEMA.md` §Telemetry for what is —
5
- and is not — collected).
6
-
7
- ## Deploy (any VPS, Node ≥ 18)
8
-
9
- ```sh
10
- TELEMETRY_DATA=/var/lib/cohorte-telemetry/events.ndjson \
11
- TELEMETRY_PORT=8787 node collector.mjs
12
- ```
13
-
14
- Put it behind HTTPS (Caddy example):
15
-
16
- ```
17
- telemetry.yourdomain.tld {
18
- reverse_proxy 127.0.0.1:8787
19
- log { output discard } # GDPR: do NOT keep access logs with IPs for this vhost
20
- }
21
- ```
22
-
23
- Then set the public URL as the `endpoint:` default in
24
- `profile/cohorte.config.template.yaml` (anchor `cfg:telemetry_endpoint`) and release —
25
- consenting installs start sending to it.
26
-
27
- ## API
28
-
29
- | Method | Path | Purpose |
30
- |---|---|---|
31
- | `POST` | `/v1/events` | Ingest one event (strict field allowlist, 4 KB max) |
32
- | `DELETE` | `/v1/install/<install_id>` | **Right to erasure** — drops every event for that id |
33
- | `GET` | `/healthz` | Liveness |
34
-
35
- Storage is an append-only NDJSON file — point your dashboard (or a cron that loads
36
- into SQLite/Postgres) at it. One JSON object per line:
37
-
38
- ```json
39
- {"v":1,"install_id":"…","ts":"…","core_version":"1.2.0","os":"Darwin","event":"phase","phase":"build","feature_hash":"a1b2c3d4e5f6","seconds":412,"results":"ok,ok","received_at":"…"}
40
- ```
41
-
42
- ## GDPR checklist (operator side)
43
-
44
- - [x] Opt-in only — the client never sends without recorded consent
45
- - [x] No IPs stored (collector ignores them; disable proxy access logs)
46
- - [x] Data minimization — hashed feature ids, no repo names/paths/code
47
- - [x] Erasure — `DELETE /v1/install/<id>`; the user finds their id via `/doctor`
48
- - [x] Withdrawal — `telemetry.enabled: false` in `~/.claude/cohorte.config.yaml`
49
- - [ ] Your privacy notice — link the repo README §Privacy from wherever you present cohorte
@@ -1,79 +0,0 @@
1
- #!/usr/bin/env node
2
- // Reference telemetry collector for cohorte — zero-dependency, GDPR-first.
3
- // Deploy on any VPS behind HTTPS (Caddy/nginx). Storage: NDJSON append-only file,
4
- // one event per line — read it from your own dashboard however you like.
5
- //
6
- // TELEMETRY_DATA=/var/lib/cohorte-telemetry/events.ndjson \
7
- // TELEMETRY_PORT=8787 node collector.mjs
8
- //
9
- // GDPR notes:
10
- // - IPs are NEVER stored (this process doesn't read them; make sure your reverse
11
- // proxy's access log is disabled or anonymized for this vhost).
12
- // - DELETE /v1/install/<install_id> erases every event for that id (right to erasure —
13
- // the install_id printed by /doctor is the user's erasure key).
14
- // - Events are validated against a strict allowlist of fields; anything else is dropped.
15
- import { createServer } from "node:http";
16
- import { appendFileSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
17
- import { dirname } from "node:path";
18
-
19
- const DATA = process.env.TELEMETRY_DATA || "./events.ndjson";
20
- const PORT = Number(process.env.TELEMETRY_PORT || 8787);
21
- const MAX_BODY = 4096;
22
-
23
- mkdirSync(dirname(DATA), { recursive: true });
24
-
25
- const FIELDS = ["v", "install_id", "ts", "core_version", "os", "event",
26
- "phase", "feature_hash", "seconds", "results"];
27
- const ID_RE = /^[A-Za-z0-9-]{8,64}$/;
28
-
29
- function sanitize(raw) {
30
- const e = {};
31
- for (const k of FIELDS) if (k in raw) e[k] = raw[k];
32
- if (e.v !== 1) return null;
33
- if (typeof e.install_id !== "string" || !ID_RE.test(e.install_id)) return null;
34
- if (typeof e.event !== "string" || e.event.length > 32) return null;
35
- for (const k of ["ts", "core_version", "os", "phase", "feature_hash", "results"])
36
- if (k in e && (typeof e[k] !== "string" || e[k].length > 64)) return null;
37
- if ("seconds" in e && typeof e.seconds !== "number") return null;
38
- e.received_at = new Date().toISOString();
39
- return e;
40
- }
41
-
42
- const server = createServer((req, res) => {
43
- const done = (code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
44
-
45
- if (req.method === "GET" && req.url === "/healthz") return done(200, { ok: true });
46
-
47
- if (req.method === "POST" && req.url === "/v1/events") {
48
- let body = "";
49
- req.on("data", (c) => { body += c; if (body.length > MAX_BODY) req.destroy(); });
50
- req.on("end", () => {
51
- try {
52
- const e = sanitize(JSON.parse(body));
53
- if (!e) return done(400, { error: "invalid event" });
54
- appendFileSync(DATA, JSON.stringify(e) + "\n");
55
- done(204, {});
56
- } catch { done(400, { error: "bad json" }); }
57
- });
58
- return;
59
- }
60
-
61
- // Right to erasure: drop every event carrying this install_id.
62
- const del = req.url?.match(/^\/v1\/install\/([A-Za-z0-9-]{8,64})$/);
63
- if (req.method === "DELETE" && del) {
64
- const id = del[1];
65
- let kept = [], dropped = 0;
66
- if (existsSync(DATA))
67
- for (const line of readFileSync(DATA, "utf8").split("\n")) {
68
- if (!line.trim()) continue;
69
- if (line.includes(`"install_id":"${id}"`)) { dropped++; continue; }
70
- kept.push(line);
71
- }
72
- writeFileSync(DATA, kept.length ? kept.join("\n") + "\n" : "");
73
- return done(200, { erased: dropped });
74
- }
75
-
76
- done(404, { error: "not found" });
77
- });
78
-
79
- server.listen(PORT, () => console.log(`cohorte telemetry collector on :${PORT} → ${DATA}`));