🔖 Version 1.0.1 #4
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- L'attente qui précède un message d'une autre session (`SendMessage`), ou
|
||||
un avis d'inactivité d'une autre session, n'est plus comptée quand elle
|
||||
dure moins de `--pause` : toute entrée `user` arrivée après la fin d'un
|
||||
tour de l'agent ouvre une nouvelle plage, même écrite par Claude Code
|
||||
(`isMeta`). Une entrée `isMeta` écrite pendant un tour (rappel système,
|
||||
sortie de hook) ne coupe toujours pas la plage (#2).
|
||||
- Fichier `VERSION` : la version de l'outil, la même que sa constante
|
||||
`VERSION` (vérifié par les tests).
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Première version de `outils/temps-session.py`.
|
||||
|
||||
@@ -23,8 +23,9 @@ Les évolutions de chaque version sont dans `CHANGELOG.md`.
|
||||
Calcule le temps actif d'une session Claude Code sur un ticket, prêt à saisir
|
||||
sur Gitea. Il lit le transcript de la session (`~/.claude/projects/*/<session>.jsonl`
|
||||
et ses sous-agents) et ne compte que le temps où l'agent travaille : l'attente
|
||||
d'une réponse de l'humain n'est jamais comptée. Il retire ce qui est déjà
|
||||
saisi sur le ticket : le relancer ne double rien.
|
||||
d'une réponse de l'humain, ou d'un message d'une autre session, n'est jamais
|
||||
comptée. Il retire ce qui est déjà saisi sur le ticket : le relancer ne
|
||||
double rien.
|
||||
|
||||
Python 3.11 ou plus, sans dépendance. Il ne lit que le transcript et l'API
|
||||
Gitea (GET). **Il n'écrit ni n'envoie rien** : il affiche une ligne JSON par
|
||||
|
||||
+30
-7
@@ -15,8 +15,10 @@ Lit le transcript de la session (~/.claude/projects/*/<session>.jsonl et ses
|
||||
sous-agents) et ne compte que le temps ou l'agent travaille : du message de
|
||||
l'humain (ou de sa reponse a une question) jusqu'au dernier evenement avant
|
||||
qu'il reprenne la main. L'attente de l'humain n'est jamais comptee, qu'elle
|
||||
dure une minute ou trois jours. Au sein d'un tour, un trou de plus de --pause
|
||||
secondes (autorisation restee sans reponse) n'est pas compte non plus.
|
||||
dure une minute ou trois jours ; celle d'un message d'une autre session non
|
||||
plus (entree user, meme ecrite par Claude Code, arrivee apres la fin du
|
||||
tour). Au sein d'un tour, un trou de plus de --pause secondes (autorisation
|
||||
restee sans reponse) n'est pas compte non plus.
|
||||
|
||||
Une ligne par seance de travail (tours separes de moins de --seance secondes),
|
||||
pour ne pas couvrir le ticket de saisies : sa duree est le temps actif cumule,
|
||||
@@ -41,7 +43,7 @@ Ne lit que le transcript et l'API Gitea (GET). N'ecrit rien, n'envoie rien.
|
||||
"""
|
||||
import argparse, datetime, glob, json, os, sys, tomllib, urllib.request
|
||||
|
||||
VERSION = "1.0.0"
|
||||
VERSION = "1.0.1"
|
||||
MIN = 60
|
||||
|
||||
|
||||
@@ -89,13 +91,34 @@ def evenements(session):
|
||||
questions.add(b.get("id"))
|
||||
ev.append((iso(d["timestamp"]), f in principal, d))
|
||||
ev.sort(key=lambda e: e[0])
|
||||
return [(t, p and humain(d, questions)) for t, p, d in ev]
|
||||
out, rendu = [], False
|
||||
for t, p, d in ev:
|
||||
fil = p and not d.get("isSidechain")
|
||||
ouvre = p and (humain(d, questions) or (rendu and fil and d["type"] == "user"))
|
||||
out.append((t, ouvre))
|
||||
if fil:
|
||||
rendu = rendu_la_main(d)
|
||||
return out
|
||||
|
||||
|
||||
def rendu_la_main(d):
|
||||
"""Vrai si, apres l'entree d du transcript principal, l'agent attend : sa
|
||||
reponse a fini son tour (stop_reason autre que tool_use). Toute entree
|
||||
user qui suit relance alors le travail, meme ecrite par Claude Code
|
||||
(message ou avis d'une autre session) : l'attente qui la precede n'est
|
||||
pas comptee. Une entree user ecrite pendant un tour (rappel, sortie de
|
||||
hook) ne change rien."""
|
||||
if d["type"] == "assistant":
|
||||
fin = (d.get("message") or {}).get("stop_reason")
|
||||
return fin is not None and fin != "tool_use"
|
||||
return False
|
||||
|
||||
|
||||
def plages(ev, pause):
|
||||
"""Une plage s'arrete quand l'agent attend l'humain, quelle que soit la
|
||||
duree de l'attente ; au sein d'un tour, un trou de plus de --pause (une
|
||||
autorisation restee sans reponse) l'arrete aussi."""
|
||||
"""Une plage s'arrete quand l'agent attend (l'humain ou une autre
|
||||
session : voir rendu_la_main), quelle que soit la duree de l'attente ; au
|
||||
sein d'un tour, un trou de plus de --pause (une autorisation restee sans
|
||||
reponse) l'arrete aussi."""
|
||||
out = []
|
||||
for t, h in ev:
|
||||
if out and not h and (t - out[-1][1]).total_seconds() <= pause:
|
||||
|
||||
@@ -31,9 +31,22 @@ def humain(t, texte):
|
||||
return {"type": "user", "timestamp": t, "message": {"role": "user", "content": texte}}
|
||||
|
||||
|
||||
def agent(t):
|
||||
def agent(t, fin=None):
|
||||
"""Réponse de l'agent ; fin = son stop_reason (end_turn : il rend la main)."""
|
||||
return {"type": "assistant", "timestamp": t, "message": {"role": "assistant",
|
||||
"content": [{"type": "text", "text": "…"}]}}
|
||||
"content": [{"type": "text", "text": "…"}], "stop_reason": fin}}
|
||||
|
||||
|
||||
def meta(t, texte):
|
||||
"""Entrée user écrite par Claude Code, pas par l'humain : message d'une
|
||||
autre session, avis, rappel système, sortie de hook."""
|
||||
return {"type": "user", "isMeta": True, "timestamp": t,
|
||||
"message": {"role": "user", "content": texte}}
|
||||
|
||||
|
||||
def resultat(t):
|
||||
return {"type": "user", "timestamp": t, "message": {"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "x", "content": "…"}]}}
|
||||
|
||||
|
||||
# Deux tours : A de 10:00 à 10:05 (300 s), B de 10:08 à 10:10 (120 s).
|
||||
@@ -46,7 +59,7 @@ TRANSCRIPT = [humain("2026-10-21T10:00:00Z", "A"), agent("2026-10-21T10:02:00Z")
|
||||
APPELS = []
|
||||
|
||||
|
||||
def lancer(ts, saisies, *options, jeton=True, refus=False):
|
||||
def lancer(ts, saisies, *options, jeton=True, refus=False, session=SESSION):
|
||||
"""Lance main() avec un Gitea qui renvoie `saisies` ; rend les lignes JSON.
|
||||
jeton=False n'ajoute pas --jeton ; refus=True attend que l'outil refuse,
|
||||
sans avoir appelé Gitea."""
|
||||
@@ -58,7 +71,7 @@ def lancer(ts, saisies, *options, jeton=True, refus=False):
|
||||
ts.gitea = gitea
|
||||
APPELS.clear()
|
||||
sys.argv = ["temps-session.py", "--depot", "organisation/depot", "--ticket", "1",
|
||||
"--session", SESSION, *(["--jeton", str(JETON)] if jeton else []), *options]
|
||||
"--session", session, *(["--jeton", str(JETON)] if jeton else []), *options]
|
||||
sortie = io.StringIO()
|
||||
erreur = io.StringIO()
|
||||
try:
|
||||
@@ -194,6 +207,49 @@ jeton = "autre.jeton"
|
||||
verifier(sortie.getvalue().strip() == "temps-session.py " + ts.VERSION,
|
||||
"--version affiche %r" % sortie.getvalue())
|
||||
|
||||
# 13. Le fichier VERSION et la constante de l'outil (qui se télécharge
|
||||
# seul) disent la même version.
|
||||
version = (DEPOT / "VERSION").read_text().strip() if (DEPOT / "VERSION").exists() else None
|
||||
verifier(version == ts.VERSION, "VERSION dit %r, l'outil %r" % (version, ts.VERSION))
|
||||
|
||||
# 14 à 17 : entrées user écrites par Claude Code (isMeta). Chaque cas a
|
||||
# sa session ; le tour A va de 10:00 à 10:05 (300 s).
|
||||
def cas(numero, entrees):
|
||||
session = "00000000-0000-0000-0000-0000000000%02d" % numero
|
||||
(projet / (session + ".jsonl")).write_text("".join(json.dumps(d) + "\n" for d in entrees))
|
||||
return [l["time"] for l in lancer(ts, [], "--config", str(config), session=session)]
|
||||
|
||||
tour_a = [humain("2026-10-21T10:00:00Z", "A"), agent("2026-10-21T10:05:00Z", "end_turn")]
|
||||
reprise = [agent("2026-10-21T10:26:00Z", "tool_use"), resultat("2026-10-21T10:26:30Z"),
|
||||
agent("2026-10-21T10:27:00Z", "end_turn")]
|
||||
|
||||
# 14. L'agent a rendu la main ; une autre session lui écrit 20 min plus
|
||||
# tard (sous --pause) et il reprend : l'attente n'est pas comptée,
|
||||
# restent A et la reprise (10:25 à 10:27), 420 s.
|
||||
temps = cas(14, tour_a + [meta("2026-10-21T10:25:00Z", "Another Claude session sent a message: "
|
||||
'<cross-session-message from="autre">…</cross-session-message>')] + reprise)
|
||||
verifier(temps == [420], "message d'une autre session après l'attente : %r au lieu de 420 s" % temps)
|
||||
|
||||
# 15. Même chose pour un avis d'inactivité d'une autre session.
|
||||
temps = cas(15, tour_a + [meta("2026-10-21T10:25:00Z", '[Cross-session idle notice] "autre" …')] + reprise)
|
||||
verifier(temps == [420], "avis d'une autre session après l'attente : %r au lieu de 420 s" % temps)
|
||||
|
||||
# 16. Une entrée isMeta écrite pendant un tour (rappel système, sortie de
|
||||
# hook) ne coupe pas la plage : 300 s, pas 240.
|
||||
temps = cas(16, [humain("2026-10-21T10:00:00Z", "A"), agent("2026-10-21T10:02:00Z", "tool_use"),
|
||||
meta("2026-10-21T10:03:00Z", "<system-reminder>…</system-reminder>"),
|
||||
resultat("2026-10-21T10:04:00Z"), agent("2026-10-21T10:05:00Z", "end_turn")])
|
||||
verifier(temps == [300], "entrée isMeta pendant un tour : %r au lieu de 300 s" % temps)
|
||||
|
||||
# 17. Un sous-agent qui finit son tour (isSidechain) ne rend pas la main
|
||||
# de l'agent principal : la plage n'est pas coupée non plus.
|
||||
fin_sous_agent = agent("2026-10-21T10:02:30Z", "end_turn")
|
||||
fin_sous_agent["isSidechain"] = True
|
||||
temps = cas(17, [humain("2026-10-21T10:00:00Z", "A"), agent("2026-10-21T10:02:00Z", "tool_use"),
|
||||
fin_sous_agent, meta("2026-10-21T10:03:00Z", "<system-reminder>…</system-reminder>"),
|
||||
resultat("2026-10-21T10:04:00Z"), agent("2026-10-21T10:05:00Z", "end_turn")])
|
||||
verifier(temps == [300], "fin de tour d'un sous-agent : %r au lieu de 300 s" % temps)
|
||||
|
||||
if echecs:
|
||||
print("%d échec(s) :" % len(echecs))
|
||||
for e in echecs:
|
||||
|
||||
Reference in New Issue
Block a user