#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE' >&2
Usage: progress-snapshot.sh --plans <path> --project <name> [--state-file <path>] [--writing-lint-proposals <path>] [--deferred-ops <path>]
Required:
--plans <path> Plans.md ファイルパス
--project <name> project 名 (basename of repo)
Optional:
--state-file <path> session state JSON
期待 fields: elapsed_minutes / estimated_total_minutes /
cost_so_far_usd / cost_estimate_usd
なければ全て 0 で fallback
--writing-lint-proposals <path>
writing-rule-list.sh --proposals に forward する path。
未指定なら writing-rule-list.sh の default を使う。
--deferred-ops <path>
deferred-ops.jsonl の path (Phase 140.2)。未指定なら
Plans.md と同じディレクトリの .claude/state/deferred-ops.jsonl。
Exit: 0=success / 1=runtime error / 2=usage error
USAGE
exit 2
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLANS_PATH=""
PROJECT=""
STATE_FILE=""
WRITING_LINT_PROPOSALS_PATH=""
DEFERRED_OPS_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--plans) PLANS_PATH="${2:-}"; shift 2 ;;
--project) PROJECT="${2:-}"; shift 2 ;;
--state-file) STATE_FILE="${2:-}"; shift 2 ;;
--writing-lint-proposals) WRITING_LINT_PROPOSALS_PATH="${2:-}"; shift 2 ;;
--deferred-ops) DEFERRED_OPS_PATH="${2:-}"; shift 2 ;;
-h|--help) usage ;;
*) echo "ERROR: unknown argument: $1" >&2; usage ;;
esac
done
if [[ -z "$PLANS_PATH" || -z "$PROJECT" ]]; then
echo "ERROR: --plans and --project are required" >&2
usage
fi
if [[ ! -f "$PLANS_PATH" ]]; then
echo "ERROR: Plans.md not found: $PLANS_PATH" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: python3 is required" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required" >&2
exit 1
fi
ELAPSED_MIN=0
ESTIMATE_MIN=0
COST_SO_FAR=0
COST_ESTIMATE=0
if [[ -n "$STATE_FILE" && -f "$STATE_FILE" ]]; then
ELAPSED_MIN="$(jq -r '.elapsed_minutes // 0' "$STATE_FILE" 2>/dev/null || echo 0)"
ESTIMATE_MIN="$(jq -r '.estimated_total_minutes // 0' "$STATE_FILE" 2>/dev/null || echo 0)"
COST_SO_FAR="$(jq -r '.cost_so_far_usd // 0' "$STATE_FILE" 2>/dev/null || echo 0)"
COST_ESTIMATE="$(jq -r '.cost_estimate_usd // 0' "$STATE_FILE" 2>/dev/null || echo 0)"
fi
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
WRITING_RULE_LIST="$SCRIPT_DIR/writing-rule-list.sh"
WRITING_LINT_PENDING_JSON="[]"
if [[ -x "$WRITING_RULE_LIST" ]]; then
if [[ -n "$WRITING_LINT_PROPOSALS_PATH" ]]; then
WRITING_LINT_PENDING_JSON="$(bash "$WRITING_RULE_LIST" --status pending --json --proposals "$WRITING_LINT_PROPOSALS_PATH" 2>/dev/null || echo "[]")"
else
WRITING_LINT_PENDING_JSON="$(bash "$WRITING_RULE_LIST" --status pending --json 2>/dev/null || echo "[]")"
fi
fi
export PLANS_PATH_PY="$PLANS_PATH"
export PROJECT_PY="$PROJECT"
export TIMESTAMP_PY="$TIMESTAMP"
export ELAPSED_MIN_PY="$ELAPSED_MIN"
export ESTIMATE_MIN_PY="$ESTIMATE_MIN"
export COST_SO_FAR_PY="$COST_SO_FAR"
export COST_ESTIMATE_PY="$COST_ESTIMATE"
export WRITING_LINT_PENDING_JSON_PY="$WRITING_LINT_PENDING_JSON"
if [[ -z "$DEFERRED_OPS_PATH" ]]; then
DEFERRED_OPS_PATH="$(dirname "$PLANS_PATH")/.claude/state/deferred-ops.jsonl"
fi
export DEFERRED_OPS_PATH_PY="$DEFERRED_OPS_PATH"
exec python3 - <<'PYEOF'
import os
import re
import json
import sys
PLANS_PATH = os.environ["PLANS_PATH_PY"]
PROJECT = os.environ["PROJECT_PY"]
TIMESTAMP = os.environ["TIMESTAMP_PY"]
ELAPSED_MIN = int(os.environ["ELAPSED_MIN_PY"] or 0)
ESTIMATE_MIN = int(os.environ["ESTIMATE_MIN_PY"] or 0)
def to_float(s):
try:
return float(s)
except (TypeError, ValueError):
return 0.0
COST_SO_FAR = to_float(os.environ["COST_SO_FAR_PY"])
COST_ESTIMATE = to_float(os.environ["COST_ESTIMATE_PY"])
try:
_pending_raw = json.loads(os.environ.get("WRITING_LINT_PENDING_JSON_PY", "[]"))
except (TypeError, ValueError):
_pending_raw = []
if not isinstance(_pending_raw, list):
_pending_raw = []
_pending_count = len(_pending_raw)
writing_lint_pending = [
{
"id": rec.get("id", ""),
"pattern": rec.get("pattern", ""),
"approve_command": "scripts/writing-rule-approve.sh --id {}".format(rec.get("id", "")),
"pending_count": _pending_count,
}
for rec in _pending_raw
if isinstance(rec, dict)
]
deferred_ops_pending = []
_deferred_path = os.environ.get("DEFERRED_OPS_PATH_PY", "")
_deferred_pending_raw = []
if _deferred_path and os.path.isfile(_deferred_path):
try:
with open(_deferred_path, encoding="utf-8") as fh:
for _line in fh:
_line = _line.strip()
if not _line:
continue
try:
_rec = json.loads(_line)
except ValueError:
continue
if isinstance(_rec, dict) and _rec.get("status") == "pending":
_deferred_pending_raw.append(_rec)
except OSError:
_deferred_pending_raw = []
_deferred_count = len(_deferred_pending_raw)
deferred_ops_pending = [
{
"id": rec.get("id", ""),
"command": rec.get("command", ""),
"approve_command": "bin/harness deferred approve {}".format(rec.get("id", "")),
"pending_count": _deferred_count,
}
for rec in _deferred_pending_raw
]
ROW_RE = re.compile(
r"^\|\s*(\d+(?:\.\d+)*)\s*\|\s*(.+?)\s*\|\s*.+?\s*\|\s*.+?\s*\|\s*(cc:\S+(?:\s*\[[a-f0-9]+\])?)\s*\|\s*$"
)
todo = []
wip = []
done = []
with open(PLANS_PATH, "r", encoding="utf-8") as f:
for line in f:
m = ROW_RE.match(line)
if not m:
continue
number, title, status = m.group(1), m.group(2), m.group(3)
short_title = title.split("。")[0]
if len(short_title) > 80:
short_title = short_title[:77] + "..."
if status in ("cc:todo", "cc:TODO"):
todo.append({"number": number, "title": short_title})
elif status in ("cc:wip", "cc:WIP"):
wip.append({"number": number, "title": short_title})
elif status.startswith("cc:done") or status.startswith("cc:完了"):
commit_match = re.search(r"\[([a-f0-9]+)\]", status)
commit = commit_match.group(1)[:7] if commit_match else ""
done.append({"number": number, "title": short_title, "commit": commit})
total = len(todo) + len(wip) + len(done)
if total == 0:
progress_pct = 0
else:
progress_pct = round(len(done) * 100 / total)
current_task = wip[0]["title"] if wip else ""
done_recent = done[-5:][::-1]
snapshot = {
"schema": "progress-snapshot.v1",
"project": PROJECT,
"current_task": current_task,
"progress_pct": progress_pct,
"todo_tasks": todo,
"wip_tasks": wip,
"done_tasks": done,
"elapsed_minutes": ELAPSED_MIN,
"estimated_total_minutes": ESTIMATE_MIN,
"cost_so_far_usd": COST_SO_FAR,
"cost_estimate_usd": COST_ESTIMATE,
"alerts": [],
"generated_at": TIMESTAMP,
"_done_recent_items": done_recent,
"_todo_count": len(todo),
"_wip_count": len(wip),
"_done_count": len(done),
"writing_lint_pending": writing_lint_pending,
"deferred_ops_pending": deferred_ops_pending,
}
print(json.dumps(snapshot, ensure_ascii=False, indent=2))
PYEOF