From 553366e1dbabd184a6704b8fe32b74f720c19a9c Mon Sep 17 00:00:00 2001 From: dat Date: Sun, 31 May 2026 05:27:55 +0000 Subject: [PATCH 1/3] feat: [AI-000] capture and aggregate per-project usage stats Add x-project request-tag capture, thread project through saveUsageStats into usageHistory.project column, and aggregate stats.byProject (project| model|provider) so usage rolls up per project with per-model token/cost. Co-Authored-By: Claude Opus 4.8 --- .claude/.devkit/hook-log.jsonl | 214 +++++++ .claude/CLAUDE.md | 52 ++ .claude/agents/coder.md | 34 ++ .claude/agents/planner.md | 50 ++ .claude/agents/reviewer.md | 102 ++++ .claude/agents/security.md | 82 +++ .claude/agents/tester.md | 41 ++ .../_devkit_usage_lib.cpython-312.pyc | Bin 0 -> 8181 bytes .claude/bin/_devkit_usage_lib.py | 197 +++++++ .claude/bin/devkit-doctor.sh | 233 ++++++++ .claude/bin/devkit-projects.py | 313 ++++++++++ .claude/bin/devkit-state.sh | 535 ++++++++++++++++++ .claude/bin/devkit-statusline.sh | 362 ++++++++++++ .claude/bin/devkit-usage.py | 170 ++++++ .claude/bin/devkit_projects_render.py | 96 ++++ .claude/devkit-plan.json | 31 + .claude/hooks/_lib.sh | 49 ++ .claude/hooks/auth-check.sh | 42 ++ .claude/hooks/block-credential-writes.sh | 93 +++ .claude/hooks/block-dangerous-bash.sh | 150 +++++ .claude/hooks/block-protected-push.sh | 112 ++++ .claude/hooks/context-monitor.sh | 73 +++ .claude/hooks/post-write-graphql.sh | 44 ++ .claude/hooks/post-write-lint.sh | 48 ++ .claude/hooks/pr-review-reminder.sh | 34 ++ .claude/hooks/pre-commit-security.sh | 115 ++++ .claude/hooks/pre-push-tests.sh | 269 +++++++++ .claude/hooks/validate-branch-name.sh | 78 +++ .claude/hooks/validate-commit-msg.sh | 85 +++ .claude/hooks/workflow-resume.sh | 44 ++ .claude/hooks/workflow-status.sh | 35 ++ .claude/rules/agent-behavior.md | 34 ++ .claude/rules/code-review.md | 27 + .claude/rules/coding-style.md | 31 + .claude/rules/context-hygiene.md | 41 ++ .claude/rules/dangerous-actions.md | 44 ++ .claude/rules/design-principles.md | 61 ++ .claude/rules/git-workflow.md | 66 +++ .claude/rules/glossary.md | 66 +++ .claude/rules/meaningful-names.md | 95 ++++ .claude/rules/paradigms.md | 70 +++ .claude/rules/security.md | 30 + .claude/rules/skill-authoring.md | 128 +++++ .claude/rules/testing.md | 25 + .claude/rules/typescript/coding-style.md | 31 + .claude/rules/typescript/graphql-workflow.md | 46 ++ .claude/rules/typescript/security.md | 27 + .claude/rules/typescript/testing.md | 43 ++ .claude/rules/typescript/type-safety.md | 142 +++++ .claude/rules/worktree.md | 35 ++ .claude/settings.json | 97 ++++ .claude/settings.local.json | 8 + .claude/skills/_shared/gitlab-access.md | 175 ++++++ .claude/skills/audit/SKILL.md | 41 ++ .claude/skills/bugfix/SKILL.md | 61 ++ .claude/skills/diagnose/REPORT-FORMAT.md | 65 +++ .claude/skills/diagnose/SKILL.md | 97 ++++ .../diagnose/scripts/hitl-loop.template.sh | 43 ++ .claude/skills/feature/SKILL.md | 115 ++++ .claude/skills/git-workflow/SKILL.md | 147 +++++ .claude/skills/grill-me/SKILL.md | 49 ++ .claude/skills/prd-authoring/SKILL.md | 214 +++++++ .../reference/knowledge-base-guide.md | 45 ++ .../prd-authoring/reference/prd-template.md | 177 ++++++ .claude/skills/prd-review/SKILL.md | 142 +++++ .claude/skills/review/SKILL.md | 52 ++ .claude/skills/test-plan-generator/SKILL.md | 178 ++++++ .claude/skills/to-issues/SKILL.md | 73 +++ .claude/skills/to-issues/TICKET-TEMPLATE.md | 67 +++ .claude/skills/token-report/SKILL.md | 52 ++ .claude/skills/zoom-out/SKILL.md | 45 ++ .../handlers/chatCore/nonStreamingHandler.js | 2 +- open-sse/handlers/chatCore/requestDetail.js | 5 +- .../handlers/chatCore/sseToJsonHandler.js | 4 +- .../handlers/chatCore/streamingHandler.js | 2 +- src/lib/db/repos/usageRepo.js | 43 +- src/lib/db/schema.js | 1 + src/sse/handlers/chat.js | 6 +- src/sse/services/auth.js | 22 + 79 files changed, 6842 insertions(+), 11 deletions(-) create mode 100644 .claude/.devkit/hook-log.jsonl create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/agents/coder.md create mode 100644 .claude/agents/planner.md create mode 100644 .claude/agents/reviewer.md create mode 100644 .claude/agents/security.md create mode 100644 .claude/agents/tester.md create mode 100644 .claude/bin/__pycache__/_devkit_usage_lib.cpython-312.pyc create mode 100755 .claude/bin/_devkit_usage_lib.py create mode 100755 .claude/bin/devkit-doctor.sh create mode 100755 .claude/bin/devkit-projects.py create mode 100755 .claude/bin/devkit-state.sh create mode 100755 .claude/bin/devkit-statusline.sh create mode 100755 .claude/bin/devkit-usage.py create mode 100755 .claude/bin/devkit_projects_render.py create mode 100644 .claude/devkit-plan.json create mode 100755 .claude/hooks/_lib.sh create mode 100755 .claude/hooks/auth-check.sh create mode 100755 .claude/hooks/block-credential-writes.sh create mode 100755 .claude/hooks/block-dangerous-bash.sh create mode 100755 .claude/hooks/block-protected-push.sh create mode 100755 .claude/hooks/context-monitor.sh create mode 100755 .claude/hooks/post-write-graphql.sh create mode 100755 .claude/hooks/post-write-lint.sh create mode 100755 .claude/hooks/pr-review-reminder.sh create mode 100755 .claude/hooks/pre-commit-security.sh create mode 100755 .claude/hooks/pre-push-tests.sh create mode 100755 .claude/hooks/validate-branch-name.sh create mode 100755 .claude/hooks/validate-commit-msg.sh create mode 100755 .claude/hooks/workflow-resume.sh create mode 100755 .claude/hooks/workflow-status.sh create mode 100644 .claude/rules/agent-behavior.md create mode 100644 .claude/rules/code-review.md create mode 100644 .claude/rules/coding-style.md create mode 100644 .claude/rules/context-hygiene.md create mode 100644 .claude/rules/dangerous-actions.md create mode 100644 .claude/rules/design-principles.md create mode 100644 .claude/rules/git-workflow.md create mode 100644 .claude/rules/glossary.md create mode 100644 .claude/rules/meaningful-names.md create mode 100644 .claude/rules/paradigms.md create mode 100644 .claude/rules/security.md create mode 100644 .claude/rules/skill-authoring.md create mode 100644 .claude/rules/testing.md create mode 100644 .claude/rules/typescript/coding-style.md create mode 100644 .claude/rules/typescript/graphql-workflow.md create mode 100644 .claude/rules/typescript/security.md create mode 100644 .claude/rules/typescript/testing.md create mode 100644 .claude/rules/typescript/type-safety.md create mode 100644 .claude/rules/worktree.md create mode 100644 .claude/settings.json create mode 100644 .claude/settings.local.json create mode 100644 .claude/skills/_shared/gitlab-access.md create mode 100644 .claude/skills/audit/SKILL.md create mode 100644 .claude/skills/bugfix/SKILL.md create mode 100644 .claude/skills/diagnose/REPORT-FORMAT.md create mode 100644 .claude/skills/diagnose/SKILL.md create mode 100755 .claude/skills/diagnose/scripts/hitl-loop.template.sh create mode 100644 .claude/skills/feature/SKILL.md create mode 100644 .claude/skills/git-workflow/SKILL.md create mode 100644 .claude/skills/grill-me/SKILL.md create mode 100644 .claude/skills/prd-authoring/SKILL.md create mode 100644 .claude/skills/prd-authoring/reference/knowledge-base-guide.md create mode 100644 .claude/skills/prd-authoring/reference/prd-template.md create mode 100644 .claude/skills/prd-review/SKILL.md create mode 100644 .claude/skills/review/SKILL.md create mode 100644 .claude/skills/test-plan-generator/SKILL.md create mode 100644 .claude/skills/to-issues/SKILL.md create mode 100644 .claude/skills/to-issues/TICKET-TEMPLATE.md create mode 100644 .claude/skills/token-report/SKILL.md create mode 100644 .claude/skills/zoom-out/SKILL.md diff --git a/.claude/.devkit/hook-log.jsonl b/.claude/.devkit/hook-log.jsonl new file mode 100644 index 00000000000..b96a17674b9 --- /dev/null +++ b/.claude/.devkit/hook-log.jsonl @@ -0,0 +1,214 @@ +{"ts":"2026-05-26T20:36:49Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-26T20:36:55Z","hook":"block-dangerous-bash","verdict":"blocked","reason":"rm -rf targeting a critical path is forbidden. Command: rm -rf /"} +{"ts":"2026-05-26T20:36:55Z","hook":"block-protected-push","verdict":"blocked","reason":"direct push to master"} +{"ts":"2026-05-26T20:36:55Z","hook":"block-credential-writes","verdict":"blocked","reason":"Editing .env is forbidden.: /home/dat/apps/9router/.env"} +{"ts":"2026-05-26T20:38:39Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:50:50Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:51:37Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:51:42Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:51:46Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:51:53Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:51:56Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:52:02Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:52:13Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:56:38Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:56:40Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:56:44Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:56:48Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:56:51Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:56:57Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:00Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:04Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:09Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:13Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:19Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:26Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:33Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:40Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:47Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:57:50Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:58:00Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:58:04Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:59:40Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T06:59:49Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T07:06:21Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-27T07:06:45Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T07:06:49Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T07:06:59Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T07:07:16Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T07:07:22Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T07:20:01Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-27T07:20:12Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:53:52Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:53:53Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:53:58Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:53:59Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:54:09Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:54:22Z","hook":"block-dangerous-bash","verdict":"blocked","reason":"Agent must not use sudo. Command: docker ps -a | grep 9router; echo \"---vol contents---\"; sudo ls -la /var/lib/docker/volumes/9router-data/_data 2>/dev/null || echo \"cannot read (need sudo, run yourself)\"\nIf an operation genuinely requires elevated privileges, the engineer should run it manually."} +{"ts":"2026-05-31T02:54:24Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:54:34Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:54:40Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:54:50Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:54:56Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T02:56:23Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:05:42Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:05:45Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:05:46Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:05:57Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:06:12Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:07:53Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:07:57Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:08:00Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:11:03Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:11:08Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:11:12Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:12:21Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:15:51Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:15:54Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:16:00Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:16:13Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:16:52Z","hook":"block-dangerous-bash","verdict":"blocked","reason":"rm -rf on '9router-data:/data' isn't in the safe-path allowlist (tmp, node_modules, build, dist, .next, __pycache__, caches, coverage).\nCommand: export PATH=\"$HOME/.local/bin:$PATH\"; TMP=$(mktemp -d); echo \"tmp=$TMP\"\ndocker run --rm -v 9router-data:/data alpine sh -c \\\n \"apk add -q sqlite 2>/dev/null && sqlite3 /data/db/data.sqlite \\\".backup '/tmp/snap.sqlite'\\\" && sqlite3 /tmp/snap.sqlite .dump\" \\\n | gzip > \"$TMP/9router-test.sql.gz\"\necho \"exit=${PIPESTATUS[0]}/${PIPESTATUS[1]}\"; ls -la \"$TMP/\"\necho \"=== sanity: gunzip head ===\"; gunzip -c \"$TMP/9router-test.sql.gz\" | head -5\nrm -rf \"$TMP\"\nIf you really need to remove this, ask the user to run it manually."} +{"ts":"2026-05-31T03:16:58Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:17:08Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:17:22Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:17:46Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:17:49Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:17:58Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:18:01Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:18:14Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:18:17Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:18:27Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:18:42Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:18:47Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:41:23Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:41:59Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:42:31Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:42:35Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:50:35Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:50:38Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:50:46Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:50:49Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:51:09Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:51:54Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:52:27Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:52:54Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:53:08Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:53:17Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:53:33Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:53:47Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:54:48Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:55:48Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:57:21Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:57:28Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T03:57:45Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:00:40Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:00:43Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:00:48Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:04:06Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:04:10Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:04:13Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:04:21Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:04:25Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:05:31Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:05:40Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:05:57Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:04Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:08Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:11Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:18Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:21Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:27Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:30Z","hook":"block-protected-push","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:30Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:34Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:39Z","hook":"block-protected-push","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:39Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:39Z","hook":"pre-push-tests","verdict":"allowed","reason":"no test framework detected"} +{"ts":"2026-05-31T04:06:44Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:51Z","hook":"block-protected-push","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:06:51Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:07:04Z","hook":"block-protected-push","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:07:04Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:07:04Z","hook":"pre-push-tests","verdict":"allowed","reason":"no test framework detected"} +{"ts":"2026-05-31T04:07:17Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:08:27Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:09:34Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:09:49Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:09:52Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:10:11Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:10:31Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:10:53Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:11:00Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:11:13Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:11:35Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:11:47Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:11:52Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:11:57Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:12:00Z","hook":"block-protected-push","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:12:00Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:12:00Z","hook":"pre-push-tests","verdict":"allowed","reason":"no test framework detected"} +{"ts":"2026-05-31T04:12:11Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:12:24Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:12:42Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:12:47Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:12:52Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:12:56Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:13:01Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:13:07Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:13:11Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:13:17Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:13:23Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:13:26Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:13:30Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:13:36Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:13:42Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:14:12Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:14:16Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:14:25Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:14:28Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:14:36Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:14:42Z","hook":"block-dangerous-bash","verdict":"blocked","reason":"Agent must not use sudo. Command: echo \"===listeners===\"; ss -ltnp 2>/dev/null | grep \":20128\"; echo \"===ps next/node===\"; ps -eo pid,user,etime,cmd 2>/dev/null | grep -iE \"next|node|20128|9router\" | grep -v grep | head; echo \"===whoami===\"; whoami; echo \"===dauden home db===\"; sudo -n true 2>/dev/null && echo \"have sudo\" || echo \"no sudo\"\nIf an operation genuinely requires elevated privileges, the engineer should run it manually."} +{"ts":"2026-05-31T04:14:49Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:14:58Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:15:05Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:15:10Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:15:21Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:15:30Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:15:43Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:15:57Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:16:08Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:16:18Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:16:36Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:16:52Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:17:25Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:17:44Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:17:50Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:17:59Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:19:08Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:19:13Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:19:16Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:19:34Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:19:48Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:19:53Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:19:58Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:20:30Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:21:44Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:21:52Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:01Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:10Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:14Z","hook":"block-credential-writes","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:14Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:22Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:23Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:29Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:33Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:38Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:43Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:22:59Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:23:02Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:23:41Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:23:50Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:24:24Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:24:27Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:24:43Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} +{"ts":"2026-05-31T04:24:43Z","hook":"block-dangerous-bash","verdict":"allowed","reason":""} diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000000..224563e670a --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,52 @@ +# Ringkas Engineering Harness + +You are an AI pair programmer for a Ringkas engineer. Your role is to help build +high-quality, secure software that meets Ringkas engineering standards and ISO 27001 +security controls. + +## Rules + +Load and follow ALL rule files in `.claude/rules/`. Every file applies. +Language-specific rules override common rules where they conflict. + +## Quality Gates (Non-Negotiable) + +1. Security first: check ISO 27001 controls in `.claude/rules/security.md` + before completing any task touching auth, PII, or external data. +2. Test coverage: new code requires tests — follow `.claude/rules/testing.md`. +3. Code review: run `/review` before marking a feature complete. +4. Git discipline: follow `.claude/rules/git-workflow.md`. + +## Available Skills + +### Workflows (orchestrated multi-agent) +- /feature — full feature: plan → code → test → review → security +- /bugfix — bug fix: diagnose → code → test → review +- /review — code review (+ auto security if auth/PII detected) +- /audit — ISO 27001 security audit + dependency CVE scan + +### Investigation & planning discipline +- /diagnose — six-phase debugging loop (feedback loop → reproduce → hypothesise → instrument → fix + regression test → cleanup); writes `.devkit/diagnose.md` consumed by /bugfix +- /grill-me — interrogates a plan / PRD one question at a time with recommended answers; writes `.devkit/grill-result.md` consumed by /feature, /bugfix, /to-issues +- /zoom-out — high-level map of an unfamiliar code area (modules, inbound callers, outbound deps) in glossary vocabulary; no deep file reads +- /to-issues — breaks an EP- PRD into vertical-slice LT- / AI- / DO- tickets (HITL vs AFK); writes `.devkit/issues.md` + +### Standalone +- /git-workflow — guided branch → commit → MR creation +- /prd-authoring — create PRDs in Notion +- /prd-review — quality gate for draft PRDs +- /test-plan-generator — generate Testmo test cases from a PRD +- /token-report — per-repo Claude token usage table + +## Automatic Hooks + +These run automatically — never bypass them: +- After file write/edit: lint check (Ruff for Python, ESLint for TypeScript) +- Before git commit: security scan for hardcoded secrets +- Before git commit: branch name format validation +- Before git commit: commit message format validation +- On session end: PR reviewer checklist + +## Adding New Rules + +Drop any .md file into `.claude/rules/` — picked up automatically next session. diff --git a/.claude/agents/coder.md b/.claude/agents/coder.md new file mode 100644 index 00000000000..ada1fe68f38 --- /dev/null +++ b/.claude/agents/coder.md @@ -0,0 +1,34 @@ +--- +name: coder +model: sonnet +description: Implement code from a plan with atomic commits +tools: [Read, Write, Edit, Bash, Grep, Glob] +--- + +# Coder Agent + +## Role +Implement code following the plan in `.devkit/plan.md`. Make atomic commits for each logical change. + +## Input +1. Read `.devkit/plan.md` for the implementation plan +2. Read ONLY the source files listed in the plan +3. Do NOT read review files, security audits, or other artifacts + +## Rules +- Follow Ringkas coding conventions strictly +- Immutability: create new objects, never mutate existing ones +- Functions < 50 lines, files < 800 lines +- Python: Ruff clean, get_settings() not os.getenv(), Pydantic for schemas +- TypeScript: no `any`, UPPER_CASE enums, Yup for DTO validation +- Django PII models: add `history = HistoricalRecords()` +- Each commit: one logical change, conventional commit message +- Run lint after each file edit (ruff check / npx eslint) + +## Output Format +Make git commits directly. Each commit message follows: +`(scope?): [] ` -- present tense, not capitalized, no period. +Ticket ID in square brackets when one exists (EP-/LT-/AI-/DO-); omit brackets for devkit-internal commits without a ticket. + +## Completion +When all plan tasks are implemented and committed, print `## CODE COMPLETE`. diff --git a/.claude/agents/planner.md b/.claude/agents/planner.md new file mode 100644 index 00000000000..4e2ee3a41f8 --- /dev/null +++ b/.claude/agents/planner.md @@ -0,0 +1,50 @@ +--- +name: planner +model: opus +description: Generate phased implementation plans from ticket descriptions +tools: [Read, Glob, Grep, Bash] +--- + +# Planner Agent + +## Role +Generate a phased implementation plan for a feature or task. Output a structured plan that the Coder and Tester agents can follow. + +## Input +You will receive a ticket description or feature request. Read from disk: +1. Project structure: `ls -la` and `find . -name "*.py" -o -name "*.ts" | head -40` +2. Existing patterns: grep for similar features in the codebase +3. Ticket description: provided in your prompt + +Do NOT read full source files. Only scan structure and patterns. + +## Rules +- Follow Ringkas coding conventions (immutability, <50 line functions, <800 line files) +- Python projects: Ruff linting, Pydantic schemas, get_settings() not os.getenv() +- TypeScript projects: no `any` types, UPPER_CASE enums, @ui/* imports in jupiter +- Django models with PII must include `history = HistoricalRecords()` +- Every endpoint needs auth/authz +- Plan must include test strategy per task + +## Output Format +Write your plan to `.devkit/plan.md` with this structure: + +``` +# Implementation Plan + +## Summary +One paragraph describing what we're building. + +## Tasks + +### Task 1: [name] +**Files:** [create/modify list] +**Tests:** [test file paths] +**Acceptance:** [criteria] + +### Task 2: [name] +... +``` + +## Completion +When done, print `## PLAN COMPLETE` and confirm `.devkit/plan.md` is written. diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md new file mode 100644 index 00000000000..d8d1b16de26 --- /dev/null +++ b/.claude/agents/reviewer.md @@ -0,0 +1,102 @@ +--- +name: reviewer +model: sonnet +description: Review code against Ringkas conventions and ISO 27001 security checklist +tools: [Read, Bash, Grep, Glob] +--- + +# Reviewer Agent + +## Role +Review code changes against Ringkas coding conventions and ISO 27001 controls. Output structured findings. + +## Input +1. Get the diff: `git diff HEAD~N` or for MR review, follow `skills/_shared/gitlab-access.md` +2. Read only the changed files shown in the diff +3. Do NOT read plan files, test files, or security artifacts + +## Rules -- Convention Review + +All projects: +- [ ] Functions < 50 lines +- [ ] Files < 800 lines +- [ ] No deep nesting (> 4 levels) +- [ ] Errors handled explicitly -- no empty catch blocks +- [ ] No mutation of existing objects +- [ ] No hardcoded values +- [ ] Meaningful names (see `.claude/rules/meaningful-names.md`): + - No single letters outside loop indices / math / lambdas / catch / `_` / `id` + - No `tmp`, `temp`, `val`, `var`, `obj`, `data`, `info`, `foo`, `bar`, `baz` + - No cryptic abbreviations: `usr` `pwd` `cfg` `mgr` `hdlr` `req` `resp` (spell them out) + - Booleans prefixed with `is`/`has`/`should`/`can`/`was` + - Collections plural; functions are verbs; variables are nouns + - Casing matches language (camelCase TS/JS, snake_case Python) + - Flag as MEDIUM (or HIGH if the bad name is in a public API) + +TypeScript (saturn/jupiter) — see `.claude/rules/type-safety.md`: +- [ ] No `any` types — explicit, generic constraint, or implicit. Flag `any[]`, `Record`, `Map`, function parameters/returns typed `any`. Prefer `unknown` + type guard, `` generics, union types, discriminated unions, or `Record`. +- [ ] No `as any` casts. `as unknown as T` is acceptable only when narrowing is impossible upstream. +- [ ] No `// @ts-ignore` — use `// @ts-expect-error` with a comment, or fix the type. +- [ ] No `Function` as a type annotation — name the signature: `(arg: T) => U`. +- [ ] Severity: MEDIUM by default; HIGH when the offender is in a public API (exported function, route handler, schema) +- [ ] UPPER_CASE enum values +- [ ] No direct antd imports in jupiter (use @ui/*) +- [ ] RTL-safe CSS in jupiter (ms-X/me-X not ml-X/mr-X) +- [ ] Yup ValidationError for DTO validation in saturn + +Python (regulus/alcor/phobos/ai-backoffice) — see `.claude/rules/type-safety.md`: +- [ ] No `Any` from typing — flag explicit annotations, return types, generic args (`list[Any]`, `dict[str, Any]`, `Callable[..., Any]`), and `**kwargs: Any`. Prefer `object` + narrowing, generics, `unknown`-equivalent (`object`), TypedDict, or Pydantic schema. +- [ ] No bare `dict` / `list` / `tuple` (without parameters). Always specify element types: `dict[str, int]`, never `dict`. +- [ ] No `# type: ignore` without an error code AND a written reason: `# type: ignore[no-untyped-call] # reason`. +- [ ] No `cast()` to bypass real type errors. +- [ ] Modern syntax only: `X | None` not `Optional[X]`, `X | Y` not `Union[X, Y]`, `list[T]` not `List[T]`. +- [ ] Every function has parameter and return-type annotations (use `-> None` explicitly). +- [ ] Every class attribute has a type annotation. +- [ ] Pydantic v2 only — no v1 syntax (`@validator`, `Config` class, `parse_obj`, `dict()`). +- [ ] Pydantic models at trust boundaries use `ConfigDict(strict=True, extra="forbid")`. +- [ ] Discriminated unions use a literal `status`/`type` field + `match`/`assert_never`. No "one class with optional fields" for variant states. +- [ ] Severity: MEDIUM by default; HIGH when offender is in a public API (DRF view, Pydantic schema, alcor agent type, phobos `en_*` tool, exported function). +- [ ] get_settings() not os.getenv() directly +- [ ] Pydantic schemas have no default=None on required fields (alcor) +- [ ] Literal instead of Enum for string enums (alcor) +- [ ] New agent types use @register_builder decorator (alcor) +- [ ] MCP tool functions prefixed with en_ (phobos) +- [ ] New Django models with PII have history = HistoricalRecords() + +## Rules -- ISO 27001 Security + +- [ ] No hardcoded secrets +- [ ] All user inputs validated at system boundaries +- [ ] No SQL string concatenation -- ORM or parameterized queries only +- [ ] Every endpoint has auth/authz checked +- [ ] Error messages do not contain: stack traces, paths, PII +- [ ] Sensitive data not written to logs + +## Output Format +Write `.devkit/review.md`: + +``` +## Code Review Report + +**Files reviewed:** [list] +**Date:** [YYYY-MM-DD] + +### CRITICAL -- must fix before commit +- `path/to/file.py:42` -- [issue]. Fix: [action]. + +### HIGH -- should fix before PR +- `path/to/file.ts:15` -- [issue]. Fix: [action]. + +### MEDIUM -- address if possible +- `path/to/file.py:80` -- [issue]. + +### Passed +- Security: PASS / FAIL +- Conventions: PASS / FAIL -- N violations +- Tests: PASS / FAIL + +### Verdict: APPROVED / NEEDS FIXES +``` + +## Completion +Print `## REVIEW COMPLETE` when `.devkit/review.md` is written. diff --git a/.claude/agents/security.md b/.claude/agents/security.md new file mode 100644 index 00000000000..ee441262554 --- /dev/null +++ b/.claude/agents/security.md @@ -0,0 +1,82 @@ +--- +name: security +model: haiku +description: ISO 27001 security audit and dependency vulnerability scan +tools: [Read, Bash, Grep, Glob] +--- + +# Security Agent + +## Role +Run ISO 27001 compliance checks and dependency vulnerability scan on code changes. + +## Input +1. Get the diff: `git diff HEAD~N` or GitLab MR diff (follow `skills/_shared/gitlab-access.md`) +2. Read only the changed files + +## ISO 27001 Controls + +### A.9 Access Control +- [ ] Auth required (JWT with signature verification) +- [ ] Authorization checked per resource +- [ ] Principle of least privilege +- [ ] No privilege escalation vector + +### A.10 Data Handling +- [ ] PII fields identified and encrypted at rest +- [ ] All communication over HTTPS +- [ ] PII not in logs +- [ ] Retention considered + +### A.12.4 Audit Trail +- [ ] Data modifications logged: timestamp, user ID, action, resource ID +- [ ] Django PII models have HistoricalRecords() +- [ ] Auth events logged with IP + +### A.14.2 Secure Development +- [ ] Inputs validated at API boundary +- [ ] No SQL injection (ORM only) +- [ ] No XSS vectors +- [ ] File uploads validated (type, size) + +## Package Vulnerability Scan + +Detect package manager from lock files, then run audit: + +| Lock file | Command | +|-----------|---------| +| pnpm-lock.yaml | `pnpm audit --audit-level high` | +| yarn.lock | `yarn audit --level high` | +| package-lock.json | `npm audit --audit-level=high` | +| requirements.txt | `pip-audit -r requirements.txt` | +| poetry.lock | `pip-audit` | +| Pipfile.lock | `pipenv check` | + +## Output Format +Write `.devkit/security-audit.md`: + +``` +## ISO 27001 Security Audit Report + +**Date:** [YYYY-MM-DD] + +### A.9 Access Control: PASS / FAIL +Findings: [list with file:line] + +### A.10 Data Handling: PASS / FAIL +Findings: [list] + +### A.12.4 Audit Logging: PASS / FAIL +Findings: [list] + +### A.14.2 Secure Development: PASS / FAIL +Findings: [list] + +### Dependencies: PASS / FAIL +Findings: [CVE or package with severity] + +### Overall: COMPLIANT / NON-COMPLIANT +``` + +## Completion +Print `## AUDIT COMPLETE` when `.devkit/security-audit.md` is written. diff --git a/.claude/agents/tester.md b/.claude/agents/tester.md new file mode 100644 index 00000000000..847c868b1cf --- /dev/null +++ b/.claude/agents/tester.md @@ -0,0 +1,41 @@ +--- +name: tester +model: sonnet +description: Generate tests for implemented code +tools: [Read, Write, Edit, Bash, Grep, Glob] +--- + +# Tester Agent + +## Role +Write tests for the code changes in this workflow. Cover happy path, error cases, and edge cases. + +## Input +1. Read `.devkit/plan.md` for what was implemented +2. Read the git diff to see what changed: `git diff HEAD~N` (where N = number of commits in this workflow) +3. Read the changed source files to understand the implementation + +## Rules +- Python: pytest with @pytest.mark.unit or @pytest.mark.integration on every test +- TypeScript: Jest with React Testing Library for components +- Test file location: + - Python: `tests//test_.py` + - TypeScript saturn: `tests/unitTest//.test.ts` + - TypeScript jupiter: co-located `.test.tsx` +- Naming: `test___` +- Cover minimum: happy path, one error case, one edge case per function +- Tests must be independent -- no shared mutable state +- Run tests after writing to verify they pass + +## Output Format +Write test files directly in the project. Run them to verify: +```bash +# Python +pytest tests/ -v --tb=short + +# TypeScript +npx jest --verbose +``` + +## Completion +When all tests are written and passing, print `## TESTS COMPLETE`. diff --git a/.claude/bin/__pycache__/_devkit_usage_lib.cpython-312.pyc b/.claude/bin/__pycache__/_devkit_usage_lib.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01d787972a303fe70d01af0a7b19d038a45cbd9d GIT binary patch literal 8181 zcmcIpZ%`XYmhX{f^cN5U2_eAtm_IhMjlhn*@t@dPf@Fu71(#t{iF}kSGy@ElG;Ghv z7*<}B-Rv&DbyeW9yO6rvg;d>La5-1!kN%Jkn^atNRrleLMG9q()Mc~zu(>aXjj!Tm zuj=k~k46HA?A@1bP)|?)dHuTQ^?Sef`d`Y+9SBm8tr^pN2>p({7>Uyh%*Gc`m`4JN zAb}A~2_|WZm>5zvC(IEueXI>z(rfPM4-}~od_vjlY{Vs$M=FJih+C)? zT<~56&q{b!3x2^3Pmdi5b#`R@3H3tN>s-VuR7YwAPsArQ2wvz_E9??#;OU<<2X;^W zW&dalivq9WSW1awXAM! z;T184qqB8{dJ#JQ7iafsbtZK73lyESR2yTEPq@mXE3SaMu5D=O9;il`{SNK_Iw%MG3`LCc_K zj!y_0D@buw`QxWd|YWif#o#pM|%*qEG>xbLsF_es(-sEoiLh^hJxdh*a@R)=7xe& z3)xT6=PqB4_2nz8f6|w?*W}omywmmic}yB<*LXo%;T<6sez2j;#$71PqYP4*Nyd)U zVxJ6B3$l!mA=TKQL1Tq;Mu-?mFc~KS+cNZ|)gghMg68W;wHm#~Y(}Z{%phZAKWaiM zJ>8^LEi?KIo0<_eGw4+`$xNVY=2y`K6X2$LF2t|`qRe;Eo!W8+`0oC`w&UOFI7|hY ze%Mny7S%LPDSWS-5_vhrOR1}|ge1UoJgo+MH74@a|3I3u9PH>g+0hZOY7R+}QZPCd z7jZc-quIoipiD^Ws4h0;AfZV~k>PYDW2)wOIhII^VT@(0*#{=a^&*jERf&S|C}d-K z?5C8*3GY!=!AQ1y&Lb|xT~z}Kb7~^ zFLW(joV~PoJnKC;b9T+;TXnT$T`h~lIak}Vt?kKLS;e+ikUhCtr_^23-W09Wc=(fH-y%E}k zZ4Ci4ZidP5K9cQ+Y-g!^8Crf0a#xbKn>)xGkx8{EZ!>5Z^!e*H)dGWQ3L!(!mZT_P zo_eJ}oz%Z9e~{PL1cmLZDOQjb@cIS>`HKZH>7Yte7Z%K?CP`_s883x^|WkIb+)t`H^2^V3w2Hto-ih$&FR_{`KE71VB`@acljCFFQ4p_~a8 zJ3LDUH$e8}_vpzqdQcHsOwXj>n7lc;;9X=^U5(j_&>uF4R`^N79wxxzT~NeCZa^!h zlUj9jpl_hF2lDgb-ssuxa8IcJHly=I7I|40?Xs+LJiLQYoFQYk3dPxP2*uey;KR^st3co*P@#FFU46X+;a3K>i3C1MNNy1b z_c!>!C!oto((PNN;_OOCc%|ZO&Utp3J*!Iv^e-lI{;BO6gY!6#Mu?Ru9yC(-bTYJr zz)XSR6ig()&SaRKY?;v)yTR5KZC8n6hB0&v^Z%XIOlM_j&e6F|8PlJf`-~ky4|{1= zqhxRwLW2QVgE<4hhf-uS?4P2jFt>>9-f2B-X+5_NvVcHKws)+;n78yCBRb)!Bv=Hi zq0+{xpK63x!Dh?>e0KxiT!tI-ezTvkI?@gt*Ob0$hBGMqAFJ%xd6hd+GAM!qKF{Ctq>$nbxvmr5QaQ1#kUY5^Z{g?q||u6jej|ok`jp``00{C zDe*rl!bSttw-*O=b@dZYwkI+muC&v+h4#WhZ5sg$3Q+Vp`aIKKqD6^?uYtD#&VV|q zfJII5Lx8~q0i2MlQj8CUUq0Ur3gKdBZ-3Xt?h6Ca3!MWm_VcZAIRQ3Q5drvs3m^o7 zl-88BfO9pQG0PN7Skf#yPg51!nbLgUGJ}RTnUeu0CYnKG38vO8k`fgptXTk919H=> z5&&ybVeRYJVVX%+G^?1pDq%T=50R0a0v-)ud%$bbc)+4@)X~u#IvS_sY`|W0D5i)S zM>j}g3F{gs5{E(|^Xq%8n?tW4DRKF#h@-ma6r}`lA9U-4pW+00nnvqY$X`Dl{?hH8 z_s{v?tY4VSx?7h{X5Gi8U&>qEtJd1AwRXi?m$#L_QFgOzHnL*lzjRd-tF1(y| zH7$;2UBTtUomp4s^ox0`>*qIKyRl-eT{l_m?!15Rsy~qR2Xg*{kC3^1|16jHH7rbJ zeSujvU+tSeHFxS)r}G~Fe9v6ZqURIOzK2%m^|%66dFJcp>gIRP?Ox_j+&hwUpILQ> zvhL7t`g87XAjR#Q;qsNyBORL`fS?~T&yn%d~`;DHPJ#Tv! z#J{fpq^xD#3S-tCsLVax^W?D`Rr&y@+TEX)RRT`6yEmyL^WmY^&_48$w;J+~_t`^7 z%pV_SAdhcV?4ZEGUjW?o1ZoY`Mc`tF!Ihxci{=3O6_TM`QjEY{H)T)=%`$@?qC`~! zs%RP`fa9rDa$pQ)D^P`gr!a@WzRwxT57gW5fg+nRz19MXwCM1R6&;%r@iF36Je4v< zWt=fxft6wAR)B;A)S}YOuteF?;6s5|qAZUA?#ys7``^Os7QvCRTxDRa4MrB#@ffW2 z>6Eb;tGI5>SjUJf@>GJ5Mq!=T7zM&861r~7*mmq$X7s#n&)7kIpHu4%V&6i$+!$dz zuRAi1Y37!YVP3Oj92ust2MLv+QJSE3XFe+o8akhX>s$I&8vQc%_uY32d-!V;#o>#@ z{6Rnn{){gQ#^FC$kd#|Kg3-Z6Etz{C-cmrs%y2`?w~(V+I#QAy#6$x^OJ zpkIYUG^j+BAeco5QGpYqGWh4z(*^&^2(j=mOeaGI!wjI5!FYg^rkjM`Dbs-iQ`Q2% zBobr?Yn#Rju&Xh6ic71*ZO1{mkECQQlK2q>iDUr)yJiuI^Q}x-8)>AZQL{o=hs1iQ zlY&V=L9-WPJKX{%*cVqq2GEwYVe(;V1??Yj=_6QT-!)Dm4;lx+Vdw{1A1t6|10RiU z4Gz&t87z&)5*VOy2{|Sxn(L+hzTS`sEb0LPYGRQM)%7&;EYPx(7I&(sHe zSOtnWZ>#uia{xTS8#iyPxcDV?`o+&Hy&I^RtC?ZfJhk&L&As%tvUqf<`Q1}@{n@6I zxjmf4|7?O$SZzP7uz2fpL^T7KP( zYEP^?%;o+yuYaL=(Y!QrZ*;|bZq~GJ11s|o)s)xd8+JcJUU$uGDDUH!*sSjm8E7(l z_pb5H?>XLeEFI199ShvL1=aKK1l|fPcIWDX59*HIZJh1OH|%-m+*{`s&*d5p&4xd% z=V9y`-@4@eC;x5#-BY>1^Ev*zv)w>>+1CnfU;v@8^y)qDgSIoIZ(Vb~@nF8G^}Q4C zo>;n+YdZ0u>G^vX^9_67>3*wwQ3SI2-A#|}Tz$<3;=DBv_X4M1n}O|h!uz9o*o%i| z*s~{(%TVneuxjpIpL!a=s=0Ul@v#GW>Y=LKuT|DURk{C7vzgpj(t#5}0@7_H_->vAAR_v62nU%=P;ai=|elKHT#F?5XmFlZv>!>%yPeuPN*Ba<`SX8owb8}`^f zYI?y8FCXu(fQFBc*~5(W<1?hweZ)ZdK4T_j)*g0S@0ZhZB}2+?TJ}_j4><4dHvxtF z0fv+h5Nh|^xbU;i`$udrcFNi+gHwzHQ{}C2tU<2Yh>!-h*=1r$scZHZP#|GN(BZAb zjuFeelT|K-4iyd}BPJR&WB|k$Z88ulq>}5^k08v`Ie^n5(N>q^qf$b+%!?%As_>U} z?k@8X_a<73SJFcO{3vLj2Bc4W|TgzrGKfVDWyu8!>#`8CyUwCH4xhL=TEi}zF%vkcy z%2j85)>*#*o^8`I+oXH8TNhgNY(IcQN~5wC6-eu;AACYE@n^R~7T995VXVODR;mS) zhR_(H_Eg%*r_7kgh!%M&fe9=ae&Y#-)dD_mv0og_Qa@tBp9a9g0A~k*Gp+=tfU_bn zBAjK|*J{-w=qY)0Tk4AN=L#rP7;_-QU1x8#fp^AEZ{G>1p@L;88D!WWc1Q2DxDss2 zuqp7AAr1hmxNd!7FdFp2plTxOc5G@cyj68uzh+w zks92Bhr=ggLt=tDjF;gy;c~(FUW8i%g>M~}u%hzd>cJ%o1f7(C;qo00^5MdDOB*pg zg+5>W?5}v@jF4-F*6{!}ygYv-$X}obw9u;+oUakM;vm)-j;&6aCu8;bN{1sZ!_`lU z^wE*Rg#yKAf;=$GpFVAn5U<7JaPdQlTun4cbK zd=AJ4?9`%b*1|c~IO@6T^jvhG0XjtWR(u6UXiohl3iu)kW*D|jE2rB8F5E~Gwniag znWD+n4X!<=0z*UO?uJO#Kfq7d*)E56VL892o?b(pNv-m#^{@&nqxv z(P`}a#JM-`+P&=B_kPpT@STGnnD6!Fj`!pa^yC{`=Ul|&3V_FD^DSF<7uc{mX5{^ZtW?vkfKA(41+)`#wYG)?i!|d7a1ibbXl7F0bR;B2y>tPjelWoF1}qdZ;U7U0zD%+V$w<#Bk8zoJ zsRW1zVZa#aK~6gT#CnNN!w$)EB4E>DA7L01^|ooJGmh%iN4^Qt#1q*0{qzgq8J?dz nchPr1a+bOcE7Yu~804y*cIG`z%gDW9u`tb#tM)KKx{Ut?g^#QE literal 0 HcmV?d00001 diff --git a/.claude/bin/_devkit_usage_lib.py b/.claude/bin/_devkit_usage_lib.py new file mode 100755 index 00000000000..0bcef015f05 --- /dev/null +++ b/.claude/bin/_devkit_usage_lib.py @@ -0,0 +1,197 @@ +"""Shared transcript-scanning primitives for devkit-usage and devkit-projects. + +No I/O at import time. Callers pass search roots explicitly. +""" + +from __future__ import annotations + +import glob +import json +import os +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Iterable, Optional + + +@dataclass(frozen=True) +class UsageEntry: + timestamp: datetime + session_id: str + cwd: Optional[str] + usage: dict + + +def parse_ts(ts_str: Optional[str]) -> Optional[datetime]: + """Parse a Claude transcript ISO-8601 timestamp; returns None on invalid input.""" + if not ts_str: + return None + try: + if isinstance(ts_str, str) and ts_str.endswith("Z"): + ts_str = ts_str[:-1] + "+00:00" + return datetime.fromisoformat(ts_str) + except (ValueError, TypeError): + return None + + +_TOTAL_TOKEN_FIELDS = ( + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", +) + +_CONTEXT_TOKEN_FIELDS = ( + "input_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", +) + + +def total_tokens(usage: Optional[dict]) -> int: + """Sum input + output + cache_creation + cache_read tokens. Returns 0 for None or non-dict.""" + if not isinstance(usage, dict): + return 0 + return sum(usage.get(field, 0) for field in _TOTAL_TOKEN_FIELDS) + + +def context_tokens_of(usage: Optional[dict]) -> int: + """Tokens occupying the context window (exclude output — it's not in-context).""" + if not isinstance(usage, dict): + return 0 + return sum(usage.get(field, 0) for field in _CONTEXT_TOKEN_FIELDS) + + +def discover_search_roots() -> list[Path]: + """Directories to glob for *.jsonl transcripts. + + Supports multiple Claude CLIs that share the Anthropic transcript format: + - Vanilla Claude Code ~/.claude/projects/ + - CCS ~/.ccs/shared/context-groups//projects/ + + Extra paths can be added via DEVKIT_TRANSCRIPT_PATHS (colon-separated). + """ + roots: list[Path] = [] + + claude_dir = Path.home() / ".claude" / "projects" + if claude_dir.is_dir(): + roots.append(claude_dir) + + ccs_base = Path.home() / ".ccs" / "shared" / "context-groups" + if ccs_base.is_dir(): + try: + for group in ccs_base.iterdir(): + proj = group / "projects" + if proj.is_dir(): + roots.append(proj) + except OSError: + pass + + for entry in os.environ.get("DEVKIT_TRANSCRIPT_PATHS", "").split(":"): + entry = entry.strip() + if entry and Path(entry).is_dir(): + roots.append(Path(entry)) + + return roots + + +def iter_usage_entries( + roots: list[Path], + since: Optional[datetime] = None, +) -> Iterable[UsageEntry]: + """Yield UsageEntry instances from every *.jsonl under the given roots. + + `since`: if set, files with mtime older than this are skipped, and entries + whose parsed timestamp is older are filtered out. + """ + if not roots: + return + + since_ts = since.timestamp() if since else None + + for root in roots: + for path in glob.iglob(str(root / "**" / "*.jsonl"), recursive=True): + if since_ts is not None: + try: + if os.path.getmtime(path) < since_ts: + continue + except OSError: + continue + + try: + fh = open(path, "r", encoding="utf-8", errors="ignore") + except OSError: + continue + + with fh: + for line in fh: + if '"usage"' not in line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + msg = entry.get("message") or {} + usage = msg.get("usage") + if not usage: + continue + timestamp = parse_ts(entry.get("timestamp")) + if timestamp is None: + continue + if since is not None and timestamp < since: + continue + yield UsageEntry( + timestamp=timestamp, + session_id=entry.get("sessionId") or "", + cwd=entry.get("cwd"), + usage=usage, + ) + + +# Sentinel labels for the report. Wrapped in parens so they sort and display +# distinctly from real project names (which never start with '(' on a real fs). +OTHER_LABEL = "(other)" +PARENT_ROOT_LABEL = "(parent-root)" + + +def _is_under(child: str, parent: str) -> bool: + """True if `child` equals `parent` or is a subdirectory of `parent`.""" + if child == parent: + return True + if not parent.endswith("/"): + parent = parent + "/" + return child.startswith(parent) + + +def attribute(cwd: Optional[str], parents: list[str], paths: list[str]) -> str: + """Return the project label for `cwd`. + + Rules (first match wins): + 1. Explicit-path match → basename(p) + 2. Parent match (longest parent wins) → first directory component under P + 3. cwd == parent exactly → PARENT_ROOT_LABEL + 4. otherwise → OTHER_LABEL + """ + if not cwd: + return OTHER_LABEL + + for path in paths: + if _is_under(cwd, path): + return os.path.basename(path.rstrip("/")) or OTHER_LABEL + + matching_parent = None + for parent in parents: + if _is_under(cwd, parent): + if matching_parent is None or len(parent) > len(matching_parent): + matching_parent = parent + + if matching_parent is None: + return OTHER_LABEL + + if cwd == matching_parent: + return PARENT_ROOT_LABEL + + parent_norm = matching_parent if matching_parent.endswith("/") else matching_parent + "/" + rest = cwd[len(parent_norm):] + first_segment = rest.split("/", 1)[0] + return first_segment or PARENT_ROOT_LABEL diff --git a/.claude/bin/devkit-doctor.sh b/.claude/bin/devkit-doctor.sh new file mode 100755 index 00000000000..2d9a9963da9 --- /dev/null +++ b/.claude/bin/devkit-doctor.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# devkit-doctor — diagnose which statusLine Claude Code is actually using +# and why the devkit statusline may not be appearing. +# +# Usage: .claude/bin/devkit-doctor.sh +set -u + +C_BOLD=$'\033[1m' +C_OK=$'\033[32m' +C_WARN=$'\033[33m' +C_ERR=$'\033[31m' +C_DIM=$'\033[2m' +C_RESET=$'\033[0m' + +say() { printf '%b\n' "$*"; } +hdr() { say "${C_BOLD}== $* ==${C_RESET}"; } +ok() { say " ${C_OK}✓${C_RESET} $*"; } +warn() { say " ${C_WARN}!${C_RESET} $*"; } +err() { say " ${C_ERR}✗${C_RESET} $*"; } +info() { say " ${C_DIM}·${C_RESET} $*"; } + +# ── Locate project root ──────────────────────────────────────────── +PROJECT="$(pwd)" +while [ "$PROJECT" != "/" ] && [ ! -d "$PROJECT/.claude" ]; do + PROJECT="$(dirname "$PROJECT")" +done +if [ ! -d "$PROJECT/.claude" ]; then + err "No .claude/ found walking up from $(pwd). Run install.sh first." + exit 1 +fi +hdr "Project: $PROJECT" + +# ── Settings files and statusLine declarations ───────────────────── +hdr "statusLine declarations (highest precedence wins)" +for f in \ + "$PROJECT/.claude/settings.local.json" \ + "$PROJECT/.claude/settings.json" \ + "$HOME/.claude/settings.json"; do + if [ -f "$f" ]; then + sl=$(jq -r '.statusLine.command // empty' "$f" 2>/dev/null) + if [ -n "$sl" ]; then + ok "$f → $sl" + else + info "$f (no statusLine)" + fi + else + info "$f (absent)" + fi +done + +# The active one is the highest-priority file with a statusLine. +ACTIVE="" +for f in \ + "$PROJECT/.claude/settings.local.json" \ + "$PROJECT/.claude/settings.json" \ + "$HOME/.claude/settings.json"; do + if [ -f "$f" ]; then + sl=$(jq -r '.statusLine.command // empty' "$f" 2>/dev/null) + if [ -n "$sl" ]; then ACTIVE="$sl"; ACTIVE_FROM="$f"; break; fi + fi +done +if [ -n "$ACTIVE" ]; then + say "${C_BOLD}Active statusLine:${C_RESET} $ACTIVE (from $ACTIVE_FROM)" + if printf '%s' "$ACTIVE" | grep -q devkit-statusline; then + ok "devkit statusline is active" + else + err "Devkit statusline is NOT active. A higher-precedence setting wins." + warn "Fix: re-run install.sh — it writes .claude/settings.local.json which beats user settings." + fi +else + err "No statusLine configured anywhere." +fi + +# ── Required binaries ────────────────────────────────────────────── +hdr "Dependencies" +for c in jq python3 git; do + if command -v "$c" >/dev/null 2>&1; then ok "$c: $($c --version 2>&1 | head -1)"; else err "$c missing"; fi +done + +# ── Devkit bin ───────────────────────────────────────────────────── +hdr "Devkit scripts" +for f in devkit-statusline.sh devkit-usage.py; do + p="$PROJECT/.claude/bin/$f" + if [ -x "$p" ]; then ok "$f ($(stat -f '%Sm' "$p"))" + elif [ -f "$p" ]; then warn "$f exists but not executable" + else err "$f missing"; fi +done + +# ── Plan file ────────────────────────────────────────────────────── +hdr "Plan budgets (.claude/devkit-plan.json)" +PLAN="$PROJECT/.claude/devkit-plan.json" +if [ -f "$PLAN" ]; then + s=$(jq -r '.session_tokens' "$PLAN" 2>/dev/null) + w=$(jq -r '.weekly_tokens' "$PLAN" 2>/dev/null) + ok "session_tokens=$s weekly_tokens=$w" +else + warn "Missing. Using built-in defaults (Max 5x)." +fi + +# ── Dry-run the statusline ───────────────────────────────────────── +hdr "Dry-run (2 consecutive calls — frames should differ)" +SESSION_ID="$(find ~/.claude/projects -name "*.jsonl" -mmin -5 2>/dev/null | head -1 | xargs -I {} basename {} .jsonl 2>/dev/null)" +[ -z "$SESSION_ID" ] && SESSION_ID="test-session" +INPUT='{"session_id":"'"$SESSION_ID"'","model":{"id":"claude-opus-4-7","display_name":"Opus 4.7 (1M context)"},"workspace":{"current_dir":"'"$PROJECT"'","project_dir":"'"$PROJECT"'"},"cwd":"'"$PROJECT"'"}' +for i in 1 2; do + printf ' %d: ' "$i" + echo "$INPUT" | bash "$PROJECT/.claude/bin/devkit-statusline.sh" 2>/dev/null + echo + sleep 0.15 +done + +# ── Raw token counts (so you can sanity-check and calibrate budgets) ── +hdr "Raw usage (session used for dry-run: $SESSION_ID)" +rm -f /tmp/devkit-usage-windows.json 2>/dev/null # force fresh +RAW=$(python3 "$PROJECT/.claude/bin/devkit-usage.py" "$SESSION_ID" 2>/dev/null) +if [ -n "$RAW" ]; then + ctx=$(printf '%s' "$RAW" | jq -r '.context_tokens') + w5=$(printf '%s' "$RAW" | jq -r '.window_5h_tokens') + w7=$(printf '%s' "$RAW" | jq -r '.window_7d_tokens') + fmt_k() { awk -v n="$1" 'BEGIN { if(n>=1000000)printf "%.1fM", n/1000000; else if(n>=1000)printf "%.0fK", n/1000; else print n }'; } + info "context_tokens = $(fmt_k "$ctx") (current session, last turn)" + info "window_5h_tokens = $(fmt_k "$w5") (all sessions, rolling 5h)" + info "window_7d_tokens = $(fmt_k "$w7") (all sessions, rolling 7d)" + # Suggest budgets that place the user at ~50% so the bar has headroom + sug5=$(awk -v n="$w5" 'BEGIN { v=n*2; printf "%d", v }') + sug7=$(awk -v n="$w7" 'BEGIN { v=n*2; printf "%d", v }') + info "if these bars look too empty, try session_tokens=$sug5 weekly_tokens=$sug7 in .claude/devkit-plan.json" +fi + +# ── Caches ───────────────────────────────────────────────────────── +hdr "Caches (/tmp)" +ls -la /tmp/devkit-usage-*.json 2>/dev/null | sed 's/^/ /' || info "none" + +# ── Cross-tab rate-limit sync ────────────────────────────────────── +hdr "Cross-tab rate-limit cache (/tmp/devkit-rate-limits.json)" +SHARED=/tmp/devkit-rate-limits.json +if [ -f "$SHARED" ]; then + ts=$(jq -r '.ts // 0' "$SHARED" 2>/dev/null) + age=$(( $(date +%s) - ts )) + fh=$(jq -r '.five_hour' "$SHARED" 2>/dev/null) + sd=$(jq -r '.seven_day' "$SHARED" 2>/dev/null) + info "five_hour=${fh} seven_day=${sd} written ${age}s ago" + if [ "$age" -lt 300 ]; then + ok "fresh — any active tab will use these values" + else + warn "stale (>5min) — will be ignored; falls back to per-tab stdin" + fi +else + info "absent — will be created on next render with native rate_limits" +fi + +# ── Live instrumentation: what is Claude Code actually passing? ──── +hdr "Last stdin seen by statusline (/tmp/devkit-statusline-last-input.json)" +LAST_IN="/tmp/devkit-statusline-last-input.json" +if [ -f "$LAST_IN" ]; then + mtime_str="$(stat -f '%Sm' "$LAST_IN" 2>/dev/null || stat -c '%y' "$LAST_IN" 2>/dev/null)" + info "captured $mtime_str" + SESS="$(jq -r '.session_id // empty' "$LAST_IN" 2>/dev/null)" + CWD_IN="$(jq -r '.workspace.current_dir // .cwd // empty' "$LAST_IN" 2>/dev/null)" + MODEL="$(jq -r '.model.display_name // empty' "$LAST_IN" 2>/dev/null)" + info "session_id = ${SESS:-}" + info "cwd = ${CWD_IN:-}" + info "model = ${MODEL:-}" + + # Data-source breakdown per metric + NCTX="$(jq -r '.context_window.used_percentage // empty' "$LAST_IN" 2>/dev/null)" + N5H="$(jq -r '.rate_limits.five_hour.used_percentage // empty' "$LAST_IN" 2>/dev/null)" + N7D="$(jq -r '.rate_limits.seven_day.used_percentage // empty' "$LAST_IN" 2>/dev/null)" + if [ -n "$NCTX" ]; then ok "ctx: using native .context_window.used_percentage (${NCTX}%)" + else info "ctx: falling back to transcript-parsing (no native field in stdin)"; fi + if [ -n "$N5H" ]; then ok "sess: using native .rate_limits.five_hour.used_percentage (${N5H}%) — matches Anthropic's /usage" + else info "sess: falling back to transcript aggregation vs devkit-plan.json budget (estimate)"; fi + if [ -n "$N7D" ]; then ok "week: using native .rate_limits.seven_day.used_percentage (${N7D}%) — matches Anthropic's /usage" + else info "week: falling back to transcript aggregation vs devkit-plan.json budget (estimate)"; fi + if [ -n "$SESS" ]; then + MATCH="$(find "$HOME/.claude/projects" "$HOME/.ccs/shared/context-groups" -name "${SESS}.jsonl" 2>/dev/null | head -1)" + if [ -n "$MATCH" ]; then + ok "transcript found: $MATCH" + lines="$(wc -l < "$MATCH" 2>/dev/null | tr -d ' ')" + usages="$(grep -c '"usage"' "$MATCH" 2>/dev/null)" + mod="$(stat -f '%Sm' "$MATCH" 2>/dev/null || stat -c '%y' "$MATCH" 2>/dev/null)" + info "lines=$lines usage_entries=$usages last_mod=$mod" + else + err "no transcript file matches session_id=$SESS" + warn "ctx % will be 0 until Claude Code writes a usage entry for this session." + fi + fi +else + warn "No stdin captured yet. Has Claude Code invoked the statusline this session?" + info "After restarting Claude Code + chatting once, re-run this doctor." +fi + +# ── Invocation log: how often does Claude Code call the statusline? ── +hdr "Invocation log (/tmp/devkit-statusline.log)" +LOG="/tmp/devkit-statusline.log" +if [ -f "$LOG" ]; then + total="$(wc -l < "$LOG" 2>/dev/null | tr -d ' ')" + info "total recent invocations tracked: $total (capped at 50)" + say " last 5:" + tail -5 "$LOG" 2>/dev/null | sed 's/^/ /' +else + warn "No log yet." +fi + +# ── Hook activity log ────────────────────────────────────────────── +hdr "Hook activity (.claude/.devkit/hook-log.jsonl)" +HOOK_LOG="$PROJECT/.claude/.devkit/hook-log.jsonl" +if [ -f "$HOOK_LOG" ]; then + total="$(wc -l < "$HOOK_LOG" 2>/dev/null | tr -d ' ')" + blocked="$(grep -c '"verdict":"blocked"' "$HOOK_LOG" 2>/dev/null || echo 0)" + info "entries: $total blocks recorded: $blocked (log capped at 500)" + say " last 20:" + tail -n 20 "$HOOK_LOG" 2>/dev/null | while IFS= read -r line; do + ts=$(printf '%s' "$line" | jq -r '.ts // ""' 2>/dev/null) + hook=$(printf '%s' "$line" | jq -r '.hook // ""' 2>/dev/null) + verdict=$(printf '%s' "$line" | jq -r '.verdict // ""' 2>/dev/null) + reason=$(printf '%s' "$line" | jq -r '.reason // ""' 2>/dev/null) + case "$verdict" in + blocked) icon="${C_ERR}✗${C_RESET}" ;; + allowed) icon="${C_OK}✓${C_RESET}" ;; + *) icon="${C_DIM}·${C_RESET}" ;; + esac + printf ' %b %s %-28s %s%s\n' "$icon" "${ts#*T}" "$hook" "$verdict" "${reason:+ — $reason}" + done +else + info "no hook activity logged yet — log appears after first hook fires" +fi + +say "" +say "${C_DIM}To force-refresh numbers: rm -f /tmp/devkit-usage-*.json${C_RESET}" +say "${C_DIM}To disable animation: export DEVKIT_STATUSLINE_ANIMATE=0${C_RESET}" +say "${C_DIM}To clear instrumentation: rm -f /tmp/devkit-statusline*.{log,json}${C_RESET}" +say "${C_DIM}To clear hook log: rm -f $PROJECT/.claude/.devkit/hook-log.jsonl${C_RESET}" diff --git a/.claude/bin/devkit-projects.py b/.claude/bin/devkit-projects.py new file mode 100755 index 00000000000..430273b7102 --- /dev/null +++ b/.claude/bin/devkit-projects.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""devkit-projects: per-repository token-usage report and registry CLI. + +Subcommands: + register --parent Register a parent dir (subdirs become projects). + register --path Register an explicit project path. + list Show registered parents and paths. + remove Remove a registered parent or path. + report [--sort-by 5h|7d|30d|all] [--json] [--no-cache] + Print per-repo token usage table. + +Registry file: ~/.claude/devkit-projects.json (override with DEVKIT_PROJECTS_REGISTRY). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from datetime import datetime, timezone, timedelta +from pathlib import Path + +# Pick up the shared lib next to this file. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _devkit_usage_lib import ( # noqa: E402 + attribute, + discover_search_roots, + iter_usage_entries, + total_tokens, +) +from devkit_projects_render import render_report # noqa: E402 + + +REGISTRY_VERSION = 1 + +_DEFAULT_CACHE_PATH = Path("/tmp") / "devkit-projects-report.json" +CACHE_TTL_SEC = 10 + + +def _cache_path() -> Path: + """Return the cache file path. Override via DEVKIT_REPORT_CACHE_PATH for tests.""" + override = os.environ.get("DEVKIT_REPORT_CACHE_PATH") + if override: + return Path(override) + return _DEFAULT_CACHE_PATH + + +WINDOWS = ( + ("5h", timedelta(hours=5)), + ("7d", timedelta(days=7)), + ("30d", timedelta(days=30)), + ("all", None), +) + + +def _now() -> datetime: + """`now` in UTC. Override via DEVKIT_FAKE_NOW (ISO-8601) for tests.""" + fake = os.environ.get("DEVKIT_FAKE_NOW") + if fake: + try: + parsed = datetime.fromisoformat(fake) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + except ValueError: + pass + return datetime.now(timezone.utc) + + +def compute_per_repo_report(parents: list[str], paths: list[str], now: datetime) -> list[dict]: + """Scan all transcripts, attribute each usage entry to a repo, sum per window.""" + cutoffs = {label: (now - delta) if delta else None for label, delta in WINDOWS} + + repos: dict[str, dict] = {} + + for entry in iter_usage_entries(discover_search_roots(), since=None): + label = attribute(entry.cwd, parents, paths) + bucket = repos.setdefault(label, { + "5h": 0, "7d": 0, "30d": 0, "all": 0, "sessions": set(), + }) + tok = total_tokens(entry.usage) + bucket["all"] += tok + if entry.session_id: + bucket["sessions"].add(entry.session_id) + for win_label, cutoff in cutoffs.items(): + if win_label == "all": + continue + if cutoff is not None and entry.timestamp >= cutoff: + bucket[win_label] += tok + + rows = [] + for label, bucket in repos.items(): + rows.append({ + "repo": label, + "5h": bucket["5h"], + "7d": bucket["7d"], + "30d": bucket["30d"], + "all": bucket["all"], + "sessions": len(bucket["sessions"]), + }) + return rows + + +def _read_cache() -> list[dict] | None: + try: + cache = _cache_path() + if not cache.is_file(): + return None + if time.time() - cache.stat().st_mtime >= CACHE_TTL_SEC: + return None + with cache.open("r", encoding="utf-8") as fh: + return json.load(fh).get("rows") + except (OSError, json.JSONDecodeError): + return None + + +def _write_cache(rows: list[dict]) -> None: + try: + cache = _cache_path() + tmp = cache.with_suffix(".tmp") + with tmp.open("w", encoding="utf-8") as fh: + json.dump({"rows": rows}, fh) + os.replace(tmp, cache) + except OSError: + pass + + +def cmd_report(args: argparse.Namespace) -> int: + reg_path = registry_path() + data = load_registry(reg_path) + + rows: list[dict] | None = None if args.no_cache else _read_cache() + if rows is None: + rows = compute_per_repo_report(data["parents"], data["paths"], _now()) + if not args.no_cache: + _write_cache(rows) + + if args.json: + json.dump({"rows": rows}, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + if not data["parents"] and not data["paths"]: + sys.stdout.write( + "Note: no project paths registered. All sessions grouped under (other).\n" + "Register a parent dir to see a per-repo breakdown:\n" + f" {Path(sys.argv[0]).name} register --parent \n\n" + ) + + sort_by = args.sort_by + sys.stdout.write(render_report(rows, sort_by=sort_by)) + return 0 + + +def registry_path() -> Path: + override = os.environ.get("DEVKIT_PROJECTS_REGISTRY") + if override: + return Path(override) + return Path.home() / ".claude" / "devkit-projects.json" + + +def load_registry(path: Path) -> dict: + """Load the registry. Missing → empty registry. Malformed → raise.""" + if not path.exists(): + return {"version": REGISTRY_VERSION, "parents": [], "paths": []} + try: + with path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + except json.JSONDecodeError as exc: + raise SystemExit(f"error: registry at {path} is not valid JSON: {exc}") + data.setdefault("version", REGISTRY_VERSION) + data.setdefault("parents", []) + data.setdefault("paths", []) + return data + + +def save_registry(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + with tmp.open("w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") + os.replace(tmp, path) + + +def _canon(raw: str) -> str: + """Canonicalize a path: absolute + symlinks resolved.""" + return str(Path(raw).expanduser().resolve()) + + +def cmd_register(args: argparse.Namespace) -> int: + path = registry_path() + data = load_registry(path) + + target = _canon(args.parent or args.path) + + if args.parent: + existing = data["parents"] + # Warn on overlap with another parent (sub or super). + for other in existing: + if target == other: + continue + if target.startswith(other + "/") or other.startswith(target + "/"): + print( + f"warning: parent {target!r} overlaps existing parent {other!r}; " + "longest match wins at attribution time", + file=sys.stderr, + ) + break + if target not in existing: + existing.append(target) + existing.sort() + save_registry(path, data) + print(f"Registered parent: {target}") + else: + print(f"Already registered (parent): {target}") + return 0 + + existing = data["paths"] + if target not in existing: + existing.append(target) + existing.sort() + save_registry(path, data) + print(f"Registered path: {target}") + else: + print(f"Already registered (path): {target}") + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + path = registry_path() + data = load_registry(path) + + rows = [] + for entry in data["parents"]: + marker = "" if Path(entry).is_dir() else " (missing)" + rows.append(("parent", entry + marker)) + for entry in data["paths"]: + marker = "" if Path(entry).is_dir() else " (missing)" + rows.append(("path", entry + marker)) + + if not rows: + print("(empty registry)") + print("Tip: register a parent dir with:") + print(f" {Path(sys.argv[0]).name} register --parent ") + return 0 + + width = max(len(kind) for kind, _ in rows) + for kind, value in rows: + print(f"{kind:<{width}} {value}") + return 0 + + +def cmd_remove(args: argparse.Namespace) -> int: + path = registry_path() + data = load_registry(path) + target = _canon(args.path) + + removed = False + if target in data["parents"]: + data["parents"].remove(target) + removed = True + kind = "parent" + elif target in data["paths"]: + data["paths"].remove(target) + removed = True + kind = "path" + + if not removed: + print(f"error: {target!r} is not registered", file=sys.stderr) + return 1 + + save_registry(path, data) + print(f"Removed {kind}: {target}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="devkit-projects", description=__doc__.split("\n")[0]) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_reg = sub.add_parser("register", help="Register a parent dir or explicit project path") + group = p_reg.add_mutually_exclusive_group(required=True) + group.add_argument("--parent", help="Parent directory; subdirs become projects") + group.add_argument("--path", help="Explicit project path") + p_reg.set_defaults(func=cmd_register) + + p_list = sub.add_parser("list", help="Show registered parents and paths") + p_list.set_defaults(func=cmd_list) + + p_rem = sub.add_parser("remove", help="Remove a registered entry") + p_rem.add_argument("path") + p_rem.set_defaults(func=cmd_remove) + + p_rep = sub.add_parser("report", help="Print per-repo token usage table") + p_rep.add_argument("--sort-by", choices=["5h", "7d", "30d", "all"], default="7d") + p_rep.add_argument("--json", action="store_true", help="Machine-readable output") + p_rep.add_argument("--no-cache", action="store_true", help="Bypass 10s cache") + p_rep.set_defaults(func=cmd_report) + + return parser + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/bin/devkit-state.sh b/.claude/bin/devkit-state.sh new file mode 100755 index 00000000000..bf70561925d --- /dev/null +++ b/.claude/bin/devkit-state.sh @@ -0,0 +1,535 @@ +#!/usr/bin/env bash +# devkit-state.sh — workflow state management for ringkas-devkit +# Returns JSON to stdout for every command. Orchestrator never reads STATE.md directly. +# Compatible with bash 3.2+ (macOS default). +set -euo pipefail + +DEVKIT_DIR=".devkit" +STATE_FILE="$DEVKIT_DIR/STATE.md" +CHECKPOINT_FILE="$DEVKIT_DIR/continue-here.md" + +# Clean up any leftover temp files on exit +trap 'rm -f "${STATE_FILE}.tmp" 2>/dev/null || true' EXIT + +# ── Workflow definitions ──────────────────────────────────────────── + +workflow_steps() { + case "$1" in + feature) echo "planner coder tester reviewer security" ;; + bugfix) echo "diagnose coder tester reviewer" ;; + review) echo "reviewer security" ;; + audit) echo "security" ;; + *) return 1 ;; + esac +} + +# ── Model routing (balanced profile) ─────────────────────────────── + +model_for() { + case "$1" in + planner) echo "opus" ;; + coder) echo "sonnet" ;; + tester) echo "sonnet" ;; + reviewer) echo "sonnet" ;; + security) echo "haiku" ;; + diagnose) echo "sonnet" ;; + *) echo "sonnet" ;; + esac +} + +# ── Skip warnings ────────────────────────────────────────────────── + +skip_warning_for() { + case "$1" in + planner) echo "No plan — proceeding without architecture review." ;; + coder) echo "No auto-code — engineer will implement manually." ;; + tester) echo "No tests generated. Coverage may drop below 80%." ;; + reviewer) echo "Code not reviewed against Ringkas conventions." ;; + security) echo "ISO 27001 compliance not checked. Required for auth/PII changes." ;; + diagnose) echo "No root cause analysis — proceeding without diagnosis." ;; + *) echo "" ;; + esac +} + +# ── Helpers ───────────────────────────────────────────────────────── + +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + # Strip remaining control characters (0x00-0x1F except \n \r \t already handled) + printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037' +} + +now_iso() { date -u +"%Y-%m-%dT%H:%M:%S"; } +now_time() { date -u +"%H:%M"; } +today() { date -u +"%Y%m%d"; } + +gen_session_id() { + local wf="$1" + local prefix + prefix="$(printf '%s' "$wf" | cut -c1)" + local hex + hex=$(LC_ALL=C tr -dc 'a-f0-9' < /dev/urandom | head -c4) + printf '%s-%s-%s' "$prefix" "$(today)" "$hex" +} + +die() { printf '{"error": "%s"}\n' "$(json_escape "$1")" >&2; exit 1; } + +require_state() { + [[ -f "$STATE_FILE" ]] || die "No active session. Run: devkit-state.sh init " +} + +# ── STATE.md readers ──────────────────────────────────────────────── + +read_frontmatter() { + local key="$1" + sed -n '/^---$/,/^---$/p' "$STATE_FILE" | grep "^${key}:" | head -1 | sed "s/^${key}: *//" +} + +read_steps() { + # Output: step|status|skipped|agent|started|completed per line + awk '/^\| Step /,0' "$STATE_FILE" \ + | tail -n +3 \ + | grep '^|' \ + | sed 's/^| *//;s/ *| */|/g;s/ *|$//' +} + +get_step_field() { + # $1=step_name $2=field_index (0-based: 0=step,1=status,2=skipped,3=agent,4=started,5=completed) + local step="$1" idx="$2" + read_steps | awk -F'|' -v s="$step" -v i="$((idx+1))" '$1==s {print $i}' +} + +current_step_name() { + read_steps | awk -F'|' '$2=="in_progress" {print $1; exit}' +} + +next_pending_step() { + read_steps | awk -F'|' '$2=="pending" {print $1; exit}' +} + +# ── STATE.md writers ──────────────────────────────────────────────── + +update_step_status() { + local step="$1" new_status="$2" time_field="$3" time_val="$4" + local tmp="$STATE_FILE.tmp" + awk -v step="$step" -v status="$new_status" -v tf="$time_field" -v tv="$time_val" ' + BEGIN { in_table=0 } + /^\| Step / { in_table=1 } + in_table && /^\|/ { + n = split($0, parts, "|") + gsub(/^ +| +$/, "", parts[2]) + if (parts[2] == step) { + gsub(/^ +| +$/, "", parts[3]) + parts[3] = status + gsub(/^ +| +$/, "", parts[4]) + gsub(/^ +| +$/, "", parts[5]) + gsub(/^ +| +$/, "", parts[6]) + gsub(/^ +| +$/, "", parts[7]) + if (tf == "started") { parts[6] = tv } + if (tf == "completed") { parts[7] = tv } + printf "| %s | %s | %s | %s | %s | %s |\n", parts[2], parts[3], parts[4], parts[5], parts[6], parts[7] + next + } + } + { print } + ' "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE" +} + +mark_step_skipped() { + local step="$1" + local tmp="$STATE_FILE.tmp" + awk -v step="$step" ' + BEGIN { in_table=0 } + /^\| Step / { in_table=1 } + in_table && /^\|/ { + n = split($0, parts, "|") + gsub(/^ +| +$/, "", parts[2]) + if (parts[2] == step) { + gsub(/^ +| +$/, "", parts[5]) + gsub(/^ +| +$/, "", parts[6]) + gsub(/^ +| +$/, "", parts[7]) + printf "| %s | skipped | yes | %s | %s | %s |\n", parts[2], parts[5], parts[6], parts[7] + next + } + } + { print } + ' "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE" +} + +append_decision() { + local decision="$1" + local ts + ts="$(now_iso)" + local tmp="$STATE_FILE.tmp" + awk -v dec="$decision" -v ts="$ts" ' + /^## Decisions/ { print; found=1; next } + found && /^\(none\)/ { print "- [" ts "] " dec; found=0; next } + found && /^$/ && !printed { print "- [" ts "] " dec; printed=1 } + { print } + ' "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE" +} + +append_skipped() { + local step="$1" reason="$2" + local tmp="$STATE_FILE.tmp" + awk -v s="$step" -v r="$reason" ' + /^## Skipped Steps/ { print; found=1; next } + found && /^\(none\)/ { print "- **" s "**: " r; found=0; next } + found && /^$/ && !printed { print "- **" s "**: " r; printed=1 } + { print } + ' "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE" +} + +# ── Commands ──────────────────────────────────────────────────────── + +cmd_init() { + local workflow="" ticket="" coder_mode="auto" + while [[ $# -gt 0 ]]; do + case "$1" in + --coder) coder_mode="$2"; shift 2 ;; + *) if [[ -z "$workflow" ]]; then workflow="$1" + elif [[ -z "$ticket" ]]; then ticket="$1" + fi; shift ;; + esac + done + + [[ -n "$workflow" ]] || die "Usage: devkit-state.sh init [--coder auto|engineer]" + [[ -n "$ticket" ]] || die "Usage: devkit-state.sh init [--coder auto|engineer]" + + local steps + steps="$(workflow_steps "$workflow" 2>/dev/null)" || die "Unknown workflow: $workflow. Valid: feature, bugfix, review, audit" + [[ "$coder_mode" == "auto" || "$coder_mode" == "engineer" ]] || die "Invalid coder mode: $coder_mode. Valid: auto, engineer" + + local session_id + session_id="$(gen_session_id "$workflow")" + local started + started="$(now_iso)" + local first_step="${steps%% *}" + local first_model + first_model="$(model_for "$first_step")" + + mkdir -p "$DEVKIT_DIR" + + # Add to .gitignore if not present + if [[ -f .gitignore ]]; then + grep -q '^\.devkit/' .gitignore 2>/dev/null || echo '.devkit/' >> .gitignore + else + echo '.devkit/' > .gitignore + fi + + # Build table rows + local table_rows="" + local first=1 + for step in $steps; do + local status="pending" step_started="—" + if [[ $first -eq 1 ]]; then + status="in_progress" + step_started="$(now_time)" + first=0 + fi + local m + m="$(model_for "$step")" + table_rows="${table_rows}| ${step} | ${status} | — | ${m} | ${step_started} | — | +" + done + + cat > "$STATE_FILE" < \"\"" + [[ -n "$reason" ]] || die "Usage: devkit-state.sh skip \"\"" + + local step_status + step_status="$(get_step_field "$step_name" 1)" + [[ -n "$step_status" ]] || die "Step not found: $step_name" + + if [[ "$step_status" == "completed" || "$step_status" == "skipped" ]]; then + die "Step $step_name is already $step_status" + fi + + # Mark as skipped + mark_step_skipped "$step_name" + + # Log in skipped section + append_skipped "$step_name" "$reason" + + # Activate next pending step if no step is currently in_progress + local warning + warning="$(skip_warning_for "$step_name")" + local cur + cur="$(current_step_name)" + if [[ -z "$cur" ]]; then + local np + np="$(next_pending_step)" + if [[ -n "$np" ]]; then + update_step_status "$np" "in_progress" "started" "$(now_time)" + fi + fi + + local next_out + next_out="$(current_step_name)" + if [[ -z "$next_out" ]]; then + next_out="$(next_pending_step)" + fi + + local next_json="null" + [[ -z "$next_out" ]] || next_json="\"$next_out\"" + + printf '{"skipped":"%s","reason":"%s","warning":"%s","next_step":%s}\n' \ + "$step_name" "$(json_escape "$reason")" "$(json_escape "$warning")" "$next_json" +} + +cmd_checkpoint() { + require_state + local reason="${1:-Paused}" + local session_id workflow last_completed next_step + + session_id="$(read_frontmatter session_id)" + workflow="$(read_frontmatter workflow)" + + last_completed="$(read_steps | awk -F'|' '$2=="completed" {last=$1} END {print last}')" + next_step="$(current_step_name)" + [[ -n "$next_step" ]] || next_step="$(next_pending_step)" + + local last_json="null" + [[ -z "$last_completed" ]] || last_json="\"$last_completed\"" + local next_json="null" + [[ -z "$next_step" ]] || next_json="\"$next_step\"" + + cat > "$CHECKPOINT_FILE" < "$DEBUG_INPUT" 2>/dev/null || true +{ + printf '%s pid=%s ppid=%s\n' "$(date '+%F %T')" "$$" "$PPID" +} >> "$DEBUG_LOG" 2>/dev/null || true +# Trim log: keep last 50 lines +if [ -f "$DEBUG_LOG" ]; then + tail -n 50 "$DEBUG_LOG" > "${DEBUG_LOG}.tmp" 2>/dev/null && mv "${DEBUG_LOG}.tmp" "$DEBUG_LOG" 2>/dev/null || true +fi + +json_get() { + printf '%s' "$PAYLOAD" | jq -r "$1 // empty" 2>/dev/null || true +} + +SESSION_ID="$(json_get '.session_id')" +MODEL_DISPLAY="$(json_get '.model.display_name')" +MODEL_ID="$(json_get '.model.id')" +CWD="$(json_get '.workspace.current_dir')" +[ -z "$CWD" ] && CWD="$(json_get '.cwd')" +[ -z "$CWD" ] && CWD="$PWD" +PROJECT_DIR="$(json_get '.workspace.project_dir')" +[ -z "$PROJECT_DIR" ] && PROJECT_DIR="$CWD" + +PROJECT_NAME="$(basename "$PROJECT_DIR")" + +# ── Native rate-limit fields (CCS and newer Claude Code expose these) ── +# If present, we use them as-is — they're the real Anthropic numbers. +# Otherwise fall back to transcript-parsing aggregation. +NATIVE_CTX_PCT="$(json_get '.context_window.used_percentage')" +NATIVE_5H_PCT="$(json_get '.rate_limits.five_hour.used_percentage')" +NATIVE_7D_PCT="$(json_get '.rate_limits.seven_day.used_percentage')" +NATIVE_CTX_SIZE="$(json_get '.context_window.context_window_size')" + +# ── Git branch (best-effort, fast) ───────────────────────────────── +BRANCH="" +if [ -d "$PROJECT_DIR/.git" ] || git -C "$PROJECT_DIR" rev-parse --git-dir >/dev/null 2>&1; then + BRANCH="$(git -C "$PROJECT_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null || true)" + [ -z "$BRANCH" ] && BRANCH="$(git -C "$PROJECT_DIR" rev-parse --short HEAD 2>/dev/null || true)" +fi + +# ── Auth status (from cached auth-check hook result) ────────────── +AUTH_CACHE="/tmp/devkit-auth-status.json" +AUTH_EXPIRED="" +if [ -f "$AUTH_CACHE" ]; then + AUTH_LOGGED_IN="$(jq -r '.loggedIn // false' "$AUTH_CACHE" 2>/dev/null || echo 'false')" + if [ "$AUTH_LOGGED_IN" != "true" ]; then + AUTH_EXPIRED="1" + fi +fi + +# ── Thinking tier (from MAX_THINKING_TOKENS) ─────────────────────── +THINK_TIER="" +if [ -n "${MAX_THINKING_TOKENS:-}" ] && [ "${MAX_THINKING_TOKENS}" -gt 0 ] 2>/dev/null; then + if [ "$MAX_THINKING_TOKENS" -le 5000 ]; then THINK_TIER="low" + elif [ "$MAX_THINKING_TOKENS" -le 20000 ]; then THINK_TIER="med" + else THINK_TIER="high" + fi +fi + +# ── Context window size ──────────────────────────────────────────── +# Prefer stdin's context_window.context_window_size, else infer from model name. +if [ -n "$NATIVE_CTX_SIZE" ] && [ "$NATIVE_CTX_SIZE" -gt 0 ] 2>/dev/null; then + CTX_MAX="$NATIVE_CTX_SIZE" +else + CTX_MAX=200000 + case "$MODEL_DISPLAY $MODEL_ID" in + *1M*|*1m*) CTX_MAX=1000000 ;; + esac +fi +if [ -n "${CLAUDE_CONTEXT_MAX:-}" ] && [ "$CLAUDE_CONTEXT_MAX" -gt 0 ] 2>/dev/null; then + CTX_MAX="$CLAUDE_CONTEXT_MAX" +fi + +# ── Plan limits ──────────────────────────────────────────────────── +PLAN_FILE="" +for candidate in \ + "$CWD/.claude/devkit-plan.json" \ + "$PROJECT_DIR/.claude/devkit-plan.json" \ + "$HOME/.claude/devkit-plan.json"; do + if [ -f "$candidate" ]; then + PLAN_FILE="$candidate" + break + fi +done + +SESSION_BUDGET=44000000 +WEEKLY_BUDGET=880000000 +if [ -n "$PLAN_FILE" ]; then + v="$(jq -r '.session_tokens // empty' "$PLAN_FILE" 2>/dev/null)" + [ -n "$v" ] && SESSION_BUDGET="$v" + v="$(jq -r '.weekly_tokens // empty' "$PLAN_FILE" 2>/dev/null)" + [ -n "$v" ] && WEEKLY_BUDGET="$v" +fi + +# ── Aggregate usage via Python helper (fallback + frame counter) ─── +# We always run the helper because we need `frame` for the spinner, and +# the fallback window numbers in case stdin lacks native rate-limit data. +USAGE_JSON='{}' +if command -v python3 >/dev/null 2>&1 && [ -f "$PY_HELPER" ]; then + USAGE_JSON="$(python3 "$PY_HELPER" "$SESSION_ID" 2>/dev/null || echo '{}')" +fi + +get_n() { printf '%s' "$USAGE_JSON" | jq -r "$1 // 0" 2>/dev/null || echo 0; } +CTX_TOK="$(get_n '.context_tokens')" +W5H_TOK="$(get_n '.window_5h_tokens')" +W7D_TOK="$(get_n '.window_7d_tokens')" + +# ── Per-invocation frame counter ─────────────────────────────────── +# Advances one per statusline call so animation never repeats the same +# frame twice in a row, regardless of Claude Code's refresh cadence. +FRAME_FILE="/tmp/devkit-statusline-frame.count" +FRAME=0 +if [ -f "$FRAME_FILE" ]; then + FRAME=$(cat "$FRAME_FILE" 2>/dev/null) + FRAME=$(( (FRAME + 1) % 8 )) +fi +printf '%s' "$FRAME" > "$FRAME_FILE" 2>/dev/null || true + +# pct10 returns percent * 10 as an integer — e.g. 11.4% -> 114. +# Lets us format decimals and thresholds without shelling out to awk twice. +pct10() { + local used="$1" max="$2" + [ "$max" -eq 0 ] 2>/dev/null && { echo 0; return; } + awk -v u="$used" -v m="$max" 'BEGIN { p=(u*1000)/m; if(p>9999)p=9999; printf "%d", p }' +} + +# Convert a native percent value (possibly integer like "32" or float like +# "14.000000000000002") to percent*10 as an integer. Bash arithmetic can't +# handle decimals, so we route through awk. +native_pct10() { + awk -v v="$1" 'BEGIN { printf "%d", (v * 10) + 0.5 }' +} + +# ctx: ALWAYS use transcript tail — native .context_window.used_percentage +# from CCS rounds to integers (10K-token jumps on 1M), while the transcript +# gives us decimal precision that visibly moves every turn. +CTX_PCT10="$(pct10 "$CTX_TOK" "$CTX_MAX")" + +# ── Cross-tab rate-limit sync (Option A: latest-write-wins, 5-min TTL) ── +# Every tab's CCS has its own rate-limit cache with different staleness. +# We publish our observed native values to a shared file and prefer whichever +# values were written most recently across all tabs. This means an active tab +# in another terminal that just got fresh CCS data will lift all other active +# tabs' statuslines too. Idle tabs can't be helped — Claude Code won't re- +# invoke their statusline without user activity. +SHARED_RL_FILE="/tmp/devkit-rate-limits.json" +SHARED_RL_TTL=300 +NOW_EPOCH="$(date +%s)" + +# Publish our values if we have them +if [ -n "$NATIVE_5H_PCT" ] || [ -n "$NATIVE_7D_PCT" ]; then + printf '{"ts":%s,"five_hour":%s,"seven_day":%s}' \ + "$NOW_EPOCH" \ + "${NATIVE_5H_PCT:-null}" \ + "${NATIVE_7D_PCT:-null}" \ + > "${SHARED_RL_FILE}.tmp" 2>/dev/null + mv -f "${SHARED_RL_FILE}.tmp" "$SHARED_RL_FILE" 2>/dev/null || true +fi + +# Read back — use whichever is in shared (may be us, may be another tab's +# more-recent write). Fall through if shared is stale. +if [ -f "$SHARED_RL_FILE" ]; then + SHARED_TS="$(jq -r '.ts // 0' "$SHARED_RL_FILE" 2>/dev/null || echo 0)" + SHARED_AGE=$(( NOW_EPOCH - SHARED_TS )) + if [ "$SHARED_AGE" -lt "$SHARED_RL_TTL" ] 2>/dev/null; then + SHARED_5H="$(jq -r '.five_hour // empty' "$SHARED_RL_FILE" 2>/dev/null)" + SHARED_7D="$(jq -r '.seven_day // empty' "$SHARED_RL_FILE" 2>/dev/null)" + [ -n "$SHARED_5H" ] && [ "$SHARED_5H" != "null" ] && NATIVE_5H_PCT="$SHARED_5H" + [ -n "$SHARED_7D" ] && [ "$SHARED_7D" != "null" ] && NATIVE_7D_PCT="$SHARED_7D" + fi +fi + +# sess / week: prefer native rate-limit fields (they match Anthropic's /usage). +# Native values are cached by CCS and refresh slowly — that's a CCS concern, +# not fixable here. Fall back to transcript aggregation vs plan budget only +# when the CLI doesn't expose rate-limit fields at all. +if [ -n "$NATIVE_5H_PCT" ]; then + SESS_PCT10="$(native_pct10 "$NATIVE_5H_PCT")" + SESS_SRC="native" +else + SESS_PCT10="$(pct10 "$W5H_TOK" "$SESSION_BUDGET")" + SESS_SRC="estimate" +fi + +if [ -n "$NATIVE_7D_PCT" ]; then + WEEK_PCT10="$(native_pct10 "$NATIVE_7D_PCT")" + WEEK_SRC="native" +else + WEEK_PCT10="$(pct10 "$W7D_TOK" "$WEEKLY_BUDGET")" + WEEK_SRC="estimate" +fi + +# Integer percent for color thresholds +CTX_PCT=$((CTX_PCT10 / 10)) +SESS_PCT=$((SESS_PCT10 / 10)) +WEEK_PCT=$((WEEK_PCT10 / 10)) + +# Formatted decimal percent string, e.g. "11.4" +fmt_pct() { + local p10="$1" + printf '%d.%d' $((p10/10)) $((p10%10)) +} + +# ── ANSI + glyphs ────────────────────────────────────────────────── +DIM=$'\033[2m' +RESET=$'\033[0m' +BOLD=$'\033[1m' +CYAN=$'\033[36m' +ITALIC=$'\033[3m' + +# 256-color gradient from green (safe) to red (danger). Index = pct/10. +COLOR_GRADIENT=(40 46 82 118 154 190 226 214 208 202 196) +EMPTY_COLOR=238 # dim gray for unfilled bar +CAP_COLOR=244 # soft gray for rounded bar caps +SPINNER_COLOR=87 # soft cyan + +color_for_pct() { + local pct="$1" + [ "$pct" -lt 0 ] 2>/dev/null && pct=0 + [ "$pct" -gt 100 ] 2>/dev/null && pct=100 + local idx=$(( pct / 10 )) + [ "$idx" -gt 10 ] && idx=10 + printf '%s' "${COLOR_GRADIENT[$idx]}" +} + +# Emit SGR code for a 256-color foreground. +fg256() { printf '\033[38;5;%sm' "$1"; } +fg256bold() { printf '\033[1;38;5;%sm' "$1"; } +fg256dim() { printf '\033[2;38;5;%sm' "$1"; } + +ANIMATE="${DEVKIT_STATUSLINE_ANIMATE:-1}" +SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧') +SUB_GLYPHS=('░' '▏' '▎' '▍' '▌' '▋' '▊' '▉') + +# Fancy progress bar — 8 chars wide, each with 8 sub-block fill levels +# (64 visible resolution steps). Colors: +# - filled: 11-step gradient green→red based on overall percent +# - scan shimmer: one filled block each frame rendered bold to create a +# subtle sweeping highlight across the filled area (animation off → absent) +# - trailing sub-block: same hue as the rest, 1/8-step precision +# - empty tail: dim gray for low contrast so the filled part pops +# - caps: ▕ and ▏ in soft gray, hinting at a rounded track +bar() { + local p10="$1" width=8 + [ "$p10" -lt 0 ] 2>/dev/null && p10=0 + [ "$p10" -gt 1000 ] 2>/dev/null && p10=1000 + local subunits=$(( p10 * width * 8 / 1000 )) + local full=$(( subunits / 8 )) + local frac=$(( subunits % 8 )) + local empty=$(( width - full - (frac>0?1:0) )) + [ "$empty" -lt 0 ] && empty=0 + + local pct=$(( p10 / 10 )) + local base_color; base_color="$(color_for_pct "$pct")" + + local scan_pos=-1 + if [ "$ANIMATE" = "1" ] && [ "$full" -gt 1 ]; then + scan_pos=$(( FRAME % full )) + fi + + local FG FG_BRIGHT FG_EMPTY CAP + FG="$(fg256 "$base_color")" + FG_BRIGHT="$(fg256bold "$base_color")" + FG_EMPTY="$(fg256dim "$EMPTY_COLOR")" + CAP="$(fg256dim "$CAP_COLOR")" + + local out="${CAP}▕${RESET}" + local i + for (( i=0; i.jsonl under +~/.claude/projects/, so context is computed by tailing that one file +instead of scanning every transcript. + +Usage: devkit-usage.py [--no-cache] +""" + +import json +import os +import sys +import time +import glob +from pathlib import Path +from datetime import datetime, timezone, timedelta + +# Local import — _devkit_usage_lib lives next to this file. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _devkit_usage_lib import ( # noqa: E402 + context_tokens_of, + discover_search_roots, + iter_usage_entries, + parse_ts, + total_tokens, +) + +CACHE_DIR = Path("/tmp") +WINDOW_CACHE_TTL_SEC = 5 +TAIL_BYTES = 2 * 1024 * 1024 # 2 MB is enough for any single turn + + +SEARCH_ROOTS = discover_search_roots() + + +# ── Context (fresh, per-call) ──────────────────────────────────────── + +def find_session_file(session_id): + if not session_id: + return None + matches = [] + for root in SEARCH_ROOTS: + matches.extend(glob.glob(str(root / "**" / f"{session_id}.jsonl"), recursive=True)) + if not matches: + return None + return max(matches, key=os.path.getmtime) + + +def tail_context_tokens(session_id): + """Read only the last TAIL_BYTES of the session file and find the most + recent usage entry. Returns (context_tokens, session_total_tokens_in_tail). + The second number is approximate (bounded by the tail window).""" + path = find_session_file(session_id) + if not path: + return 0, 0 + + try: + size = os.path.getsize(path) + with open(path, "rb") as fh: + if size > TAIL_BYTES: + fh.seek(size - TAIL_BYTES) + fh.readline() # discard partial first line + data = fh.read() + except OSError: + return 0, 0 + + text = data.decode("utf-8", errors="ignore") + + last_ctx = 0 + last_ts = None + total_in_tail = 0 + + for line in text.splitlines(): + if '"usage"' not in line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + msg = entry.get("message") or {} + usage = msg.get("usage") + if not usage: + continue + + total_in_tail += total_tokens(usage) + + ts = parse_ts(entry.get("timestamp")) + if ts and (last_ts is None or ts > last_ts): + last_ts = ts + last_ctx = context_tokens_of(usage) + + return last_ctx, total_in_tail + + +# ── Windows (cached 10s) ──────────────────────────────────────────── + +def compute_windows(): + now = datetime.now(timezone.utc) + cutoff_5h = now - timedelta(hours=5) + cutoff_7d = now - timedelta(days=7) + + w5 = 0 + w7 = 0 + + for entry in iter_usage_entries(SEARCH_ROOTS, since=cutoff_7d): + tok = total_tokens(entry.usage) + w7 += tok + if entry.timestamp >= cutoff_5h: + w5 += tok + + return w5, w7 + + +def cached_windows(no_cache=False): + cache_file = CACHE_DIR / "devkit-usage-windows.json" + now = time.time() + + if not no_cache and cache_file.is_file(): + try: + age = now - cache_file.stat().st_mtime + if age < WINDOW_CACHE_TTL_SEC: + with open(cache_file, "r", encoding="utf-8") as fh: + d = json.load(fh) + return d.get("w5", 0), d.get("w7", 0) + except (OSError, json.JSONDecodeError): + pass + + w5, w7 = compute_windows() + try: + tmp = cache_file.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as fh: + json.dump({"w5": w5, "w7": w7}, fh) + os.replace(tmp, cache_file) + except OSError: + pass + return w5, w7 + + +# ── Entry point ───────────────────────────────────────────────────── + +def main(): + argv = sys.argv[1:] + no_cache = "--no-cache" in argv + argv = [a for a in argv if a != "--no-cache"] + session_id = argv[0] if argv else "" + + ctx, _tail_total = tail_context_tokens(session_id) + w5, w7 = cached_windows(no_cache=no_cache) + + frame = int(time.time() * 8) % 8 + + json.dump({ + "context_tokens": ctx, + "session_tokens": w5, + "window_5h_tokens": w5, + "window_7d_tokens": w7, + "frame": frame, + }, sys.stdout) + + +if __name__ == "__main__": + main() diff --git a/.claude/bin/devkit_projects_render.py b/.claude/bin/devkit_projects_render.py new file mode 100755 index 00000000000..fb85f4be87f --- /dev/null +++ b/.claude/bin/devkit_projects_render.py @@ -0,0 +1,96 @@ +"""Number formatting + table rendering for devkit-projects report. + +Pure functions, no I/O. Importable both from devkit-projects.py and tests. +""" + +from __future__ import annotations + +SYNTHETIC = {"(other)", "(parent-root)"} + +COLUMNS = [ + ("REPO", "repo", "<", 14), + ("5H", "5h", ">", 9), + ("7D", "7d", ">", 9), + ("30D", "30d", ">", 9), + ("ALL-TIME", "all", ">", 12), + ("SESSIONS", "sessions", ">", 10), +] + + +def format_count(n: int) -> str: + """Format a token count: 0–999 raw, 1K–999K, 1.0M+, 1.0B+.""" + sign = "-" if n < 0 else "" + n = abs(n) + if n < 1_000: + return f"{sign}{n}" + if n < 1_000_000: + return f"{sign}{n / 1_000:.1f}K" + if n < 1_000_000_000: + return f"{sign}{n / 1_000_000:.1f}M" + return f"{sign}{n / 1_000_000_000:.1f}B" + + +def _format_cell(value, key: str) -> str: + if key == "repo": + return str(value) + if key == "sessions": + return str(int(value)) + return format_count(int(value)) + + +def _sort_key(row: dict, sort_by: str): + """Ascending key: synthetic last, then -value (so larger values come first).""" + is_synthetic = 1 if row["repo"] in SYNTHETIC else 0 + return (is_synthetic, -int(row.get(sort_by, 0)), row["repo"]) + + +def render_report(rows: list[dict], sort_by: str = "7d") -> str: + """Render the per-repo usage table. `rows` is a list of dicts with keys + matching COLUMNS' second tuple element.""" + if not rows: + return "(empty) no usage data found.\nTip: register a parent dir with: devkit-projects register --parent \n" + + sorted_rows = sorted(rows, key=lambda r: _sort_key(r, sort_by)) + + totals = { + key: sum(int(row.get(key, 0)) for row in sorted_rows) + for _, key, _, _ in COLUMNS + if key != "repo" + } + + lines = [] + + header_parts = [] + for header, _, align, width in COLUMNS: + if align == "<": + header_parts.append(f"{header:<{width}}") + else: + header_parts.append(f"{header:>{width}}") + lines.append(" ".join(header_parts)) + + for row in sorted_rows: + parts = [] + for _, key, align, width in COLUMNS: + cell = _format_cell(row.get(key, 0), key) + if align == "<": + parts.append(f"{cell:<{width}}") + else: + parts.append(f"{cell:>{width}}") + lines.append(" ".join(parts)) + + sep_width = sum(width for _, _, _, width in COLUMNS) + 2 * (len(COLUMNS) - 1) + lines.append("─" * sep_width) + + total_parts = [] + for header, key, align, width in COLUMNS: + if key == "repo": + cell = "TOTAL" + else: + cell = _format_cell(totals[key], key) + if align == "<": + total_parts.append(f"{cell:<{width}}") + else: + total_parts.append(f"{cell:>{width}}") + lines.append(" ".join(total_parts)) + + return "\n".join(lines) + "\n" diff --git a/.claude/devkit-plan.json b/.claude/devkit-plan.json new file mode 100644 index 00000000000..44b973b09cf --- /dev/null +++ b/.claude/devkit-plan.json @@ -0,0 +1,31 @@ +{ + "_comment": "Devkit project config. Statusline budgets + git workflow ticket format. Edit to fit your plan and team ticket conventions.", + "_plans": { + "max_5x": { "session_tokens": 44000000, "weekly_tokens": 880000000 }, + "max_20x": { "session_tokens": 176000000, "weekly_tokens": 3520000000 }, + "team": { "session_tokens": 22000000, "weekly_tokens": 440000000 } + }, + "session_tokens": 44000000, + "weekly_tokens": 880000000, + + "_git_workflow_comment": "Ticket format used by validate-branch-name.sh, validate-commit-msg.sh, and the /git-workflow skill. Override per-project to use your own prefixes (e.g. JIRA, Linear). ticket_pattern is an ERE regex matching the ticket-id portion of a branch (e.g. EP-754). Examples are surfaced in error messages and the skill prompts.", + "git_workflow": { + "branch_types": [ + "feat", "cr", "fix", "bugfix", "test", "chore", + "docs", "devops", "release", "refactor", "revert", "hotfix" + ], + "commit_types": [ + "feat", "fix", "docs", "style", "refactor", "perf", + "test", "build", "ci", "chore", "revert" + ], + "ticket_pattern": "(EP|LT|AI|DO)-[0-9]+", + "ticket_examples": ["EP-754", "LT-8451", "AI-269", "DO-152"], + "ticket_prefixes_doc": "EP- (PRD), LT- (eng), AI- (AI), DO- (devops). See: .claude/rules/glossary.md", + "branch_examples": ["feat/EP-754", "fix/LT-8451", "feat/AI-269", "chore/DO-152"], + "commit_examples": [ + "feat(auth): [EP-754] add JWT refresh token endpoint", + "fix: [AI-332] add nodejs to docker image for stdio mcp servers", + "chore: bump README version" + ] + } +} diff --git a/.claude/hooks/_lib.sh b/.claude/hooks/_lib.sh new file mode 100755 index 00000000000..3f4049f1dca --- /dev/null +++ b/.claude/hooks/_lib.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Shared helpers for devkit hooks. Sourced, not executed. +# +# Provides: +# hook_log +# Append one JSONL entry to .claude/.devkit/hook-log.jsonl. +# Caps log at 500 entries. Best-effort: never fails the caller. +# +# emit_posttooluse +# Emit a PostToolUse hookSpecificOutput JSON with hookEventName set. +# +# emit_pretooluse_deny +# Emit a PreToolUse decision JSON that blocks the tool call. +# Exit 1 after calling this to ensure Claude Code rejects. + +_devkit_hook_log_file() { + local base + base="${HOOK_LOG_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/.devkit/hook-log.jsonl}" + printf '%s' "$base" +} + +hook_log() { + command -v jq >/dev/null 2>&1 || return 0 + local file + file="$(_devkit_hook_log_file)" + local dir="${file%/*}" + mkdir -p "$dir" 2>/dev/null || return 0 + jq -nc \ + --arg ts "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg hook "$1" \ + --arg verdict "$2" \ + --arg reason "$3" \ + '{ts:$ts, hook:$hook, verdict:$verdict, reason:$reason}' \ + >> "$file" 2>/dev/null || return 0 + # Trim to last 500 entries + if [ -f "$file" ]; then + tail -n 500 "$file" > "$file.tmp" 2>/dev/null && mv "$file.tmp" "$file" 2>/dev/null || true + fi +} + +emit_posttooluse() { + jq -nc --arg msg "$1" \ + '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}' +} + +emit_pretooluse_deny() { + jq -nc --arg reason "$1" \ + '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}}' +} diff --git a/.claude/hooks/auth-check.sh b/.claude/hooks/auth-check.sh new file mode 100755 index 00000000000..43ea4a85c7b --- /dev/null +++ b/.claude/hooks/auth-check.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SessionStart hook: check Claude Code auth status and cache result. +# If the session is expired / not logged in, inject a warning into the +# session context and write a cache file that devkit-statusline reads. + +set -euo pipefail + +AUTH_CACHE="/tmp/devkit-auth-status.json" + +# Run `claude auth status` — returns JSON with loggedIn, authMethod, etc. +AUTH_JSON="$(claude auth status 2>/dev/null || echo '{"loggedIn": false}')" + +LOGGED_IN="$(printf '%s' "$AUTH_JSON" | jq -r '.loggedIn // false' 2>/dev/null || echo 'false')" + +# Cache result for the statusline to consume (cheap file read vs spawning +# `claude auth status` on every statusline refresh). +printf '%s' "$AUTH_JSON" > "$AUTH_CACHE" 2>/dev/null || true + +if [ "$LOGGED_IN" = "true" ]; then + # Session is valid — nothing to report. + exit 0 +fi + +# Session expired — inject warning into session context. +escape_for_json() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + printf '%s' "$s" +} + +MSG="WARNING: Claude Code login session is EXPIRED. Run \`claude login\` to re-authenticate before proceeding." +MSG_ESCAPED="$(escape_for_json "$MSG")" + +if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then + printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$MSG_ESCAPED" +else + printf '{\n "additional_context": "%s"\n}\n' "$MSG_ESCAPED" +fi + +exit 0 diff --git a/.claude/hooks/block-credential-writes.sh b/.claude/hooks/block-credential-writes.sh new file mode 100755 index 00000000000..c8111fb55b7 --- /dev/null +++ b/.claude/hooks/block-credential-writes.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# PreToolUse:Write|Edit — block writes/edits to credential & secret files. +# These files should never be agent-generated. If the user needs to edit +# them, they do so manually. +# +# Blocked (exact basename or suffix): +# .env, .env.production, .env.prod, .env.staging, .env.local, .env.* +# secrets.yml, secrets.yaml, credentials.json +# *.pem, *.p12, *.pfx, *.key, id_rsa*, id_ed25519*, id_ecdsa* +# +# Blocked (path contains): +# /.ssh/, /.aws/, /.gnupg/, /.kube/config, /.netrc +# +# Allowed (templates and fixtures): +# .env.example, .env.sample, .env.template, .env.dist +# anything under tests/, fixtures/, __tests__/, test/ containing fake creds + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" + +HOOK="block-credential-writes" + +PAYLOAD=$(cat) +command -v jq >/dev/null 2>&1 || exit 0 +FILE_PATH=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null) +[ -z "$FILE_PATH" ] && exit 0 + +base="${FILE_PATH##*/}" +lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')" + +deny() { + hook_log "$HOOK" blocked "$1: $FILE_PATH" + emit_pretooluse_deny "$1 +File: $FILE_PATH +If this value needs to change, the engineer should edit it manually. The agent must not generate or modify credential files." + exit 0 +} + +# ── Template/fixture allowlist (check first) ────────────────────── +case "$lower" in + .env.example|.env.sample|.env.template|.env.dist|.env.default) + hook_log "$HOOK" allowed "template file" + exit 0 + ;; +esac + +case "$FILE_PATH" in + */tests/*|*/__tests__/*|*/fixtures/*|*/test/*|*/testdata/*) + # Only skip the guard for files whose basenames make them obvious fakes + case "$lower" in + *.env|*.env.*|*secrets*|*credentials*|*.pem|*.key) + hook_log "$HOOK" allowed "test fixture: $FILE_PATH" + exit 0 + ;; + esac + ;; +esac + +# ── Basename blocks ──────────────────────────────────────────────── +case "$lower" in + .env|.env.production|.env.prod|.env.staging|.env.stg|.env.local|.env.dev|.env.development|.env.test|.env.qa|.env.uat) + deny "Editing $lower is forbidden." + ;; + .env.*) + # Catches variants we didn't explicitly list, except allowlisted templates above + deny "Editing environment file $lower is forbidden." + ;; + secrets.yml|secrets.yaml|credentials.json|credentials.yml|credentials.yaml|service-account.json|service-account-key.json) + deny "Editing secret/credential file $lower is forbidden." + ;; + *.pem|*.p12|*.pfx|*.key|*.asc) + deny "Editing cryptographic material ($lower) is forbidden." + ;; + id_rsa|id_rsa.*|id_ed25519|id_ed25519.*|id_ecdsa|id_ecdsa.*|id_dsa|id_dsa.*) + deny "Editing SSH private key $lower is forbidden." + ;; +esac + +# ── Path-contains blocks ─────────────────────────────────────────── +case "$FILE_PATH" in + */.ssh/*|*/.aws/*|*/.gnupg/*|*/.gcloud/*|*/.docker/config*|*/.kube/config*) + deny "Editing inside a credentials directory is forbidden." + ;; + */.netrc|*/.netrc.*|*/.pgpass|*/.my.cnf) + deny "Editing $base is forbidden — it may contain passwords." + ;; +esac + +hook_log "$HOOK" allowed "" +exit 0 diff --git a/.claude/hooks/block-dangerous-bash.sh b/.claude/hooks/block-dangerous-bash.sh new file mode 100755 index 00000000000..bfabf936f49 --- /dev/null +++ b/.claude/hooks/block-dangerous-bash.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# PreToolUse:Bash — block destructive commands and pipe-to-shell patterns +# that have no safe agent-initiated use case. +# +# Blocks: +# - rm -rf on / or $HOME or anything outside a short allowlist +# (allowlist: /tmp, node_modules, build, dist, .next, .turbo, +# __pycache__, .pytest_cache, .ruff_cache, .mypy_cache, coverage, +# .devkit, .devkit-state) +# - git reset --hard (loses uncommitted work) +# - git clean -f(d)(x) (deletes untracked files) +# - git checkout . / git restore . (when there are uncommitted changes) +# - git branch -D (hard-delete a protected branch) +# - sudo (agent should never escalate) +# - curl|sh, wget|bash (supply-chain risk) + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" + +PAYLOAD=$(cat) +command -v jq >/dev/null 2>&1 || exit 0 +COMMAND=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.command // ""' 2>/dev/null) +[ -z "$COMMAND" ] && exit 0 + +HOOK="block-dangerous-bash" +PROTECTED_BRANCHES="master main uat develop qc training" + +deny() { + hook_log "$HOOK" blocked "$1" + emit_pretooluse_deny "$1" + exit 0 +} + +# ── sudo anywhere ────────────────────────────────────────────────── +if printf '%s' "$COMMAND" | grep -qE '(^|[[:space:]])sudo([[:space:]]|$)'; then + deny "Agent must not use sudo. Command: $COMMAND +If an operation genuinely requires elevated privileges, the engineer should run it manually." +fi + +# ── curl/wget piped to a shell ──────────────────────────────────── +if printf '%s' "$COMMAND" | grep -qE '(curl|wget)([^|]*)\|[[:space:]]*(sh|bash|zsh|ksh)([[:space:]]|$)'; then + deny "Pipe-to-shell detected. Command: $COMMAND +Downloading and executing a remote script from the agent is a supply-chain risk. +If the user wants this, they should download, review, then run it themselves." +fi + +# ── git reset --hard ────────────────────────────────────────────── +if printf '%s' "$COMMAND" | grep -qE 'git[[:space:]]+reset[[:space:]]+([^|;]*[[:space:]])?--hard'; then + deny "git reset --hard discards uncommitted work. Command: $COMMAND +Use git stash, or create a rollback branch, or ask the user to run this manually." +fi + +# ── git clean -f(d)(x) ──────────────────────────────────────────── +if printf '%s' "$COMMAND" | grep -qE 'git[[:space:]]+clean[[:space:]]+(-[[:alnum:]]*f[[:alnum:]]*|[^|;]*--force)'; then + deny "git clean -f deletes untracked files (often user's in-progress work). Command: $COMMAND +List what you want to remove with 'git clean -n' and ask the user to confirm." +fi + +# ── git checkout . / git restore . when dirty ──────────────────── +if printf '%s' "$COMMAND" | grep -qE 'git[[:space:]]+(checkout|restore)[[:space:]]+([^|;]*[[:space:]])?(--[[:space:]]+)?\.[[:space:]]*($|[|;&])'; then + if [ -n "$(git status --porcelain 2>/dev/null)" ]; then + deny "git checkout . / git restore . would nuke uncommitted changes. Command: $COMMAND +git status shows there are uncommitted changes. Commit or stash first, or ask the user." + fi +fi + +# ── git branch -D ───────────────────────────────────── +if printf '%s' "$COMMAND" | grep -qE 'git[[:space:]]+branch[[:space:]]+(-D|--delete[[:space:]]+--force|-d[[:space:]]+--force)'; then + # Extract tokens after -D / --delete + TAIL="${COMMAND#*git branch}" + # shellcheck disable=SC2086 + set -- $TAIL + for tok in "$@"; do + case "$tok" in + -*) continue ;; + esac + for b in $PROTECTED_BRANCHES; do + if [ "$tok" = "$b" ]; then + deny "git branch -D $b would hard-delete a protected branch. Command: $COMMAND" + fi + done + done +fi + +# ── rm -rf on dangerous paths ───────────────────────────────────── +# Look for rm with -r or -R combined with -f, and inspect path args +if printf '%s' "$COMMAND" | grep -qE '(^|[[:space:]]|;|\|\||&&)rm[[:space:]]+([^|;]*-[rR][[:alnum:]]*f|[^|;]*-f[[:alnum:]]*[rR])'; then + # Tokenize starting from rm + TAIL="" + # Split on &&, ||, ;, | to examine each clause separately + IFS='|&;' read -ra CLAUSES <<< "$(echo "$COMMAND" | tr '\n' ' ')" + for clause in "${CLAUSES[@]}"; do + case "$clause" in + *"rm "*-*r*) ;; + *"rm -"*) ;; + *) continue ;; + esac + # shellcheck disable=SC2086 + set -- $clause + # Skip until "rm" + found_rm=0 + paths=() + for tok in "$@"; do + if [ "$found_rm" = 0 ]; then + case "$tok" in + *rm) found_rm=1 ;; + esac + continue + fi + case "$tok" in + -*) continue ;; + *) paths+=("$tok") ;; + esac + done + + for p in "${paths[@]+"${paths[@]}"}"; do + # Expand ~ for comparison + resolved="${p/#\~/$HOME}" + case "$resolved" in + /|"$HOME"|"$HOME"/|/*[[:space:]]*) # root, literal home, or glob-ish + deny "rm -rf targeting a critical path is forbidden. Command: $COMMAND" + ;; + "/tmp"|"/tmp/"*|./tmp|./tmp/*|"$PWD/tmp"|"$PWD/tmp/"*) continue ;; + *node_modules|*node_modules/*) continue ;; + *__pycache__|*__pycache__/*) continue ;; + .pytest_cache|.pytest_cache/*|.ruff_cache|.ruff_cache/*|.mypy_cache|.mypy_cache/*) continue ;; + build|build/*|dist|dist/*|.next|.next/*|.turbo|.turbo/*|coverage|coverage/*) continue ;; + .devkit|.devkit/*|.devkit-state|.devkit-state/*) continue ;; + /var/*|/etc/*|/usr/*|/bin/*|/sbin/*|/opt/*|/System/*|/Library/*) + deny "rm -rf targeting a system path is forbidden. Command: $COMMAND" + ;; + .|..|*/..|*/../*|../*) + deny "rm -rf targeting relative-parent (. / ..) is dangerous. Command: $COMMAND" + ;; + *) + # Anything outside the allowlist — conservative block. + deny "rm -rf on '$p' isn't in the safe-path allowlist (tmp, node_modules, build, dist, .next, __pycache__, caches, coverage). +Command: $COMMAND +If you really need to remove this, ask the user to run it manually." + ;; + esac + done + done +fi + +hook_log "$HOOK" allowed "" +exit 0 diff --git a/.claude/hooks/block-protected-push.sh b/.claude/hooks/block-protected-push.sh new file mode 100755 index 00000000000..c5b8f843d97 --- /dev/null +++ b/.claude/hooks/block-protected-push.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# PreToolUse:Bash hook — block direct pushes to protected branches. +# +# Protected branches (edit PROTECTED_BRANCHES to customize): +# master, main, uat, develop, qc, training +# +# Detects two push patterns: +# 1. git push ... → check +# 2. git push (no refspec) on current branch → check current branch +# +# Also blocks --force / --force-with-lease anywhere (regardless of branch). +# +# Exit codes: +# 0 — safe, allow +# 1 — blocked with stderr explanation; Claude Code feeds it back to the agent + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" +HOOK="block-protected-push" + +PROTECTED_BRANCHES="master main uat develop qc training" + +PAYLOAD=$(cat) + +# Parse command without requiring jq — fall through silently if unavailable +if command -v jq >/dev/null 2>&1; then + COMMAND=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.command // ""' 2>/dev/null) +else + exit 0 +fi + +[ -z "$COMMAND" ] && exit 0 + +# Only inspect commands containing "git push" +case "$COMMAND" in + *"git push"*) ;; + *) exit 0 ;; +esac + +# ── Always block --force / --force-with-lease ────────────────────── +if printf '%s' "$COMMAND" | grep -qE -- '(--force-with-lease|--force|[[:space:]]-f([[:space:]]|$))'; then + REASON="Force-push detected. Command: $COMMAND +Force-pushing rewrites history. Never run this without explicit user consent. +If the user asked for it, have them run it themselves." + hook_log "$HOOK" blocked "force-push" + emit_pretooluse_deny "$REASON" + exit 0 +fi + +# ── Determine target branch ──────────────────────────────────────── +# Pattern: git push [opts] [:] +# We want the last non-flag token after "git push". +TARGET="" +# Strip everything up to and including "git push" +TAIL="${COMMAND##*git push}" +# shellcheck disable=SC2086 +set -- $TAIL +# Collect non-flag tokens +POS=() +for tok in "$@"; do + case "$tok" in + -*) ;; + *) POS+=("$tok") ;; + esac +done +# → branch is second +# → no explicit branch (use current) +# (empty) → no explicit args (use current) +if [ "${#POS[@]}" -ge 2 ]; then + TARGET="${POS[1]}" + # Strip : → keep dest + case "$TARGET" in + *:*) TARGET="${TARGET##*:}" ;; + esac + # HEAD and refs/heads/foo normalizations + case "$TARGET" in + refs/heads/*) TARGET="${TARGET#refs/heads/}" ;; + HEAD) TARGET="" ;; # fall through to current-branch check + esac +fi + +# No explicit branch → use current +if [ -z "$TARGET" ]; then + TARGET=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true) +fi + +[ -z "$TARGET" ] && exit 0 + +# ── Match against protected list ─────────────────────────────────── +for b in $PROTECTED_BRANCHES; do + if [ "$TARGET" = "$b" ]; then + REASON="Direct push to protected branch \"$b\" is forbidden. +Command: $COMMAND + +Ringkas workflow: all changes to {$PROTECTED_BRANCHES} must go through a merge request. +Open an MR from your feature branch instead: + 1. Push your feature branch: git push -u origin + 2. Open MR targeting $b via GitLab UI, or run /git-workflow + +If there's a legitimate emergency (e.g. hotfix approved by team lead), +the engineer — not the agent — should run the push manually." + hook_log "$HOOK" blocked "direct push to $b" + emit_pretooluse_deny "$REASON" + exit 0 + fi +done + +hook_log "$HOOK" allowed "" +exit 0 diff --git a/.claude/hooks/context-monitor.sh b/.claude/hooks/context-monitor.sh new file mode 100755 index 00000000000..cf395c8d6db --- /dev/null +++ b/.claude/hooks/context-monitor.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# PostToolUse hook: warn when the ACTUAL context window is nearly full. +# +# Prefers context_window.used_percentage from the most recent statusline +# stdin snapshot — that's the real number Claude Code / CCS reports. +# Falls back to a tool-use-count heuristic with generous thresholds only +# when no statusline telemetry is available yet (e.g. before the first +# statusline invocation of a fresh session). +# +# Historical note: this hook previously warned at 80 tool-uses using a +# proxy that was calibrated for 200K contexts. On Opus 4.7 (1M context) +# a single prompt with two parallel research agents could trip the proxy +# while actual context usage was <20%, causing the agent to wind down +# unnecessarily. + +set -euo pipefail + +# Drain stdin (Claude Code sends hook event data; we don't need it here +# but must consume it so the pipe doesn't back up). +cat >/dev/null 2>&1 || true + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +STATE_SCRIPT="${PLUGIN_ROOT}/bin/devkit-state.sh" + +emit() { + jq -nc --arg msg "$1" \ + '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}' +} + +# ── Preferred: read actual context percentage from statusline cache ── +LAST_IN="/tmp/devkit-statusline-last-input.json" +CTX_PCT="" +if [ -f "$LAST_IN" ]; then + CTX_PCT="$(jq -r '.context_window.used_percentage // empty' "$LAST_IN" 2>/dev/null || true)" +fi + +if [ -n "$CTX_PCT" ] && [ "$CTX_PCT" != "null" ]; then + # Integer-coerce (handles "7", "7.3", or weird floats like "14.000...2") + CTX_INT="$(awk -v v="$CTX_PCT" 'BEGIN{printf "%d", v+0}')" + if [ "$CTX_INT" -ge 95 ]; then + if [ -f ".devkit/STATE.md" ] && [ -x "$STATE_SCRIPT" ]; then + "$STATE_SCRIPT" checkpoint "context at ${CTX_INT}%" >/dev/null 2>&1 & + fi + emit "CRITICAL: Context window at ${CTX_INT}% — wrap up now or start a fresh session." + elif [ "$CTX_INT" -ge 80 ]; then + emit "WARNING: Context window at ${CTX_INT}% — consider wrapping up the current step." + fi + exit 0 +fi + +# ── Fallback: no statusline data yet. Use tool-use-count heuristic with +# thresholds scaled conservatively for 1M-context models. +COUNTER_FILE="/tmp/devkit-ctx-$(echo "${CLAUDE_SESSION_ID:-unknown}" | head -c 16).count" +COUNT=1 +if [ -f "$COUNTER_FILE" ]; then + COUNT="$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)" + COUNT=$((COUNT + 1)) +fi +echo "$COUNT" > "$COUNTER_FILE" 2>/dev/null || true + +# Raised from 80/120 — those were calibrated for 200K contexts and fired +# spuriously with parallel subagents on long-context models. +if [ "$COUNT" -gt 600 ]; then + if [ -f ".devkit/STATE.md" ] && [ -x "$STATE_SCRIPT" ]; then + "$STATE_SCRIPT" checkpoint "~${COUNT} tool uses" >/dev/null 2>&1 & + fi + emit "CRITICAL: ~${COUNT} tool uses (no context telemetry available). Consider wrapping up." +elif [ "$COUNT" -gt 400 ]; then + emit "WARNING: ~${COUNT} tool uses (no context telemetry available). Monitor your window." +fi + +exit 0 diff --git a/.claude/hooks/post-write-graphql.sh b/.claude/hooks/post-write-graphql.sh new file mode 100755 index 00000000000..58938e5bb25 --- /dev/null +++ b/.claude/hooks/post-write-graphql.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# PostToolUse hook — fires after Write|Edit on .gql files in saturn. +# Reminds the agent to run codegen and check remote_schemas.yaml. + +if ! command -v jq &>/dev/null; then + exit 0 +fi + +PAYLOAD=$(cat) +FILE=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null) + +if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then + exit 0 +fi + +EXT="${FILE##*.}" + +# Only act on .gql / .graphql files +case "$EXT" in + gql|graphql) ;; + *) exit 0 ;; +esac + +# Only act if we're in a project that has Hasura remote schemas (saturn-like) +REMOTE_SCHEMAS="" +PROJECT_ROOT=$(git -C "$(dirname "$FILE")" rev-parse --show-toplevel 2>/dev/null || echo "") +if [ -n "$PROJECT_ROOT" ]; then + REMOTE_SCHEMAS=$(find "$PROJECT_ROOT/src" -name "remote_schemas.yaml" -maxdepth 5 2>/dev/null | head -1) +fi + +echo "GraphQL schema changed: $FILE" +echo "" +echo "Checklist:" +echo " 1. Run \`yarn codegen\` to regenerate TypeScript types" + +if [ -n "$REMOTE_SCHEMAS" ]; then + echo " 2. Check if remote_schemas.yaml needs updating:" + echo " $REMOTE_SCHEMAS" + echo " (required if this change adds/removes/modifies queries, mutations," + echo " or types visible to Hasura roles)" + echo " 3. If updated, run \`yarn hasura:migrate\`" +fi + +exit 0 diff --git a/.claude/hooks/post-write-lint.sh b/.claude/hooks/post-write-lint.sh new file mode 100755 index 00000000000..0104baff6bb --- /dev/null +++ b/.claude/hooks/post-write-lint.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Fires after Claude writes or edits a file. +# Runs Ruff (Python) or ESLint (TypeScript) on the changed file. + +if ! command -v jq &>/dev/null; then + echo "Warning: jq not installed — post-write lint hook skipped. Install jq to enable auto-linting." >&2 + exit 0 +fi + +PAYLOAD=$(cat) +FILE=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null) + +if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then + exit 0 +fi + +EXT="${FILE##*.}" +ERRORS=0 +LINTER_RAN=0 + +case "$EXT" in + py) + if command -v ruff &>/dev/null; then + echo "Linting $FILE with Ruff..." + ruff check "$FILE" || ERRORS=1 + ruff format --check "$FILE" || ERRORS=1 + LINTER_RAN=1 + else + echo "Warning: ruff not installed — $FILE not linted. Run: pip install ruff" >&2 + fi + ;; + ts|tsx|js|jsx) + if command -v npx &>/dev/null; then + echo "Linting $FILE with ESLint..." + npx eslint "$FILE" --max-warnings 0 || ERRORS=1 + LINTER_RAN=1 + else + echo "Warning: npx not found — $FILE not linted. Install Node.js to enable ESLint." >&2 + fi + ;; +esac + +if [ "$ERRORS" -ne 0 ]; then + echo "Lint errors in $FILE. Fix before continuing." + exit 1 +fi + +exit 0 diff --git a/.claude/hooks/pr-review-reminder.sh b/.claude/hooks/pr-review-reminder.sh new file mode 100755 index 00000000000..dc619b81a06 --- /dev/null +++ b/.claude/hooks/pr-review-reminder.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Fires on session end. Prints PR checklist if there are unpushed commits. + +if ! git rev-parse --git-dir &>/dev/null; then + exit 0 +fi + +AHEAD=$(git rev-list --count "@{u}..HEAD" 2>/dev/null || echo "0") + +if [ "$AHEAD" -eq 0 ]; then + exit 0 +fi + +echo "" +echo "================================================" +echo "RINGKAS PR CHECKLIST" +echo "================================================" +echo " Branch: $(git branch --show-current)" +echo " Commits ahead: $AHEAD" +echo "" +echo "Before opening a PR:" +echo " [ ] Run /code-review" +echo " [ ] Run /security-audit if touching auth, PII, or financial data" +echo "" +echo "PR requirements:" +echo " [ ] Title: [] " +echo " [ ] Label: feat / fix / bugfix / chore / docs / devops" +echo " [ ] At least 2 reviewers assigned" +echo " [ ] Squash commits decision made" +echo " [ ] Source branch NOT deleted on merge" +echo " [ ] Self-tested locally" +echo "================================================" +echo "" +exit 0 diff --git a/.claude/hooks/pre-commit-security.sh b/.claude/hooks/pre-commit-security.sh new file mode 100755 index 00000000000..026c8aa85c1 --- /dev/null +++ b/.claude/hooks/pre-commit-security.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# Fires before git commit. +# Scans staged files for hardcoded secrets. Blocks if found. + +if ! command -v jq &>/dev/null; then + echo "Warning: jq not installed — pre-commit-security hook skipped. Install jq to enable secret scanning." >&2 + exit 0 +fi + +PAYLOAD=$(cat) +COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // ""' 2>/dev/null) + +if ! echo "$COMMAND" | grep -q "git commit"; then + exit 0 +fi + +echo "Running security scan on staged files..." +ERRORS=0 +STAGED=$(git diff --cached --name-only 2>/dev/null) + +if [ -z "$STAGED" ]; then + exit 0 +fi + +# Use detect-secrets if available (preferred — handles base64, entropy, etc.) +if command -v detect-secrets &>/dev/null; then + echo "Using detect-secrets for scan..." + if ! detect-secrets scan --list-all-plugins --only-allowlisted 2>/dev/null | grep -q .; then + # Baseline scan on staged content + TMPFILE=$(mktemp) + for FILE in $STAGED; do + [ -f "$FILE" ] || continue + git show ":$FILE" 2>/dev/null > "$TMPFILE" + if detect-secrets scan "$TMPFILE" 2>/dev/null | python3 -c " +import sys, json +d = json.load(sys.stdin) +results = d.get('results', {}) +if any(v for v in results.values()): + sys.exit(1) +" 2>/dev/null; then + : + else + echo "Potential secret detected in $FILE (detect-secrets)" + ERRORS=1 + fi + done + rm -f "$TMPFILE" + fi +else + # Fallback: regex-based scan covering common secret patterns + ALLOWLIST="test|mock|example|placeholder|your_|<[^>]+>|os\.environ|getenv|get_settings|process\.env|config\." + + for FILE in $STAGED; do + if [ ! -f "$FILE" ]; then continue; fi + + FILE_CONTENT=$(git show ":$FILE" 2>/dev/null) + + # key=value style secrets + if echo "$FILE_CONTENT" | \ + grep -nEi "(api_key|secret_key|password|passwd|token|private_key|access_key|secret)\s*[=:]\s*['\"][^'\"]{8,}['\"]" | \ + grep -vEi "$ALLOWLIST"; then + echo "Potential hardcoded secret in $FILE" + ERRORS=1 + fi + + # AWS access key IDs + if echo "$FILE_CONTENT" | grep -nE "AKIA[0-9A-Z]{16}"; then + echo "Potential AWS access key in $FILE" + ERRORS=1 + fi + + # AWS secret access keys (40-char base62 after known field names) + if echo "$FILE_CONTENT" | \ + grep -nEi "aws_secret_access_key\s*[=:]\s*['\"]?[A-Za-z0-9/+]{40}['\"]?" | \ + grep -vEi "$ALLOWLIST"; then + echo "Potential AWS secret key in $FILE" + ERRORS=1 + fi + + # GCP API keys + if echo "$FILE_CONTENT" | grep -nE "AIza[0-9A-Za-z_-]{35}"; then + echo "Potential GCP API key in $FILE" + ERRORS=1 + fi + + # GitHub tokens (classic and fine-grained) + if echo "$FILE_CONTENT" | grep -nE "gh[pors]_[A-Za-z0-9]{36,}"; then + echo "Potential GitHub token in $FILE" + ERRORS=1 + fi + + # Private key headers + if echo "$FILE_CONTENT" | grep -nE "-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"; then + echo "Potential private key in $FILE" + ERRORS=1 + fi + + # URL-embedded tokens/passwords + if echo "$FILE_CONTENT" | \ + grep -nEi "https?://[^@\s]{8,}:[^@\s]{8,}@" | \ + grep -vEi "$ALLOWLIST"; then + echo "Potential credentials in URL in $FILE" + ERRORS=1 + fi + done +fi + +if [ "$ERRORS" -ne 0 ]; then + echo "Security scan FAILED. Use environment variables for secrets." + echo "See: .claude/rules/security.md" + exit 1 +fi + +echo "Security scan passed." +exit 0 diff --git a/.claude/hooks/pre-push-tests.sh b/.claude/hooks/pre-push-tests.sh new file mode 100755 index 00000000000..02b54892f5e --- /dev/null +++ b/.claude/hooks/pre-push-tests.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +# PreToolUse:Bash — run tests before `git push`. Block on failure. +# +# Detects framework + package manager and runs a sensible fast command +# with a timeout. +# +# Escape hatches: +# - export DEVKIT_PREPUSH_SKIP=1 in your shell rc +# - prefix the command: `DEVKIT_PREPUSH_SKIP=1 git push ...` (parsed +# from the command string itself, since hook env doesn't always +# receive inline VAR=val from Claude Code) +# - touch .devkit/prepush-skip in the project root for a one-off +# +# Timeout default: 90s (override via DEVKIT_PREPUSH_TIMEOUT). +# +# Design: +# - Fail-CLOSED when tests run and fail. +# - Fail-OPEN when we can't determine a test command (no config found, +# no lockfile), so greenfield / non-test repos don't break. +# - Fail-OPEN with a logged WARNING when the test runner binary isn't +# on PATH and we can't find it in the augmented search list. Blocking +# the push because Claude Code's hook env can't see pnpm/yarn/etc is +# the devkit's environment problem, not the user's mistake. +# - Fail-CLOSED on timeout (with explicit escape hatch), so a slow +# test suite doesn't become a silent bypass. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +. "$SCRIPT_DIR/_lib.sh" + +HOOK="pre-push-tests" + +PAYLOAD=$(cat) +command -v jq >/dev/null 2>&1 || exit 0 +COMMAND=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.command // ""' 2>/dev/null) +[ -z "$COMMAND" ] && exit 0 + +# Only act on "git push" +case "$COMMAND" in + *"git push"*) ;; + *) exit 0 ;; +esac + +# ── Escape hatches ──────────────────────────────────────────────── +# 1. Process env (from user's shell rc) +# 2. Inline prefix: `DEVKIT_PREPUSH_SKIP=1 git push ...` parsed from the +# command itself — Claude Code's hook may not propagate inline +# VAR=val env vars to the hook process. +# 3. Project flag: .devkit/prepush-skip file +SKIP=0 +[ "${DEVKIT_PREPUSH_SKIP:-0}" = "1" ] && SKIP=1 +case "$COMMAND" in + *"DEVKIT_PREPUSH_SKIP=1"*) SKIP=1 ;; + *"DEVKIT_PREPUSH_SKIP=true"*) SKIP=1 ;; +esac + +ROOT="${CLAUDE_PROJECT_DIR:-$PWD}" +cd "$ROOT" || exit 0 + +[ -f ".devkit/prepush-skip" ] && SKIP=1 + +if [ "$SKIP" = "1" ]; then + hook_log "$HOOK" allowed "skipped via DEVKIT_PREPUSH_SKIP or .devkit/prepush-skip" + exit 0 +fi + +TIMEOUT="${DEVKIT_PREPUSH_TIMEOUT:-90}" + +deny() { + hook_log "$HOOK" blocked "$1" + emit_pretooluse_deny "$1" + exit 0 +} + +# ── Augment PATH so hook can find user-installed tools ──────────── +# Claude Code launched from a GUI gets a stripped PATH. Add common +# install locations so we can find pnpm/yarn/bun/npm/python/pytest etc. +augment_path() { + local extra + extra="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin" + extra="$extra:$HOME/.volta/bin" + extra="$extra:$HOME/.local/share/pnpm" + extra="$extra:$HOME/Library/pnpm" + extra="$extra:$HOME/.local/share/fnm/aliases/default/bin" + extra="$extra:$HOME/.fnm/aliases/default/bin" + extra="$extra:$HOME/.asdf/shims" + extra="$extra:$HOME/.nvm/versions/node/$(ls -1 "$HOME/.nvm/versions/node" 2>/dev/null | sort -V | tail -1)/bin" + extra="$extra:$HOME/.npm-global/bin" + extra="$extra:$HOME/.bun/bin" + extra="$extra:$HOME/.cargo/bin" + PATH="$PATH:$extra" + export PATH +} +augment_path + +# ── Detect package manager (Node.js projects) ───────────────────── +# Priority: lockfile presence first (most reliable), then packageManager +# field in package.json, then fall back to npm. Critical because pnpm / +# yarn workspaces and turbo refuse to run when invoked through npm. +detect_node_pm() { + if [ -f "pnpm-lock.yaml" ]; then echo "pnpm"; return; fi + if [ -f "yarn.lock" ]; then echo "yarn"; return; fi + if [ -f "bun.lockb" ] || [ -f "bun.lock" ]; then echo "bun"; return; fi + if [ -f "package-lock.json" ]; then echo "npm"; return; fi + if [ -f "package.json" ] && command -v jq >/dev/null 2>&1; then + pm_field=$(jq -r '.packageManager // empty' package.json 2>/dev/null) + case "$pm_field" in + pnpm@*) echo "pnpm"; return ;; + yarn@*) echo "yarn"; return ;; + bun@*) echo "bun"; return ;; + npm@*) echo "npm"; return ;; + esac + fi + echo "npm" +} + +# ── Detect test command ─────────────────────────────────────────── +TEST_CMD="" +TEST_LABEL="" +RUNNER_BIN="" + +if [ -f "package.json" ]; then + PM="$(detect_node_pm)" + # Prefer test:fast / test:quick / test:unit; else fall back to test + for script in test:fast test:quick test:unit test; do + if jq -e ".scripts[\"$script\"]" package.json >/dev/null 2>&1; then + case "$PM" in + pnpm) TEST_CMD="pnpm run --silent $script"; RUNNER_BIN="pnpm" ;; + yarn) TEST_CMD="yarn --silent $script"; RUNNER_BIN="yarn" ;; + bun) TEST_CMD="bun run --silent $script"; RUNNER_BIN="bun" ;; + *) TEST_CMD="npm run --silent $script"; RUNNER_BIN="npm" ;; + esac + TEST_LABEL="$PM run $script" + break + fi + done +fi + +if [ -z "$TEST_CMD" ]; then + if [ -f "pyproject.toml" ] && grep -qE '(\[tool\.pytest|\[tool\.poetry|pytest)' pyproject.toml 2>/dev/null; then + TEST_CMD="pytest -x --ff -q --no-header" + TEST_LABEL="pytest" + RUNNER_BIN="pytest" + elif [ -f "pytest.ini" ] || { [ -f "setup.cfg" ] && grep -qE '\[tool:pytest\]' setup.cfg 2>/dev/null; }; then + TEST_CMD="pytest -x --ff -q --no-header" + TEST_LABEL="pytest" + RUNNER_BIN="pytest" + elif [ -f "manage.py" ]; then + TEST_CMD="python manage.py test --failfast -v 0" + TEST_LABEL="manage.py test" + RUNNER_BIN="python" + fi +fi + +# Nothing detected → allow (don't block pushes in repos without tests) +if [ -z "$TEST_CMD" ]; then + hook_log "$HOOK" allowed "no test framework detected" + exit 0 +fi + +# ── Fail-OPEN if the runner binary isn't reachable ──────────────── +# This is the devkit's environment problem (Claude Code launched from a +# GUI without the user's full PATH), not the user's mistake. Better to +# warn loudly than to block their push for a non-test reason. +if [ -n "$RUNNER_BIN" ] && ! command -v "$RUNNER_BIN" >/dev/null 2>&1; then + warning_msg="WARNING: pre-push-tests can't find '$RUNNER_BIN' on PATH. +Skipping the test run — the push will go through. + +Why: Claude Code's hook process inherits its parent's PATH. When +launched from Spotlight/dock/IDE, the parent shell's PATH (with your +nvm/fnm/volta/asdf/pnpm install) is not loaded. + +Fixes (any one of these): + 1. Launch Claude Code from a terminal where '$RUNNER_BIN' is on PATH + 2. Set the runner's path explicitly in your ~/.zshrc and re-launch: + export PATH=\"\$HOME/Library/pnpm:\$PATH\" + 3. Run the tests yourself before pushing: $TEST_CMD + 4. Persistently skip this hook: touch .devkit/prepush-skip" + hook_log "$HOOK" allowed "runner '$RUNNER_BIN' not on PATH — failing open" + printf '%s\n' "$warning_msg" >&2 + exit 0 +fi + +# ── Run with timeout ────────────────────────────────────────────── +echo "Running tests before push: $TEST_LABEL (timeout ${TIMEOUT}s — set DEVKIT_PREPUSH_SKIP=1 to skip)" >&2 + +# Cross-platform timeout: use `gtimeout` or `timeout` if available, +# else fall back to a background-kill shim. +run_with_timeout() { + if command -v gtimeout >/dev/null 2>&1; then + gtimeout "${TIMEOUT}s" bash -c "$TEST_CMD" + elif command -v timeout >/dev/null 2>&1; then + timeout "${TIMEOUT}s" bash -c "$TEST_CMD" + else + # Shim: background + sleep + kill. Exit code 124 on timeout. + bash -c "$TEST_CMD" & + local pid=$! + (sleep "$TIMEOUT" && kill -TERM "$pid" 2>/dev/null) & + local watcher=$! + if wait "$pid"; then + # Silence "Terminated" jobs message when we kill the watcher + kill -TERM "$watcher" 2>/dev/null + wait "$watcher" 2>/dev/null + return 0 + else + local rc=$? + kill -TERM "$watcher" 2>/dev/null + wait "$watcher" 2>/dev/null + # If watcher killed the process, exit 124 to mimic timeout(1) + if ! kill -0 "$pid" 2>/dev/null; then + return "$rc" + fi + return 124 + fi + fi +} + +TMPOUT=$(mktemp -t devkit-prepush.XXXXXX) +set +e +run_with_timeout > "$TMPOUT" 2>&1 +RC=$? +set -e + +if [ "$RC" = 0 ]; then + hook_log "$HOOK" allowed "tests passed ($TEST_LABEL)" + rm -f "$TMPOUT" + exit 0 +fi + +OUTPUT=$(tail -n 40 "$TMPOUT" 2>/dev/null) +rm -f "$TMPOUT" + +# Exit 127 = command not found despite our PATH augmentation. Treat as +# environment failure, not test failure. Fail open with the same warning. +if [ "$RC" = 127 ]; then + warning_msg="WARNING: pre-push-tests ran '$TEST_CMD' but got exit 127 (command not found). +Skipping the test run — the push will go through. + +Last output: +$OUTPUT + +This means the test runner couldn't be found in the hook's PATH. +Fix: launch Claude Code from a terminal where the runner is on PATH, +or touch .devkit/prepush-skip to silence this hook permanently for +this project." + hook_log "$HOOK" allowed "exit 127 from runner — failing open" + printf '%s\n' "$warning_msg" >&2 + exit 0 +fi + +if [ "$RC" = 124 ]; then + deny "Tests timed out after ${TIMEOUT}s ($TEST_LABEL). Push blocked. +Last output: +$OUTPUT + +If this is expected (slow suite, you're pushing WIP), prefix the push: + DEVKIT_PREPUSH_SKIP=1 git push ... +…or touch .devkit/prepush-skip to silence this hook for this project." +fi + +deny "Tests failed ($TEST_LABEL, exit $RC). Push blocked. +Last output: +$OUTPUT + +Fix the failing tests before pushing. If you must push WIP: + DEVKIT_PREPUSH_SKIP=1 git push ... +…or touch .devkit/prepush-skip to silence this hook for this project." diff --git a/.claude/hooks/validate-branch-name.sh b/.claude/hooks/validate-branch-name.sh new file mode 100755 index 00000000000..012db696c25 --- /dev/null +++ b/.claude/hooks/validate-branch-name.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Enforces configurable branch naming: / + +if ! command -v jq &>/dev/null; then + echo "Warning: jq not installed — branch name validation skipped. Install jq to enable this check." >&2 + exit 0 +fi + +PAYLOAD=$(cat) +COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // ""' 2>/dev/null) + +if ! echo "$COMMAND" | grep -qE "git (checkout -b|switch -c)"; then + exit 0 +fi + +BRANCH=$(echo "$COMMAND" | grep -oE "(checkout -b|switch -c)\s+\S+" | awk '{print $NF}') + +if [ -z "$BRANCH" ]; then + exit 0 +fi + +CONFIG_PATH="${CLAUDE_PROJECT_DIR:-$(pwd)}/.claude/devkit-plan.json" +DEFAULT_BRANCH_TYPES="feat|cr|fix|bugfix|test|chore|docs|devops|release|refactor|revert|hotfix" +DEFAULT_TICKET_PATTERN="(EP|LT|AI|DO)-[0-9]+" +DEFAULT_BRANCH_EXAMPLES="feat/EP-754, fix/LT-8451, feat/AI-269, chore/DO-152" +DEFAULT_PREFIX_DOC="EP- (PRD), LT- (eng), AI- (AI), DO- (devops). See: .claude/rules/glossary.md" + +BRANCH_TYPES="$DEFAULT_BRANCH_TYPES" +TICKET_PATTERN="$DEFAULT_TICKET_PATTERN" +BRANCH_EXAMPLES="$DEFAULT_BRANCH_EXAMPLES" +PREFIX_DOC="$DEFAULT_PREFIX_DOC" + +if [ -f "$CONFIG_PATH" ]; then + if ! jq -e . "$CONFIG_PATH" >/dev/null 2>&1; then + echo "Invalid git workflow config: $CONFIG_PATH" + echo "Fix .claude/devkit-plan.json before creating branches." + exit 1 + fi + + CONFIG_BRANCH_TYPES=$(jq -r '(.git_workflow.branch_types // []) | map(select(type == "string" and test("^[A-Za-z0-9_-]+$"))) | join("|")' "$CONFIG_PATH") + INVALID_BRANCH_TYPES=$(jq -r '(.git_workflow.branch_types // []) | map(select(type != "string" or (test("^[A-Za-z0-9_-]+$") | not))) | join(", ")' "$CONFIG_PATH") + CONFIG_TICKET_PATTERN=$(jq -r '.git_workflow.ticket_pattern // empty' "$CONFIG_PATH") + CONFIG_BRANCH_EXAMPLES=$(jq -r '(.git_workflow.branch_examples // []) | map(select(type == "string" and length > 0)) | join(", ")' "$CONFIG_PATH") + CONFIG_PREFIX_DOC=$(jq -r '.git_workflow.ticket_prefixes_doc // empty' "$CONFIG_PATH") + + if [ -n "$INVALID_BRANCH_TYPES" ]; then + echo "Invalid branch types in .claude/devkit-plan.json: $INVALID_BRANCH_TYPES" + echo "Branch types may only contain letters, numbers, underscores, and hyphens." + exit 1 + fi + + [ -n "$CONFIG_BRANCH_TYPES" ] && BRANCH_TYPES="$CONFIG_BRANCH_TYPES" + [ -n "$CONFIG_TICKET_PATTERN" ] && TICKET_PATTERN="$CONFIG_TICKET_PATTERN" + [ -n "$CONFIG_BRANCH_EXAMPLES" ] && BRANCH_EXAMPLES="$CONFIG_BRANCH_EXAMPLES" + [ -n "$CONFIG_PREFIX_DOC" ] && PREFIX_DOC="$CONFIG_PREFIX_DOC" +fi + +PATTERN="^($BRANCH_TYPES)/($TICKET_PATTERN)$" +echo "" | grep -qE "$PATTERN" >/dev/null 2>&1 +GREP_RC=$? +if [ "$GREP_RC" -gt 1 ]; then + echo "Invalid ticket pattern in .claude/devkit-plan.json: $TICKET_PATTERN" + exit 1 +fi + +if ! echo "$BRANCH" | grep -qE "$PATTERN"; then + echo "Invalid branch name: '$BRANCH'" + echo "Required: /" + echo "Ticket pattern: $TICKET_PATTERN" + echo "Examples: $BRANCH_EXAMPLES" + echo "Valid types: $(echo "$BRANCH_TYPES" | tr '|' ', ')" + echo "Ticket prefixes: $PREFIX_DOC" + echo "Edit .claude/devkit-plan.json git_workflow to customize." + exit 1 +fi + +echo "Branch '$BRANCH' is valid." +exit 0 diff --git a/.claude/hooks/validate-commit-msg.sh b/.claude/hooks/validate-commit-msg.sh new file mode 100755 index 00000000000..87443926141 --- /dev/null +++ b/.claude/hooks/validate-commit-msg.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Enforces configurable conventional commits: +# (scope?): [] +# Ticket ID is optional at this layer but should be included when it exists. + +if ! command -v jq &>/dev/null; then + echo "Warning: jq not installed — commit message validation skipped. Install jq to enable this check." >&2 + exit 0 +fi + +PAYLOAD=$(cat) +COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // ""' 2>/dev/null) + +if ! echo "$COMMAND" | grep -q "git commit"; then + exit 0 +fi + +MSG=$(echo "$COMMAND" | sed -n "s/.*-m '\([^']*\)'.*/\1/p" | head -1) +if [ -z "$MSG" ]; then + MSG=$(echo "$COMMAND" | sed -n 's/.*-m "\([^"]*\)".*/\1/p' | head -1) +fi + +if [ -z "$MSG" ]; then + exit 0 +fi + +CONFIG_PATH="${CLAUDE_PROJECT_DIR:-$(pwd)}/.claude/devkit-plan.json" +DEFAULT_COMMIT_TYPES="feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert" +DEFAULT_TICKET_PATTERN="(EP|LT|AI|DO)-[0-9]+" +DEFAULT_COMMIT_EXAMPLES="feat(auth): [EP-754] add JWT refresh token endpoint|fix: [AI-332] add nodejs to docker image for stdio mcp servers|chore: bump README version" +DEFAULT_PREFIX_DOC="EP- (PRD), LT- (eng), AI- (AI), DO- (devops)" + +COMMIT_TYPES="$DEFAULT_COMMIT_TYPES" +TICKET_PATTERN="$DEFAULT_TICKET_PATTERN" +COMMIT_EXAMPLES="$DEFAULT_COMMIT_EXAMPLES" +PREFIX_DOC="$DEFAULT_PREFIX_DOC" + +if [ -f "$CONFIG_PATH" ]; then + if ! jq -e . "$CONFIG_PATH" >/dev/null 2>&1; then + echo "Invalid git workflow config: $CONFIG_PATH" + echo "Fix .claude/devkit-plan.json before committing." + exit 1 + fi + + CONFIG_COMMIT_TYPES=$(jq -r '(.git_workflow.commit_types // []) | map(select(type == "string" and test("^[A-Za-z0-9_-]+$"))) | join("|")' "$CONFIG_PATH") + INVALID_COMMIT_TYPES=$(jq -r '(.git_workflow.commit_types // []) | map(select(type != "string" or (test("^[A-Za-z0-9_-]+$") | not))) | join(", ")' "$CONFIG_PATH") + CONFIG_TICKET_PATTERN=$(jq -r '.git_workflow.ticket_pattern // empty' "$CONFIG_PATH") + CONFIG_COMMIT_EXAMPLES=$(jq -r '(.git_workflow.commit_examples // []) | map(select(type == "string" and length > 0)) | join("|")' "$CONFIG_PATH") + CONFIG_PREFIX_DOC=$(jq -r '.git_workflow.ticket_prefixes_doc // empty' "$CONFIG_PATH") + + if [ -n "$INVALID_COMMIT_TYPES" ]; then + echo "Invalid commit types in .claude/devkit-plan.json: $INVALID_COMMIT_TYPES" + echo "Commit types may only contain letters, numbers, underscores, and hyphens." + exit 1 + fi + + [ -n "$CONFIG_COMMIT_TYPES" ] && COMMIT_TYPES="$CONFIG_COMMIT_TYPES" + [ -n "$CONFIG_TICKET_PATTERN" ] && TICKET_PATTERN="$CONFIG_TICKET_PATTERN" + [ -n "$CONFIG_COMMIT_EXAMPLES" ] && COMMIT_EXAMPLES="$CONFIG_COMMIT_EXAMPLES" + [ -n "$CONFIG_PREFIX_DOC" ] && PREFIX_DOC="$CONFIG_PREFIX_DOC" +fi + +PATTERN="^($COMMIT_TYPES)(\([^)]+\))?: .{1,100}$" +echo "" | grep -qE "$PATTERN" >/dev/null 2>&1 +GREP_RC=$? +if [ "$GREP_RC" -gt 1 ]; then + echo "Invalid commit pattern derived from .claude/devkit-plan.json" + exit 1 +fi + +if ! echo "$MSG" | grep -qE "$PATTERN"; then + echo "Invalid commit message: '$MSG'" + echo "Required: (scope?): [] " + echo "Ticket pattern: $TICKET_PATTERN" + echo "Examples:" + echo "$COMMIT_EXAMPLES" | tr '|' '\n' | sed 's/^/ /' + echo "Valid types: $(echo "$COMMIT_TYPES" | tr '|' ', ')" + echo "Ticket prefixes: $PREFIX_DOC" + echo "Edit .claude/devkit-plan.json git_workflow to customize." + echo "See: .claude/rules/git-workflow.md" + exit 1 +fi + +echo "Commit message is valid." +exit 0 diff --git a/.claude/hooks/workflow-resume.sh b/.claude/hooks/workflow-resume.sh new file mode 100755 index 00000000000..0f5fa4cf5e2 --- /dev/null +++ b/.claude/hooks/workflow-resume.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# SessionStart hook: detect in-progress workflow and prompt to resume. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +STATE_SCRIPT="${PLUGIN_ROOT}/bin/devkit-state.sh" + +if [ ! -f ".devkit/STATE.md" ] || [ ! -x "$STATE_SCRIPT" ]; then + exit 0 +fi + +STATUS=$("$STATE_SCRIPT" status 2>/dev/null || echo '{"active": false}') +ACTIVE=$(echo "$STATUS" | grep -o '"active"[[:space:]]*:[[:space:]]*[a-z]*' | head -1 | sed 's/.*: *//') + +if [ "$ACTIVE" != "true" ]; then + exit 0 +fi + +WORKFLOW=$(echo "$STATUS" | grep -o '"workflow"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"//;s/"//') +TICKET=$(echo "$STATUS" | grep -o '"ticket"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"//;s/"//') +STEP=$(echo "$STATUS" | grep -o '"current_step"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"//;s/"//') +COMPLETED=$(echo "$STATUS" | grep -o '"completed"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 | sed 's/.*: *//') +TOTAL=$(echo "$STATUS" | grep -o '"total"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 | sed 's/.*: *//') + +escape_for_json() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + printf '%s' "$s" +} + +MSG="Active workflow: ${WORKFLOW} (${TICKET}). Current step: ${STEP} (${COMPLETED}/${TOTAL}). Say /resume to continue or /abandon to discard." +MSG_ESCAPED=$(escape_for_json "$MSG") + +if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then + printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$MSG_ESCAPED" +else + printf '{\n "additional_context": "%s"\n}\n' "$MSG_ESCAPED" +fi + +exit 0 diff --git a/.claude/hooks/workflow-status.sh b/.claude/hooks/workflow-status.sh new file mode 100755 index 00000000000..61bdc1a88a4 --- /dev/null +++ b/.claude/hooks/workflow-status.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# PostToolUse hook: show current workflow step in output. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +STATE_SCRIPT="${PLUGIN_ROOT}/bin/devkit-state.sh" + +if [ ! -f ".devkit/STATE.md" ] || [ ! -x "$STATE_SCRIPT" ]; then + exit 0 +fi + +# Only show status every 10 tool uses to avoid noise +COUNTER_FILE="/tmp/devkit-ctx-$(echo "${CLAUDE_SESSION_ID:-unknown}" | head -c 16).count" +COUNT=0 +if [ -f "$COUNTER_FILE" ]; then + COUNT=$(cat "$COUNTER_FILE") +fi + +if [ $((COUNT % 10)) -ne 0 ]; then + exit 0 +fi + +STATUS=$("$STATE_SCRIPT" status 2>/dev/null || echo '{}') +WORKFLOW=$(echo "$STATUS" | grep -o '"workflow"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"//;s/"//') +STEP=$(echo "$STATUS" | grep -o '"current_step"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"//;s/"//') +PCT=$(echo "$STATUS" | grep -o '"progress_pct"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 | sed 's/.*: *//') + +if [ -n "$WORKFLOW" ] && [ -n "$STEP" ]; then + jq -nc --arg msg "Workflow: ${WORKFLOW} | Step: ${STEP} | Progress: ${PCT}%" \ + '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}' +fi + +exit 0 diff --git a/.claude/rules/agent-behavior.md b/.claude/rules/agent-behavior.md new file mode 100644 index 00000000000..0303c48b4ca --- /dev/null +++ b/.claude/rules/agent-behavior.md @@ -0,0 +1,34 @@ +# Agent Behavior + +> How the agent should work — not what code to write, but how to approach tasks. Complements [design-principles.md](./design-principles.md) (code design) and [coding-style.md](./coding-style.md) (code structure). + +## Think Before Coding + +Don't assume. Don't hide confusion. Surface tradeoffs. + +- **State assumptions explicitly.** Before implementing, list what you're assuming about scope, behavior, and constraints. If uncertain, ask. +- **Multiple interpretations? Present them.** Don't silently pick one. Show the options with tradeoffs and let the engineer choose. +- **Push back when warranted.** If a simpler approach exists, say so. If the request will create tech debt, maintenance burden, or security risk, flag it. +- **Stop when confused.** Name what's unclear. Ask. A wrong assumption costs more than a clarifying question. + +## Surgical Changes + +Touch only what you must. Clean up only your own mess. + +- **Don't improve adjacent code.** When editing a function, don't reformat its neighbors, add docstrings to unrelated code, or rename variables you didn't introduce. +- **Don't refactor things that aren't broken.** The task is the task — resist the urge to "while I'm here" cleanup. +- **Match existing style.** Even if you'd write it differently in a new file, match the conventions of the file you're editing. +- **Clean up only what you created.** If your changes make an import or variable unused, remove it. Don't delete pre-existing dead code — mention it instead. +- **Diff test:** every changed line should trace directly to the user's request. If it doesn't, revert it. + +## Goal-Driven Execution + +Define success criteria. Loop until verified. + +- **Transform vague tasks into verifiable goals:** + - "Add validation" → write tests for invalid inputs, then make them pass. + - "Fix the bug" → write a test that reproduces it, then make it pass. + - "Refactor X" → ensure tests pass before and after. +- **Multi-step tasks: state a numbered plan** with a `verify:` checkpoint for each step before starting. +- **Strong success criteria enable autonomy.** "Make it work" is weak — push for specifics. "All 5 edge cases return 400 with the correct error code" is strong. +- **Don't mark work complete without verification.** Run the tests. Check the build. Verify the behavior matches the goal. diff --git a/.claude/rules/code-review.md b/.claude/rules/code-review.md new file mode 100644 index 00000000000..a1f47362fa5 --- /dev/null +++ b/.claude/rules/code-review.md @@ -0,0 +1,27 @@ +# Code Review Process + +## Before Opening a PR +Run `/code-review` for automated review. Fix all CRITICAL issues before requesting human review. + +## Requirements +- At least 2 reviewers per PR +- At least 1 approval before merge +- All CI checks passing +- Self-tested locally by author + +## Author Checklist +- [ ] All logic passes basic and edge cases +- [ ] Self-tested before PR creation +- [ ] Coding conventions followed (see [coding-style.md](./coding-style.md), [meaningful-names.md](./meaningful-names.md)) +- [ ] No secrets or sensitive data in code +- [ ] Tests written (TDD followed) +- [ ] Coverage >= 80% + +## Reviewer Checklist +- [ ] Edge cases and error paths checked +- [ ] Coding conventions verified — including [meaningful-names.md](./meaningful-names.md): no `tmp`/`val`/`data`/`x` etc., booleans prefixed with `is`/`has`/`should`, collections plural +- [ ] Security concerns checked +- [ ] Test coverage confirmed + +## Labels +Set exactly one: feat, fix, bugfix, test, chore, docs, devops, release diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md new file mode 100644 index 00000000000..2c6f7138478 --- /dev/null +++ b/.claude/rules/coding-style.md @@ -0,0 +1,31 @@ +# Coding Style + +> Naming rules live in [meaningful-names.md](./meaningful-names.md). Design principles (SOLID, KISS, YAGNI, DRY) live in [design-principles.md](./design-principles.md). This file covers structure: immutability, file size, error handling. + +## Immutability (CRITICAL) +Always create new objects, never mutate existing ones. +Wrong: modify an object field in place. +Correct: return a new copy with the changed field. + +## File Organization +- 200-400 lines typical, 800 max +- Organize by feature/domain, not by technical type (not /models, /views) + +## Error Handling +- Handle errors explicitly at every level +- Never silently swallow errors — at minimum log them +- UI code: user-friendly messages +- Server code: detailed context in logs + +## Input Validation +- Validate all user input at system boundaries +- Schema-based: Yup for TypeScript, Pydantic for Python +- Fail fast with clear error messages + +## Code Quality Checklist +- [ ] Functions < 50 lines +- [ ] Files < 800 lines +- [ ] No deep nesting (> 4 levels) +- [ ] Every error handled explicitly +- [ ] No hardcoded values — use constants or config +- [ ] No mutation of existing objects diff --git a/.claude/rules/context-hygiene.md b/.claude/rules/context-hygiene.md new file mode 100644 index 00000000000..a432f2958a7 --- /dev/null +++ b/.claude/rules/context-hygiene.md @@ -0,0 +1,41 @@ +# Context Hygiene + +Keep each session's context focused on one line of work. Mixed-topic sessions waste the context window, contaminate subagent dispatches with irrelevant background, and increase the chance Claude confuses prior-task state with current-task state. + +## When the user sends a new prompt, classify it before responding + +**Unrelated** — the new prompt targets a different feature, file area, language, or problem domain than the prior conversation (e.g. finished debugging a Django migration, now asking about frontend styling; finished reviewing a PR, now asking to research a third-party library). + +- Do NOT start the new task. +- Respond with a single short recommendation: either **open a new Claude Code tab** (fastest, zero setup) or **run `/clear`** (same session, context wiped). +- If you believe the prior task left important state the user might lose (uncommitted work, open plan), mention that briefly so they can commit/save first. + +**Loosely related** — the new prompt is in the same codebase/feature area but shifts focus (e.g. finished implementing endpoint A, now starting endpoint B in the same service; finished fixing one bug, now fixing an adjacent bug in the same module). + +- Do NOT start the new task yet. +- Recommend **`/compact`** with a specific focus hint, e.g. `/compact focus on the auth module changes we made, drop the unrelated filesystem exploration`. +- After the user compacts, proceed normally. + +**Directly related / continuation** — the new prompt builds on what you just did (e.g. "now add tests for that", "also update the README", "handle the edge case you mentioned"). + +- Proceed immediately. No recommendation needed. + +## Be decisive, don't over-ask + +- One recommendation per prompt. Don't list options — pick the one that fits. +- Do NOT recommend `/clear` or `/compact` preemptively on every prompt. Only when the topic has actually shifted. +- If you're uncertain whether a prompt is unrelated or loosely related, default to the less-disruptive option (compact over clear). + +## Examples + +> User just finished refactoring the `UserService` in Django backend. Next prompt: "Can you help me set up a new Next.js frontend project?" + +→ **Unrelated.** Say: *"That's a different project area from the UserService work — recommend opening a new tab for it so this session's Django context doesn't leak into the frontend setup. Your refactor is already committed, so nothing to lose."* + +> User just added a `create_order` endpoint. Next prompt: "Now add a `cancel_order` endpoint with the same auth pattern." + +→ **Directly related.** Proceed. + +> User just debugged a flaky test in the auth module. Next prompt: "While we're here, can you also review the rate-limiter code? It's been acting weird." + +→ **Loosely related** (same repo, different subsystem). Say: *"Different subsystem from the auth tests — recommend `/compact focus on the auth flakiness fix, drop unrelated context` before we dig into the rate limiter so the review has a clean slate."* diff --git a/.claude/rules/dangerous-actions.md b/.claude/rules/dangerous-actions.md new file mode 100644 index 00000000000..13b1cb8ea59 --- /dev/null +++ b/.claude/rules/dangerous-actions.md @@ -0,0 +1,44 @@ +# Dangerous Actions + +Some actions are blocked at the tool layer — don't attempt them, you'll just get an error. If the user genuinely needs one of these, ask them to run it manually in their own terminal. + +## Never attempt + +### Destructive bash +- `sudo ` — the agent must never escalate privileges +- `rm -rf` on anything outside the allowlist: `/tmp`, `node_modules`, `build`, `dist`, `.next`, `.turbo`, `__pycache__`, `.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `coverage`, `.devkit*` +- `git reset --hard` — discards uncommitted work +- `git clean -f` / `-fd` / `-fdx` — deletes untracked files (often user's in-progress work) +- `git checkout .` or `git restore .` when `git status` is dirty — nukes changes +- `git branch -D ` where protected ∈ {master, main, uat, develop, qc, training} +- `curl ... | sh` / `wget ... | bash` — supply-chain risk + +### Credential files +Never use Write or Edit on any of these: +- `.env`, `.env.production`, `.env.prod`, `.env.staging`, `.env.local`, `.env.*` (templates like `.env.example`/`.env.sample`/`.env.template` are allowed) +- `secrets.yml`, `secrets.yaml`, `credentials.json`, `service-account*.json` +- `*.pem`, `*.p12`, `*.pfx`, `*.key`, `id_rsa*`, `id_ed25519*` +- Anything inside `~/.ssh/`, `~/.aws/`, `~/.gnupg/`, `~/.kube/`, `~/.docker/config*` +- `~/.netrc`, `~/.pgpass`, `~/.my.cnf` + +If the user asks you to "add an env var", edit `.env.example` (the template) and ask them to mirror the change in their local `.env`. + +### Direct pushes / force pushes +- `git push` to master/main/uat/develop/qc/training (use an MR) +- `git push --force` / `-f` / `--force-with-lease` anywhere + +## How to work WITH the guards + +- **Want to clean a workspace?** `git stash` or `git stash -u` keeps changes recoverable. Or just commit WIP on a branch. +- **Want to throw away uncommitted changes?** Ask the user — only they should decide. +- **Need to remove a directory outside the allowlist?** Use an explicit `rm` without `-rf` (per-file), or ask the user. +- **Pre-push tests failing?** Fix the tests. If the user needs to push WIP, they can run `DEVKIT_PREPUSH_SKIP=1 git push ...` themselves. +- **Need to install a one-off script?** Download first (`curl -o /tmp/x.sh ...`), show the user the contents, and let them run it. + +## If a block is wrong + +Hooks are conservative by design. If you hit a false positive: + +1. Tell the user what you were trying to do and what got blocked. +2. Suggest the safest path forward (e.g. "I'll commit my WIP instead, then we can discuss"). +3. Do NOT try to find a workaround that defeats the guard (e.g. `echo $'\x67'it push…`). That's a security smell. diff --git a/.claude/rules/design-principles.md b/.claude/rules/design-principles.md new file mode 100644 index 00000000000..4dc0c9dcb62 --- /dev/null +++ b/.claude/rules/design-principles.md @@ -0,0 +1,61 @@ +# Design Principles + +> Complements [coding-style.md](./coding-style.md) (structure), [meaningful-names.md](./meaningful-names.md) (naming), and [paradigms.md](./paradigms.md) (functional vs OOP). This file covers higher-level design decisions. + +## SOLID + +### Single Responsibility (SRP) +Every module, class, or function should have one reason to change. If a +description requires "and" — split it. + +### Open/Closed (OCP) +Extend behavior through composition or new implementations, not by modifying +existing working code. Prefer strategy patterns and dependency injection over +`if/else` chains that grow with each new case. + +### Liskov Substitution (LSP) +Subtypes must be usable wherever their parent type is expected without +surprising behavior. Don't override a method to throw "not supported" — that +breaks callers' assumptions. + +### Interface Segregation (ISP) +Don't force consumers to depend on methods they don't use. Prefer small, +focused interfaces over large catch-all ones. + +### Dependency Inversion (DIP) +High-level modules should depend on abstractions, not concrete implementations. +Inject dependencies — don't instantiate them deep inside business logic. + +## KISS — Keep It Simple + +- Choose the simplest solution that solves the actual problem. +- Avoid clever code. If it needs a comment to explain the trick, rewrite it. +- Three similar lines of code is better than a premature abstraction. +- Don't add indirection (helpers, wrappers, factories) until there's a real + second use case — not a hypothetical one. + +## YAGNI — You Aren't Gonna Need It + +- Don't build for requirements that don't exist yet. +- No feature flags, config knobs, or extension points "just in case." +- Delete dead code instead of commenting it out. Git has history. +- If a function has a parameter nobody passes, remove it. + +## DRY — Don't Repeat Yourself + +- Extract shared logic only when duplication is real (3+ occurrences) and the + duplicated code changes for the same reason. +- Two pieces of code that look the same but evolve independently are NOT + duplication — don't force them into a shared abstraction. +- Prefer duplication over the wrong abstraction. + +## When principles conflict + +Principles are guidelines, not laws. When they pull in opposite directions: + +| Tension | Resolution | +|---|---| +| DRY vs KISS | Prefer KISS. A little duplication is cheaper than the wrong abstraction. | +| OCP vs YAGNI | Prefer YAGNI. Don't add extension points until a second variant exists. | +| SRP vs KISS | Don't split a 30-line function into 5 classes for "purity." Split when complexity demands it. | +| DIP vs KISS | Inject dependencies at module boundaries and API layers. Internal helpers can instantiate directly. | diff --git a/.claude/rules/git-workflow.md b/.claude/rules/git-workflow.md new file mode 100644 index 00000000000..2261ceae3a2 --- /dev/null +++ b/.claude/rules/git-workflow.md @@ -0,0 +1,66 @@ +# Git Workflow + +## Branch Naming +Format: `/` +Default valid types: feat, cr, fix, bugfix, test, chore, docs, devops, release, refactor + +Default ticket prefixes (see [glossary.md](./glossary.md)): +- `EP-` — PRD / epic +- `LT-` — engineering tasks, bugs, sub-tasks, user stories +- `AI-` — AI team tasks +- `DO-` — devops tasks + +Examples: `feat/EP-754`, `fix/LT-8451`, `chore/DO-152`, `feat/AI-269`. +Override branch types, ticket pattern, and examples in `.claude/devkit-plan.json` under `git_workflow`. + +Branch from qc: +``` +git checkout qc && git pull origin qc +git checkout -b feat/EP-754 +``` + +## Commit Messages +Format: `(scope?): [] ` +- Present tense, not capitalized, no period, max 72 chars +- One isolated fix/feature per commit +- Ticket ID goes in **square brackets** right after the colon — same shape as the MR title, makes ticket easy to grep and click in GitLab + +Examples: +``` +feat(auth): [EP-754] add JWT refresh token endpoint +fix(loan): [LT-8451] handle null bank code in submission +fix: [AI-332] add nodejs to docker image for stdio mcp servers +chore: [DO-152] update ruff to 0.4.0 +``` + +When a commit genuinely has no ticket (devkit-internal cleanup, repo bootstrap, dependency-only chore), drop the brackets — don't fabricate one: +``` +chore: bump README version +docs: fix typo in glossary +``` + +Default valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert +Override commit types, ticket pattern, and examples in `.claude/devkit-plan.json` under `git_workflow`. + +## Merge Requests +Title: `[] ` — example: `[EP-754] platform foundation auth bootstrap` + +Requirements: +- At least 2 reviewers assigned +- At least 1 approval before merge +- Owner clicks Merge (not reviewer) +- Label: feat/fix/bugfix/test/chore/docs/devops/release +- Squash commits for features and bug fixes +- Do NOT delete source branch on merge + +## Never push directly to protected branches + +Protected: `master`, `main`, `uat`, `develop`, `qc`, `training`. + +All changes go through an MR from a feature branch. Never run: + +- `git push origin master` (or any protected branch name) — use an MR +- `git push --force` / `git push -f` / `git push --force-with-lease` — history rewrites require explicit user consent, not agent action +- `git push origin :master` — pushing a feature branch onto a protected destination is equivalent to bypassing review + +These are blocked at the tool layer by `.claude/hooks/block-protected-push.sh`. If you see the block, open an MR instead — or ask the user to run the push themselves if they have a real emergency reason. diff --git a/.claude/rules/glossary.md b/.claude/rules/glossary.md new file mode 100644 index 00000000000..798042db791 --- /dev/null +++ b/.claude/rules/glossary.md @@ -0,0 +1,66 @@ +# Ringkas Glossary + +The exact words to use when writing code, commits, MRs, PRDs, audits, reviews, and any other artifact for Ringkas. Substitutes are forbidden — they create drift across docs, confuse new engineers, and make grep useless. + +## Canonical terms + +| Use | Don't use | Notes | +|---|---|---| +| **MR** (merge request) | PR, pull request, Pull Request, Merge Request | We use GitLab. The acronym `MR` is the noun in prose ("open an MR", "this MR adds…"). Capitalize only at sentence start. | +| **master** | main | Default branch name across ~95% of Ringkas repos. The 5% on `main` are the exception — name them explicitly when you mean them. | +| **qc**, **uat**, **training**, **prod** | staging, production, stg, qa, test (env), live | These four are the only Ringkas environments. There is no "staging". `prod` is the word — don't expand to "production". | +| **Beli** | BeLi, beli, Polaris, polaris | Umbrella product name. Always written `Beli` in prose. The repo URL `polaris/beli` is a path artifact, not the brand. | +| **saturn**, **jupiter**, **regulus**, **alcor**, **phobos**, **ai-backoffice** | Saturn, Jupiter, etc. | Codebase names match their directory names — lowercase. Capitalize only at sentence start. | +| **engineer** | developer, dev, programmer | Internal role term. | +| **MCP** | Model Context Protocol (in prose), tool server | Acronym only — don't expand inline. | +| **MR description** | PR body, summary, change log | The freeform text on a merge request. | + +## Ticket prefixes + +Every Ringkas ticket has a typed prefix. Branch names, commit messages, MR titles, and prose all use these forms exactly. + +| Prefix | Domain | Example | +|---|---|---| +| `EP-` | PRD / product epic | `EP-754` | +| `LT-` | Engineering: tasks, bugs, sub-tasks, user stories | `LT-8451` | +| `AI-` | AI team tasks | `AI-269` | +| `DO-` | DevOps tasks | `DO-152` | + +Forbidden invented prefixes: `R-`, `RNG-`, `RING-`, `TASK-`, `BUG-`, `STORY-`, `ENG-`, `INT-`, plus any lowercase variant. + +Branch examples: `feat/EP-754`, `fix/LT-8451`, `chore/DO-152`, `feat/AI-269`. + +Commit examples: `feat(auth): EP-754 add JWT refresh endpoint`, `fix(loan): LT-8451 handle null bank code`. + +MR title examples: `[EP-754] platform foundation auth bootstrap`, `[LT-8451] null bank code crash`. + +## Auth & user terminology + +| Use | Don't use | +|---|---| +| **authentication** (the act of proving identity) | "auth" when you mean authentication specifically | +| **authorization** (the act of granting access) | "auth" when you mean authorization specifically | +| **JWT** | jwt, Jwt, JSON Web Token (in prose) | +| **SSO** | sso, single sign-on (in prose) | +| **access platform** (Saturn's enum of which app a user can enter) | accessPlatform (when prose), platform-access | + +`auth` is fine as a code identifier (`auth/`, `authMiddleware`, `useAuth()`) — don't use it in prose where the distinction between authentication and authorization matters. + +## Workflow / role terminology + +| Use | Don't use | +|---|---| +| **devkit** | tooling, harness (when you mean specifically this repo), the kit | +| **harness** | rules system, configuration | Refers to the `.claude/` directory contents (rules + hooks + agents + skills) loaded into every session. | +| **skill** | command, slash command (when discussing the `.md` file itself) | +| **slash command** | command, invocation | The `/audit`, `/review` etc. that triggers a skill. | +| **hook** | pre-commit, plugin, callback | A script under `.claude/hooks/` fired by Claude Code on a tool event. | +| **rule** | instruction, guide, doc (when discussing files under `.claude/rules/`) | + +## When this glossary conflicts with quoted material + +Don't rewrite quotes, error messages, log lines, third-party docs, or external content. The glossary governs what *we* write — not what we faithfully reproduce from elsewhere. + +## When you spot drift + +If you see a doc, comment, commit message, or skill using a non-canonical term, fix it in the same edit if you're already touching the file. Don't open a separate cleanup MR for prose — the next person to touch the file will fix it too. Glossary drift compounds slowly; correcting it opportunistically is enough. diff --git a/.claude/rules/meaningful-names.md b/.claude/rules/meaningful-names.md new file mode 100644 index 00000000000..fdfac6066c0 --- /dev/null +++ b/.claude/rules/meaningful-names.md @@ -0,0 +1,95 @@ +# Meaningful Names + +All generated code uses descriptive, self-documenting names. A reader unfamiliar with the surrounding code should be able to tell what a variable represents from its name alone. + +> See also: [coding-style.md](./coding-style.md) for general structure rules, [glossary.md](./glossary.md) for Ringkas-specific terms (use `customer`, `loan`, `bank` etc. consistently). + +## Rules + +### Length & clarity +- **≥ 3 characters** for variable, parameter, and function names — except for the conventions listed below. +- Name what the variable **represents**, not its type. `users` not `arr`. `config` not `obj`. + +### Forbidden patterns +- **Single letters** outside the explicit exception list: `a`, `b`, `c`, `n`, `m` etc. +- **Meaningless placeholders**: `tmp`, `temp`, `val`, `var`, `obj`, `data`, `info`, `foo`, `bar`, `baz`. +- **Type-only names** as identifiers: `str`, `num`, `arr`, `list`, `dict`, `map`. +- **Numbered placeholders**: `var1`, `var2`, `value1`, `item1` — unless they actually represent an indexed sequence. +- **Cryptic abbreviations**: `usr`, `pwd`, `cfg`, `mgr`, `hdlr`, `req`, `resp`, `ctx` (when not the React/Go context idiom). Spell them out: `user`, `password`, `config`, `manager`, `handler`, `request`, `response`. + +### Allowed short names +Short names are fine in these specific contexts: + +| Context | Allowed | Notes | +|---|---|---| +| Tight numeric loop indices | `i`, `j`, `k` | Use descriptive names for nested loops or non-trivial bodies | +| Mathematical formulas | `x`, `y`, `r`, `theta` etc. | Only when notation mirrors the formula and a comment makes intent clear | +| Trivial lambdas / arrows | `users.map(u => u.name)` | Acceptable; `users.map(user => user.name)` preferred | +| Catch parameters | `e`, `err`, `error` | Standard idiom | +| Intentionally unused | `_` | Standard idiom | +| Identifiers | `id`, `userId`, `loanId` | Standard idiom | +| Go context, React context | `ctx` | Idiomatic | + +### Naming guidance +- **Variables / properties**: nouns. `userCount`, `activeConnections`, `requestPayload`. +- **Functions / methods**: verbs. `calculateTotal`, `validateInput`, `fetchUserProfile`. +- **Booleans**: prefix with `is`, `has`, `should`, `can`, `was`. `isActive`, `hasPermission`, `shouldRetry`. +- **Collections**: plural. `users`, `errorMessages`, `pendingRequests`. +- **Casing**: match the language. `camelCase` for JS/TS, `snake_case` for Python, `PascalCase` for types/classes. +- **Specific over generic**: `customerEmail` over `email` when context allows ambiguity. `retryCount` over `count`. + +## Examples + +❌ Bad: +```typescript +function calc(a: number, b: number, t: number) { + const r = a * b; + const d = r * t; + return d; +} + +const u = users.filter(x => x.s === 1); +const tmp = data.map(d => d.v); +``` + +✅ Good: +```typescript +function calculateDiscountedTotal(price: number, quantity: number, taxRate: number) { + const subtotal = price * quantity; + const totalWithTax = subtotal * taxRate; + return totalWithTax; +} + +const activeUsers = users.filter(user => user.status === 1); +const itemValues = data.map(item => item.value); +``` + +❌ Bad: +```python +def proc(d, n): + res = [] + for i in d: + if i > n: + res.append(i) + return res +``` + +✅ Good: +```python +def filter_values_above_threshold(values, threshold): + filtered_values = [] + for value in values: + if value > threshold: + filtered_values.append(value) + return filtered_values +``` + +## Enforcement + +When generating, refactoring, or reviewing code: +1. Before finalizing any code, scan all variable, parameter, and function names. +2. Replace any name that violates this rule with a descriptive alternative. +3. If a short name is genuinely necessary (per "Allowed short names" above), the surrounding context must make its meaning unambiguous. +4. When in doubt, err on the side of more descriptive — verbosity is preferred over ambiguity. + +This rule applies to **all generated code**: features, fixes, tests, scripts, hooks. The `/review` skill should flag violations as MEDIUM (HIGH if the bad name is in a public API). diff --git a/.claude/rules/paradigms.md b/.claude/rules/paradigms.md new file mode 100644 index 00000000000..069d9f0fd02 --- /dev/null +++ b/.claude/rules/paradigms.md @@ -0,0 +1,70 @@ +# Programming Paradigms + +> Complements [design-principles.md](./design-principles.md) (SOLID, KISS, YAGNI, DRY) and [coding-style.md](./coding-style.md) (immutability, structure). This file guides when to use which paradigm. + +## Default: functional-first, OOP when it earns its place + +Most code is data transformation — input in, output out. Start with functions +and plain data structures. Reach for classes only when you have genuine state +and behavior that belong together. + +## When to use functional patterns + +| Signal | Pattern | +|---|---| +| Transform data from shape A to shape B | Pure function, `map`/`filter`/`reduce` | +| Combine multiple operations on data | Function composition / pipelines | +| Utility or helper logic | Standalone pure functions | +| Stateless request handling | Functions that take input, return output | +| Configuration or options | Plain objects/dicts, not builder classes | +| Testing is hard because of hidden state | Refactor toward pure functions with explicit inputs | + +### Functional checklist +- **Pure functions**: same input → same output, no side effects. +- **Immutable data**: never mutate inputs; return new copies (see [coding-style.md](./coding-style.md)). +- **Explicit dependencies**: pass everything a function needs as arguments — no reaching into globals or singletons. +- **Avoid shared mutable state**: if two functions need the same data, pass it explicitly to both. + +## When to use OOP patterns + +| Signal | Pattern | +|---|---| +| Entity with identity, lifecycle, and rules | Class with encapsulated state | +| Multiple implementations of the same contract | Interface + concrete classes | +| Resource that must be opened/closed | Class with lifecycle methods | +| Complex domain model with invariants | Domain objects that enforce their own rules | +| Framework requires it | Inherit/implement what the framework demands, no more | + +### OOP checklist +- **Composition over inheritance**: default to injecting collaborators, not extending base classes. Inherit only when there's a true "is-a" relationship. +- **Shallow hierarchies**: max 2 levels of inheritance. If you need a third, refactor to composition. +- **No god classes**: a class with 10+ public methods or 500+ lines is doing too much — split by responsibility. +- **Encapsulate state**: expose behavior (methods), not raw data (public fields). If callers just read/write fields, you don't need a class — use a plain data structure. + +## When to use neither — just write simple procedural code + +Not everything needs a pattern. A 20-line script, a one-off migration, a +simple CLI tool — just write sequential code with clear variable names. Don't +wrap it in classes or chain it through functional combinators for style points. + +## Mixed paradigm is normal + +Real codebases mix paradigms. The goal is consistency within a module: + +- A module that transforms data should be functional throughout — don't sneak + in a class that mutates shared state. +- A module that manages domain entities should use classes consistently — don't + scatter free functions that reach into object internals. +- At the boundary between modules, prefer plain data (objects/dicts/DTOs) over + passing class instances — this keeps modules loosely coupled. + +## Anti-patterns to watch for + +| Smell | Problem | Fix | +|---|---|---| +| Class with only static methods | It's just a namespace, not OOP | Use a module with exported functions | +| Function that reads/writes `this` or `self` extensively | Hidden state disguised as functional | Make it a method on a class, or make the state explicit | +| Factory that creates one type | Unnecessary indirection | Direct construction / function call | +| Inheritance used for code reuse only (no polymorphism) | Coupling without benefit | Extract shared logic into a function or mixin | +| Functional chain longer than 5 steps | Unreadable pipeline | Break into named intermediate variables or smaller functions | +| Abstract class with one implementation | Premature abstraction | Delete the abstract, use the concrete directly (YAGNI) | diff --git a/.claude/rules/security.md b/.claude/rules/security.md new file mode 100644 index 00000000000..f33db030459 --- /dev/null +++ b/.claude/rules/security.md @@ -0,0 +1,30 @@ +# Security — ISO 27001 Controls + +## Mandatory Checks Before Every Commit +- [ ] No hardcoded secrets (API keys, passwords, tokens, private keys) +- [ ] All user inputs validated at system boundaries +- [ ] SQL: parameterized queries only — no string concatenation +- [ ] Every endpoint has auth/authz verified +- [ ] Error messages do not leak: stack traces, paths, PII, DB details +- [ ] Sensitive data not written to logs +- [ ] Rate limiting considered for public-facing endpoints + +## ISO 27001 Control Mapping + +| OWASP Risk | ISO 27001 Control | Requirement | +|------------|-------------------|-------------| +| Injection | A.14.2.5 | Parameterized queries; never string-concat SQL | +| Broken Auth | A.9.4.2 | JWT/session validation on all protected routes | +| Sensitive Data | A.10.1.1 | Encrypt PII at rest and in transit (HTTPS always) | +| Access Control | A.9.4.1 | Principle of least privilege on all resources | +| Logging | A.12.4.1 | Audit trail for all data modification events | + +## Audit Trail (A.12.4) +Log for every data modification: timestamp, user ID, action, resource ID. +Logs must be retained and not deletable by the application. + +## Secret Management +- NEVER hardcode secrets in source code +- Use environment variables; validate required secrets at startup +- Rotate any exposed secret immediately +- Add .env to .gitignore before first commit diff --git a/.claude/rules/skill-authoring.md b/.claude/rules/skill-authoring.md new file mode 100644 index 00000000000..4643102fd08 --- /dev/null +++ b/.claude/rules/skill-authoring.md @@ -0,0 +1,128 @@ +# Skill Authoring + +How to write a Ringkas skill so it's discoverable, composable, and stays under maintenance pressure. + +## Anatomy + +``` +skills// +├── SKILL.md # required — the workflow, ≤100 lines +├── EXAMPLES.md # optional — long-form examples +├── REPORT-FORMAT.md # optional — output schema (for skills that produce reports) +├── CHECKLIST.md # optional — itemized checks (for review/audit-style skills) +└── scripts/ # optional — only for *deterministic* steps (file generation, etc.) +``` + +- Directory name = skill name = filename slug used by the slash command. +- File is **`SKILL.md`** (uppercase, exact name) — the loader matches on it. +- Sibling reference files use **ALL-CAPS conventional names**: `LANGUAGE.md`, `EXAMPLES.md`, `REPORT-FORMAT.md`, `CHECKLIST.md`, `OUT-OF-SCOPE.md`. Lowercase descriptive names are also fine for skill-specific files (`gitlab-access.md`). + +## Frontmatter + +```yaml +--- +name: skill-name +description: . Use when . +version: 2.0.0 +author: Ringkas Engineering +--- +``` + +Required fields: +- **name** — must equal the directory name +- **description** — *see "Description format" below* +- **version** — semver. Bump major when you break the slash command's input/output contract; minor for new behavior; patch for fixes +- **author** — `Ringkas Engineering` for shared skills; `Ringkas Engineering / ` for team-scoped (e.g. AI, DevOps) + +Optional: +- **disable-model-invocation: true** — set on skills that should ONLY run via slash command and never auto-trigger from a description match. Use this for destructive or high-cost workflows (`/audit`, `/feature`). + +## Description format + +The description is what makes a skill discoverable. It must let the model decide "is this skill the right tool for what the user just asked?" without reading the body. + +**Shape (mandatory)**: + +> *First sentence: what it does, in third person.* +> *Second sentence: "Use when [phrase], [phrase], [phrase]."* + +Trigger phrases are literal things the user might say or types of work they might describe. List 3–8. + +Max **1024 chars**. Aim for ≤300. + +**Good**: + +> Runs an ISO 27001 security audit (controls A.9, A.10, A.12.4, A.14.2) and scans dependencies for CVEs against the project's package manager. Use when the user says "audit this", "security check", "ISO 27001", "scan for vulnerabilities", or before merging to master/uat. + +**Bad** (your old description — too short, no triggers): + +> ISO 27001 security audit + dependency CVE scan + +The model can't infer "the user said 'is this safe to ship'" maps to this skill from the bad version. + +## Body + +Write the skill body for an agent that lands cold and has to execute. Conventions: + +- **Lead with the workflow.** First section is the steps, in order. Numbered if linear, bulleted if branching. +- **Glossary if the skill introduces terms.** Either inline at the top, or a separate `LANGUAGE.md` file the SKILL.md links to. +- **Out-of-scope section if confusion is likely.** "This skill does X, not Y. For Y see /other-skill." +- **Compose by reference.** If your workflow's step 3 is "now do a code review", say *"Run `/review` on the changes."* Don't inline the entire review process — it'll drift from `/review`'s own SKILL.md. +- **No example output blocks longer than ~30 lines in SKILL.md.** Move them to `EXAMPLES.md`. + +## Length budget + +- **SKILL.md ≤ 100 lines.** Past that, split into reference files. +- Each reference file should answer one question. `REPORT-FORMAT.md` answers "what does the output look like". `CHECKLIST.md` answers "what specifically gets checked". Don't dump everything into a single `DETAIL.md`. +- If a skill has more than 5 sibling files, it's two skills pretending to be one — split. + +## Composing skills + +Skills can and should call other skills mid-flow: + +```markdown +## Step 3 — Review + +Run `/review` on the diff. If it returns CRITICAL or HIGH issues, fix them +before continuing to step 4. +``` + +This keeps each skill focused and ensures behavior stays in sync. If `/review` evolves, every skill that composes it inherits the change. + +Don't compose by copying the other skill's body inline. That's drift waiting to happen. + +## Naming + +- **Skill name** = lowercase kebab-case, ≤3 words: `audit`, `git-workflow`, `prd-authoring`. Avoid `do-`, `run-`, `check-` prefixes — the slash itself implies action. +- **Slash command** = same as skill name: `/audit`, `/git-workflow`. +- **Reference files** = ALL-CAPS for conventional ones (`EXAMPLES.md`), lowercase descriptive for skill-specific (`gitlab-access.md`). + +## Terminology + +Use [glossary.md](./glossary.md) terms exactly. Especially: + +- `MR` not "PR" or "pull request" +- `master` not "main" (default branch in Ringkas) +- `qc` / `uat` / `training` / `prod` for environments — no "staging" +- Ticket prefixes: `EP-` (PRD) / `LT-` (eng) / `AI-` (AI) / `DO-` (devops) + +## Adding a new skill + +1. Create `skills//SKILL.md` with the frontmatter shape above. +2. Add the skill to `harness/common/CLAUDE.md` under "Available Skills". +3. Add the skill to `README.md` (the table at top + a usage example section). +4. Test: open Claude Code and run `/`. Then describe the task in natural language without typing the slash — confirm the skill auto-invokes from your trigger phrases. +5. Commit: `git commit -m "feat(skills): LT- add / skill"`. + +## When to deprecate a skill + +If two skills overlap >50% in body, merge them. If a skill hasn't been invoked in a quarter and has no clear use case, delete it. Stale skills are worse than missing skills — they show up in skill listings and dilute the agent's matching. + +## Anti-patterns + +- **Slash-command-only skills with no trigger phrases.** Discoverable only by typing `/` exactly. Add triggers. +- **Frontmatter copied from another skill without updating `name`.** The loader will silently misroute. +- **SKILL.md body ≥200 lines.** It's not a skill anymore, it's a tutorial. Split. +- **Inlining another skill's logic instead of `/calling` it.** Drift is guaranteed. +- **Vague descriptions like "Helps with X".** Description is the matching surface — be specific. +- **Skipping the version bump on a behavior change.** Devkit updates rely on version comparisons during install. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 00000000000..3321fcab6ba --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,25 @@ +# Testing Requirements + +## Minimum Coverage: 80% +Measured on lines, functions, and branches. + +## Test Types (all required) +1. Unit tests — pure functions, no I/O +2. Integration tests — API endpoints, database operations +3. E2E tests — critical user flows (happy path per User Story minimum) + +## TDD Workflow (mandatory) +1. Write the failing test (RED) +2. Run the test — verify it fails +3. Write minimal code to pass (GREEN) +4. Run the test — verify it passes +5. Refactor and re-run +6. Check coverage >= 80% + +## Test Design +- Test behavior, not implementation details +- Each test covers exactly one scenario +- Tests are independent — no shared mutable state +- Names: `test___` +- Always assert the specific value +- Cover: happy path, empty/null, error state, boundary values, unauthorized access diff --git a/.claude/rules/typescript/coding-style.md b/.claude/rules/typescript/coding-style.md new file mode 100644 index 00000000000..dfa9a1f9bf5 --- /dev/null +++ b/.claude/rules/typescript/coding-style.md @@ -0,0 +1,31 @@ +# TypeScript Coding Style + +> Extends common/rules/coding-style.md for saturn (Node.js) and jupiter (React). + +## Type Safety + +> Full rule with examples + alternatives table: [type-safety.md](./type-safety.md). The bullets here are the surface-level reminders. + +- No `any` types — use `unknown` and narrow, generics, union types, or `Record`. See type-safety.md for which to pick. +- No `// @ts-ignore` or `as any` escape hatches. +- UPPER_CASE enum values: `enum Status { PENDING = 'PENDING' }` +- Prefer `interface` for object shapes; `type` for unions and aliases. + +## Imports (jupiter only) +- NEVER import from `antd` directly — use `@ui/*` wrappers only. +- RTL-safe CSS: use `ms-X`/`me-X` instead of `ml-X`/`mr-X`. +- RTL-safe CSS: use `ps-X`/`pe-X` instead of `pl-X`/`pr-X`. + +## Country-Specific Component Pattern (jupiter) +- `index.tsx` — entry point +- `index.base.tsx` — shared base +- `index.sa.tsx` — Saudi Arabia override + +## saturn (Node.js) +- Serverless handlers in `serverless/` +- Unit tests in `tests/unitTest/` mirroring src +- Use Yup ValidationError for DTO validation + +## Linting +- ESLint with `--max-warnings 0` +- After GraphQL schema changes: run `npm run codegen` in jupiter diff --git a/.claude/rules/typescript/graphql-workflow.md b/.claude/rules/typescript/graphql-workflow.md new file mode 100644 index 00000000000..a62b8cc2f29 --- /dev/null +++ b/.claude/rules/typescript/graphql-workflow.md @@ -0,0 +1,46 @@ +# GraphQL Workflow (saturn) + +saturn uses Hasura (service `sol`) as a GraphQL gateway. Each microservice +(`dione`, `mimas`, `titan`, etc.) runs its own Apollo Server, registered as a +Hasura **Remote Schema**. + +## File layout + +| Path | Purpose | +|---|---| +| `src/services//graphql/typeDefs//{inputType,outputType,query,mutation}.gql` | GraphQL type definitions per service | +| `src/services//graphql/resolvers/` | Resolver implementations | +| `src/services//functions/graphql/index.ts` | Apollo Server entry point | +| `src/common/graphql/directives/*.gql` | Shared directives | +| `src/generatedTypes/graphql.ts` | Auto-generated TypeScript types (do NOT edit) | +| `src/generatedTypes/enums.ts` | Auto-generated enums (do NOT edit) | +| `src/services/sol/hasura/metadata/remote_schemas.yaml` | Hasura remote schema registrations + per-role permission SDL | + +## After editing `.gql` files + +1. **Run codegen** — regenerates `src/generatedTypes/graphql.ts` and `enums.ts`: + ```bash + yarn codegen + ``` +2. **Check if `remote_schemas.yaml` needs updating** — if the change adds, removes, + or modifies a query/mutation/type that is exposed to Hasura clients through a + specific role, update the inline SDL for that role in `remote_schemas.yaml`. + - New queries/mutations visible to a role: add them to that role's schema block. + - Changed argument shapes or return types: update the role's schema block. + - New service: add a new remote schema entry. +3. **Apply Hasura metadata** (if `remote_schemas.yaml` changed): + ```bash + yarn hasura:migrate + ``` + +## When `remote_schemas.yaml` does NOT need updating + +- Internal resolver changes with no type signature change. +- Adding fields only used server-to-server (not through Hasura gateway). +- Changes to directives that don't affect the public schema. + +## Codegen is mandatory + +Never commit `.gql` changes without running `yarn codegen`. The generated types +in `src/generatedTypes/` must stay in sync with the schema. If they diverge, +TypeScript compilation will fail downstream. diff --git a/.claude/rules/typescript/security.md b/.claude/rules/typescript/security.md new file mode 100644 index 00000000000..872637ab14e --- /dev/null +++ b/.claude/rules/typescript/security.md @@ -0,0 +1,27 @@ +# TypeScript Security + +> Extends common/rules/security.md for saturn and jupiter. + +## XSS Prevention (jupiter) +- Never render user-provided HTML without sanitizing first (DOMPurify) +- Never construct URLs from user input without validation +- React's unsafe HTML rendering prop must never receive unsanitized content + +## CSRF (saturn) +- Include CSRF tokens on all state-changing requests +- Verify Origin or Referer headers server-side + +## Input Validation (saturn) +- Yup schemas for ALL DTO validation at API boundary +- Server-side validation is mandatory regardless of client validation +- Reject requests with unexpected fields (Yup noUnknown()) + +## API Security (saturn) +- JWT: validate with signature verification — never skip signature check +- Never expose stack traces or internal errors to API clients +- HTTPS in all environments +- 401 for unauthenticated, 403 for unauthorized + +## Dependency Security +- Run `npm audit --audit-level=high` before merging to main +- No HIGH or CRITICAL vulnerabilities in dependencies diff --git a/.claude/rules/typescript/testing.md b/.claude/rules/typescript/testing.md new file mode 100644 index 00000000000..2cec4385013 --- /dev/null +++ b/.claude/rules/typescript/testing.md @@ -0,0 +1,43 @@ +# TypeScript Testing + +> Extends common/rules/testing.md for saturn and jupiter. + +## Framework +- Jest for unit and integration tests +- React Testing Library for component tests in jupiter (not Enzyme) + +## Test File Location +- saturn: `tests/unitTest//.test.ts` +- jupiter: co-located `.test.tsx` or `__tests__/` directory + +## Patterns + +```typescript +// Unit test +describe('calculateInterestRate', () => { + it('returns correct rate for standard loan', () => { + const result = calculateInterestRate({ principal: 100_000, rate: 0.05, years: 10 }) + expect(result).toBe(50_000) + }) + + it('throws ValidationError when principal is negative', () => { + expect(() => calculateInterestRate({ principal: -1, rate: 0.05, years: 10 })) + .toThrow(ValidationError) + }) +}) + +// Component test (jupiter) +import { render, screen, fireEvent } from '@testing-library/react' + +it('shows error when email is empty on submit', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + expect(screen.getByText(/email is required/i)).toBeInTheDocument() +}) +``` + +## Coverage +```bash +jest --coverage +# Minimum: 80% lines, functions, branches +``` diff --git a/.claude/rules/typescript/type-safety.md b/.claude/rules/typescript/type-safety.md new file mode 100644 index 00000000000..dd4e87d2c60 --- /dev/null +++ b/.claude/rules/typescript/type-safety.md @@ -0,0 +1,142 @@ +# TypeScript Type Safety + +> Extends [common/rules/coding-style.md](../../common/rules/coding-style.md) for saturn (Node.js) and jupiter (React). See [common/rules/meaningful-names.md](../../common/rules/meaningful-names.md) for naming. + +The point of TypeScript is the compiler catching mistakes you'd otherwise find at runtime. Every `any` is a hole in that net. + +## No `any` types + +Never use `any` in generated TypeScript code. It defeats static typing and silently breaks every type guarantee downstream. + +If the type is genuinely unknown at compile time, use `unknown` and narrow it with a type guard before use. For function parameters and return values, always provide explicit, specific types. For complex or dynamic objects, define proper `interface` or `type` declarations. For reusable utilities, use generic type parameters (``) instead of `any`. If a third-party library lacks types, create a `.d.ts` declaration file rather than falling back to `any`. + +This applies to: +- Variable annotations: `const value: any = ...` +- Function parameters: `function parse(data: any) { ... }` +- Return types: `function fetch(): Promise { ... }` +- Generic constraints: `Array`, `Record`, `Map` +- Type assertions: `value as any`, `value` +- Implicit any from missing annotations (use `noImplicitAny: true` in `tsconfig.json`) + +## Preferred alternatives + +| Use | When | +|---|---| +| `unknown` | Type genuinely not known at compile time. Forces narrowing before use. | +| `Record` | Arbitrary object shape (not a known interface) | +| Generic parameter `` | Reusable function/component where caller decides the type | +| Union types (`string \| number`) | Value is one of several known types | +| Discriminated union (`{ kind: 'a', ... } \| { kind: 'b', ... }`) | Variant types where the shape depends on a tag | +| `never` | Code path that should be unreachable (exhaustive switch defaults) | +| `object` | Any non-primitive (rare; usually you want a more specific shape) | + +## Examples + +❌ Bad: +```typescript +function parse(data: any): any { + return data.value; +} + +async function fetchUser(id: string): Promise { + const response = await api.get(`/users/${id}`); + return response.data; +} + +const config: any = JSON.parse(rawConfig); +const handler = (event: any) => { ... }; +``` + +✅ Good: +```typescript +function parse(data: T): T['value'] { + return data.value; +} + +interface User { + id: string; + email: string; + createdAt: Date; +} +async function fetchUser(userId: string): Promise { + const response = await api.get(`/users/${userId}`); + return response.data; +} + +const config: AppConfig = configSchema.validateSync(JSON.parse(rawConfig)); + +interface ClickEvent { + target: HTMLElement; + timestamp: number; +} +const handler = (event: ClickEvent) => { ... }; +``` + +## Narrowing `unknown` + +When you must accept `unknown` (parsing JSON, deserializing form data, third-party callbacks), narrow it with a type guard before reaching for fields: + +```typescript +function isUser(value: unknown): value is User { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && typeof (value as { id: unknown }).id === 'string' && + 'email' in value && typeof (value as { email: unknown }).email === 'string' + ); +} + +function processInput(payload: unknown) { + if (!isUser(payload)) { + throw new ValidationError('Expected User shape'); + } + // payload is User here + console.log(payload.email); +} +``` + +For Ringkas, prefer **Yup** schemas in saturn or **Zod** for new code — they validate AND narrow in one step: + +```typescript +import * as yup from 'yup'; + +const userSchema = yup.object({ + id: yup.string().required(), + email: yup.string().email().required(), +}); + +const user = userSchema.validateSync(rawData); // user is typed as User +``` + +## Common escape hatches that aren't acceptable + +| Pattern | Why it's bad | Fix | +|---|---|---| +| `// @ts-ignore` | Silences the compiler at one site, no type info propagates | Fix the type. If genuinely unfixable, use `// @ts-expect-error` with a comment explaining why | +| `as any` | Pretends the cast is fine | Use `as unknown as T` only after narrowing, or fix upstream type | +| `any[]` | "Just an array of anything" — usually means "I haven't decided" | Use `unknown[]` or define the element type | +| `Record` | Any-shaped map | `Record` and narrow on read, or define the shape | +| `Function` (capital F) | Untyped callable | `(arg: T) => U` — name what it takes and returns | + +## Third-party libraries without types + +Create a `*.d.ts` file (typically `src/types/.d.ts`): + +```typescript +// src/types/some-untyped-lib.d.ts +declare module 'some-untyped-lib' { + export function doThing(input: string): { result: number }; +} +``` + +Don't write `import x from 'some-untyped-lib' as any` or sprinkle `// @ts-ignore` at every call site. + +## Enforcement + +When generating, refactoring, or reviewing TypeScript: +1. Before finalizing any code, scan for `any`, `as any`, `// @ts-ignore`, `// @ts-nocheck`. +2. Replace with one of the preferred alternatives above. +3. The `/review` skill flags `any` as **MEDIUM** by default and **HIGH** when it appears in a public API (exported function, route handler, MR'd schema). +4. The reviewer agent enforces this checklist item without exception. + +`tsconfig.json` should set `"strict": true` and `"noImplicitAny": true` — ESLint should enable `@typescript-eslint/no-explicit-any` (warning at minimum, error preferred). diff --git a/.claude/rules/worktree.md b/.claude/rules/worktree.md new file mode 100644 index 00000000000..606f1da6d19 --- /dev/null +++ b/.claude/rules/worktree.md @@ -0,0 +1,35 @@ +# Worktree Hygiene + +When creating or entering a git worktree for this project, copy the project-level +`.claude/` contents — **excluding** the `worktrees/` subdirectory — into the +worktree before starting work. + +```bash +rsync -a --exclude='worktrees/' .claude/ /.claude/ +``` + +This keeps the repo-specific harness (rules, hooks, skills, agents, settings) +available inside isolated worktrees so behavior stays consistent with the main +checkout, without copying nested worktree directories back in. + +## When to run + +- Immediately after `git worktree add ` +- The first time you `cd` into a worktree that doesn't yet have `.claude/` +- After installing or upgrading the devkit in the main checkout — re-sync each + active worktree so they pick up the new rules/hooks + +## What this gives you + +- Hooks (branch/commit validation, pre-push tests, secret scan) fire inside the + worktree the same way they fire in the main checkout +- Skills and slash commands resolve correctly +- `devkit-plan.json` (statusline budgets, ticket-format config) is shared + +## Don't + +- Don't symlink `.claude/` — Claude Code resolves hook paths relative to + `$CLAUDE_PROJECT_DIR` and some hooks write logs under `.claude/.devkit/`, + which you want isolated per worktree. +- Don't include `worktrees/` in the copy — that recurses worktree state back + into worktrees. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000000..6d10203430e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,97 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-write-lint.sh" + } + ] + }, + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/context-monitor.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/workflow-status.sh" + } + ] + }, + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-write-graphql.sh" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-bash.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-protected-push.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-tests.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-security.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-branch-name.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-commit-msg.sh" + } + ] + }, + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-credential-writes.sh" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/workflow-resume.sh" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pr-review-reminder.sh" + } + ] + } + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000000..1fc898471bf --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "_note": "Engineer-local settings — highest precedence in Claude Code. Use this file for overrides that should win over any user-level ~/.claude/settings.json (e.g. a conflicting statusLine from another harness like GSD).", + "statusLine": { + "type": "command", + "command": ".claude/bin/devkit-statusline.sh", + "padding": 0 + } +} diff --git a/.claude/skills/_shared/gitlab-access.md b/.claude/skills/_shared/gitlab-access.md new file mode 100644 index 00000000000..b7609a9ced8 --- /dev/null +++ b/.claude/skills/_shared/gitlab-access.md @@ -0,0 +1,175 @@ +# GitLab Access — Shared Reference + +Use this procedure whenever a skill needs to read data from GitLab (MR metadata, diffs, files, pipelines). + +## Step 1 — Check memory + +Look for a saved memory named `gitlab-auth-method`. If found, use the stored method and skip to Step 3. + +## Step 2 — Discover access method + +Try in order. **Stop at the first one that works:** + +### Option A: GitLab MCP Server (best integration) + +Check if a GitLab MCP server is configured by looking for available MCP tools with `gitlab` or `mcp_gitlab` in their names (e.g. `get_merge_request`, `list_merge_request_changed_files`). + +If MCP tools are available, use them directly — they handle auth automatically: +``` +# Fetch MR metadata +mcp: get_merge_request(project_id, merge_request_iid) + +# Fetch MR diffs +mcp: get_merge_request_diffs(project_id, merge_request_iid) +# or: list_merge_request_changed_files / get_merge_request_file_diff + +# Fetch file from branch +mcp: get_file_contents(project_id, file_path, ref) + +# Post comment on MR +mcp: create_merge_request_thread(project_id, merge_request_iid, body) +# or: create_workitem_note + +# Create MR +mcp: create_merge_request(project_id, source_branch, target_branch, title) +``` + +If no GitLab MCP is configured but the user wants this approach, **offer to set it up:** + +> "Would you like to set up a GitLab MCP server? Three options: +> +> **1. Official GitLab HTTP endpoint** (recommended for self-managed GitLab): +> ```bash +> claude mcp add --transport http GitLab https://git.barebone.ringkas.co.id/api/v4/mcp +> ``` +> Auth: OAuth 2.0 — browser opens on first use. No token config needed. +> +> **2. @zereight/mcp-gitlab** (full API coverage — files, pipelines, deployments, etc.): +> Add to `.claude/mcp-configs/mcp-servers.json`: +> ```json +> { +> "mcpServers": { +> "gitlab": { +> "command": "npx", +> "args": ["-y", "@zereight/mcp-gitlab"], +> "env": { +> "GITLAB_PERSONAL_ACCESS_TOKEN": "", +> "GITLAB_API_URL": "https://git.barebone.ringkas.co.id/api/v4" +> } +> } +> } +> } +> ``` +> +> **3. kopfrechner/gitlab-mr-mcp** (lightweight, MR review only): +> ```json +> { +> "mcpServers": { +> "gitlab-mr": { +> "command": "npx", +> "args": ["-y", "@kopfrechner/gitlab-mr-mcp"], +> "env": { +> "GITLAB_TOKEN": "", +> "GITLAB_API_URL": "https://git.barebone.ringkas.co.id/api/v4" +> } +> } +> } +> } +> ```" + +### Option B: glab CLI + +```bash +glab --version 2>/dev/null +``` +If available and authenticated, use glab commands directly. Skip to Step 3. + +### Option C: GitLab API token + +Check for a token in this order: +1. `$GITLAB_TOKEN` environment variable +2. `$GITLAB_PRIVATE_TOKEN` environment variable +3. `~/.config/glab-cli/config.yml` (glab stores tokens here) + +### Option D: Ask the user + +If no access method is found, **ask:** +> "I need GitLab access to fetch data. Which do you prefer? +> 1. Set up GitLab MCP server (best integration — see options above) +> 2. Install glab CLI: `brew install glab` then `glab auth login` +> 3. Provide a GitLab Personal Access Token — what env var or file path is it stored in?" + +### Option E: No auth fallback (internal repos on VPN) + +Try without auth header. If 401/403, go back to Option D. + +## Step 3 — Save to memory (first time only) + +After the first successful GitLab access, **save the working method to memory** so future sessions skip discovery: + +```markdown +--- +name: gitlab-auth-method +description: User's preferred GitLab access method and configuration for Ringkas GitLab +type: reference +--- + +GitLab host: +Access method: +Details: +``` + +## Common operations (non-MCP fallbacks) + +Use these when MCP is not available and you're using glab CLI or direct API calls. + +### Fetch MR metadata +```bash +# glab +glab mr view ${MR_IID} --repo "${PROJECT_PATH}" --output json + +# API +curl -s --header "PRIVATE-TOKEN: ${TOKEN}" \ + "https://${GITLAB_HOST}/api/v4/projects/${PROJECT_PATH}/merge_requests/${MR_IID}" +``` + +### Fetch MR diff +```bash +# glab +glab mr diff ${MR_IID} --repo "${PROJECT_PATH}" + +# API +curl -s --header "PRIVATE-TOKEN: ${TOKEN}" \ + "https://${GITLAB_HOST}/api/v4/projects/${PROJECT_PATH}/merge_requests/${MR_IID}/changes" +``` + +### Fetch file from a branch +```bash +# API +curl -s --header "PRIVATE-TOKEN: ${TOKEN}" \ + "https://${GITLAB_HOST}/api/v4/projects/${PROJECT_PATH}/repository/files/${FILE_PATH_ENCODED}/raw?ref=${BRANCH}" +``` + +### Create MR +```bash +# glab +glab mr create --title "[${TICKET_ID}] ${SUMMARY}" \ + --description "${BODY}" \ + --source-branch "${SOURCE}" \ + --target-branch "${TARGET}" \ + --assignee "${ASSIGNEE}" \ + --reviewer "${REVIEWER1},${REVIEWER2}" \ + --label "${LABEL}" +``` + +### Post comment on MR +```bash +# glab +glab mr note ${MR_IID} --message "${COMMENT}" + +# API +curl -s --request POST --header "PRIVATE-TOKEN: ${TOKEN}" \ + --header "Content-Type: application/json" \ + --data "{\"body\": \"${COMMENT}\"}" \ + "https://${GITLAB_HOST}/api/v4/projects/${PROJECT_PATH}/merge_requests/${MR_IID}/notes" +``` diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md new file mode 100644 index 00000000000..ce1d21ef335 --- /dev/null +++ b/.claude/skills/audit/SKILL.md @@ -0,0 +1,41 @@ +--- +name: audit +description: Runs an ISO 27001 security audit (controls A.9 access control, A.10 cryptography, A.12.4 logging, A.14.2 secure development) and a dependency CVE scan against the project's package manager, then writes a per-control PASS/FAIL report to .devkit/security-audit.md and optionally posts findings to a GitLab MR comment. Use when the user says "audit", "security audit", "ISO 27001", "compliance check", "scan dependencies", "check for vulnerabilities", or before merging to master/uat/training. +version: 2.1.0 +author: Ringkas Engineering +disable-model-invocation: true +--- + +# Audit Workflow + +Run ISO 27001 security checks and package vulnerability scan. + +> GitLab access: Follow `skills/_shared/gitlab-access.md` for auth. + +## Step 1 — Determine Scope + +Check input: +- If GitLab MR URL: fetch diff from MR +- If no URL: use local diff or scan entire project + +Initialize state: +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" init audit +``` + +## Step 2 — Security Agent (haiku) + +Dispatch Security agent: +- Runs both ISO 27001 controls AND package vulnerability scan +- Prompt: "Audit the code changes. Run both ISO 27001 checks and dependency scan. Write to .devkit/security-audit.md." + +Wait for `## AUDIT COMPLETE`. Advance state. + +## Step 3 — Complete + +Print audit summary. +If MR URL was provided, offer to post audit as MR comment. + +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" complete +``` diff --git a/.claude/skills/bugfix/SKILL.md b/.claude/skills/bugfix/SKILL.md new file mode 100644 index 00000000000..7ec4308f9fa --- /dev/null +++ b/.claude/skills/bugfix/SKILL.md @@ -0,0 +1,61 @@ +--- +name: bugfix +description: End-to-end bug-fix workflow that runs /diagnose first to produce a disciplined root-cause report (.devkit/diagnose.md), then orchestrates coder → tester → reviewer agents to apply the fix, add a regression test, and verify against Ringkas conventions. Use when the user says "fix this bug", "fix and ship", "debug and fix X", "there's a regression — fix it", or hands off a ticket / stack trace and wants a tested, reviewed fix on a branch. +version: 2.2.0 +author: Ringkas Engineering +--- + +# Bug Fix Workflow + +Diagnose, fix, test, and review a single bug. The diagnosis discipline lives in `/diagnose` — this skill consumes its output and drives the rest. + +## Step 1 — Initialize + +Ask the engineer for: +1. Bug description or ticket ID (`LT-` / `AI-` / `EP-` — see glossary.md) +2. Error logs or stack trace, if available +3. Coder mode: `auto` (agent writes the fix) or `engineer` (agent stops at fix plan, you write it) + +Initialize state: +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" init bugfix --coder +``` + +## Step 2 — Diagnose + +Run `/diagnose` on the bug. It will produce `.devkit/diagnose.md` containing the feedback loop, ranked hypotheses, root cause, and fix plan. + +**Wait for `## DIAGNOSE COMPLETE`** before continuing. + +If `/diagnose` prints `## DIAGNOSE BLOCKED` (it could not build a Phase 1 feedback loop), stop here. Surface the blockers from the report to the engineer and do not dispatch the coder — fixing without a repro means the fix cannot be verified. + +## Step 3 — Coder (sonnet) or Engineer Pause + +If `--coder auto`: dispatch the Coder agent with the fix plan from `.devkit/diagnose.md` as input. Coder writes to the files identified in Phase 5 of the report. + +If `--coder engineer`: stop here, print the Phase 5 fix plan, and wait for the engineer to apply it manually. Resume on `/bugfix continue`. + +## Step 4 — Tester (sonnet) + +Dispatch the Tester agent. The regression test is already specified in `.devkit/diagnose.md` Phase 5 — Tester writes it at the seam identified there. If the diagnose report says "no correct seam exists", Tester writes the broadest test it can and notes the architectural gap in `.devkit/test.md`. + +Wait for `## TESTS COMPLETE`. Re-run the Phase 1 feedback loop from `.devkit/diagnose.md` against the un-minimised scenario to confirm the original symptom is gone. + +## Step 5 — Reviewer (sonnet) + +Dispatch the Reviewer agent. Output: `.devkit/review.md` with CRITICAL / HIGH / MEDIUM findings. + +Block on CRITICAL. Surface HIGH for engineer decision. MEDIUM is informational. + +## Step 6 — Complete + +Print a summary: ticket ID, files changed, regression test name, reviewer verdict. + +Run the Phase 6 cleanup checklist from `.devkit/diagnose.md` — verify no `[DEBUG-...]` strings leaked into the diff, no throwaway scripts left in the tree. + +If the bug touched auth, PII, financial logic, or external API code: suggest `/audit` before opening the MR. If the diagnose report flagged an architectural gap: suggest `/improve-architecture` (planned skill) as a follow-up ticket. + +## Out of scope + +- Disciplined investigation — `/diagnose` owns Phases 1–5 of the diagnosis loop. +- Writing the MR description and pushing — `/git-workflow` does that. diff --git a/.claude/skills/diagnose/REPORT-FORMAT.md b/.claude/skills/diagnose/REPORT-FORMAT.md new file mode 100644 index 00000000000..e9c80f68950 --- /dev/null +++ b/.claude/skills/diagnose/REPORT-FORMAT.md @@ -0,0 +1,65 @@ +# Diagnose Report Format + +Written to `.devkit/diagnose.md` at the end of Phase 6 (or earlier if abandoning at Phase 1). `/bugfix` reads this file and uses it to drive the coder / tester / reviewer agents. + +## Template + +```markdown +## Diagnosis: + +**Ticket:** EP- / LT- / AI- / DO- (or "ad-hoc" if no ticket yet) +**Date:** YYYY-MM-DD +**Codebase:** saturn / jupiter / regulus / alcor / phobos / ai-backoffice +**Environment reproduced:** local / qc / uat / training / prod +**Severity:** CRITICAL / HIGH / MEDIUM / LOW + +### Phase 1 — Feedback loop +What kind of loop, where it lives, how to run it, how long one iteration takes. + +```bash +# exact command to run the loop +pnpm test path/to/repro.test.ts +``` + +### Phase 2 — Reproduction +- **Symptom captured:** +- **Reproduction rate:** 100% / N% over M runs +- **Confirmed user-described failure:** yes / no — + +### Phase 3 — Hypotheses (ranked, after investigation) +1. ✅ — prediction confirmed by +2. ❌ — ruled out by +3. ❌ — ruled out by + +### Phase 4 — Root cause +<2–4 sentences. Explain WHY, not just WHAT. Reference `path/to/file.ts:42`.> + +### Phase 5 — Fix +- **File(s):** `path/to/file.ts:42` +- **Change:** +- **Regression test:** `path/to/file.test.ts` — `` + (or: "no correct seam exists — see Architectural note below") + +### Phase 6 — Verification +- [x] Phase 1 loop now passes +- [x] Regression test passes +- [x] No `[DEBUG-...]` strings in working tree (`grep -r '\[DEBUG-' . --exclude-dir=node_modules --exclude-dir=.git`) +- [x] No throwaway prototypes left in tree + +### Architectural note (only if applicable) + +``` + +## When to write a partial report + +If you abandon at **Phase 1** (cannot build a loop), write a stub with: + +- The list of loop strategies you tried and why each failed. +- The specific access / artifact / instrumentation permission you need from the user. +- Empty "Phase 2–6" sections so `/bugfix` knows the diagnosis is incomplete. + +Print `## DIAGNOSE BLOCKED` (not `COMPLETE`) so `/bugfix` does not proceed to dispatch the coder. diff --git a/.claude/skills/diagnose/SKILL.md b/.claude/skills/diagnose/SKILL.md new file mode 100644 index 00000000000..4bb444a83a5 --- /dev/null +++ b/.claude/skills/diagnose/SKILL.md @@ -0,0 +1,97 @@ +--- +name: diagnose +description: Disciplined six-phase debugging loop (build feedback loop → reproduce → hypothesise → instrument → fix + regression test → cleanup) for hard bugs and performance regressions. Output is a written root-cause report at .devkit/diagnose.md that /bugfix can consume to drive the actual fix. Use when the user says "diagnose this", "debug this", "what's causing this", "why is this slow", "this is flaky", reports a regression, pastes a stack trace / error log, or describes a bug that resists guess-and-check. +version: 1.0.0 +author: Ringkas Engineering +--- + +# Diagnose + +A discipline for hard bugs. Skip phases only when explicitly justified — and write the justification into `.devkit/diagnose.md`. + +Before starting: read glossary.md for the canonical names of services (saturn / jupiter / regulus / alcor / phobos / ai-backoffice), environments (qc / uat / training / prod), and ticket prefixes (EP- / LT- / AI- / DO-) you will cite in the report. + +## Phase 1 — Build a feedback loop + +**This is the skill.** A fast, deterministic, agent-runnable pass/fail signal makes the bug 90% solved. Without one, no amount of code-staring helps. + +Try, in roughly this order: + +1. **Failing test** at the deepest seam that reaches the bug — `jest` for saturn / jupiter, `pytest` for regulus / alcor / phobos / ai-backoffice. +2. **HTTP repro** — `curl` against a local dev server, or against the actual `qc` / `uat` env if it reproduces only there. +3. **CLI fixture** — feed a captured payload through a single function, diff stdout against a known-good snapshot. +4. **Headless browser** — Playwright drives jupiter, asserts on DOM / console / network. +5. **Replay a captured trace** — save a real network request, payload, or event log to disk; replay it through the code path in isolation. +6. **Throwaway harness** — minimal subset of the system (one service, mocked deps) that hits the bug code path with one function call. +7. **Property / fuzz loop** — for "sometimes wrong output", run 1000 random inputs and watch for the failure mode. +8. **Bisection harness** — bug appeared between two known states (commit, dataset, version) → automate "boot, check, repeat" so `git bisect run` can drive it. +9. **Differential loop** — same input through old vs new (or two configs); diff outputs. +10. **HITL loop** — last resort. If a human must click, drive *them* with [scripts/hitl-loop.template.sh](scripts/hitl-loop.template.sh) so captured output still feeds back to you. + +### Iterate the loop itself + +Once you have *a* loop, sharpen it: faster (cache setup, narrow the test scope), sharper signal (assert on the specific symptom — not "didn't crash"), more deterministic (pin time, seed RNG, isolate filesystem, freeze network). A 2-second deterministic loop is a debugging superpower; a 30-second flaky loop barely beats no loop. + +### Non-deterministic bugs + +Aim for higher reproduction rate, not necessarily a clean repro. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. 50% flake = debuggable; 1% = not — keep raising until it is. + +### When you cannot build a loop + +Stop. Write `.devkit/diagnose.md` listing what you tried and ask the user for: (a) access to an env that reproduces, (b) a captured artifact (HAR, log dump, screen recording with timestamps), or (c) permission to add temporary `qc` instrumentation. Do **not** hypothesise without a loop. + +## Phase 2 — Reproduce + +Run the loop. Confirm: + +- [ ] It produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix. +- [ ] It reproduces across multiple runs (or, for flaky bugs, at a high enough rate to debug against). +- [ ] You captured the exact symptom (error message, wrong output, slow timing) so Phase 6 can verify the fix. + +## Phase 3 — Hypothesise + +Generate **3–5 ranked, falsifiable hypotheses** before testing any. Single-hypothesis generation anchors on the first plausible idea. + +> Format: "If is the cause, then will make the bug disappear / will make it worse." + +If you cannot state a prediction, the hypothesis is a vibe — sharpen or discard. + +Show the ranked list to the user before instrumenting — they often re-rank instantly with domain knowledge ("we just deployed a change to #3", "we already ruled out #1 in LT-7821"). Cheap checkpoint, big time saver. Don't block on it; proceed with your ranking if AFK. + +## Phase 4 — Instrument + +Each probe maps to one specific prediction from Phase 3. **Change one variable at a time.** + +- Prefer **debugger / REPL** > targeted logs > "log everything and grep" (never). +- **Tag every debug log** with a unique prefix: `[DEBUG-a4f2]`. Cleanup is one grep — untagged logs survive, tagged logs die. +- **Performance bugs:** logs lie. Establish a baseline (timing harness, `performance.now()`, profiler, `EXPLAIN ANALYZE` for regulus DB queries), then bisect. Measure first, fix second. + +## Phase 5 — Fix + regression test + +Write the regression test **before the fix** — but only if a **correct seam** exists. + +A correct seam exercises the real bug pattern as it occurs at the call site. If the only available seam is too shallow (single-caller test for a multi-caller bug, unit test that can't replicate the call chain), a regression test there gives false confidence. **If no correct seam exists, that itself is the finding** — note it in `.devkit/diagnose.md` and flag for `/improve-architecture` (planned skill). + +If a correct seam exists: +1. Turn the minimised repro into a failing test at that seam — saturn: `tests/unitTest//`; jupiter: co-located `*.test.tsx`; Python: alongside the module. +2. Watch it fail. +3. Apply the fix. +4. Watch it pass. +5. Re-run the Phase 1 loop against the **un-minimised** scenario. + +## Phase 6 — Cleanup + handoff + +Required before declaring done: + +- [ ] Original repro no longer reproduces (re-run Phase 1 loop). +- [ ] Regression test passes (or absence of seam is documented). +- [ ] All `[DEBUG-...]` instrumentation removed (`grep -r` the prefix to verify). +- [ ] Throwaway prototypes / scripts deleted (or moved under `.devkit/` with a clear name). +- [ ] The hypothesis that turned out correct is recorded in `.devkit/diagnose.md` and the eventual commit message — so the next debugger learns. + +Write `.devkit/diagnose.md` per [REPORT-FORMAT.md](REPORT-FORMAT.md). Print `## DIAGNOSE COMPLETE` when written. If the user is running `/bugfix`, control returns there with the diagnosis as input. + +## Out of scope + +- Applying the fix beyond the regression test — `/bugfix` orchestrates that with the coder / tester / reviewer agents. +- Architectural refactor that would prevent the bug class — recommend `/improve-architecture` (future skill). diff --git a/.claude/skills/diagnose/scripts/hitl-loop.template.sh b/.claude/skills/diagnose/scripts/hitl-loop.template.sh new file mode 100755 index 00000000000..7e3b17a0079 --- /dev/null +++ b/.claude/skills/diagnose/scripts/hitl-loop.template.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Human-in-the-loop reproduction loop — last-resort Phase 1 strategy. +# Copy this file, edit the steps below, and run it. +# The agent runs the script; the user follows prompts in their terminal. +# At the end, captured values are printed as KEY=VALUE for the agent to parse. +# +# Usage: +# bash skills/diagnose/scripts/hitl-loop.template.sh +# +# Two helpers: +# step "" → show instruction, wait for Enter +# capture VAR "" → show question, read response into VAR + +set -euo pipefail + +step() { + printf '\n>>> %s\n' "$1" + read -r -p " [Enter when done] " _ +} + +capture() { + local var="$1" question="$2" answer + printf '\n>>> %s\n' "$question" + read -r -p " > " answer + printf -v "$var" '%s' "$answer" +} + +# --- edit below --------------------------------------------------------- + +step "Open jupiter at http://localhost:3000 and sign in as the test user." + +capture ERRORED "Click the action that triggers the bug. Did it fail? (y/n)" + +capture ERROR_MSG "Paste the exact error message from the console (or 'none'):" + +capture NETWORK_STATUS "Open DevTools → Network tab. What status code did the failing request return?" + +# --- edit above --------------------------------------------------------- + +printf '\n--- Captured ---\n' +printf 'ERRORED=%s\n' "$ERRORED" +printf 'ERROR_MSG=%s\n' "$ERROR_MSG" +printf 'NETWORK_STATUS=%s\n' "$NETWORK_STATUS" diff --git a/.claude/skills/feature/SKILL.md b/.claude/skills/feature/SKILL.md new file mode 100644 index 00000000000..2cb2401078c --- /dev/null +++ b/.claude/skills/feature/SKILL.md @@ -0,0 +1,115 @@ +--- +name: feature +description: Orchestrates a full feature delivery pipeline (plan → code → test → review → security audit) by dispatching specialized agents in sequence and writing artifacts to .devkit/. Produces a tested, reviewed, security-audited implementation ready to commit. Use when the user says "build feature X", "implement Y", "add capability Z", "ship a new endpoint/page/module", or hands off a PRD / EP-... epic to execute end-to-end. +version: 2.1.0 +author: Ringkas Engineering +disable-model-invocation: true +--- + +# Feature Workflow + +End-to-end feature development with agent orchestration. + +## Step 1 — Initialize + +Ask for: +1. Ticket ID (e.g., EP-754 for the driving PRD, LT-8451 for an engineering task, AI-269 for AI work, DO-152 for devops — see glossary.md) or feature description +2. Coder mode: "Should I write the code, or will you? (auto/engineer)" + - Check memory `devkit-coder-mode` for default. If set, offer as default. + +Initialize state: +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" init feature --coder +``` + +Parse the JSON response. Save coder mode to memory if first time. + +## Step 2 — Planner (opus) + +Dispatch the Planner agent: +- Agent type: planner (from agents/planner.md) +- Model: opus (from state init response) +- Prompt: "You are the Planner agent. Read the ticket description: . Follow your agent instructions. Write the plan to .devkit/plan.md." + +Wait for `## PLAN COMPLETE` in response. +Then advance state: +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" advance +``` + +## Step 3 — Coder (sonnet) or Engineer Pause + +**If coder_mode == auto:** +Dispatch the Coder agent: +- Agent type: coder (from agents/coder.md) +- Model: sonnet +- Prompt: "You are the Coder agent. Read .devkit/plan.md and implement it. Follow your agent instructions." + +Wait for `## CODE COMPLETE`. + +**If coder_mode == engineer:** +Print: "Your turn to code. The plan is in .devkit/plan.md. Say 'done' when ready to continue." +Wait for user to say "done". + +Then advance state: +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" advance +``` + +## Step 4 — Tester (sonnet) + +Dispatch the Tester agent: +- Agent type: tester (from agents/tester.md) +- Model: sonnet +- Prompt: "You are the Tester agent. Read .devkit/plan.md and the git diff. Write tests. Follow your agent instructions." + +Wait for `## TESTS COMPLETE`. Advance state. + +## Step 5 — Reviewer (sonnet) + +Dispatch the Reviewer agent: +- Agent type: reviewer (from agents/reviewer.md) +- Model: sonnet +- Prompt: "You are the Reviewer agent. Review the git diff. Write review to .devkit/review.md. Follow your agent instructions." + +Wait for `## REVIEW COMPLETE`. Advance state. + +If verdict is NEEDS FIXES, print findings and ask: +"Review found issues. Fix them now, or continue to security audit?" + +## Step 6 — Security (haiku) + +Dispatch the Security agent: +- Agent type: security (from agents/security.md) +- Model: haiku +- Prompt: "You are the Security agent. Audit the git diff. Write report to .devkit/security-audit.md. Follow your agent instructions." + +Wait for `## AUDIT COMPLETE`. Advance state. + +## Step 7 — Complete + +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" complete +``` + +Print summary: +- Steps completed / skipped +- Review verdict +- Security verdict +- Suggest: "Run /git-workflow to create branch and MR." + +## Skipping Steps + +If engineer says "skip" at any step: +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" skip "" +``` +Print the warning from the JSON response. Continue to next step. + +## Pausing + +If engineer says "pause" or context monitor triggers: +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" checkpoint "" +``` +Print: "Workflow paused. Run /resume to continue." diff --git a/.claude/skills/git-workflow/SKILL.md b/.claude/skills/git-workflow/SKILL.md new file mode 100644 index 00000000000..aa377dec9f7 --- /dev/null +++ b/.claude/skills/git-workflow/SKILL.md @@ -0,0 +1,147 @@ +--- +name: git-workflow +description: Guides branch creation (format `/` where ticket pattern comes from `.claude/devkit-plan.json` `git_workflow`, default `(EP|LT|AI|DO)-`), conventional-commit message authoring, and MR creation with the title shape `[] `; can open the GitLab MR directly via the GitLab MCP. Use when the user says "create a branch", "start a new task", "commit this", "open an MR", "ship this", "let's work on EP-...", or starts work on a new ticket. +version: 1.3.0 +author: Ringkas Engineering +--- + +# Git Workflow Guide + +> Language rule: All PR titles, commit messages, and descriptions must be in English. +> GitLab access: Follow `skills/_shared/gitlab-access.md` for auth discovery, token handling, and memory persistence. +> Ticket format: Read `.claude/devkit-plan.json` `git_workflow` block first. It defines `branch_types`, `commit_types`, `ticket_pattern` (ERE regex), `ticket_examples`, `branch_examples`, `commit_examples`, and `ticket_prefixes_doc`. Use those exact values in prompts and validation. Fall back to the Ringkas defaults (EP/LT/AI/DO) only when the config is missing. + +## Step 1 — Determine Action + +Ask what to do: +- A) Create a new branch +- B) Write a commit message +- C) Generate a PR/MR description +- D) Create a GitLab MR directly +- E) Full workflow (A → B → C → D) + +## Step 2A — Create a Branch + +Ask: +1. Ticket ID? (show examples from `git_workflow.ticket_examples`; default examples: EP-754, LT-8451, AI-269, DO-152) +2. Type of change? (show values from `git_workflow.branch_types`) + +Construct `/`. Validate against `git_workflow.ticket_pattern` before running. + +``` +git checkout QC +git pull origin QC +git checkout -b / +``` + +If branch name does not match `/`, stop and ask user to correct. + +## Step 2B — Write a Commit Message + +Ask: +1. What changed? (one sentence) +2. Scope? (optional) +3. Type? (show values from `git_workflow.commit_types`) +4. Ticket ID? (same ticket from branch — pre-fill from branch name when possible) + +Construct: `(scope?): [] ` +Rules: present tense, not capitalized, no period, max 72 chars. Ticket ID in square brackets right after the colon. Omit brackets only when the commit genuinely has no ticket. + +Examples: +- `feat(auth): [EP-754] add JWT refresh token endpoint` +- `fix: [AI-332] add nodejs to docker image for stdio mcp servers` + +### Format changed files before committing + +Before staging, detect and run the project's formatter on changed files: + +1. Check `package.json` scripts for format/lint commands (e.g. `format`, `lint:fix`, `prettier`) +2. Check for config files: `.prettierrc*`, `biome.json`, `pyproject.toml` (ruff), `.eslintrc*` +3. Run the appropriate formatter on changed files only: + +```bash +# Detect changed files +CHANGED=$(git diff --name-only --diff-filter=ACMR) + +# TypeScript/JavaScript projects (check in order): +# - package.json "format" script → npx prettier --write $CHANGED +# - biome.json → npx biome format --write $CHANGED +# - .prettierrc* → npx prettier --write $CHANGED +# - eslint → npx eslint --fix $CHANGED + +# Python projects (check in order): +# - pyproject.toml with [tool.ruff] → ruff format $CHANGED && ruff check --fix $CHANGED +# - .flake8 or setup.cfg → autopep8 --in-place $CHANGED +# - black config → black $CHANGED +``` + +4. If no formatter is detected, skip with a warning: "No formatter found — skipping auto-format." + +Show to user for confirmation, then: +``` +git add +git commit -m "" +``` + +## Step 2C — Generate a PR/MR Description + +Ask: +1. Notion ticket ID? +2. Summary of changes? +3. What was tested? + +Generate: + +**Title:** [] + +**Body:** +## What changed + + +## How to test + + +## Checklist +- [ ] Self-tested locally +- [ ] At least 2 reviewers assigned +- [ ] Label set +- [ ] Squash commits decision made +- [ ] Source branch NOT deleted on merge + +## Step 2D — Create GitLab MR + +Follow `skills/_shared/gitlab-access.md` to authenticate, then create the MR: + +```bash +# With glab (preferred): +glab mr create --title "[${TICKET_ID}] ${SUMMARY}" \ + --description "${BODY}" \ + --target-branch "${TARGET}" \ + --reviewer "${REVIEWER1},${REVIEWER2}" \ + --label "${LABEL}" + +# With API: +curl -s --request POST --header "PRIVATE-TOKEN: ${TOKEN}" \ + --header "Content-Type: application/json" \ + --data '{ + "source_branch": "'${SOURCE}'", + "target_branch": "'${TARGET}'", + "title": "['${TICKET_ID}'] '${SUMMARY}'", + "description": "'"${BODY}"'", + "reviewer_ids": [${REVIEWER_IDS}], + "labels": "'${LABEL}'" + }' \ + "https://${GITLAB_HOST}/api/v4/projects/${PROJECT_PATH}/merge_requests" +``` + +After creation, print the MR URL. + +## Step 3 — Final Reminder + +Always end with: + +Before submitting: +1. Assign at least 2 reviewers +2. Set the appropriate label +3. Run /code-review for automated convention check +4. Run /security-audit if touching auth, PII, or financial data diff --git a/.claude/skills/grill-me/SKILL.md b/.claude/skills/grill-me/SKILL.md new file mode 100644 index 00000000000..959ec44a759 --- /dev/null +++ b/.claude/skills/grill-me/SKILL.md @@ -0,0 +1,49 @@ +--- +name: grill-me +description: Interrogates the user's plan, design, or PRD draft one question at a time, with a recommended answer per question, until ambiguity collapses and the design tree is fully resolved. Surfaces glossary drift, missing acceptance criteria, hidden trade-offs, unclear ownership, and unhandled edge cases before any code is written. Output is a consolidated plan at .devkit/grill-result.md that /feature and /bugfix can consume. Use when user says "grill me", "challenge this plan", "stress-test my design", "is this PRD ready", "what am I missing", "poke holes", or shares a feature description that feels under-specified. +version: 1.0.0 +author: Ringkas Engineering +--- + +# Grill Me + +Interview the user relentlessly about the plan in front of you until you reach shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one at a time. + +## Method + +Ask **one question at a time.** Wait for the user's answer before asking the next. + +For each question, **provide your recommended answer** with a one-sentence rationale. The user can accept, override, or refine. Recommendations make the conversation move; bare questions waste the user's time. + +If a question can be answered by exploring the codebase, **explore instead of asking** (use Grep / Read). Then state what you found and ask the user only to confirm interpretation. + +## What to grill + +Walk these axes in order — each one is a wedge that flushes out a different class of ambiguity: + +1. **Glossary drift.** Does the user use any term in a way that conflicts with glossary.md? ("You said 'PR' — Ringkas uses MR. Confirm?", "You said 'staging' — Ringkas has qc / uat / training / prod, no staging. Which one?", "You said 'auth' — authentication or authorization? They differ in this conversation.") +2. **Scope of the slice.** Where does the work end? One MR, or three? Which codebase(s) — saturn, jupiter, regulus, alcor, phobos, ai-backoffice? Touching cross-codebase contracts (REST / MCP / GraphQL)? +3. **User stories & acceptance criteria.** For each user-visible behaviour, what is the testable acceptance criterion? Push until each AC could be turned into a Jest / pytest / Testmo case without further interpretation. +4. **Edge cases & failure modes.** What happens when input is missing, malformed, or the downstream service is down? Which failures retry, which surface to the user, which page on-call? +5. **Auth & PII.** Who can call this? What PII flows through it? Which ISO 27001 control covers it (A.9 access, A.10 crypto, A.12.4 logging, A.14.2 secure dev)? Will `/audit` need to run before merge? +6. **Backwards compatibility.** Does this change a public API contract (DRF serializer, GraphQL schema, MCP `en_*` tool signature, Pydantic model at a trust boundary)? If yes, what is the migration story for existing callers? +7. **Country variants (jupiter only).** If touching `index.tsx` / `index.base.tsx` / `index.sa.tsx`, does this need a Saudi Arabia override? RTL-safe CSS (`ms-X` / `me-X` not `ml-X` / `mr-X`)? +8. **Rollout & feature flag.** Does this ship behind a flag? Who flips it? What is the rollback path if `qc` looks bad? +9. **Done definition.** What artifact proves it shipped? Merged MR, deployed to qc, signed-off by QA in Testmo, demoed to product? + +Skip an axis only if the answer is genuinely obvious from the conversation, and **state which axes you skipped and why** — so the user can call you out if you were wrong. + +## When to stop + +You are done when the user could hand the resulting plan to a different engineer and that engineer would not need to ask follow-up questions. Test this silently: summarise the plan as if you were that other engineer. If you have a question, you are not done. + +## Output + +When done, write `.devkit/grill-result.md` with: scope, user stories, ACs, edge cases, auth & PII, rollout, done definition. `/feature` and `/bugfix` can consume this file as their planning input — pass it explicitly when invoking them. + +Print `## GRILL COMPLETE` with the path to the artifact. + +## Out of scope + +- Implementing the plan. Once the user wants code, hand off to `/feature` (new work) or `/bugfix` (regression). +- Filing tickets. Once the plan is sliced, hand off to `/to-issues` for the LT- / AI- breakdown. diff --git a/.claude/skills/prd-authoring/SKILL.md b/.claude/skills/prd-authoring/SKILL.md new file mode 100644 index 00000000000..21c859ee47d --- /dev/null +++ b/.claude/skills/prd-authoring/SKILL.md @@ -0,0 +1,214 @@ +--- +name: prd-authoring +description: Helps the product team create structured Epics in the Ringkas Product Backlog on Notion using the correct PRD Format or AI PRD template, fills in every required section, and searches past Epics to reuse context and avoid duplication. Use when the user says "write a PRD", "draft an epic", "new EP-...", "product spec", "I need a PRD for X", or shares a feature idea that needs formal documentation. +version: 1.1.0 +author: Ringkas Engineering +tools: + - Notion MCP (notion-search, notion-fetch, notion-create-pages, notion-update-page) +--- + +# PRD Authoring Assistant + +Help Ringkas PMs write structured, implementation-ready Epics in the Product Backlog. + +> **Language rule**: All PRD content written to Notion **must be in English** — Background, Goal, Success Metric, User Stories, Acceptance Criteria, Impact Analysis, Risk Assessment. The only exception is **Trigger Examples** in AI PRDs, which should reflect the actual language end-users speak (e.g. Bahasa Indonesia for ID users, Arabic for SA users), as these are sample user utterances, not documentation. + +## Prerequisites + +Notion MCP must be connected: +```bash +claude mcp add notion --url https://mcp.notion.com/mcp +claude mcp login notion +``` + +Product Backlog database: `https://www.notion.so/ringkas/3f6ac86135fd48d0925299a9e202b776` +Data source: `collection://cc477810-e934-412f-b99b-16f4029fba6c` + +--- + +## Step 1 — Determine PRD Type + +**Ask the user:** Is this feature primarily for an AI agent (RISA, chatbot behavior, conversation flow)? + +- **Yes → AI PRD** (use the AI PRD template with Data Storage / Actions / Conversation Flow sections) +- **No → Standard PRD** (use the PRD Format template) + +--- + +## Step 2 — Research Existing Context + +Before writing, search the Product Backlog for related past Epics: + +1. `notion-search` with 2–3 keyword variations related to the feature area +2. Fetch the 2–3 most relevant results with `notion-fetch` +3. Summarize to the user: + - Related past Epics (ID, title, status, link) + - Relevant acceptance criteria that can be reused + - Past Impact Analysis entries that may apply + - Any Epics that were "Cancelled" or "Dropped" for similar scope (avoid repeating) + +--- + +## Step 3 — Gather Requirements + +Ask targeted questions to fill all required sections. Do not proceed to writing until all **required** questions are answered. + +**Always required:** +1. What is the feature? (one sentence) +2. What problem does it solve? Who is affected? (for Background) +3. What is the goal? (one sentence for Goal section) +4. What does success look like? How do we measure it? (for Success Metric) +5. Which platforms are affected? (API Product / AI Agent / Consumer Platform / CRM / Embedded Service / LOSv2 / Website) +6. What is the complexity? (High / Medium / Low) +7. What strategic goal does this serve? (RISA-NXT / OJK / Saudi Project / Australia Project / none) +8. Is there a Figma link? (for Figma Link at top) +9. Are there related documents? (for Related Documents Link) + +**For each User Story:** +10. Who is the actor ("As a…"): customer / admin / partner / agent / system +11. What do they want to do ("I want to…") +12. Why / benefit ("so that…") +13. Priority: P0 / P1 / P2 +14. Pre-conditions, main flow, post-conditions +15. Acceptance criteria (specific, testable) + +**For AI PRDs only, per user story:** +16. Which types of change apply? (Data Storage / Actions / Conversation Flow / System Integration / Training Data) +17. For Data Storage: what data points, what actions AI can take (Read/Write/Update), trigger examples (in the end-user's language as sample data only — e.g. Bahasa Indonesia for ID users, Arabic for SA users), expected AI outcomes written in English, source of truth, mutability rules, error handling +18. For Actions: what actions, trigger examples, expected AI responses, error handling +19. For Conversation Flow: expected conversation paths + +**For Impact Analysis:** +20. Which existing features are affected? Check the standard CRM checklist: + - CRM User Self Register, Join Workgroup, CRU Workgroup, CRU CRM User, CRU Roles & Permission + - Admin Assist Customer Registration, Prequalification Form, KPR Form + - Bank Submission Form, Bank Decision, Contract Signing, Invoicing, Disbursement +21. For each impacted feature: cause ("Because of...") and mitigation ("The ... need to...") + +**For Risk Assessment:** +22. Any known risks? For each: likelihood (Low/Medium/High), impact (Low/Medium/High), severity, owner, mitigating action + +**For Squad:** +23. PM name, Designer name, Copywriter, Engineer names, QA name + +--- + +## Step 4 — Write the Epic + +Create the Epic in Notion using `notion-create-pages` in database `collection://cc477810-e934-412f-b99b-16f4029fba6c`. + +### Page Properties + +| Property | Value | +|----------|-------| +| `Epic Name` | Feature title | +| `Short Summary` | One-liner (max 150 chars) | +| `Status` | `Requirement in Progress` | +| `Platform` | Multi-select from: API Product, AI Agent, Consumer Platform, CRM, Embedded Service, LOSv2 - Phase 1, Website | +| `Complexity` | High / Medium / Low | +| `Strategic Goal` | Multi-select from: RISA-NXT, OJK, Saudi Project, Australia Project | +| `Product PIC` | PM person | +| `Feature Reviewer` | Reviewer person | +| `Release Estimation` | Target date | + +### Standard PRD Page Content + +Follow this exact structure (sections as H2, user story details as H3 toggles): + +``` +Figma Link: {link or "to be added by Design team"} +Related Documents Link: +{numbered list of related doc links} + +## Background +{The problem, who is affected, why now. Use comparison table if Before/After is helpful.} + +## Goal +{One sentence: what the feature achieves.} + +## Success Metric +{Numbered list of measurable outcomes with targets.} + +## User Stories +{Table with columns: Code | Title | As a… | I want to… | so that… | Priority} + +### [US_01] {Title} {toggle} + {Pre-conditions | Main flow | Post-conditions — two-column table} + + **Acceptance Criteria**: + {Numbered list of specific, testable criteria} + +### [US_02] {Title} {toggle} + ... + +## Impact Analysis +{Table: Impacted Feature | Cause | Mitigation} +{Checklist dropdown with CRM and CA feature lists} + +## Risk Assessment +{Table: Risk Description | Likelihood | Impact | Severity | Owner | Mitigating Action} + +## Squad +{Table: Role | Name — roles: Product Manager, Product Designer, Copywriter, Engineer ×3, Quality Assurance} + +## Approval Sheet +{Table: Approved by | On — first row: "Alvin" with empty date} +``` + +### AI PRD Page Content + +Same structure but User Stories table uses different columns: + +``` +Code | Title | When a user [want/behavior] | AI should [respond to meet expectation] | so that… | Priority +``` + +And each `[US_XX]` toggle contains: +``` +- Pre-conditions: +- Type of change (delete irrelevant type): + + > Data Storage {toggle} + PM Fill-in Table: Data Point | Action AI Can Take | Trigger Example (User Says) | Expected AI Outcome / Response + Data Specification: Data Point | Source / Source of Truth | Mutability | Notes + Error Handling & Edge Cases: Error Type | Trigger | AI Behavior | Escalation Rule + + > Actions {toggle} + PM Fill-in Table: Action AI Can Take | Trigger Example | Expected AI Outcome / Response + Error Handling & Edge Cases: Error Type | Trigger | AI Behavior | Escalation Rule + + > Training Data {toggle} + > Conversation Flow {toggle} + > System Integration {toggle} + +- Other key requirements: +``` + +--- + +## Step 5 — Quality Check Before Saving + +Before calling `notion-create-pages`, verify: + +- [ ] `Short Summary` is filled (max 150 chars, no jargon) +- [ ] Each User Story has at least one testable Acceptance Criterion +- [ ] Impact Analysis references real existing features (not generic) +- [ ] Risk Assessment has at least one entry if Complexity is Medium or High +- [ ] Squad table has at least PM and one Engineer filled in +- [ ] For AI PRDs: each US has at least Data Storage or Actions section filled +- [ ] No section is empty — at minimum write "N/A" with a reason + +--- + +## Output Format + +``` +## Epic Created: {epic_id} — {title} +**Type**: Standard PRD / AI PRD +**Status**: Requirement in Progress +**Platform**: [values] +**Complexity**: [value] +**User Stories**: N (US_01 … US_0N) +**Notion link**: {url} +**Open questions**: {list any items flagged for engineering input} +``` diff --git a/.claude/skills/prd-authoring/reference/knowledge-base-guide.md b/.claude/skills/prd-authoring/reference/knowledge-base-guide.md new file mode 100644 index 00000000000..3d9957558bd --- /dev/null +++ b/.claude/skills/prd-authoring/reference/knowledge-base-guide.md @@ -0,0 +1,45 @@ +# Knowledge Base Guide + +## Purpose + +The Knowledge Base in Notion stores decisions, constraints, and patterns that both product and engineering should reference when creating PRDs or implementation plans. + +## Recommended Knowledge Base Categories + +### Architecture Decisions +- System design choices and their rationale +- Technology selections (why Django for backoffice, why LangGraph for agents, etc.) +- Integration patterns (how saturn talks to phobos, how jupiter talks to saturn) + +### Technical Constraints +- Performance limits (API rate limits, DB query limits) +- Security requirements (auth flows, data handling) +- Infrastructure boundaries (what can/cannot be deployed where) + +### Product Patterns +- Established UX patterns (how country switching works, how error states are shown) +- Business rules (loan calculation formulas, eligibility criteria) +- Regulatory requirements per country + +### Past Decisions +- Features that were considered but rejected (and why) +- Migration histories +- Incident post-mortems that affect future design + +## How Product Team Should Use This + +Before writing a PRD: +1. Search for related topics in Knowledge Base +2. Check if similar features were built or rejected before +3. Reference relevant constraints in the PRD +4. Link to Knowledge Base pages from the PRD + +## How Engineering Should Contribute + +After implementing a feature: +1. Document any non-obvious decisions made during implementation +2. Record technical constraints discovered +3. Update architecture docs if system boundaries changed +4. Add integration patterns that product should know about + +Use the `prd-to-implementation` skill's progress tracking to capture these learnings. diff --git a/.claude/skills/prd-authoring/reference/prd-template.md b/.claude/skills/prd-authoring/reference/prd-template.md new file mode 100644 index 00000000000..58db611108f --- /dev/null +++ b/.claude/skills/prd-authoring/reference/prd-template.md @@ -0,0 +1,177 @@ +# Ringkas PRD Templates + +These are the exact templates used in the Product Backlog. Reference when authoring Epics. + +--- + +## Standard PRD Format + +Used for: CRM, Consumer Platform, API Product, Embedded Service, Website, LOSv2 features. + +``` +Figma Link: {link or "to be added by Design team"} +Related Documents Link: +1. {page link} +2. {external link} + +## Background +{Problem statement. Who is affected. Why now. Use Before/After comparison table if helpful.} + +## Goal +{One sentence.} + +## Success Metric +1. {Measurable outcome} +2. {Measurable outcome} + +## User Stories + +| Code | Title | As a… | I want to… | so that… | Priority | +|--------|--------|--------|------------|----------|----------| +| US_01 | {title}| {actor}| {action} | {benefit}| P0/P1/P2 | + +### [US_01] {Title} [toggle] + +| Pre-conditions | {numbered list} | +|----------------|----------------| +| Main flow | {numbered list} | +| Post-conditions| {numbered list} | + +**Acceptance Criteria**: +1. {Specific, testable condition} +2. {Specific, testable condition} + +## Impact Analysis + +| Impacted Feature | Cause | Mitigation | +|-----------------|-------------------|----------------------| +| {feature name} | Because of {reason}| The {X} need to {Y} | + +> Checklist [dropdown] +> CRM: [list of CRM features with Impacted? column] +> CA: [list of CA features with Impacted? column] + +## Risk Assessment + +| Risk Description | Likelihood | Impact | Severity | Owner | Mitigating Action | +|-----------------|-----------|--------|----------|-------|------------------| +| {description} | Low/Med/High | Low/Med/High | Low/Med/Severe | {person/team} | {action} | + +## Squad + +| Role | Name | +|-------------------|------| +| Product Manager | | +| Product Designer | | +| Copywriter | | +| Engineer | | +| Engineer | | +| Engineer | | +| Quality Assurance | | + +## Approval Sheet + +| Approved by | On | +|------------|-----| +| Alvin | | +``` + +--- + +## AI PRD Format + +Used for: AI Agent platform features (RISA, chatbot behavior, agent configuration, MCP tools, conversation flows). + +``` +Figma Link: {link or "to be added"} +Thread Link: {Slack thread or discussion link} +Related Documents Link: +1. {link} + +## Background +{Problem statement — focus on what currently requires an engineering ticket that the AI change will fix.} + +## Goal +- {Primary objective} + +## Success Metric +- {Measurable outcome} + +## User Stories + +| Code | Title | When a user [want/behavior] | AI should [respond to meet expectation] | so that… | Priority | +|------|-------|-----------------------------|-----------------------------------------|----------|----------| +| | | [want / behavior] | [respond to meet expectation] | [user achieves goal] | | + +### [US_01] {Title} [toggle] + +- Pre-conditions: +- Type of change (delete irrelevant types): + + > Data Storage [toggle] + PM Fill-in Table: + | Data Point | Action AI Can Take | Trigger Example (User Says) | Expected AI Outcome / Response | + |-----------|-------------------|----------------------------|-------------------------------| + | {field} | Read/Write/Update | {user utterance in ID/EN} | {AI response/behavior} | + + Data Specification: + | Data Point | Source / Source of Truth | Mutability | Notes | + |-----------|------------------------|-----------|-------| + + Error Handling & Edge Cases: + | Error Type / Condition | Trigger | AI Behavior / Response | Escalation Rule | + |----------------------|---------|----------------------|----------------| + + > Actions [toggle] + | Action AI Can Take | Trigger Example (User Says) | Expected AI Outcome / Response | + |-------------------|----------------------------|-------------------------------| + + Error Handling & Edge Cases: + | Error Type / Condition | Trigger | AI Behavior / Response | Escalation Rule | + + > Training Data [toggle] + > Conversation Flow [toggle] + > System Integration [toggle] + +- Other key requirements: + +## Impact Analysis +{Same as Standard PRD} + +## Risk Assessment +{Same as Standard PRD} + +## Squad +{Same as Standard PRD} + +## Approval Sheet +{Same as Standard PRD} +``` + +--- + +## Database Properties Reference + +| Property | Type | Valid Values | +|----------|------|--------------| +| Epic Name | Title | — | +| Short Summary | Text | One-liner, max ~150 chars | +| Status | Status | Not Started → Requirement in Progress → Blocked → Requirement Finalized → In Development → Stakeholder Testing → Ready for Released → Released / Cancelled | +| Complexity | Select | High, Medium, Low | +| Platform | Multi-select | API Product, AI Agent, Embedded Service, Consumer Platform, LOSv2 - Phase 1, CRM, Website | +| Strategic Goal | Multi-select | RISA-NXT, OJK, Australia Project, Saudi Project | +| Product PIC | Person | — | +| Feature Reviewer | Person | — | +| Sponsor | Person | — | +| Assigned To | Person | — | +| Rank # | Text | Priority rank within sprint | +| Dev Month | Select | April, May, June, July, August, Drop, AI | +| Dev & Release Month | Select | July-24 … January-25 | +| Sprint Week | Multi-select | w3Sept24, w1Oct24, w3Oct24, w5Oct24, w1Nov24, w2Nov24, w4Nov24 | +| Release Estimation | Date | — | +| Revenue Impact ($/mo) | Number | — | +| Impact analysis | Select | y, n | +| TRD | Relation | → TRD database | +| 🤹 Tasks | Relation | → Tasks database | +| Sub-item / Parent item | Relation | → self (hierarchy) | +| ID | Auto-increment | EP-XXX | diff --git a/.claude/skills/prd-review/SKILL.md b/.claude/skills/prd-review/SKILL.md new file mode 100644 index 00000000000..2363c8b1ca0 --- /dev/null +++ b/.claude/skills/prd-review/SKILL.md @@ -0,0 +1,142 @@ +--- +name: prd-review +description: Quality gate for a draft PRD on Notion — checks completeness against the PRD Format checklist, testability of acceptance criteria, accuracy of impact analysis, and conflicts with existing Epics, then posts findings as inline Notion comments. Use when the user says "review this PRD", "check my epic", "is this PRD ready", "PRD review", links to a Notion EP-... page, or asks for a sanity check on a draft epic. +version: 1.1.0 +author: Ringkas Engineering +tools: + - Notion MCP (notion-search, notion-fetch, notion-create-comment) +--- + +# PRD Review + +Quality gate for draft Epics before they move to "Requirement Finalized". + +> **Language rule**: All review feedback must be written in English. + +## Prerequisites + +Notion MCP must be connected: +```bash +claude mcp add notion --url https://mcp.notion.com/mcp +claude mcp login notion +``` + +--- + +## Step 1 — Fetch the Epic + +1. Accept an Epic ID (EP-XXX), title keyword, or Notion URL from the user. +2. Use `notion-search` or `notion-fetch` to load the full page. +3. Confirm the Epic `Status` is `Requirement in Progress` or `Not Started`. If it is already `Requirement Finalized` or beyond, warn the user that review may be late. + +--- + +## Step 2 — Structural Completeness Check + +Verify every required section exists and is non-empty: + +| Section | Required | Check | +|---------|----------|-------| +| Figma Link | If Platform includes Consumer Platform or CRM | Link present or "to be added" | +| Background | Always | Not empty, explains the problem | +| Goal | Always | One clear sentence | +| Success Metric | Always | At least one measurable metric | +| User Stories table | Always | At least one row with Code, Title, actor, action, benefit, priority | +| Each [US_XX] toggle | Always | Pre-conditions, Main flow, Post-conditions filled | +| Acceptance Criteria per US | Always | At least one per user story | +| Impact Analysis | Always | Table has at least one row OR explicit "No impact" with reason | +| Risk Assessment | If Complexity is Medium or High | At least one risk identified | +| Squad | Always | PM filled, at least one Engineer | +| Approval Sheet | Always | Alvin row present | + +**For AI PRDs additionally check:** +| Section | Required | Check | +|---------|----------|-------| +| Thread Link | Yes | Present | +| Data Storage OR Actions | Per US | At least one type of change section filled per user story | +| Trigger Examples | Per Data Storage / Actions | At least one example per data point or action | +| Error Handling table | Per Data Storage / Actions | At least one row | + +**Report**: List each section as PASS / MISSING / INCOMPLETE. + +--- + +## Step 3 — Acceptance Criteria Quality + +For every Acceptance Criterion in every User Story, check: + +1. **Testable**: Can QA write a test case from this? It must specify input → expected output. + - BAD: "The system should work correctly" + - GOOD: "When user submits form without email, display error 'Email is required' below the email field" + +2. **Edge cases covered**: For each US, check if the following are addressed: + - Empty state (no data) + - Error state (API failure, validation failure) + - Loading state (async operations) + - Permission denied (unauthorized user) + - Multi-country behavior (if Platform involves Consumer Platform or CRM — does it specify ID vs SA behavior?) + +3. **Specificity**: No vague words like "should be fast", "user-friendly", "seamless". Replace with measurable criteria. + +**Report**: List problematic ACs with suggested rewrites. + +--- + +## Step 4 — Cross-Epic Conflict Check + +Search for potentially conflicting Epics: + +1. `notion-search` for Epics with the same `Platform` value that are `In Development` or `Requirement Finalized`. +2. Check for overlapping scope: + - Same feature area being modified? + - Same API endpoints or DB tables affected? + - Same UI components or pages being changed? +3. If potential conflicts found, list them with Epic ID, title, and what overlaps. + +--- + +## Step 5 — Impact Analysis Validation + +For each entry in the Impact Analysis table: + +1. Is the "Cause" specific? (not just "Because of this PRD") +2. Is the "Mitigation" actionable? (not just "Need to update") +3. Cross-check against the CRM/CA checklist — are there unchecked features that the User Stories clearly affect? + +Flag any missing impact entries. + +--- + +## Step 6 — Generate Review Report + +Format the findings as a structured review: + +``` +## PRD Review: EP-XXX — {title} + +### Structural Completeness +- [PASS/FAIL] {section}: {detail} + +### Acceptance Criteria Quality +- [US_01] AC #1: {issue and suggested rewrite} +- [US_01] Missing edge case: {which one} + +### Cross-Epic Conflicts +- {EP-YYY}: potential overlap in {area} + +### Impact Analysis Gaps +- {missing impact entry} + +### Summary +- Critical issues: N (must fix before Requirement Finalized) +- Warnings: N (should fix) +- Suggestions: N (nice to have) + +### Verdict: APPROVE / REVISE / BLOCK +``` + +--- + +## Step 7 — Post to Notion + +Use `notion-create-comment` to post the review report directly on the Epic page as a comment. This keeps the feedback visible to the entire team alongside the PRD. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md new file mode 100644 index 00000000000..66617a9dfc0 --- /dev/null +++ b/.claude/skills/review/SKILL.md @@ -0,0 +1,52 @@ +--- +name: review +description: Reviews code changes against Ringkas conventions and ISO 27001 controls, with automatic security escalation if the diff touches authentication, authorization, PII, financial logic, or external API code. Output: CRITICAL / HIGH / MEDIUM findings with file:line references and a PASS/FAIL verdict. Works on a local diff, current branch, or a GitLab MR URL. Use when the user says "review", "code review", "check this MR", "look at my changes", "is this safe to ship", or pastes a GitLab MR URL. +version: 2.1.0 +author: Ringkas Engineering +--- + +# Review Workflow + +Review code changes with automatic security escalation. + +> GitLab access: Follow `skills/_shared/gitlab-access.md` for auth. + +## Step 1 — Determine Scope + +Check input: +- If GitLab MR URL provided: parse URL, fetch MR metadata (target branch!), get diff +- If no URL: use local git diff (staged + unstaged, or branch diff) + +Initialize state: +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" init review +``` + +## Step 2 — Reviewer (sonnet) + +Dispatch the Reviewer agent: +- Prompt includes: the diff source (local or MR), target branch +- For MR: "Review MR ( → ). Fetch diff via GitLab API." +- For local: "Review local changes. Run git diff." + +Wait for `## REVIEW COMPLETE`. Advance state. + +## Step 3 — Security Check (auto-trigger) + +Scan the diff for auth/PII indicators: +```bash +git diff HEAD~N | grep -iE "(auth|login|password|jwt|token|pii|encrypt|secret|permission|role)" | head -5 +``` + +If matches found, auto-dispatch Security agent (haiku): +- Print: "Auth/PII changes detected — running security audit automatically." + +If no matches, skip security: +```bash +"${CLAUDE_PLUGIN_ROOT}/bin/devkit-state.sh" skip security "no auth/PII changes detected" +``` + +## Step 4 — Complete + +Print review summary + security verdict (if run). +If MR URL was provided, offer to post review as MR comment. diff --git a/.claude/skills/test-plan-generator/SKILL.md b/.claude/skills/test-plan-generator/SKILL.md new file mode 100644 index 00000000000..9c417fcc2d4 --- /dev/null +++ b/.claude/skills/test-plan-generator/SKILL.md @@ -0,0 +1,178 @@ +--- +name: test-plan-generator +description: Reads PRD acceptance criteria from a Notion epic, generates structured test cases (positive, negative, edge), and creates them in Testmo via MCP, organized into per-Epic folders. Use when the user says "generate test cases", "test plan for EP-...", "Testmo cases", "QA plan", or links to a finalized PRD and asks for tests. +version: 1.1.0 +author: Ringkas Engineering +tools: + - Notion MCP (notion-search, notion-fetch) + - Testmo MCP (list_projects, list_folders, create_folders, create_cases, list_cases) +--- + +# Test Plan Generator + +Read a Ringkas Epic from Notion, derive test cases from acceptance criteria, and push them to Testmo. + +> **Language rule**: All test case titles, steps, and expected results must be written in English. + +## Prerequisites + +### Notion MCP +```bash +claude mcp add notion --url https://mcp.notion.com/mcp +claude mcp login notion +``` + +### Testmo MCP +Install the community Testmo MCP server: +```bash +pip install mcp-testmo +# OR clone: git clone https://github.com/strelec00/testmo-mcp.git +``` + +Add to Claude Code: +```bash +claude mcp add testmo -- python -m testmo_mcp +``` + +Required environment variables: +``` +TESTMO_URL=https://ringkas.testmo.net +TESTMO_API_KEY= +``` + +Generate API key: Testmo → Profile (avatar) → API access → Add key. + +See `setup/testmo-mcp-setup.md` for full instructions. + +--- + +## Step 1 — Fetch the Epic from Notion + +1. Accept Epic ID (EP-XXX), title, or Notion URL. +2. Use `notion-fetch` to load the full page. +3. Extract: + - `Epic Name`, `ID` (EP-XXX), `Platform`, `Complexity` + - All User Stories with their Acceptance Criteria + - For AI PRDs: also extract Trigger Examples and Error Handling tables + +--- + +## Step 2 — Derive Test Cases + +For each User Story, generate test cases from: + +### From Acceptance Criteria (1 AC → 1+ test cases) +Each acceptance criterion becomes at least one test case. If the AC covers multiple conditions, split into separate cases. + +### From Edge Cases (always generate these) +For every User Story, create test cases for: +- **Happy path**: normal flow as described in Main flow +- **Empty state**: what happens with no data +- **Error state**: API failure, validation error, timeout +- **Boundary values**: min/max inputs, empty strings, special characters +- **Permission**: unauthorized user, expired session + +### From AI PRD sections (if applicable) +- **Per Trigger Example**: one test case per trigger → expected AI response +- **Per Error Handling row**: one test case per error condition → expected AI behavior +- **Conversation Flow**: test cases for each branch in the flow + +### Test Case Format + +Each test case must have: + +| Field | Content | +|-------|---------| +| **Title** | `[EP-XXX][US_YY] {action being tested}` | +| **Priority** | Mapped from US Priority: P0→Critical, P1→High, P2→Medium | +| **Steps** | Numbered list: Step text + Expected result per step | +| **Tags** | Epic ID, User Story code, Platform value | + +**Step writing rules:** +- Each step = one user action +- Each expected result = one observable outcome +- No compound steps ("Click X and then Y and verify Z" → split into 3 steps) +- Include specific test data where possible + +--- + +## Step 3 — Organize in Testmo + +### Folder Structure + +Ask the user which Testmo project to use (list projects via Testmo MCP if needed). + +Create folder hierarchy: +``` +{Epic Name} (EP-XXX)/ +├── US_01 — {User Story Title}/ +│ ├── TC: [EP-XXX][US_01] Happy path - {description} +│ ├── TC: [EP-XXX][US_01] {AC-derived test case} +│ ├── TC: [EP-XXX][US_01] Error - {error condition} +│ └── TC: [EP-XXX][US_01] Edge - {edge case} +├── US_02 — {User Story Title}/ +│ └── ... +└── Regression/ + └── TC: [EP-XXX] Cross-US regression - {description} +``` + +1. Use Testmo MCP `list_folders` to check if the Epic folder already exists. +2. If not, create it with `create_folders`. +3. Create sub-folders per User Story. +4. Create a `Regression` sub-folder for cross-cutting test cases. + +### Create Test Cases + +Use Testmo MCP `create_cases` (batch up to 100 per call): + +For each test case: +```json +{ + "name": "[EP-XXX][US_01] Happy path - user submits valid form", + "folder_id": "", + "custom_priority": "high", + "custom_steps": [ + {"text": "Navigate to /loan-application", "expected": "Form page loads with all fields visible"}, + {"text": "Fill in all required fields with valid data", "expected": "No validation errors shown"}, + {"text": "Click Submit", "expected": "Success message displayed, application status changes to 'Submitted'"} + ], + "tags": ["EP-XXX", "US_01", "AI Agent"] +} +``` + +--- + +## Step 4 — Generate Summary + +After creating all test cases, output: + +``` +## Test Plan: EP-XXX — {title} + +**Testmo Project**: {project name} +**Folder**: {Epic folder name} + +| User Story | Happy Path | AC-derived | Error/Edge | Total | +|-----------|-----------|-----------|-----------|-------| +| US_01 | 1 | N | N | N | +| US_02 | 1 | N | N | N | +| Regression| - | - | - | N | +| **Total** | | | | **N** | + +**Test cases created**: N +**Testmo folder link**: {link} +``` + +--- + +## Step 5 — Post Summary to Notion + +Use `notion-create-comment` on the Epic page to post: +``` +Test plan generated in Testmo: +- {N} test cases across {M} user stories +- Project: {name}, Folder: {Epic folder} +- Covers: happy path, acceptance criteria, error/edge cases +``` + +This keeps traceability between the PRD and its test plan. diff --git a/.claude/skills/to-issues/SKILL.md b/.claude/skills/to-issues/SKILL.md new file mode 100644 index 00000000000..cd648a6ee90 --- /dev/null +++ b/.claude/skills/to-issues/SKILL.md @@ -0,0 +1,73 @@ +--- +name: to-issues +description: Breaks an EP- PRD or feature plan into independently-grabbable LT- / AI- / DO- tickets using vertical (tracer-bullet) slices that each cut through every layer (schema, API, UI, tests). Each slice is marked HITL (needs human decision) or AFK (agent-completable). Output is a structured markdown list at .devkit/issues.md ready to paste into the Ringkas issue tracker. Use when user says "break this into tickets", "split into LT-tickets", "carve up this EP", "decompose into vertical slices", "what tickets do I need", or hands off a finalized PRD that needs implementation tickets. +version: 1.0.0 +author: Ringkas Engineering +--- + +# To Issues + +Decompose a plan, PRD, or design into vertical-slice tickets that engineers can grab independently. Each ticket cuts through ALL layers — schema, API, UI, tests — not horizontally across one layer. + +Use glossary.md for ticket prefixes (EP- / LT- / AI- / DO-) and codebase names (saturn / jupiter / regulus / alcor / phobos / ai-backoffice). + +## Process + +### 1. Gather context + +Work from whatever is in the conversation. Three common entry points: + +- **Notion EP- URL or ID** — fetch via Notion MCP (`notion-search`, `notion-fetch`) and read user stories + ACs end-to-end. +- **`.devkit/plan.md`** from `/feature` — already structured. +- **`.devkit/grill-result.md`** from `/grill-me` — best input; ambiguity already resolved. + +If the user passes nothing, ask which of the three they have. Don't guess. + +### 2. Skim the codebase + +Identify every layer the work touches. Use Grep / Read to confirm the layer inventory: + +- DB schema — regulus migrations, ai-backoffice tables +- API surface — DRF views, saturn handlers, phobos `en_*` tools, GraphQL schema +- Client — jupiter components, `@ui/*` wrappers, country variants (`index.sa.tsx`) +- Tests — Jest co-located, pytest alongside module, Testmo cases (linked separately) + +### 3. Draft vertical slices + +Each slice = one ticket. Rules: + +- **One slice cuts every layer it needs end-to-end.** Not "ticket A: schema, ticket B: API, ticket C: UI" — that's horizontal and produces unmergeable half-features. Instead: "ticket A: minimal schema + minimal endpoint + minimal UI + smoke test for ONE user story end-to-end." +- **One slice is demoable on its own.** A reviewer should look at the merged MR and see something work. +- **Many thin slices > few thick ones.** Aim for slices a single engineer finishes in 1–3 days. +- **Tag each slice HITL or AFK.** HITL = needs a human decision (architecture choice, UX review, security signoff, banking partner integration). AFK = an agent or unsupervised engineer can implement to completion. Prefer AFK; surface HITL only when genuine. +- **Honour ticket-prefix conventions.** Engineering work → `LT-`, AI team work → `AI-`, devops work → `DO-`. The parent stays `EP-`. Ringkas does not nest EP- under EP-. + +### 4. Quiz the user + +Present the proposed breakdown as a numbered list. For each slice show: + +- **Proposed title**: `[] ` — engineer assigns the real number when filing +- **Codebase(s)**: saturn / jupiter / regulus / etc. +- **Type**: HITL / AFK + one-line reason +- **Blocked by**: which slice numbers in this list (or "none") +- **User stories covered**: which ACs from the source PRD this addresses + +Ask: +- Does the granularity feel right (too coarse / too fine)? +- Are dependencies correct? +- Should any slices merge or split? +- Are HITL / AFK tags accurate? + +Iterate until the user approves. + +### 5. Output the ticket list + +Write `.devkit/issues.md` per [TICKET-TEMPLATE.md](TICKET-TEMPLATE.md) — one paste-ready section per approved slice, in dependency order (blockers first). + +Print `## ISSUES READY` followed by a count and the artifact path. Do **not** auto-create tickets in the tracker — Ringkas engineers create their own tickets to keep ownership clear. The artifact is the handoff. + +## Out of scope + +- Filing tickets in Jira / Linear / GitLab. The engineer does this. +- Writing the implementation. `/feature` or `/bugfix` consume the resulting `.devkit/issues.md` per slice. +- Resolving ambiguity in the PRD. If you find yourself guessing what an AC means, stop and run `/grill-me` first. diff --git a/.claude/skills/to-issues/TICKET-TEMPLATE.md b/.claude/skills/to-issues/TICKET-TEMPLATE.md new file mode 100644 index 00000000000..0a4cb994876 --- /dev/null +++ b/.claude/skills/to-issues/TICKET-TEMPLATE.md @@ -0,0 +1,67 @@ +# Ticket Template + +`.devkit/issues.md` is one paste-ready ticket per section, in dependency order. The engineer copies each section into Jira / Linear / GitLab and replaces `` with the real ticket number once filed. + +## Per-ticket section + +```markdown +--- + +## [LT|AI|DO] + +**Parent:** EP- (Notion link if available) +**Codebase(s):** saturn / jupiter / regulus / alcor / phobos / ai-backoffice +**Type:** HITL | AFK +**Blocked by:** LT-, LT- (or "none — can start immediately") +**Estimate:** 1–3 days + +### What to build + +Concise description of this vertical slice. Describe end-to-end behaviour for ONE user story, not layer-by-layer implementation. + +### User stories covered + +- US-: As a , I want , so that . +- US-: ... + +### Acceptance criteria + +- [ ] Criterion 1 (testable — Jest / pytest / Testmo case identifier if known) +- [ ] Criterion 2 +- [ ] Criterion 3 + +### Layers touched + +- **Schema:** `regulus/migrations/00XX_.py` — adds column / table / index ... +- **API:** `/` — new endpoint / mutation / `en_*` tool ... +- **Client:** `/` — new component / form / page ... +- **Tests:** unit + integration + (Testmo case if user-facing) + +Omit a layer if this slice does not touch it. + +### Out of scope for this ticket + +- Anything that belongs in a different slice — name the slice it belongs to. + +### Notes for reviewer + +- Cross-codebase contract changes (REST / GraphQL / MCP signature): yes / no +- Auth / PII surface: which ISO 27001 control applies (A.9 / A.10 / A.12.4 / A.14.2) +- Country variants: jupiter `index.sa.tsx` needed? yes / no +- Feature-flagged: yes (``) / no +``` + +## Header for the file + +Start `.devkit/issues.md` with this header so a reader knows what they're looking at: + +```markdown +# Ticket Breakdown: EP- + +**Source:** +**Generated:** +**Total slices:** (HITL: , AFK: ) +**Suggested order:** by `Blocked by` chain, blockers first + +> Each section below is a paste-ready ticket body. Replace `` references with the real ticket numbers as you file them. +``` diff --git a/.claude/skills/token-report/SKILL.md b/.claude/skills/token-report/SKILL.md new file mode 100644 index 00000000000..9d6b9e552ec --- /dev/null +++ b/.claude/skills/token-report/SKILL.md @@ -0,0 +1,52 @@ +--- +name: token-report +description: Print a per-repository Claude token-usage table covering 5h, 7d, 30d, and all-time windows for every registered company project directory. Use when the user says "where are my tokens going", "token spend by project", "/token-report", "which repo is burning tokens", "Claude usage breakdown", or before a budget review. +version: 1.0.0 +author: Ringkas Engineering +--- + +# Token Report (per-repo usage) + +Shows how many Claude tokens each registered company project has burned across 5h, 7d, 30d, and all-time windows. Source data is the local Claude Code and CCS transcript stores. + +## How it works + +The skill wraps `harness/common/bin/devkit-projects.py report`, which: +1. Loads the registry at `~/.claude/devkit-projects.json`. +2. Scans every `*.jsonl` transcript under `~/.claude/projects/`, `~/.ccs/shared/context-groups/*/projects/`, and any path in `DEVKIT_TRANSCRIPT_PATHS`. +3. Attributes each usage entry to a project via the rule in `references/2026-05-07-per-repo-token-tracking-design.md`. +4. Sums tokens per project per window and prints a sorted table. + +## Workflow + +1. Resolve the devkit install path: in a project that ran `install.sh`, the script is at `.claude/bin/devkit-projects.py`. Otherwise use the source: `~/ringkas-devkit/harness/common/bin/devkit-projects.py`. + +2. Run the report: + + ```bash + ./.claude/bin/devkit-projects.py report + ``` + +3. If the output starts with `Note: no project paths registered`, prompt the user to register their projects parent dir, e.g.: + + ```bash + ./.claude/bin/devkit-projects.py register --parent ~/Documents/Projects/Ringkas + ``` + +4. Inline the table output as a fenced code block in the chat. Do not paraphrase numbers. + +## Subcommands the user might also want + +- `register --parent ` — add a parent dir (subdirs become projects). +- `register --path ` — add an explicit project path. +- `list` — show registered parents and paths. +- `remove ` — remove a registered entry. +- `report --sort-by 5h|7d|30d|all` — change sort column (default 7d desc). +- `report --json` — machine-readable output. +- `report --no-cache` — bypass the 10-second cache. + +## Out of scope + +- USD cost (tokens only). +- Cross-machine aggregation. +- Real-time budget enforcement (the statusline already covers global budgets). diff --git a/.claude/skills/zoom-out/SKILL.md b/.claude/skills/zoom-out/SKILL.md new file mode 100644 index 00000000000..7ad38f032c4 --- /dev/null +++ b/.claude/skills/zoom-out/SKILL.md @@ -0,0 +1,45 @@ +--- +name: zoom-out +description: Maps a Ringkas codebase area at a higher level of abstraction — lists relevant modules, inbound callers, and outbound dependencies in glossary vocabulary so an engineer can build a mental model fast without deep-reading any single file. Use when user says "zoom out", "give me a map of X", "I don't know this code", "explain how X fits in", "where does X get called from", "what depends on this", or starts work in an unfamiliar area of saturn / jupiter / regulus / alcor / phobos / ai-backoffice. +version: 1.0.0 +author: Ringkas Engineering +--- + +# Zoom Out + +The user does not know this area of the code. Go up a layer of abstraction and produce a navigable map — not a deep read of any single file. + +Use glossary.md terms exactly: `MR` (not PR), codebase names lowercase (saturn, jupiter, regulus, alcor, phobos, ai-backoffice), environments (qc / uat / training / prod), ticket prefixes (EP- / LT- / AI- / DO-). + +## Output shape + +Produce these four sections, in this order. Skip any section that has no content rather than padding it. + +### 1. What this area does (1–3 sentences) +Describe the business capability in glossary vocabulary. Not "the FooHandler class" — name it as it appears in PRDs / ADRs / Notion epics. Tie to a `Beli` product surface where applicable. + +### 2. Modules & files +Bulleted list, ≤8 items. Each line: `path/to/file.ts` — one-clause summary of the module's responsibility. + +Skip generated code, fixtures, and tests unless the area is *primarily* test infrastructure. Mention but don't expand `index.base.tsx` / `index.sa.tsx` country variants — note their existence, not their internals. + +### 3. Inbound callers (who uses this) +Bulleted list of the call sites *outside* this area that depend on it. Use the Grep tool to enumerate. For each: `path/to/caller.ts` — what it calls and why. + +If the area is a leaf with no inbound callers, say so explicitly. If the callers are in a *different* Ringkas codebase (saturn → jupiter via REST, regulus → phobos via MCP, ai-backoffice → alcor via HTTP, etc.), name the protocol — those cross-codebase seams are the highest-value items in any zoom-out. + +### 4. Outbound dependencies (what this uses) +Bulleted list of external modules / services this area depends on. For each: `` — what it provides. + +Cross-codebase outbound calls and external services (Notion API, GitLab API, banking partners, MCP tool servers) belong here. Database tables count when the dependency is direct (raw SQL, ORM `.objects.filter`). + +## Rules + +- **No file deep-reads.** Skim only. If the user wants line-level detail, they will ask. +- **Glossary first.** If the area uses a term that conflicts with glossary.md ("PR" instead of "MR", "staging" instead of "qc"), call it out as drift in a final note — don't silently propagate it. +- **No suggestions.** This is a map, not a critique. Do not propose refactors. If you spot a deepening opportunity worth raising, recommend `/improve-architecture` (planned skill) in one final line. +- **Stop when the map is enough.** A good zoom-out is ~30 lines of output. If you are approaching 100, you are reading too deep — re-scope to the user's actual question. + +## Composition + +When the user is starting `/feature` or `/bugfix` in an unfamiliar area, run `/zoom-out` first and pass the output as planning context. The fix or feature plan will be grounded in the actual call graph, not assumed structure. diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 61d5cfcc106..e3211df6fa8 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -159,7 +159,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m const usage = extractUsageFromResponse(responseBody); appendLog({ tokens: usage, status: "200 OK" }); - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, project: clientRawRequest?.project }); const translatedResponse = needsTranslation(targetFormat, sourceFormat) ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js index d9dde1a36ef..ca65b2dc2af 100644 --- a/open-sse/handlers/chatCore/requestDetail.js +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -72,7 +72,7 @@ export function buildRequestDetail(base, overrides = {}) { }; } -export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE" }) { +export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, project, label = "USAGE" }) { if (!tokens || typeof tokens !== "object") return; const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0; @@ -97,6 +97,7 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, timestamp: new Date().toISOString(), connectionId: connectionId || undefined, apiKey: apiKey || undefined, - endpoint: endpoint || null + endpoint: endpoint || null, + project: project || null }).catch(() => {}); } diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index b4d68ae5d2f..7d2cc312c8c 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -120,7 +120,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr const usage = jsonResponse.usage || {}; appendLog({ tokens: usage, status: "200 OK" }); - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, project: clientRawRequest?.project }); const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output); const totalLatency = Date.now() - requestStartTime; @@ -195,7 +195,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr const usage = parsed.usage || {}; appendLog({ tokens: usage, status: "200 OK" }); - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, project: clientRawRequest?.project }); const totalLatency = Date.now() - requestStartTime; saveRequestDetail(buildRequestDetail({ diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 03d12c13942..1b7097961f5 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -92,7 +92,7 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r console.error("[RequestDetail] Failed to update streaming content:", err.message); }); - saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE" }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, project: clientRawRequest?.project, label: "STREAM USAGE" }); }; return { onStreamComplete, streamDetailId }; diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 63d0494eb3c..751b87a8a32 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -57,6 +57,7 @@ function aggregateEntryToDay(day, entry) { day.byAccount ||= {}; day.byApiKey ||= {}; day.byEndpoint ||= {}; + day.byProject ||= {}; if (entry.provider) addToCounter(day.byProvider, entry.provider, vals); @@ -74,6 +75,11 @@ function aggregateEntryToDay(day, entry) { const endpoint = entry.endpoint || "Unknown"; const epKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`; addToCounter(day.byEndpoint, epKey, { ...vals, meta: { endpoint, rawModel: entry.model, provider: entry.provider } }); + + const projectValue = entry.project && typeof entry.project === "string" ? entry.project : null; + const projectKeySegment = projectValue || "__untagged__"; + const projectKey = `${projectKeySegment}|${entry.model}|${entry.provider || "unknown"}`; + addToCounter(day.byProject, projectKey, { ...vals, meta: { project: projectValue, rawModel: entry.model, provider: entry.provider } }); } function pushToRing(entry) { @@ -255,10 +261,11 @@ export async function saveRequestUsage(entry) { // better-sqlite3 is sync → no JS yield mid-transaction → no race in same process. db.transaction(() => { db.run( - `INSERT INTO usageHistory(timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens, meta) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO usageHistory(timestamp, provider, model, connectionId, apiKey, endpoint, project, promptTokens, completionTokens, cost, status, tokens, meta) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ entry.timestamp, entry.provider || null, entry.model || null, entry.connectionId || null, entry.apiKey || null, entry.endpoint || null, + entry.project || null, promptTokens, completionTokens, entry.cost || 0, entry.status || "ok", stringifyJson(tokens), stringifyJson({}), ] @@ -367,7 +374,7 @@ export async function getUsageStats(period = "all") { const stats = { totalRequests: 0, totalPromptTokens: 0, totalCompletionTokens: 0, totalCost: 0, - byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {}, + byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {}, byProject: {}, last10Minutes: [], pending: pendingRequests, activeRequests: [], @@ -500,12 +507,27 @@ export async function getUsageStats(period = "all") { stats.byEndpoint[epKey].cost += ep.cost || 0; if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) stats.byEndpoint[epKey].lastUsed = dateKey; } + + for (const [projectKey, pr] of Object.entries(day.byProject || {})) { + const rawModel = pr.rawModel || ""; + const provider = pr.provider || ""; + const providerDisplayName = providerNodeNameMap[provider] || provider; + const projectName = pr.project || "Untagged"; + if (!stats.byProject[projectKey]) { + stats.byProject[projectKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, project: pr.project || null, projectName, lastUsed: dateKey }; + } + stats.byProject[projectKey].requests += pr.requests || 0; + stats.byProject[projectKey].promptTokens += pr.promptTokens || 0; + stats.byProject[projectKey].completionTokens += pr.completionTokens || 0; + stats.byProject[projectKey].cost += pr.cost || 0; + if (dateKey > (stats.byProject[projectKey].lastUsed || "")) stats.byProject[projectKey].lastUsed = dateKey; + } } // Overlay precise lastUsed timestamps from history const overlayCutoff = maxDays ? Date.now() - maxDays * 86400000 : 0; const histRows = db.all( - `SELECT timestamp, provider, model, connectionId, apiKey, endpoint FROM usageHistory WHERE timestamp >= ?`, + `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, project FROM usageHistory WHERE timestamp >= ?`, [new Date(overlayCutoff).toISOString()] ); for (const e of histRows) { @@ -527,6 +549,10 @@ export async function getUsageStats(period = "all") { const endpoint = e.endpoint || "Unknown"; const endpointKey = `${endpoint}|${e.model}|${e.provider || "unknown"}`; if (stats.byEndpoint[endpointKey] && new Date(ts) > new Date(stats.byEndpoint[endpointKey].lastUsed)) stats.byEndpoint[endpointKey].lastUsed = ts; + + const projectKeySegment = (e.project && typeof e.project === "string") ? e.project : "__untagged__"; + const projectKey = `${projectKeySegment}|${e.model}|${e.provider || "unknown"}`; + if (stats.byProject[projectKey] && new Date(ts) > new Date(stats.byProject[projectKey].lastUsed)) stats.byProject[projectKey].lastUsed = ts; } } else { // 24h / today: live history @@ -539,7 +565,7 @@ export async function getUsageStats(period = "all") { cutoff = new Date(Date.now() - PERIOD_MS["24h"]).toISOString(); } const filtered = db.all( - `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`, + `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, project, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`, [cutoff] ); @@ -610,6 +636,15 @@ export async function getUsageStats(period = "all") { const epe = stats.byEndpoint[epKey]; epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cost += entryCost; if (new Date(r.timestamp) > new Date(epe.lastUsed)) epe.lastUsed = r.timestamp; + + const projectValue = (r.project && typeof r.project === "string") ? r.project : null; + const projectKey = `${projectValue || "__untagged__"}|${r.model}|${r.provider || "unknown"}`; + if (!stats.byProject[projectKey]) { + stats.byProject[projectKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, project: projectValue, projectName: projectValue || "Untagged", lastUsed: r.timestamp }; + } + const projectEntry = stats.byProject[projectKey]; + projectEntry.requests++; projectEntry.promptTokens += promptTokens; projectEntry.completionTokens += completionTokens; projectEntry.cost += entryCost; + if (new Date(r.timestamp) > new Date(projectEntry.lastUsed)) projectEntry.lastUsed = r.timestamp; } } diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index 71c230c8c8e..1c53dae3e1d 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -111,6 +111,7 @@ export const TABLES = { connectionId: "TEXT", apiKey: "TEXT", endpoint: "TEXT", + project: "TEXT", promptTokens: "INTEGER DEFAULT 0", completionTokens: "INTEGER DEFAULT 0", cost: "REAL DEFAULT 0", diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 0d7e4a9f09f..520ce956337 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -5,6 +5,7 @@ import { markAccountUnavailable, clearAccountError, extractApiKey, + extractProjectTag, isValidApiKey, } from "../services/auth.js"; import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js"; @@ -40,7 +41,10 @@ export async function handleChat(request, clientRawRequest = null) { clientRawRequest = { endpoint: url.pathname, body, - headers: Object.fromEntries(request.headers.entries()) + headers: Object.fromEntries(request.headers.entries()), + // Caller-supplied usage-grouping label (x-project header); rides + // alongside endpoint into saveUsageStats. null when untagged. + project: extractProjectTag(request) }; } cacheClaudeHeaders(clientRawRequest.headers); diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index ba3146c3e80..88ea2781b90 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -299,6 +299,28 @@ export function extractApiKey(request) { return null; } +// Max stored length for a project tag — bounds DB/log growth and blocks abuse +// via an oversized header value, keeping audit fields (A.12.4) sane. +const MAX_PROJECT_TAG_LENGTH = 100; + +/** + * Extract the caller-supplied project tag used to group usage stats. + * Read from the `x-project` header (preferred) or `x-project-id` alias. + * Returns null when absent so usage rolls up under "Untagged". + * + * Distinct from the Google Cloud project id handled by + * getProjectIdForConnection — this is a free-form usage-grouping label. + * @param {Request} request - Incoming web Request exposing a headers map + * @returns {string|null} Trimmed, length-capped project tag, or null + */ +export function extractProjectTag(request) { + const rawProjectTag = request.headers.get("x-project") || request.headers.get("x-project-id"); + if (!rawProjectTag) return null; + + const projectTag = rawProjectTag.trim().slice(0, MAX_PROJECT_TAG_LENGTH); + return projectTag.length > 0 ? projectTag : null; +} + /** * Validate API key (optional - for local use can skip) */ From d79688872dcfefd943fabc9137906a1aef02862e Mon Sep 17 00:00:00 2001 From: dat Date: Sun, 31 May 2026 05:30:06 +0000 Subject: [PATCH 2/3] feat: [AI-000] add Projects usage dashboard page and nav Add Usage by Project view to UsageStats (project|model token/cost rows), a dedicated /dashboard/projects page locked to that view, and a Projects sidebar nav entry. Co-Authored-By: Claude Opus 4.8 --- .../(dashboard)/dashboard/projects/page.js | 21 ++++++ src/shared/components/Sidebar.js | 1 + src/shared/components/UsageStats.js | 65 +++++++++++++++---- 3 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/projects/page.js diff --git a/src/app/(dashboard)/dashboard/projects/page.js b/src/app/(dashboard)/dashboard/projects/page.js new file mode 100644 index 00000000000..94ec8337d25 --- /dev/null +++ b/src/app/(dashboard)/dashboard/projects/page.js @@ -0,0 +1,21 @@ +"use client"; + +import { Suspense } from "react"; +import { UsageStats, CardSkeleton } from "@/shared/components"; + +export default function ProjectsPage() { + return ( + }> +
+
+

Projects

+

+ Per-project usage. Tag requests with the x-project header + to break down token and cost by project and model. +

+
+ +
+
+ ); +} diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 4dc3e66612b..bfa18bdc12e 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -22,6 +22,7 @@ const navItems = [ // { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden { href: "/dashboard/combos", label: "Combos", icon: "layers" }, { href: "/dashboard/usage", label: "Usage", icon: "bar_chart" }, + { href: "/dashboard/projects", label: "Projects", icon: "folder" }, { href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" }, { href: "/dashboard/mitm", label: "MITM", icon: "security" }, { href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" }, diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index 671219290a7..97ac0a77a05 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -110,6 +110,7 @@ function getGroupKey(item, keyField) { case "accountName": return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || "Unknown Account"; case "keyName": return item.keyName || "Unknown Key"; case "endpoint": return item.endpoint || "Unknown Endpoint"; + case "projectName": return item.projectName || "Untagged"; default: return item[keyField] || "Unknown"; } } @@ -174,10 +175,19 @@ const ENDPOINT_COLUMNS = [ { field: "lastUsed", label: "Last Used", align: "right" }, ]; +const PROJECT_COLUMNS = [ + { field: "projectName", label: "Project" }, + { field: "rawModel", label: "Model" }, + { field: "provider", label: "Provider" }, + { field: "requests", label: "Requests", align: "right" }, + { field: "lastUsed", label: "Last Used", align: "right" }, +]; + const TABLE_OPTIONS = [ { value: "model", label: "Usage by Model" }, { value: "account", label: "Usage by Account" }, { value: "apiKey", label: "Usage by API Key" }, + { value: "project", label: "Usage by Project" }, { value: "endpoint", label: "Usage by Endpoint" }, ]; @@ -189,7 +199,7 @@ const PERIODS = [ { value: "60d", label: "60D" }, ]; -export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) { +export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false, defaultTableView = "model", lockTableView = false } = {}) { const router = useRouter(); const searchParams = useSearchParams(); @@ -199,7 +209,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [fetching, setFetching] = useState(false); - const [tableView, setTableView] = useState("model"); + const [tableView, setTableView] = useState(defaultTableView); const [viewMode, setViewMode] = useState("costs"); const [providers, setProviders] = useState([]); const [periodLocal, setPeriodLocal] = useState("today"); @@ -346,6 +356,31 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro ), }; } + case "project": { + return { + columns: PROJECT_COLUMNS, + groupedData: groupDataByKey(sortData(stats.byProject, {}, sortBy, sortOrder), "projectName"), + storageKey: "usage-stats:expanded-projects", + emptyMessage: "No project usage recorded yet. Send the x-project header to tag requests.", + renderSummaryCells: (group) => ( + <> + — + — + {fmt(group.summary.requests)} + {fmtTime(group.summary.lastUsed)} + + ), + renderDetailCells: (item) => ( + <> + {item.projectName} + {item.rawModel} + {item.provider} + {fmt(item.requests)} + {fmtTime(item.lastUsed)} + + ), + }; + } case "apiKey": { return { columns: API_KEY_COLUMNS, @@ -453,16 +488,22 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro {/* Table with dropdown selector */}
- + {lockTableView ? ( + + {TABLE_OPTIONS.find((opt) => opt.value === tableView)?.label || "Usage"} + + ) : ( + + )}