eco-helpers 3.2.14 → 3.2.16
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.
- checksums.yaml +4 -4
- data/.ai-assistance/conventions/code-working-tree-protocol.md +176 -0
- data/.ai-assistance/scripts/token-logger.js +220 -0
- data/.ai-assistance/scripts/token-report.ts +158 -0
- data/.ai-assistance/scripts/token-session-start.js +66 -0
- data/.ai-assistance/skills/ep-ai-manager/SKILL.md +417 -0
- data/.ai-assistance/skills/ruby-scripting/SKILL.md +215 -0
- data/.ai-assistance/standards-version.json +10 -0
- data/.ai-assistance/token-budget.json +39 -0
- data/.claude/settings.json +103 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +83 -0
- data/eco-helpers.gemspec +1 -1
- data/lib/eco/api/usecases/CLAUDE.md +78 -0
- data/lib/eco/api/usecases/default/pages.rb +30 -0
- data/lib/eco/api/usecases/graphql/CLAUDE.md +120 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/dirty_array.rb +22 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/field_patches.rb +241 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/force_compat.rb +73 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +234 -0
- data/lib/eco/api/usecases/graphql/compat.rb +6 -0
- data/lib/eco/api/usecases/graphql/helpers/CLAUDE.md +79 -0
- data/lib/eco/api/usecases/graphql/samples/CLAUDE.md +76 -0
- data/lib/eco/api/usecases/graphql/samples/pages/CLAUDE.md +59 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb +41 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb +8 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page/base.rb +148 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page/dsl.rb +38 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples/pages.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples.rb +1 -0
- data/lib/eco/api/usecases/graphql.rb +1 -0
- data/lib/eco/api/usecases/ooze_samples/ooze_base_case.rb +4 -0
- data/lib/eco/api/usecases/ooze_samples/register_update_case.rb +7 -1
- data/lib/eco/version.rb +1 -1
- metadata +31 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1bf8d3ba40b3f6731a9790d3068ecfd8396b13af79fd5a553aed7dafa5514236
|
|
4
|
+
data.tar.gz: 386c5c215aef1afb8e5ef4c8fb72aeddd6ec4c66bb89d569e9c579063128900f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9eaaaf9ecff40b6eb646adeceb8cc7115dacf998357206f9192ff4d6bf912067a90a915b93649ef49c99332c5655459fc599fec3a516f11c49951442bc836219
|
|
7
|
+
data.tar.gz: 0020a6441d4da68b5a3b80b82494197b2f4c21656dd5babbd143e8ac6302fcd09599eb68d68b9a32895dcd0bd76f6cf76ba96ae66f17706b30a9dbd5f1d61131
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Code Working Tree Protocol
|
|
2
|
+
|
|
3
|
+
When Claude Code needs to make changes to files **outside** `bridge/inbox/`, it must
|
|
4
|
+
follow this protocol. This prevents Code's changes from mixing with in-progress CoWork
|
|
5
|
+
edits and ensures a clean, traceable commit history.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## When this applies
|
|
10
|
+
|
|
11
|
+
Any time Code intends to modify files in the working tree that are not bridge task files
|
|
12
|
+
(i.e., not `.ai-assistance/bridge/inbox/` or `.ai-assistance/bridge/outbox/`).
|
|
13
|
+
|
|
14
|
+
This includes: editing source files, updating documentation, changing scripts,
|
|
15
|
+
modifying capabilities files, etc.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Protocol
|
|
20
|
+
|
|
21
|
+
### 0. Check for a lock
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cat .ai-assistance/bridge/LOCK 2>/dev/null || echo "NO_LOCK"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **No lock:** proceed to step 1
|
|
28
|
+
- **Lock exists, EXPIRES is in the future:** stop. Tell the user:
|
|
29
|
+
> "Working tree is locked by [AGENT] ([USER]) since [ACQUIRED], working on: [INTENT].
|
|
30
|
+
> Expires at [EXPIRES]. Please wait or check if the other session is still active."
|
|
31
|
+
- **Lock exists, EXPIRES is in the past:** stale lock — safe to overwrite, proceed to step 1
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
### 1. Acquire the lock
|
|
36
|
+
|
|
37
|
+
Write `.ai-assistance/bridge/LOCK` with full watermark:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
AGENT: code
|
|
41
|
+
USER: [git config user.name, lowercased]
|
|
42
|
+
ACQUIRED: [ISO 8601 now]
|
|
43
|
+
EXPIRES: [ISO 8601 now + 30 minutes]
|
|
44
|
+
INTENT: [one sentence — what you are about to change and why]
|
|
45
|
+
FILES: [comma-separated list of files you plan to modify]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
```
|
|
50
|
+
AGENT: code
|
|
51
|
+
USER: oscar
|
|
52
|
+
ACQUIRED: 2026-06-04T10:00:00Z
|
|
53
|
+
EXPIRES: 2026-06-04T10:30:00Z
|
|
54
|
+
INTENT: Update gitlab-mcp.md with new PAT scopes and rotation info
|
|
55
|
+
FILES: .ai-assistance/integrations/gitlab-mcp.md
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### 2. Check for unstaged changes that overlap with your planned files
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git status --short
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
If the working tree is clean, skip to step 3.
|
|
67
|
+
|
|
68
|
+
If there are unstaged/staged changes, compare them against the files listed in your LOCK:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git diff --name-only HEAD
|
|
72
|
+
git diff --cached --name-only
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **No overlap with your FILES:** proceed — the changes are unrelated and won't pollute history
|
|
76
|
+
- **Overlap with one or more of your FILES:** commit the unstaged changes first.
|
|
77
|
+
Derive the commit message by running `git diff HEAD` on the overlapping files and
|
|
78
|
+
writing a short imperative summary of what actually changed — do not use a generic
|
|
79
|
+
message. Format: `wip: <what changed, e.g. "rename .claude to .ai-assistance across scripts">`
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git add -A
|
|
83
|
+
git commit -m "wip: <derived from actual diff>"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This keeps Code's subsequent commit clean and ensures both sets of changes build
|
|
87
|
+
on the correct base. On a feature branch, `wip:` commits are fine — squash before MR.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### 3. Apply your changes
|
|
92
|
+
|
|
93
|
+
Make the intended file edits. Stay within the scope declared in INTENT and FILES
|
|
94
|
+
when you acquired the lock. If scope expands, update the LOCK file before proceeding.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
### 4. Commit your changes
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
git add -A
|
|
102
|
+
git commit -m "[descriptive message — what Code changed and why]"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Commit message should be specific enough that a teammate can understand the change
|
|
106
|
+
without reading the diff. Example:
|
|
107
|
+
```
|
|
108
|
+
docs: update gitlab-mcp.md scopes and rotation info for new PAT (April 2027 expiry)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Commit authorship — developer only by default:**
|
|
112
|
+
|
|
113
|
+
Commits are authored by the developer alone (git's `user.name` / `user.email` config).
|
|
114
|
+
Do NOT add `Co-Authored-By: Claude ...` to commit messages unless the developer
|
|
115
|
+
explicitly requests it.
|
|
116
|
+
|
|
117
|
+
Rationale: the commit history is the developer's professional record. Co-authorship is
|
|
118
|
+
opt-in, not opt-out. If the developer wants to attribute AI involvement, they can add
|
|
119
|
+
it themselves or ask Claude to include it for a specific commit.
|
|
120
|
+
|
|
121
|
+
Before adding any co-authorship attribution, ask:
|
|
122
|
+
> "Would you like to add AI co-authorship to this commit, or keep it as your commit alone?"
|
|
123
|
+
|
|
124
|
+
Default answer if not asked: **developer only**.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
### 5. Release the lock
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
rm .ai-assistance/bridge/LOCK
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Quick reference
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# 0. Check lock
|
|
140
|
+
cat .ai-assistance/bridge/LOCK 2>/dev/null || echo "NO_LOCK"
|
|
141
|
+
|
|
142
|
+
# 1. Acquire lock
|
|
143
|
+
cat > .ai-assistance/bridge/LOCK << EOF
|
|
144
|
+
AGENT: code
|
|
145
|
+
USER: oscar
|
|
146
|
+
ACQUIRED: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
147
|
+
EXPIRES: $(date -u -d "+30 minutes" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+30M +"%Y-%m-%dT%H:%M:%SZ")
|
|
148
|
+
INTENT: <what you are changing>
|
|
149
|
+
FILES: <files>
|
|
150
|
+
EOF
|
|
151
|
+
|
|
152
|
+
# 2. Check for overlapping unstaged changes
|
|
153
|
+
git diff --name-only HEAD && git diff --cached --name-only
|
|
154
|
+
# If any of those files overlap with your planned FILES → commit them first:
|
|
155
|
+
git add -A && git commit -m "wip: <description of CoWork's in-progress work>"
|
|
156
|
+
# If no overlap → skip, proceed directly
|
|
157
|
+
|
|
158
|
+
# 3. Apply changes
|
|
159
|
+
# ... make edits ...
|
|
160
|
+
|
|
161
|
+
# 4. Commit your changes
|
|
162
|
+
git add -A && git commit -m "<descriptive message>"
|
|
163
|
+
|
|
164
|
+
# 5. Release lock
|
|
165
|
+
rm .ai-assistance/bridge/LOCK
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Notes
|
|
171
|
+
|
|
172
|
+
- If Code crashes mid-protocol, the LOCK will expire naturally (30 min timeout)
|
|
173
|
+
- The `wip:` commit prefix signals to teammates that this was an auto-committed
|
|
174
|
+
in-progress state — safe to squash or amend later
|
|
175
|
+
- This protocol does not apply to bridge task processing (reading inbox, writing outbox)
|
|
176
|
+
— those are read/write of bridge files only and don't touch the working tree
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* token-logger.js
|
|
4
|
+
*
|
|
5
|
+
* Claude Code Stop hook — fires after every AI response turn.
|
|
6
|
+
* Reads the session transcript, extracts token usage, accumulates weekly totals,
|
|
7
|
+
* and warns when approaching the project's budget allocation.
|
|
8
|
+
*
|
|
9
|
+
* Wired in .claude/settings.json:
|
|
10
|
+
* "Stop": [{ "type": "command", "command": "node .ai-assistance/scripts/token-logger.js" }]
|
|
11
|
+
*
|
|
12
|
+
* Reads: stdin (Stop event JSON with session_id, transcript_path, cwd)
|
|
13
|
+
* .ai-assistance/token-budget.json
|
|
14
|
+
* .ai-assistance/local/kpi/session-<id>.json (running session state)
|
|
15
|
+
* .ai-assistance/local/kpi/weekly-<YYYY-WNN>.json (weekly totals)
|
|
16
|
+
*
|
|
17
|
+
* Writes: .ai-assistance/local/kpi/session-<id>.json (updated state)
|
|
18
|
+
* .ai-assistance/local/kpi/weekly-<YYYY-WNN>.json (updated totals)
|
|
19
|
+
* .ai-assistance/local/kpi/sessions-<YYYY-WNN>.jsonl (completed turns)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const path = require("path");
|
|
24
|
+
const os = require("os");
|
|
25
|
+
|
|
26
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function isoWeek(d) {
|
|
29
|
+
const jan4 = new Date(d.getFullYear(), 0, 4);
|
|
30
|
+
const startOfWeek = new Date(jan4);
|
|
31
|
+
startOfWeek.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7));
|
|
32
|
+
const weekNum = Math.ceil(((d - startOfWeek) / 86400000 + 1) / 7);
|
|
33
|
+
return `${d.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadJson(p, fallback) {
|
|
37
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); }
|
|
38
|
+
catch { return fallback; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saveJson(p, data) {
|
|
42
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
43
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function appendJsonl(p, obj) {
|
|
47
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
48
|
+
fs.appendFileSync(p, JSON.stringify(obj) + "\n", "utf8");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Extract token usage from transcript JSONL ──────────────────────────────
|
|
52
|
+
|
|
53
|
+
function extractUsageFromTranscript(transcriptPath) {
|
|
54
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return null;
|
|
55
|
+
|
|
56
|
+
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheCreateTokens = 0;
|
|
57
|
+
let toolCalls = 0, turns = 0, found = false;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const lines = fs.readFileSync(transcriptPath, "utf8").split("\n").filter(Boolean);
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
try {
|
|
63
|
+
const entry = JSON.parse(line);
|
|
64
|
+
// Extract usage from any entry that has it
|
|
65
|
+
const usage = entry.usage || entry.message?.usage;
|
|
66
|
+
if (usage) {
|
|
67
|
+
inputTokens += usage.input_tokens || 0;
|
|
68
|
+
outputTokens += usage.output_tokens || 0;
|
|
69
|
+
cacheReadTokens += usage.cache_read_input_tokens || 0;
|
|
70
|
+
cacheCreateTokens+= usage.cache_creation_input_tokens|| 0;
|
|
71
|
+
found = true;
|
|
72
|
+
}
|
|
73
|
+
// Count tool uses
|
|
74
|
+
if (entry.type === "tool_use" || entry.tool_name) toolCalls++;
|
|
75
|
+
// Count assistant turns
|
|
76
|
+
if (entry.role === "assistant" || entry.type === "assistant") turns++;
|
|
77
|
+
} catch { /* skip malformed lines */ }
|
|
78
|
+
}
|
|
79
|
+
} catch { return null; }
|
|
80
|
+
|
|
81
|
+
if (!found) return null;
|
|
82
|
+
return { inputTokens, outputTokens, cacheReadTokens, cacheCreateTokens, toolCalls, turns };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Estimate tokens when transcript doesn't have usage data ───────────────
|
|
86
|
+
|
|
87
|
+
function estimateFromTranscript(transcriptPath) {
|
|
88
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
89
|
+
return { inputTokens: 0, outputTokens: 0, toolCalls: 0, turns: 0, estimated: true };
|
|
90
|
+
}
|
|
91
|
+
let inputChars = 0, outputChars = 0, toolCalls = 0, turns = 0;
|
|
92
|
+
try {
|
|
93
|
+
const lines = fs.readFileSync(transcriptPath, "utf8").split("\n").filter(Boolean);
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
try {
|
|
96
|
+
const entry = JSON.parse(line);
|
|
97
|
+
const content = JSON.stringify(entry.content || entry.text || "");
|
|
98
|
+
if (entry.role === "user" || entry.type === "user") { inputChars += content.length; }
|
|
99
|
+
if (entry.role === "assistant" || entry.type === "assistant") { outputChars += content.length; turns++; }
|
|
100
|
+
if (entry.type === "tool_use" || entry.tool_name) { toolCalls++; inputChars += 500 * 4; }
|
|
101
|
+
} catch { /* skip */ }
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
return {
|
|
105
|
+
inputTokens: Math.round(inputChars / 4),
|
|
106
|
+
outputTokens: Math.round(outputChars / 4),
|
|
107
|
+
cacheReadTokens: 0, cacheCreateTokens: 0,
|
|
108
|
+
toolCalls, turns, estimated: true
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
async function main() {
|
|
115
|
+
let event = {};
|
|
116
|
+
try {
|
|
117
|
+
const raw = fs.readFileSync("/dev/stdin", "utf8");
|
|
118
|
+
event = JSON.parse(raw);
|
|
119
|
+
} catch { /* no stdin or parse error — use empty event */ }
|
|
120
|
+
|
|
121
|
+
const cwd = event.cwd || process.cwd();
|
|
122
|
+
const sessionId = event.session_id || `unknown-${Date.now()}`;
|
|
123
|
+
const transcriptPath = event.transcript_path;
|
|
124
|
+
|
|
125
|
+
const budgetFile = path.join(cwd, ".ai-assistance", "token-budget.json");
|
|
126
|
+
const kpiDir = path.join(cwd, ".ai-assistance", "local", "kpi");
|
|
127
|
+
const weekId = isoWeek(new Date());
|
|
128
|
+
const sessionFile = path.join(kpiDir, `session-${sessionId}.json`);
|
|
129
|
+
const weeklyFile = path.join(kpiDir, `weekly-${weekId}.json`);
|
|
130
|
+
const turnLogFile = path.join(kpiDir, `sessions-${weekId}.jsonl`);
|
|
131
|
+
|
|
132
|
+
const budget = loadJson(budgetFile, {});
|
|
133
|
+
const project = (budget.project?.name || path.basename(cwd));
|
|
134
|
+
const priority= (budget.project?.priority || "medium");
|
|
135
|
+
const targetPct = (budget.weekly_quota?.target_utilization_pct || 75) / 100;
|
|
136
|
+
const warnAt = (budget.session_logging?.warn_at_pct || 80) / 100;
|
|
137
|
+
|
|
138
|
+
// Extract usage from transcript
|
|
139
|
+
const transcriptUsage = extractUsageFromTranscript(transcriptPath)
|
|
140
|
+
|| estimateFromTranscript(transcriptPath);
|
|
141
|
+
|
|
142
|
+
// Load previous session state (accumulate across turns in a session)
|
|
143
|
+
const prevSession = loadJson(sessionFile, {
|
|
144
|
+
session_id: sessionId, project, priority,
|
|
145
|
+
started_at: new Date().toISOString(),
|
|
146
|
+
week_id: weekId,
|
|
147
|
+
input_tokens: 0, output_tokens: 0,
|
|
148
|
+
cache_read_tokens: 0, cache_create_tokens: 0,
|
|
149
|
+
tool_calls: 0, turns: 0, estimated: false,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Use transcript totals (they accumulate naturally) not deltas
|
|
153
|
+
const sessionNow = {
|
|
154
|
+
...prevSession,
|
|
155
|
+
input_tokens: transcriptUsage.inputTokens,
|
|
156
|
+
output_tokens: transcriptUsage.outputTokens,
|
|
157
|
+
cache_read_tokens: transcriptUsage.cacheReadTokens || 0,
|
|
158
|
+
cache_create_tokens:transcriptUsage.cacheCreateTokens || 0,
|
|
159
|
+
tool_calls: transcriptUsage.toolCalls,
|
|
160
|
+
turns: transcriptUsage.turns,
|
|
161
|
+
estimated: transcriptUsage.estimated || false,
|
|
162
|
+
last_updated_at: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
saveJson(sessionFile, sessionNow);
|
|
166
|
+
|
|
167
|
+
// Update weekly totals — replace session contribution (re-compute from sessions)
|
|
168
|
+
const weekly = loadJson(weeklyFile, { week_id: weekId, projects: {}, total_tokens: 0 });
|
|
169
|
+
const sessionTotal = sessionNow.input_tokens + sessionNow.output_tokens;
|
|
170
|
+
const prevContrib = (weekly.projects[sessionId]?.tokens || 0);
|
|
171
|
+
weekly.projects[sessionId] = {
|
|
172
|
+
project, priority, tokens: sessionTotal,
|
|
173
|
+
tool_calls: sessionNow.tool_calls, turns: sessionNow.turns,
|
|
174
|
+
updated_at: new Date().toISOString()
|
|
175
|
+
};
|
|
176
|
+
weekly.total_tokens = Object.values(weekly.projects).reduce((s, p) => s + p.tokens, 0);
|
|
177
|
+
saveJson(weeklyFile, weekly);
|
|
178
|
+
|
|
179
|
+
// Log the turn to the weekly JSONL (for cross-session analysis)
|
|
180
|
+
appendJsonl(turnLogFile, {
|
|
181
|
+
ts: new Date().toISOString(), session_id: sessionId, project, priority, week_id: weekId,
|
|
182
|
+
turn_tokens: sessionTotal - prevContrib,
|
|
183
|
+
session_total_tokens: sessionTotal,
|
|
184
|
+
tool_calls: sessionNow.tool_calls,
|
|
185
|
+
estimated: sessionNow.estimated,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── Budget warnings ────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
const totalTokens = budget.weekly_quota?.total_tokens;
|
|
191
|
+
if (totalTokens) {
|
|
192
|
+
const usedPct = weekly.total_tokens / totalTokens;
|
|
193
|
+
const targetTokens = totalTokens * targetPct;
|
|
194
|
+
|
|
195
|
+
// Priority-based soft allocation
|
|
196
|
+
const weights = budget.project_allocation?.priority_weights || { high: 50, medium: 30, low: 20 };
|
|
197
|
+
const myWeight = (weights[priority] || 30) / 100;
|
|
198
|
+
const myBudget = totalTokens * targetPct * myWeight;
|
|
199
|
+
const myUsed = Object.values(weekly.projects)
|
|
200
|
+
.filter(p => p.project === project)
|
|
201
|
+
.reduce((s, p) => s + p.tokens, 0);
|
|
202
|
+
const myPct = myBudget > 0 ? myUsed / myBudget : 0;
|
|
203
|
+
|
|
204
|
+
if (usedPct >= warnAt) {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`\n[token-budget] ⚠ Week ${weekId}: ${Math.round(usedPct * 100)}% of quota used` +
|
|
207
|
+
` (${weekly.total_tokens.toLocaleString()}/${totalTokens.toLocaleString()} tokens)` +
|
|
208
|
+
` — target was ${Math.round(targetPct * 100)}%\n`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (myPct >= warnAt) {
|
|
212
|
+
process.stderr.write(
|
|
213
|
+
`[token-budget] ⚠ Project "${project}" (${priority}): ${Math.round(myPct * 100)}% of allocation` +
|
|
214
|
+
` (${myUsed.toLocaleString()}/${Math.round(myBudget).toLocaleString()} tokens)\n`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
main().catch(() => { /* never crash the hook */ });
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* token-report.ts
|
|
3
|
+
*
|
|
4
|
+
* Cross-project token usage report. Reads weekly session logs from all aligned
|
|
5
|
+
* projects (via local/paths.md) and produces a management-ready summary.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* npx ts-node .ai-assistance/scripts/token-report.ts
|
|
9
|
+
* npx ts-node .ai-assistance/scripts/token-report.ts --week 2026-W24
|
|
10
|
+
* npx ts-node .ai-assistance/scripts/token-report.ts --format json
|
|
11
|
+
*
|
|
12
|
+
* Output: Markdown table (default) or JSON (--format json)
|
|
13
|
+
* The report is printed to stdout — redirect to a file or pipe to your reporting tool.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "fs";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
|
|
19
|
+
const SCRIPTS_DIR = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
|
|
20
|
+
const AI_DIR = path.join(SCRIPTS_DIR, "..");
|
|
21
|
+
const CWD = path.join(AI_DIR, "..", "..");
|
|
22
|
+
|
|
23
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function isoWeek(d: Date): string {
|
|
26
|
+
const jan4 = new Date(d.getFullYear(), 0, 4);
|
|
27
|
+
const s = new Date(jan4); s.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7));
|
|
28
|
+
return `${d.getFullYear()}-W${String(Math.ceil(((d.getTime() - s.getTime()) / 86400000 + 1) / 7)).padStart(2, "0")}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadJson<T>(p: string, fb: T): T {
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fb; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ProjectPath { alias: string; localPath: string; }
|
|
36
|
+
|
|
37
|
+
function loadProjectPaths(): ProjectPath[] {
|
|
38
|
+
const pathsFile = path.join(AI_DIR, "local", "paths.md");
|
|
39
|
+
if (!fs.existsSync(pathsFile)) return [{ alias: path.basename(CWD), localPath: CWD }];
|
|
40
|
+
const projects: ProjectPath[] = [];
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
for (const line of fs.readFileSync(pathsFile, "utf8").split("\n")) {
|
|
43
|
+
const m = line.match(/\|\s*`([^`]+)`\s*\|\s*`([^`]+)`/);
|
|
44
|
+
if (!m) continue;
|
|
45
|
+
const [, alias, rawPath] = m;
|
|
46
|
+
const localPath = rawPath.replace(/\//g, path.sep);
|
|
47
|
+
const real = fs.existsSync(localPath) ? fs.realpathSync(localPath) : localPath;
|
|
48
|
+
if (!seen.has(real) && fs.existsSync(localPath)) { projects.push({ alias, localPath }); seen.add(real); }
|
|
49
|
+
}
|
|
50
|
+
return projects.length ? projects : [{ alias: path.basename(CWD), localPath: CWD }];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface WeeklyData {
|
|
54
|
+
week_id: string;
|
|
55
|
+
total_tokens: number;
|
|
56
|
+
projects: Record<string, { project: string; priority: string; tokens: number; tool_calls: number; turns: number; }>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface SessionTurn {
|
|
60
|
+
ts: string; session_id: string; project: string; priority: string;
|
|
61
|
+
week_id: string; turn_tokens: number; session_total_tokens: number;
|
|
62
|
+
tool_calls: number; estimated: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const args = process.argv.slice(2);
|
|
68
|
+
const weekArg = args.includes("--week") ? args[args.indexOf("--week") + 1] : null;
|
|
69
|
+
const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "text";
|
|
70
|
+
const weekId = weekArg || isoWeek(new Date());
|
|
71
|
+
|
|
72
|
+
const projects = loadProjectPaths();
|
|
73
|
+
|
|
74
|
+
// Aggregate across all projects
|
|
75
|
+
const byProject: Record<string, {
|
|
76
|
+
tokens: number; tool_calls: number; turns: number;
|
|
77
|
+
priority: string; sessions: number; estimated: number;
|
|
78
|
+
}> = {};
|
|
79
|
+
|
|
80
|
+
let grandTotal = 0;
|
|
81
|
+
|
|
82
|
+
for (const { localPath } of projects) {
|
|
83
|
+
const kpiDir = path.join(localPath, ".ai-assistance", "local", "kpi");
|
|
84
|
+
const weeklyFile = path.join(kpiDir, `weekly-${weekId}.json`);
|
|
85
|
+
const budget = loadJson<any>(path.join(localPath, ".ai-assistance", "token-budget.json"), {});
|
|
86
|
+
const projectName= budget.project?.name || path.basename(localPath);
|
|
87
|
+
const priority = budget.project?.priority || "medium";
|
|
88
|
+
|
|
89
|
+
if (!fs.existsSync(weeklyFile)) continue;
|
|
90
|
+
|
|
91
|
+
const weekly = loadJson<WeeklyData>(weeklyFile, { week_id: weekId, total_tokens: 0, projects: {} });
|
|
92
|
+
|
|
93
|
+
// Sum sessions for this project
|
|
94
|
+
let tokens = 0, toolCalls = 0, turns = 0, sessions = 0, estimated = 0;
|
|
95
|
+
for (const s of Object.values(weekly.projects)) {
|
|
96
|
+
if (s.project === projectName) {
|
|
97
|
+
tokens += s.tokens;
|
|
98
|
+
toolCalls += s.tool_calls || 0;
|
|
99
|
+
turns += s.turns || 0;
|
|
100
|
+
sessions++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Count estimated sessions from JSONL
|
|
105
|
+
const jsonl = path.join(kpiDir, `sessions-${weekId}.jsonl`);
|
|
106
|
+
if (fs.existsSync(jsonl)) {
|
|
107
|
+
const lines = fs.readFileSync(jsonl, "utf8").split("\n").filter(Boolean);
|
|
108
|
+
estimated = lines.filter(l => { try { return JSON.parse(l).estimated; } catch { return false; } }).length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (tokens > 0) {
|
|
112
|
+
byProject[projectName] = { tokens, tool_calls: toolCalls, turns, priority, sessions, estimated };
|
|
113
|
+
grandTotal += tokens;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (format === "json") {
|
|
118
|
+
console.log(JSON.stringify({ week_id: weekId, grand_total_tokens: grandTotal, projects: byProject }, null, 2));
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Text report ────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const sortedProjects = Object.entries(byProject)
|
|
125
|
+
.sort(([, a], [, b]) => b.tokens - a.tokens);
|
|
126
|
+
|
|
127
|
+
console.log(`\n${"=".repeat(70)}`);
|
|
128
|
+
console.log(` Token Usage Report — ${weekId}`);
|
|
129
|
+
console.log(` Generated: ${new Date().toISOString().slice(0, 16)}`);
|
|
130
|
+
console.log(`${"=".repeat(70)}\n`);
|
|
131
|
+
console.log(` Grand total: ${grandTotal.toLocaleString()} tokens across ${sortedProjects.length} project(s)\n`);
|
|
132
|
+
|
|
133
|
+
console.log(` ${"Project".padEnd(30)} ${"Priority".padEnd(10)} ${"Tokens".padStart(10)} ${"Share".padStart(7)} ${"Tool calls".padStart(12)} ${"Turns".padStart(6)}`);
|
|
134
|
+
console.log(` ${"-".repeat(78)}`);
|
|
135
|
+
|
|
136
|
+
for (const [name, data] of sortedProjects) {
|
|
137
|
+
const share = grandTotal > 0 ? Math.round((data.tokens / grandTotal) * 100) : 0;
|
|
138
|
+
const est = data.estimated > 0 ? " ~" : " ";
|
|
139
|
+
console.log(` ${name.padEnd(30)} ${data.priority.padEnd(10)} ${est}${data.tokens.toLocaleString().padStart(8)} ${`${share}%`.padStart(7)} ${data.tool_calls.toString().padStart(12)} ${data.turns.toString().padStart(6)}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(`\n ~ = some sessions used token estimation (transcript had no usage data)`);
|
|
143
|
+
|
|
144
|
+
// Priority allocation analysis
|
|
145
|
+
const targetPct = 75;
|
|
146
|
+
const weights = { high: 50, medium: 30, low: 20 };
|
|
147
|
+
console.log(`\n Priority allocation vs actuals (target utilization: ${targetPct}%):`);
|
|
148
|
+
for (const priority of ["high", "medium", "low"]) {
|
|
149
|
+
const w = (weights as any)[priority];
|
|
150
|
+
const actual = Object.values(byProject)
|
|
151
|
+
.filter(p => p.priority === priority)
|
|
152
|
+
.reduce((s, p) => s + p.tokens, 0);
|
|
153
|
+
const actualPct = grandTotal > 0 ? Math.round((actual / grandTotal) * 100) : 0;
|
|
154
|
+
const projects = Object.entries(byProject).filter(([, p]) => p.priority === priority).map(([n]) => n).join(", ") || "(none)";
|
|
155
|
+
console.log(` ${priority.padEnd(8)} target ~${w}% actual ${`${actualPct}%`.padStart(4)} — ${projects}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log(`\n${"=".repeat(70)}\n`);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* token-session-start.js
|
|
4
|
+
*
|
|
5
|
+
* Claude Code SessionStart hook.
|
|
6
|
+
* Checks current week's token usage, warns if over allocation,
|
|
7
|
+
* and shows the budget status for this project.
|
|
8
|
+
*
|
|
9
|
+
* Wired in .claude/settings.json:
|
|
10
|
+
* "SessionStart": [{ "type": "command", "command": "node .ai-assistance/scripts/token-session-start.js" }]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
function isoWeek(d) {
|
|
17
|
+
const jan4 = new Date(d.getFullYear(), 0, 4);
|
|
18
|
+
const s = new Date(jan4); s.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7));
|
|
19
|
+
return `${d.getFullYear()}-W${String(Math.ceil(((d - s) / 86400000 + 1) / 7)).padStart(2, "0")}`;
|
|
20
|
+
}
|
|
21
|
+
function loadJson(p, fb) { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fb; } }
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
let event = {};
|
|
25
|
+
try { event = JSON.parse(fs.readFileSync("/dev/stdin", "utf8")); } catch {}
|
|
26
|
+
|
|
27
|
+
const cwd = event.cwd || process.cwd();
|
|
28
|
+
const weekId = isoWeek(new Date());
|
|
29
|
+
const budget = loadJson(path.join(cwd, ".ai-assistance", "token-budget.json"), {});
|
|
30
|
+
const weekly = loadJson(path.join(cwd, ".ai-assistance", "local", "kpi", `weekly-${weekId}.json`), { total_tokens: 0, projects: {} });
|
|
31
|
+
|
|
32
|
+
const project = budget.project?.name || path.basename(cwd);
|
|
33
|
+
const priority = budget.project?.priority || "medium";
|
|
34
|
+
const total = budget.weekly_quota?.total_tokens;
|
|
35
|
+
const targetPct= (budget.weekly_quota?.target_utilization_pct || 75);
|
|
36
|
+
|
|
37
|
+
// My project's tokens this week across all sessions
|
|
38
|
+
const myTokens = Object.values(weekly.projects)
|
|
39
|
+
.filter(p => p.project === project)
|
|
40
|
+
.reduce((s, p) => s + p.tokens, 0);
|
|
41
|
+
|
|
42
|
+
const lines = [`\n[token-budget] Week ${weekId} | Project: ${project} (${priority})`];
|
|
43
|
+
lines.push(` All projects this week: ${weekly.total_tokens.toLocaleString()} tokens`);
|
|
44
|
+
lines.push(` This project this week: ${myTokens.toLocaleString()} tokens`);
|
|
45
|
+
|
|
46
|
+
if (total) {
|
|
47
|
+
const usedPct = Math.round((weekly.total_tokens / total) * 100);
|
|
48
|
+
const weights = budget.project_allocation?.priority_weights || { high: 50, medium: 30, low: 20 };
|
|
49
|
+
const myAlloc = Math.round(total * (targetPct / 100) * ((weights[priority] || 30) / 100));
|
|
50
|
+
const myPct = myAlloc > 0 ? Math.round((myTokens / myAlloc) * 100) : 0;
|
|
51
|
+
lines.push(` Week quota: ${usedPct}% used (target ${targetPct}%) | This project: ${myPct}% of ${myAlloc.toLocaleString()} alloc`);
|
|
52
|
+
if (usedPct >= targetPct) lines.push(` !! Over weekly target — consider deferring low-priority work`);
|
|
53
|
+
if (myPct >= 90) lines.push(` !! This project near allocation limit`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
57
|
+
|
|
58
|
+
// Also run bridge-init if it exists
|
|
59
|
+
const bridgeInit = path.join(cwd, ".ai-assistance", "scripts", "bridge-init.sh");
|
|
60
|
+
if (fs.existsSync(bridgeInit)) {
|
|
61
|
+
const { execSync } = require("child_process");
|
|
62
|
+
try { execSync(`bash "${bridgeInit}"`, { stdio: "inherit" }); } catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main().catch(() => {});
|