@mmerterden/multi-agent-pipeline 15.8.0 → 15.9.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.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,24 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [15.9.0] - 2026-08-20
20
+
21
+ ### Fixed
22
+ - **Telemetry emitter and run scripts: 21 verified defects from a refactor bug hunt.** The emitter now reads `usageLog.optOut` as a hard block, refuses non-TLS endpoints so the write-only token never travels in cleartext, resolves the credential store and version marker across all host trees (Copilot/Codex-only installs), prices each phase at its own model rate instead of opus-for-all, keeps hyphenated MCP server names, drops plugins mapped to `false`, and gates before touching the keychain. `phase-tracker.sh` uses a per-process temp file so the fail-open lock cannot publish a torn state, honors `$TRACKER_FILE` on init, and builds OTEL attrs with jq. `build-stack-plugins.mjs` aborts on a flag given without a value and reports content-only changes in `--dry-run`; `localize-commands.mjs` is Windows- and CRLF-safe; `account-resolver.sh`, `channels-multi-repo.sh` and `figma-mcp-refresh.sh` gaps closed. Covered by `test/usage-report.test.mjs`.
23
+
24
+ ### Changed
25
+ - **`purge` and `uninstall` are no longer model-auto-invocable** (`disable-model-invocation: true`): the two irreversible, full-data-loss commands run only on an explicit user request.
26
+ - **`humanizer` skill (v1.1.0):** a self-critique pass re-verifies the rewrite against the original (meaning preserved, nothing invented, patterns actually gone); trailing-participle and connective-padding patterns added.
27
+ - Stale version tables refreshed: `SECURITY.md` supported-versions moves to the 15.x line; `ROADMAP.md` "Current Release" becomes a rolling "Recent Releases".
28
+
29
+ ### Companion
30
+ - **`dev-toolkit-mcp` v2.26.0** (shipped alongside): CallTool boundary now validates arguments against each tool's inputSchema (lenient-but-safe), closing the command-injection class where a string reached a numeric shell interpolation; every caller-derived path is single-quoted; a new gate backstops it. Backward-compatible, 83 tools unchanged.
31
+
32
+ ## [15.8.1] - 2026-08-19
33
+
34
+ ### Fixed
35
+ - **Self-registration follows the endpoint redirect**: the default reporting host answers `/register` with a 308 to the canonical domain; the update step's curl now passes `-L`, so the token actually arrives instead of the redirect page. Without it, v15.8.0's self-registration silently reported "registration unreachable" on every machine.
36
+
19
37
  ## [15.8.0] - 2026-08-19
20
38
 
21
39
  ### Added
package/SECURITY.md CHANGED
@@ -6,9 +6,9 @@ We provide security fixes for the latest minor of the current major. Older versi
6
6
 
7
7
  | Version | Supported |
8
8
  | ------- | ---------------------- |
9
- | 12.x | ✅ active |
10
- | 11.x | ⚠️ security fixes only |
11
- | ≤ 10.x | ❌ end-of-life |
9
+ | 15.x | ✅ active |
10
+ | 14.x | ⚠️ security fixes only |
11
+ | ≤ 13.x | ❌ end-of-life |
12
12
 
13
13
  ## Reporting a Vulnerability
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "15.8.0",
3
+ "version": "15.9.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  description: "⚠️ Wipes every worktree, branch, log, and state file. Irreversible; asks for double confirmation. Use when every worktree, branch, log and state file should be wiped and the pipeline reset."
3
+ disable-model-invocation: true
3
4
  description-tr: "⚠️ Tüm worktree, branch, log ve state dosyalarını siler. Geri alınamaz; çift onay ister."
4
5
  ---
5
6
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  description: "Uninstall the pipeline from Claude Code + Copilot CLI. Keychain access tokens are always left untouched; --all-data also clears pipeline settings and logs. Asks for double confirmation. Use when the pipeline should be removed from Claude Code and Copilot CLI."
3
+ disable-model-invocation: true
3
4
  description-tr: "Pipeline'ı Claude Code + Copilot CLI'dan kaldırır. Keychain erişim token'larına asla dokunulmaz; --all-data ayrıca pipeline ayarlarını ve loglarını da siler. Çift onay ister."
4
5
  argument-hint: "[--dry-run] [--all-data] [--claude] [--copilot] [--target=<path>]"
5
6
  ---
@@ -106,7 +106,7 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
106
106
  REG_EP="${EP%/ingest}/register"
107
107
  RUSER=$(jq -r '.global.identities[0].username // .global.identities[0].name // empty' "$PREFS" 2>/dev/null)
108
108
  [ -z "$RUSER" ] && RUSER="$USER"
