@mmerterden/multi-agent-pipeline 15.8.1 → 15.9.1
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 +18 -0
- package/SECURITY.md +3 -3
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/purge/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/uninstall/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
- package/pipeline/lib/account-resolver.sh +16 -3
- package/pipeline/lib/channels-multi-repo.sh +5 -5
- package/pipeline/lib/figma-mcp-refresh.sh +16 -4
- package/pipeline/schemas/prefs.schema.json +1 -1
- package/pipeline/scripts/build-stack-plugins.mjs +32 -9
- package/pipeline/scripts/localize-commands.mjs +12 -3
- package/pipeline/scripts/phase-tracker.sh +43 -8
- package/pipeline/scripts/usage-report.mjs +137 -22
- package/pipeline/skills/shared/external/humanizer/SKILL.md +12 -6
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +78 -6
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.1] - 2026-08-20
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **Telemetry logs the GitHub account name, never the git `identity.name`.** The reporter resolved the run's user to `identity.username || identity.name`, and since prefs identities carried no `username`, it fell back to `identity.name` - which can be a full corporate title/brand string, landing verbatim in the usage store. It now resolves to the identity's GitHub username, then the active `gh` account login resolved live, then null; the git `identity.name` is no longer a fallback. Self-registration (`/multi-agent:update` step 5b) resolves the same way.
|
|
23
|
+
|
|
24
|
+
## [15.9.0] - 2026-08-20
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **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`.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- **`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.
|
|
31
|
+
- **`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.
|
|
32
|
+
- Stale version tables refreshed: `SECURITY.md` supported-versions moves to the 15.x line; `ROADMAP.md` "Current Release" becomes a rolling "Recent Releases".
|
|
33
|
+
|
|
34
|
+
### Companion
|
|
35
|
+
- **`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.
|
|
36
|
+
|
|
19
37
|
## [15.8.1] - 2026-08-19
|
|
20
38
|
|
|
21
39
|
### Fixed
|
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
|
-
|
|
|
10
|
-
|
|
|
11
|
-
| ≤
|
|
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.
|
|
3
|
+
"version": "15.9.1",
|
|
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
|
---
|
|
@@ -104,7 +104,11 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
|
|
|
104
104
|
if [ -z "$TOK" ]; then
|
|
105
105
|
EP=$(jq -r '.global.usageLog.endpoint // "https://mmerterden.vercel.app/api/usage/ingest"' "$PREFS" 2>/dev/null)
|
|
106
106
|
REG_EP="${EP%/ingest}/register"
|
|
107
|
-
|
|
107
|
+
# Telemetry identity is the GitHub account name, never the git
|
|
108
|
+
# identity.name (which can carry a corporate title). username -> live
|
|
109
|
+
# gh login -> OS user.
|
|
110
|
+
RUSER=$(jq -r '.global.identities[0].username // empty' "$PREFS" 2>/dev/null)
|
|
111
|
+
[ -z "$RUSER" ] && RUSER=$(gh api user --jq .login 2>/dev/null || echo "")
|
|
108
112
|
[ -z "$RUSER" ] && RUSER="$USER"
|
|
109
113
|
RESP=$(curl -sSL -m 10 -X POST -H "Content-Type: application/json" \
|
|
110
114
|
--data "{\"u\":\"$RUSER\",\"c\":\"$(hostname -s 2>/dev/null || echo unknown)\"}" \
|
|
@@ -25,7 +25,10 @@ FILTER=""
|
|
|
25
25
|
while [ $# -gt 0 ]; do
|
|
26
26
|
case "$1" in
|
|
27
27
|
--pretty) PRETTY=1; shift ;;
|
|
28
|
-
|
|
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="$
|
|
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="$
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
110
|
-
#
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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" ] && [ -
|
|
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
|
-
|
|
410
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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" "" "
|
|
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 =
|
|
81
|
-
if (
|
|
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
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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,
|
|
@@ -394,13 +452,38 @@ function appendErrorLedger(event) {
|
|
|
394
452
|
}
|
|
395
453
|
}
|
|
396
454
|
|
|
455
|
+
// Telemetry identity is the GitHub ACCOUNT NAME, never the git identity.name
|
|
456
|
+
// (which can carry a full corporate title / brand text). Order: the username
|
|
457
|
+
// stored on the run's identity (the gh login, from prefs identities), then the
|
|
458
|
+
// active gh account's login resolved live, then null. The corporate `name` is
|
|
459
|
+
// deliberately not a fallback.
|
|
460
|
+
function resolveGithubLogin() {
|
|
461
|
+
try {
|
|
462
|
+
const out = execFileSync("gh", ["api", "user", "--jq", ".login"], {
|
|
463
|
+
encoding: "utf-8",
|
|
464
|
+
timeout: 3000,
|
|
465
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
466
|
+
}).trim();
|
|
467
|
+
if (out) return out;
|
|
468
|
+
} catch {
|
|
469
|
+
/* gh absent or not authenticated - stay silent */
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function resolveUser(state) {
|
|
475
|
+
const fromIdentity = state.identity?.username;
|
|
476
|
+
if (typeof fromIdentity === "string" && fromIdentity.trim()) return fromIdentity.trim();
|
|
477
|
+
return resolveGithubLogin();
|
|
478
|
+
}
|
|
479
|
+
|
|
397
480
|
function buildEvent(state) {
|
|
398
481
|
const spend = trackerSummary(state);
|
|
399
482
|
const ctx = deriveContext(state);
|
|
400
483
|
return {
|
|
401
484
|
id: runId(state),
|
|
402
485
|
t: eventTimestamp(state, spend),
|
|
403
|
-
u: state
|
|
486
|
+
u: resolveUser(state),
|
|
404
487
|
c: commandOf(state),
|
|
405
488
|
m: state.mode || null,
|
|
406
489
|
ap: Boolean(state.autopilot),
|
|
@@ -453,14 +536,39 @@ async function post(endpoint, token, event) {
|
|
|
453
536
|
}
|
|
454
537
|
}
|
|
455
538
|
|
|
539
|
+
// The write-only ingest token rides in a header; sending it over http would
|
|
540
|
+
// leak it in cleartext, contradicting the setup contract ("transmitted only
|
|
541
|
+
// over TLS"). Accept https anywhere, and http ONLY for loopback (local dev).
|
|
542
|
+
function endpointAllowed(endpoint) {
|
|
543
|
+
let u;
|
|
544
|
+
try {
|
|
545
|
+
u = new URL(endpoint);
|
|
546
|
+
} catch {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
if (u.protocol === "https:") return true;
|
|
550
|
+
if (u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost")) {
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
|
|
456
556
|
async function main() {
|
|
457
557
|
const args = parseArgs(process.argv.slice(2));
|
|
458
558
|
if (process.env.MULTI_AGENT_SMOKE && !args.dryRun) return;
|
|
459
559
|
const prefs = resolvePrefs();
|
|
460
|
-
const token = resolveToken();
|
|
461
560
|
const endpoint = prefs.endpoint || ENDPOINT_DEFAULT;
|
|
462
561
|
|
|
463
|
-
|
|
562
|
+
// Gate BEFORE touching the keychain: optOut is a hard block, and reading the
|
|
563
|
+
// secret for a disabled feature is both wasteful and can raise a macOS
|
|
564
|
+
// Keychain prompt. A real send also requires a TLS (or loopback) endpoint.
|
|
565
|
+
if (!args.dryRun) {
|
|
566
|
+
if (prefs.optOut === true) return;
|
|
567
|
+
if (prefs.enabled !== true) return;
|
|
568
|
+
if (!endpointAllowed(endpoint)) return;
|
|
569
|
+
}
|
|
570
|
+
const token = args.dryRun ? "" : resolveToken();
|
|
571
|
+
if (!args.dryRun && !token) return;
|
|
464
572
|
|
|
465
573
|
const statePath = resolveStatePath(args);
|
|
466
574
|
let state = statePath ? readJson(statePath) : null;
|
|
@@ -487,4 +595,11 @@ async function main() {
|
|
|
487
595
|
await post(endpoint, token, event);
|
|
488
596
|
}
|
|
489
597
|
|
|
490
|
-
|
|
598
|
+
// Windows-safe entry-point check: compare file URLs, never string-split a path.
|
|
599
|
+
const invokedDirectly =
|
|
600
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
601
|
+
if (invokedDirectly) {
|
|
602
|
+
main().catch(() => process.exit(0));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export { endpointAllowed, integrationTags, enabledPlugins, readEnabledPluginNames, trackerSummary, buildEvent, resolveUser };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: humanizer
|
|
3
|
-
version: 1.
|
|
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.
|
|
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,
|
|
51
|
-
|
|
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
|
|
|
@@ -1847,16 +1847,88 @@ rules:
|
|
|
1847
1847
|
view callback bypasses that replay and is the finding, wherever it compiles.
|
|
1848
1848
|
|
|
1849
1849
|
- id: UNIT-03
|
|
1850
|
-
title:
|
|
1850
|
+
title: A view model's dependencies are injected properties; init takes only input and output
|
|
1851
1851
|
severity: important
|
|
1852
1852
|
enforcement: lint
|
|
1853
|
-
mechanism: 'custom regex:
|
|
1853
|
+
mechanism: 'custom regex: a unit view model init with a parameter beyond input:/output:, or a dependency resolved inline at a call site'
|
|
1854
1854
|
rationale: flexibility
|
|
1855
1855
|
check: >
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1856
|
+
A unit view model is constructible from its Input and its output sink ALONE - init(input:output:),
|
|
1857
|
+
collapsing to init(output:) when Input is Void. Every OTHER dependency is an @ObservationIgnored
|
|
1858
|
+
property resolved by injection (a keyed inject property wrapper, or the project's equivalent), never an
|
|
1859
|
+
init parameter and never resolved inline at the use site. Tests substitute a double through a
|
|
1860
|
+
task-local overlay or a preview registration - there is no init parameter to thread it through - and
|
|
1861
|
+
observation never tracks a service handle. The finding is an init carrying a dependency parameter
|
|
1862
|
+
(defaulted or not), or a dependency resolved at its call site. Scenes, coordinators and outside-contract
|
|
1863
|
+
answerers keep their resolving default arms; VIEW MODELS do not.
|
|
1864
|
+
|
|
1865
|
+
- id: UNIT-04
|
|
1866
|
+
title: Two typed send doors, never one untyped funnel
|
|
1867
|
+
severity: important
|
|
1868
|
+
enforcement: scan
|
|
1869
|
+
mechanism: 'custom regex: a public/internal send or handle on a view model taking a single action type that both the view and internal callers reach'
|
|
1870
|
+
rationale: flexibility
|
|
1871
|
+
check: >
|
|
1872
|
+
What the VIEW may send (the view-action type) and what the unit sends ITSELF (its own action type)
|
|
1873
|
+
are separate types, carried under one action envelope (a two-case `.view` / `.viewModel` enum) that the
|
|
1874
|
+
reducer switches on. A view physically cannot construct or send an internal action. The finding is one
|
|
1875
|
+
untyped funnel - a single action enum, or a `send`/`handle` entry point, that both the view and the
|
|
1876
|
+
machine's own effects/children reach - which lets a view invoke internal machinery. The outbound
|
|
1877
|
+
output stays a third, separate per-screen vocabulary.
|
|
1878
|
+
|
|
1879
|
+
- id: UNIT-05
|
|
1880
|
+
title: An effect returns the unit's own action and never touches state; loading is derived
|
|
1881
|
+
severity: important
|
|
1882
|
+
enforcement: scan
|
|
1883
|
+
mechanism: 'custom regex: a state write or a loading Bool/counter set inside an effect/task body in a view model'
|
|
1884
|
+
rationale: readability
|
|
1885
|
+
check: >
|
|
1886
|
+
Async work is registered by id and its closure RETURNS the unit's own action, which re-enters through
|
|
1887
|
+
the reducer - the closure cannot read or write state. Loading is DERIVED from the running-effects
|
|
1888
|
+
registry (the set of in-flight ids), never a hand-set Bool or a hand-balanced count that can drift from
|
|
1889
|
+
the truth; dismissing loading cancels the work rather than orphaning it, and a same-id re-run supersedes
|
|
1890
|
+
the one in flight. The finding is an effect body that assigns state, or a `isLoading`/loading counter set
|
|
1891
|
+
by hand instead of derived.
|
|
1892
|
+
|
|
1893
|
+
- id: UNIT-06
|
|
1894
|
+
title: A view holds the erased face, not the concrete unit
|
|
1895
|
+
severity: important
|
|
1896
|
+
enforcement: scan
|
|
1897
|
+
mechanism: 'custom regex: a SwiftUI view storing a concrete view model type instead of the erased face, or calling emit/run/an internal action from view code'
|
|
1898
|
+
rationale: flexibility
|
|
1899
|
+
check: >
|
|
1900
|
+
A view stores the ERASED face - a surface exposing only the projected view state, the view-action
|
|
1901
|
+
door, and bindings - not the concrete unit type. The concrete type's emit, effect-run, and own action
|
|
1902
|
+
are not spellable from view code; the fence is the TYPE, not a naming convention. The finding is a view
|
|
1903
|
+
whose stored model is the concrete unit (so it can reach `emit`/`run`/internal actions), or view code
|
|
1904
|
+
that calls one of those.
|
|
1905
|
+
|
|
1906
|
+
- id: UNIT-07
|
|
1907
|
+
title: Chrome on the unit path is one value-typed notice, not per-screen slots
|
|
1908
|
+
severity: important
|
|
1909
|
+
enforcement: scan
|
|
1910
|
+
mechanism: 'custom regex: a unit view model declaring its own alert/toast/modal slot properties instead of raising the shared value-typed notice'
|
|
1911
|
+
rationale: readability
|
|
1912
|
+
check: >
|
|
1913
|
+
On the unit system a failure or a prompt is one VALUE-typed notice (equatable, codable, closure-free -
|
|
1914
|
+
responses travel back as values through a respond entry point, never a callback stored beside the
|
|
1915
|
+
chrome) that the base escalates up the host chain to whoever renders it. A domain screen does not raise
|
|
1916
|
+
chrome by hand or hold its own alert/toast/modal slot trio (that is the pre-unit shape). The finding is a
|
|
1917
|
+
unit view model carrying per-screen chrome slots or passing a closure through the notice.
|
|
1918
|
+
|
|
1919
|
+
- id: UNIT-08
|
|
1920
|
+
title: View-facing state is projected from machine state, not the same type
|
|
1921
|
+
severity: important
|
|
1922
|
+
enforcement: lint
|
|
1923
|
+
mechanism: 'custom regex: a view reading the durable machine-state type directly instead of the projected view-state'
|
|
1924
|
+
rationale: readability
|
|
1925
|
+
check: >
|
|
1926
|
+
The durable machine state (every field the reducer needs, including bookkeeping the screen never draws)
|
|
1927
|
+
and the view-facing state the view renders are distinct: the view state is a PROJECTION produced by the
|
|
1928
|
+
view model (a typealias to a single machine-state field when that is all the view needs, an earned struct
|
|
1929
|
+
at two). A view reads the view state only, so adding an internal field to the machine state never changes
|
|
1930
|
+
what the view can see or spell. The finding is a view bound directly to the durable machine-state type,
|
|
1931
|
+
or a machine-state field with no reason to exist beyond what the view already renders.
|
|
1860
1932
|
|
|
1861
1933
|
- id: SAFE-03
|
|
1862
1934
|
title: A field reset that must erase the value writes the value before clear()
|