109
- RESP=$(curl -sS -m 10 -X POST -H "Content-Type: application/json" \
109
+ RESP=$(curl -sSL -m 10 -X POST -H "Content-Type: application/json" \
110
110
  --data "{\"u\":\"$RUSER\",\"c\":\"$(hostname -s 2>/dev/null || echo unknown)\"}" \
111
111
  "$REG_EP" 2>/dev/null)
112
112
  TOK=$(printf '%s' "$RESP" | jq -r '.token // empty' 2>/dev/null)
@@ -25,7 +25,10 @@ FILTER=""
25
25
  while [ $# -gt 0 ]; do
26
26
  case "$1" in
27
27
  --pretty) PRETTY=1; shift ;;
28
- --providers) FILTER="${2:-}"; shift 2 ;;
28
+ # `shift 2` on a trailing `--providers` (no value) fails under `set -e` and
29
+ # aborts silently. Consume one at a time so a missing value is an empty
30
+ # filter, not a crash.
31
+ --providers) FILTER="${2:-}"; shift; [ $# -gt 0 ] && shift ;;
29
32
  *) shift ;;
30
33
  esac
31
34
  done
@@ -97,9 +100,18 @@ prefixes=$(printf '%s\n' "$services" \
97
100
  | sed -E 's/_(Github|Jira|Bitbucket|Confluence)_.+$//' \
98
101
  | sort -u)
99
102
 
103
+ # Escape ERE metacharacters so a prefix/provider is matched literally. Without
104
+ # this a prefix containing `.` matched any character (acme.corp cross-matched
105
+ # acmexcorp, mis-associating one account's keychain with another), and a `[`
106
+ # made grep error out - swallowed by `|| true`, so the provider silently vanished.
107
+ re_escape() {
108
+ printf '%s' "$1" | sed 's/[][\\.^$*+?(){}|]/\\&/g'
109
+ }
110
+
100
111
  # Helper: given a prefix and provider keyword, find the matching token service (or "").
101
112
  find_token_for() {
102
- local prefix="$1" provider="$2"
113
+ local prefix; prefix="$(re_escape "$1")"
114
+ local provider; provider="$(re_escape "$2")"
103
115
  printf '%s\n' "$services" \
104
116
  | grep -E "^${prefix}_${provider}_.*(Token|Json|Auth)$" \
105
117
  | head -n1 || true
@@ -107,7 +119,8 @@ find_token_for() {
107
119
 
108
120
  # Helper: given a prefix and provider keyword, find the username service entry (or "").
109
121
  find_user_key_for() {
110
- local prefix="$1" provider="$2"
122
+ local prefix; prefix="$(re_escape "$1")"
123
+ local provider; provider="$(re_escape "$2")"
111
124
  printf '%s\n' "$services" \
112
125
  | grep -E "^${prefix}_${provider}_Username$" \
113
126
  | head -n1 || true
@@ -88,11 +88,11 @@ all_urls = []
88
88
  for p in projects:
89
89
  pr = p.get("pr") or {}
90
90
  if pr.get("url"):
91
- all_urls.append({"repo": p["project"], "url": pr["url"]})
91
+ all_urls.append({"repo": p.get("project") or "", "url": pr["url"]})
92
92
 
93
93
  out = []
94
94
  for p in projects:
95
- name = p["project"]
95
+ name = p.get("project") or ""
96
96
  pr = p.get("pr") or {}
97
97
  is_primary = (name == primary_name)
98
98
  cross = [u for u in all_urls if u["repo"] != name]
@@ -134,11 +134,11 @@ primary_name = state.get("project") or projects[0].get("project")
134
134
  is_primary = (repo == primary_name)
135
135
  links = []
136
136
  for p in projects:
137
- if p["project"] == repo:
137
+ if (p.get("project") or "") == repo:
138
138
  continue
139
139
  pr = p.get("pr") or {}
140
140
  if pr.get("url"):
141
- label = "Part of" if not is_primary and p["project"] == primary_name else "Related"
141
+ label = "Part of" if not is_primary and (p.get("project") or "") == primary_name else "Related"
142
142
  links.append(f"{label}: {pr['url']}")
143
143
 
144
144
  if not links:
@@ -207,7 +207,7 @@ if len(projects) <= 1:
207
207
  print("single-repo task")
208
208
  sys.exit(0)
209
209
  primary_name = state.get("project") or projects[0].get("project")
210
- extras = [p for p in projects if p["project"] != primary_name]
210
+ extras = [p for p in projects if (p.get("project") or "") != primary_name]
211
211
  extra_urls = [(p.get("pr") or {}).get("url") or f"<no-pr:{p['project']}>" for p in extras]
212
212
  print(f"1 primary ({primary_name}) + {len(extras)} extras: {' '.join(extra_urls)}")
213
213
  PY
@@ -92,6 +92,14 @@ NEW_REFRESH=$(printf '%s' "$RESPONSE" | jq -r '.refresh_token // empty' 2>/dev/n
92
92
  EXPIRES_IN=$(printf '%s' "$RESPONSE" | jq -r '.expires_in // "?"' 2>/dev/null || true)
93
93
 
94
94
  if [ -z "$NEW_ACCESS" ]; then
95
+ # Distinguish a transient network failure (empty body) from an actual rejection
96
+ # (body carries .error): the first is retryable and must NOT tell the user to
97
+ # regenerate a credential that is still valid. Exit 2 = prerequisite/transient,
98
+ # exit 1 = the grant was genuinely rejected.
99
+ if [ -z "$RESPONSE" ]; then
100
+ say "figma-mcp-refresh: no response from token endpoint (network/timeout) - retry; the credential is unchanged"
101
+ exit 2
102
+ fi
95
103
  ERR=$(printf '%s' "$RESPONSE" | jq -r '.error // .message // "unknown"' 2>/dev/null || echo "unknown")
96
104
  say "figma-mcp-refresh: refresh grant rejected ($ERR) - regenerate via tokenScripts.figma_mcp"
97
105
  exit 1
@@ -106,13 +114,17 @@ save_keychain_secret() {
106
114
  printf '%s' "$secret" | "$CRED_STORE" set "$service" - >/dev/null
107
115
  }
108
116
 
109
- # Save new access token first, then the rotated refresh token - order matters so a
110
- # mid-write failure never leaves us with a lost refresh token AND a stale access token.
111
- save_keychain_secret "$KEY_NAME" "$NEW_ACCESS"
112
-
117
+ # Save the rotated REFRESH token first, then the access token. The grant at the
118
+ # curl above already invalidated the old refresh token server-side, so the
119
+ # rotated one is the only key that can renew again; if the access-token write
120
+ # then failed we would still hold a working refresh token and recover on the
121
+ # next run. The reverse order (access first) risked persisting access while
122
+ # losing the rotated refresh - a permanent dead end needing a full regenerate.
113
123
  if [ -n "$NEW_REFRESH" ]; then
114
124
  save_keychain_secret "$REFRESH_KEY" "$NEW_REFRESH"
115
125
  fi
116
126
 
127
+ save_keychain_secret "$KEY_NAME" "$NEW_ACCESS"
128
+
117
129
  say "figma-mcp-refresh: renewed '$KEY_NAME' (expires_in=${EXPIRES_IN}s, refresh $( [ -n "$NEW_REFRESH" ] && echo rotated || echo unchanged ))"
118
130
  exit 0
@@ -1136,7 +1136,7 @@
1136
1136
  },
1137
1137
  "endpoint": {
1138
1138
  "type": "string",
1139
- "description": "Ingest URL that receives the run record. The token is sent in the X-Usage-Token header."
1139
+ "description": "Ingest URL that receives the run record. The write-only token rides in the X-Usage-Token header, so the emitter sends ONLY over https (or http on 127.0.0.1/localhost for local dev); any other http endpoint is refused before the token is read."
1140
1140
  },
1141
1141
  "token": {
1142
1142
  "type": "string",
@@ -44,9 +44,25 @@ const getArg = (k, d) => {
44
44
  const eq = args.find((a) => a.startsWith(`${k}=`));
45
45
  if (eq) return eq.slice(k.length + 1);
46
46
  const i = args.indexOf(k);
47
- return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : d;
47
+ if (i < 0) return d;
48
+ // The flag is present. A missing/typo'd value must ABORT, never fall through
49
+ // to the default: the default is the user's real marketplace and the next
50
+ // step does a destructive rmSync+cpSync there. The space form used to have the
51
+ // same silent-retarget bug the `=` form was already fixed for.
52
+ const v = args[i + 1];
53
+ if (!v || v.startsWith("--")) {
54
+ console.error(`ERROR: ${k} was given without a value. Pass ${k}=<path> or ${k} <path>.`);
55
+ process.exit(2);
56
+ }
57
+ return v;
48
58
  };
49
- const HOME = process.env.HOME;
59
+ // HOME is unset on Windows (USERPROFILE is the variable); fall back so join()
60
+ // gets a string instead of throwing a raw TypeError before any diagnostic.
61
+ const HOME = process.env.HOME || process.env.USERPROFILE;
62
+ if (!HOME) {
63
+ console.error("ERROR: neither HOME nor USERPROFILE is set; cannot resolve default paths.");
64
+ process.exit(2);
65
+ }
50
66
  const PLUGINS_REPO = getArg("--plugins-repo", join(HOME, "multi-agent-plugins"));
51
67
  const PIPE_ROOT = getArg("--pipeline", join(HOME, "multi-agent-pipeline"));
52
68
  const EXTERNAL = join(PIPE_ROOT, "pipeline/skills/shared/external");
@@ -204,17 +220,24 @@ for (const [plugin, want] of Object.entries(desired)) {
204
220
  // REMOVED inside a still-wanted skill survived in every plugin copy forever -
205
221
  // and because the overlay kept the orphan on both sides of the fingerprint
206
222
  // comparison, the version was not bumped either.
223
+ // Detect content drift in BOTH modes so --dry-run (the pre-flight for a
224
+ // destructive run) reports the same "would change" verdict a real run acts on.
225
+ // Previously contentChanged was computed only inside `if (!DRY)`, so dry-run
226
+ // was blind to a file edit that did not change set membership - exactly the
227
+ // case the fingerprint machinery was added to catch.
228
+ if (!DRY && !existsSync(kdir)) mkdirSync(kdir, { recursive: true });
207
229
  let contentChanged = false;
208
- if (!DRY) {
209
- if (!existsSync(kdir)) mkdirSync(kdir, { recursive: true });
210
- for (const s of want) {
211
- const from = join(EXTERNAL, s);
212
- const to = join(kdir, s);
213
- if (treeFingerprint(from) === (existsSync(to) ? treeFingerprint(to) : "")) continue;
230
+ for (const s of want) {
231
+ const from = join(EXTERNAL, s);
232
+ const to = join(kdir, s);
233
+ if (treeFingerprint(from) === (existsSync(to) ? treeFingerprint(to) : "")) continue;
234
+ contentChanged = true;
235
+ if (!DRY) {
214
236
  rmSync(to, { recursive: true, force: true });
215
237
  cpSync(from, to, { recursive: true });
216
- contentChanged = true;
217
238
  }
239
+ }
240
+ if (!DRY) {
218
241
  for (const s of toRemove) rmSync(join(kdir, s), { recursive: true, force: true });
219
242
  }
220
243
  const changed = setChanged || contentChanged;
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from "fs";
27
27
  import { join } from "path";
28
+ import { pathToFileURL } from "url";
28
29
 
29
30
  const DESC = /^description:[ \t]*(.*)$/;
30
31
  const DESC_TR = /^description-tr:[ \t]*(.*)$/;
@@ -38,7 +39,11 @@ const DESC_EN = /^description-en:[ \t]*(.*)$/;
38
39
  */
39
40
  export function localizeFile(file, mode) {
40
41
  const original = readFileSync(file, "utf-8");
41
- const lines = original.split("\n");
42
+ // CRLF-safe: a checkout with \r\n line endings made lines[0] === "---\r", so
43
+ // every file was skipped and the run reported "0 changed" as success. Split on
44
+ // either ending and preserve whichever this file uses when writing back.
45
+ const eol = original.includes("\r\n") ? "\r\n" : "\n";
46
+ const lines = original.split(/\r?\n/);
42
47
  // Only operate inside the first frontmatter block.
43
48
  if (lines[0] !== "---") return false;
44
49
  const end = lines.indexOf("---", 1);
@@ -70,7 +75,7 @@ export function localizeFile(file, mode) {
70
75
  lines[descIdx] = `description: ${enValue}`;
71
76
  lines.splice(enIdx, 1);
72
77
  }
73
- const next = lines.join("\n");
78
+ const next = lines.join(eol);
74
79
  if (next === original) return false;
75
80
  writeFileSync(file, next);
76
81
  return true;
@@ -107,8 +112,12 @@ export function localizeCommands(dir, mode) {
107
112
  return changed;
108
113
  }
109
114
 
115
+ // Windows-safe entry-point check: compare file URLs. The old
116
+ // `.split("/").pop()` never split a backslash path, so on Windows this was
117
+ // always false - `restore` exited 0 doing nothing and /multi-agent:sync then
118
+ // mirrored localized descriptions into the repo, the one thing it must prevent.
110
119
  const invokedDirectly =
111
- process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop());
120
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
112
121
  if (invokedDirectly) {
113
122
  const args = process.argv.slice(2);
114
123
  const mode = args[0];
@@ -73,6 +73,11 @@ ACTION="$1"; shift
73
73
 
74
74
  # State file location.
75
75
  TRACKER_FILE="${TRACKER_FILE:-}"
76
+ # Record whether the caller supplied the path so `init` honors it too (the init
77
+ # branch used to overwrite it unconditionally, so a test setting TRACKER_FILE to
78
+ # a temp path still had init clobber the real ~/.claude pointer and tree).
79
+ TRACKER_FILE_FROM_ENV=0
80
+ [ -n "$TRACKER_FILE" ] && TRACKER_FILE_FROM_ENV=1
76
81
  if [ -z "$TRACKER_FILE" ]; then
77
82
  TASK_ID_FROM_ENV="${MULTI_AGENT_TASK_ID:-}"
78
83
  if [ "$ACTION" = "init" ] && [ "$#" -ge 1 ]; then
@@ -125,9 +130,20 @@ usage_live_ping() {
125
130
  # a test gate must not leave phantom "running" rows on the timeline.
126
131
  [ -n "${MULTI_AGENT_SMOKE:-}" ] && return 0
127
132
  local task="$1" phase="$2"
128
- local script="$HOME/.claude/scripts/usage-report.mjs"
133
+ # The emitter ships into whichever host tree installed it; a Copilot- or
134
+ # Codex-only install has no ~/.claude/scripts, so resolve across all three
135
+ # roots instead of hard-coding one (a hard-coded path silently disabled every
136
+ # live ping on those hosts).
137
+ local script=""
138
+ local root
139
+ for root in "$HOME/.claude" "$HOME/.copilot" "$HOME/.codex"; do
140
+ if [ -f "$root/scripts/usage-report.mjs" ]; then
141
+ script="$root/scripts/usage-report.mjs"
142
+ break
143
+ fi
144
+ done
129
145
  local prefs="$HOME/.claude/multi-agent-preferences.json"
130
- [ -n "$task" ] && [ -f "$script" ] || return 0
146
+ [ -n "$task" ] && [ -n "$script" ] || return 0
131
147
  # Cheap gate: only spawn the emitter when usage logging is actually on, so a
132
148
  # user who never enabled it pays nothing per phase boundary. jq is already a
133
149
  # hard dependency of this script.
@@ -406,8 +422,14 @@ save_state() {
406
422
  # a failed jq yields "" - writing that would destroy the whole tracker state.
407
423
  [ -n "$1" ] || { echo "save_state: refusing to write empty state" >&2; return 65; }
408
424
  mkdir -p "$TRACKER_DIR" 2>/dev/null
409
- printf '%s\n' "$1" > "${TRACKER_FILE}.tmp"
410
- mv "${TRACKER_FILE}.tmp" "$TRACKER_FILE"
425
+ # Per-process temp name: acquire_state_lock fails open after ~5s, so two
426
+ # writers can legitimately be in the critical section. A fixed .tmp name lets
427
+ # writer B truncate the file mid-write of A, and the rename then publishes a
428
+ # corrupt document (render exits 65). $$ keeps each writer's temp private; the
429
+ # rename is still atomic, so the last full write wins instead of a torn one.
430
+ local tmp="${TRACKER_FILE}.tmp.$$"
431
+ printf '%s\n' "$1" > "$tmp"
432
+ mv "$tmp" "$TRACKER_FILE"
411
433
  }
412
434
 
413
435
  # --- read-modify-write lock ---------------------------------------------------
@@ -687,7 +709,11 @@ case "$ACTION" in
687
709
  init)
688
710
  [ "$#" -ge 1 ] || { echo "init needs <task_id>" >&2; exit 64; }
689
711
  TASK_ID="$1"
690
- TRACKER_FILE="$HOME/.claude/logs/multi-agent/${TASK_ID}/tracker-state.json"
712
+ # Honor an explicit $TRACKER_FILE (tests, isolated runs); only derive the
713
+ # canonical home path when the caller did not supply one.
714
+ if [ "$TRACKER_FILE_FROM_ENV" != "1" ]; then
715
+ TRACKER_FILE="$HOME/.claude/logs/multi-agent/${TASK_ID}/tracker-state.json"
716
+ fi
691
717
  TRACKER_DIR="$(dirname "$TRACKER_FILE")"
692
718
  mkdir -p "$TRACKER_DIR" 2>/dev/null
693
719
  NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
@@ -699,7 +725,13 @@ case "$ACTION" in
699
725
  # NOTE: the pointer is global; for concurrent runs on the same user account,
700
726
  # callers should set MULTI_AGENT_TASK_ID in their shell or prefix every
701
727
  # subsequent tracker call (the pointer flips on the most recent init).
702
- echo "$TRACKER_FILE" > "$HOME/.claude/logs/multi-agent/.tracker-current"
728
+ # Skipped ONLY when the path was supplied via $TRACKER_FILE: that caller
729
+ # already named its own file and must not repoint the shared pointer at it.
730
+ # (Smoke isolation is handled by a sandbox HOME, so it still writes a pointer
731
+ # inside its own tree - which its later calls depend on.)
732
+ if [ "$TRACKER_FILE_FROM_ENV" != "1" ]; then
733
+ echo "$TRACKER_FILE" > "$HOME/.claude/logs/multi-agent/.tracker-current"
734
+ fi
703
735
  render
704
736
  usage_live_ping "$TASK_ID" 0
705
737
  if [ "${MULTI_AGENT_QUIET:-0}" != "1" ]; then
@@ -782,7 +814,10 @@ case "$ACTION" in
782
814
  )')
783
815
  save_state "$new"
784
816
  release_state_lock
785
- emit_otel_span "phase.sub" "$PID" "$SNAME" "{\"sub_id\": \"$SID\", \"status\": \"$SSTATUS\"}"
817
+ # Build the attrs with jq so a sub_id containing " or \ can't produce
818
+ # malformed JSON that --argjson silently drops.
819
+ emit_otel_span "phase.sub" "$PID" "$SNAME" \
820
+ "$(jq -nc --arg s "$SID" --arg st "$SSTATUS" '{sub_id:$s,status:$st}')"
786
821
  render
787
822
  ;;
788
823
 
@@ -867,7 +902,7 @@ case "$ACTION" in
867
902
  )')
868
903
  save_state "$new"
869
904
  release_state_lock
870
- emit_otel_span "phase.model" "$PID" "" "{\"model\": \"$MODEL\"}"
905
+ emit_otel_span "phase.model" "$PID" "" "$(jq -nc --arg m "$MODEL" '{model:$m}')"
871
906
  if [ "${TRACKER_QUIET:-0}" != "1" ]; then
872
907
  render
873
908
  fi
@@ -24,7 +24,7 @@
24
24
  import { readFileSync, existsSync, readdirSync, appendFileSync } from "fs";
25
25
  import { homedir, platform } from "os";
26
26
  import { dirname, join } from "path";
27
- import { fileURLToPath } from "url";
27
+ import { fileURLToPath, pathToFileURL } from "url";
28
28
  import { execFileSync } from "child_process";
29
29
  import { costUsd } from "./_cost.mjs";
30
30
 
@@ -32,6 +32,19 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
32
32
  const ENDPOINT_DEFAULT = "https://mmerterden.vercel.app/api/usage/ingest";
33
33
  const TIMEOUT_MS = 2500;
34
34
 
35
+ // The emitter ships verbatim into ~/.claude, ~/.copilot and ~/.codex, so it must
36
+ // never hard-code one host's tree: a Copilot- or Codex-only install has no
37
+ // ~/.claude/lib. Resolve each helper across every installed host root.
38
+ const HOST_ROOTS = [".claude", ".copilot", ".codex"];
39
+
40
+ function hostFile(...parts) {
41
+ for (const root of HOST_ROOTS) {
42
+ const p = join(homedir(), root, ...parts);
43
+ if (existsSync(p)) return p;
44
+ }
45
+ return null;
46
+ }
47
+
35
48
  function parseArgs(argv) {
36
49
  const out = { state: null, taskId: null, phase: null, status: null, dryRun: false };
37
50
  for (let i = 0; i < argv.length; i += 1) {
@@ -77,8 +90,8 @@ function resolveToken() {
77
90
  if (typeof inline === "string" && inline.trim()) return inline.trim();
78
91
  const name = g.keychainMapping?.usage_ingest;
79
92
  if (typeof name === "string" && name.trim()) {
80
- const store = join(homedir(), ".claude", "lib", "credential-store.sh");
81
- if (existsSync(store)) {
93
+ const store = hostFile("lib", "credential-store.sh");
94
+ if (store) {
82
95
  try {
83
96
  const out = execFileSync("bash", [store, "get", name.trim()], {
84
97
  encoding: "utf-8",
@@ -94,13 +107,29 @@ function resolveToken() {
94
107
  return "";
95
108
  }
96
109
 
110
+ // A plugin key mapped to `false` is de-selected (e.g. /multi-agent:stack writes
111
+ // the unpicked toolkits as false), so an object form must be filtered by truthy
112
+ // value, never by key presence. Project settings and the user-global
113
+ // ~/.claude/settings.json are merged, matching install/_plugin-skills.mjs.
114
+ function readEnabledPluginNames(settings) {
115
+ const raw = settings?.enabledPlugins ?? settings?.plugins ?? null;
116
+ if (!raw) return [];
117
+ if (Array.isArray(raw)) return raw.filter((p) => typeof p === "string");
118
+ return Object.entries(raw)
119
+ .filter(([, v]) => v === true || v === "true")
120
+ .map(([k]) => k);
121
+ }
122
+
97
123
  function enabledPlugins(state) {
98
124
  const root = state.projectRoot || process.cwd();
99
- const settings = readJson(join(root, ".claude", "settings.json"));
100
- const raw = settings?.enabledPlugins ?? settings?.plugins ?? [];
101
- const list = Array.isArray(raw) ? raw : Object.keys(raw || {});
102
- return list
103
- .map((p) => (typeof p === "string" ? p.split("@")[0] : null))
125
+ const names = new Set();
126
+ for (const s of [
127
+ readJson(join(root, ".claude", "settings.json")),
128
+ readJson(join(homedir(), ".claude", "settings.json")),
129
+ ]) {
130
+ for (const name of readEnabledPluginNames(s)) names.add(name.split("@")[0]);
131
+ }
132
+ return Array.from(names)
104
133
  .filter(Boolean)
105
134
  .slice(0, 20);
106
135
  }
@@ -114,7 +143,10 @@ function integrationTags(state) {
114
143
  if (state.figmaAccess?.tier) apps.add("figma");
115
144
  for (const call of state.telemetry?.mcpCalls ?? []) {
116
145
  const tool = String(call?.tool || "");
117
- const m = tool.match(/^mcp__([a-z0-9_]+?)__/i) || tool.match(/^mcp__([a-z0-9_]+)/i);
146
+ // Server segment may contain hyphens (mcp__dev-toolkit__ios_tap), so the
147
+ // class must accept them or "dev-toolkit" collapses to "dev".
148
+ const m =
149
+ tool.match(/^mcp__([a-z0-9_-]+?)__/i) || tool.match(/^mcp__([a-z0-9_-]+)/i);
118
150
  if (m) apps.add(m[1].replace(/_/g, "-").toLowerCase());
119
151
  }
120
152
  return Array.from(apps).slice(0, 20);
@@ -212,11 +244,16 @@ function resolveStatePath({ state, taskId }) {
212
244
  }
213
245
 
214
246
  function packageVersion() {
215
- try {
216
- const marker = readFileSync(join(homedir(), ".claude", ".pipeline-version"), "utf-8").trim();
217
- if (marker) return marker;
218
- } catch {
219
- /* marker absent */
247
+ // The installer writes .pipeline-version per target, so read it from whichever
248
+ // host root exists (Copilot- or Codex-only installs have no ~/.claude).
249
+ const marker = hostFile(".pipeline-version");
250
+ if (marker) {
251
+ try {
252
+ const v = readFileSync(marker, "utf-8").trim();
253
+ if (v) return v;
254
+ } catch {
255
+ /* marker unreadable */
256
+ }
220
257
  }
221
258
  const candidates = [
222
259
  join(__dirname, "..", "..", "package.json"),
@@ -318,10 +355,31 @@ function trackerSummary(state) {
318
355
  phs.sort((a, b) => Number(a.p) - Number(b.p));
319
356
 
320
357
  const total = tin + tout + tcache;
321
- const table = readJson(join(__dirname, "cost-table.json"));
322
- const rate = table?.prices?.opus;
323
- const cost =
324
- total > 0 && rate ? Number(costUsd(rate, tin, tout, tcache).toFixed(4)) : null;
358
+ // Price each phase at its OWN model's rate, matching phase-tracker.sh; a run
359
+ // with fable phases priced entirely at opus under-reports ~2x and never
360
+ // agrees with the tracker card. Fall back to opus only for a phase whose
361
+ // model is missing or unknown in the table.
362
+ const prices = readJson(join(__dirname, "cost-table.json"))?.prices ?? {};
363
+ let cost = null;
364
+ if (total > 0) {
365
+ let acc = 0;
366
+ let priced = false;
367
+ for (const p of list) {
368
+ const rate = prices[p?.model || ""] || prices.opus;
369
+ if (!rate) continue;
370
+ const c = costUsd(
371
+ rate,
372
+ Number(p?.tokens_in || 0),
373
+ Number(p?.tokens_out || 0),
374
+ Number(p?.tokens_cached || 0),
375
+ );
376
+ if (c != null) {
377
+ acc += c;
378
+ priced = true;
379
+ }
380
+ }
381
+ if (priced) cost = Number(acc.toFixed(4));
382
+ }
325
383
  return {
326
384
  tk: total || null,
327
385
  cost,
@@ -453,14 +511,39 @@ async function post(endpoint, token, event) {
453
511
  }
454
512
  }
455
513
 
514
+ // The write-only ingest token rides in a header; sending it over http would
515
+ // leak it in cleartext, contradicting the setup contract ("transmitted only
516
+ // over TLS"). Accept https anywhere, and http ONLY for loopback (local dev).
517
+ function endpointAllowed(endpoint) {
518
+ let u;
519
+ try {
520
+ u = new URL(endpoint);
521
+ } catch {
522
+ return false;
523
+ }
524
+ if (u.protocol === "https:") return true;
525
+ if (u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost")) {
526
+ return true;
527
+ }
528
+ return false;
529
+ }
530
+
456
531
  async function main() {
457
532
  const args = parseArgs(process.argv.slice(2));
458
533
  if (process.env.MULTI_AGENT_SMOKE && !args.dryRun) return;
459
534
  const prefs = resolvePrefs();
460
- const token = resolveToken();
461
535
  const endpoint = prefs.endpoint || ENDPOINT_DEFAULT;
462
536
 
463
- if (!args.dryRun && (prefs.enabled !== true || !token)) return;
537
+ // Gate BEFORE touching the keychain: optOut is a hard block, and reading the
538
+ // secret for a disabled feature is both wasteful and can raise a macOS
539
+ // Keychain prompt. A real send also requires a TLS (or loopback) endpoint.
540
+ if (!args.dryRun) {
541
+ if (prefs.optOut === true) return;
542
+ if (prefs.enabled !== true) return;
543
+ if (!endpointAllowed(endpoint)) return;
544
+ }
545
+ const token = args.dryRun ? "" : resolveToken();
546
+ if (!args.dryRun && !token) return;
464
547
 
465
548
  const statePath = resolveStatePath(args);
466
549
  let state = statePath ? readJson(statePath) : null;
@@ -487,4 +570,11 @@ async function main() {
487
570
  await post(endpoint, token, event);
488
571
  }
489
572
 
490
- main().catch(() => process.exit(0));
573
+ // Windows-safe entry-point check: compare file URLs, never string-split a path.
574
+ const invokedDirectly =
575
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
576
+ if (invokedDirectly) {
577
+ main().catch(() => process.exit(0));
578
+ }
579
+
580
+ export { endpointAllowed, integrationTags, enabledPlugins, readEnabledPluginNames, trackerSummary, buildEvent };
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: humanizer
3
- version: 1.0.0
3
+ version: 1.1.0
4
4
  description: |
5
5
  Remove AI-generated writing patterns from text. Makes output sound natural
6
6
  and human-written. Detects inflated language, filler phrases, AI vocabulary,
@@ -17,7 +17,11 @@ You are a writing editor. Your job is to rewrite text so it reads like a human w
17
17
  1. Scan for the patterns below
18
18
  2. Rewrite problematic sections
19
19
  3. Keep the meaning - change the delivery
20
- 4. Do a final check: "Does this still sound like AI?" If yes, fix it
20
+ 4. **Self-critique pass (do not skip).** Put the rewrite next to the original and check three things, in order:
21
+ - **Meaning preserved.** Every claim, number, name, caveat, and conditional in the original is still present and still says the same thing. De-AI-ing must not quietly drop a "not", flip a hedge into a certainty, or merge two distinct points into one.
22
+ - **Nothing invented.** You added no fact, source, statistic, or example that was not in the original. Removing inflation is the job; inventing detail to replace it is a worse failure than the inflation was.
23
+ - **Patterns actually gone.** Re-scan the rewrite against the list below - a first pass often trades one tell for another (drops an em dash, adds "moreover").
24
+ If any check fails, fix it and repeat step 4. Only stop when all three hold.
21
25
 
22
26
  ## Patterns to Fix
23
27
 
@@ -47,11 +51,11 @@ Keep ONLY functional status/severity marks that a fixed template explicitly defi
47
51
 
48
52
  These words appear far more in AI output than human writing. Replace or remove them.
49
53
 
50
- **Flag words:** additionally, crucial, delve, enhance, foster, garner, intricate, landscape (abstract),
51
- pivotal, showcase, tapestry (abstract), testament, underscore, vibrant, leverage, streamline,
52
- seamless, robust, comprehensive, innovative
54
+ **Flag words:** additionally, moreover, furthermore, consequently, crucial, delve, enhance, foster,
55
+ garner, intricate, landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore,
56
+ vibrant, leverage, streamline, seamless, robust, comprehensive, innovative
53
57
 
54
- **Fix:** Use simpler alternatives. "Additionally" → "also". "Utilize" → "use". "Leverage" → "use". "Facilitate" → "help".
58
+ **Fix:** Use simpler alternatives. "Additionally" / "Moreover" / "Furthermore" → "also" or drop it. "Utilize" → "use". "Leverage" → "use". "Facilitate" → "help".
55
59
 
56
60
  ### Mechanical Structure
57
61
 
@@ -63,6 +67,8 @@ seamless, robust, comprehensive, innovative
63
67
  | Negative parallelism | "It's not just X; it's Y" | State the point directly |
64
68
  | False ranges | "from the Big Bang to dark matter" | List topics directly |
65
69
  | Passive voice overuse | "The results are preserved automatically" | Name the actor: "The system preserves results" |
70
+ | Trailing participle clause | "...refactored the parser, ensuring seamless integration and improving readability" | End the sentence at the fact. Split off the tacked-on "-ing" claim or delete it |
71
+ | Connective padding | "Moreover, ... Furthermore, ... Consequently, ..." | Most are removable; if a link is real use "and", "so", "but" |
66
72
 
67
73
  ### Style Problems
68
74