From 09c3d00b61e0705df97ee51cdb7602d7664b75ff Mon Sep 17 00:00:00 2001 From: ShintaroKawakami Date: Mon, 3 Aug 2026 21:45:13 +0900 Subject: [PATCH 1/5] feat: add project-scoped JTT AgentMemory gateway (#1) Co-authored-by: ShintaroKawakami --- .env.example | 8 + AGENTS.md | 2 +- README.md | 2 +- deploy/macos/run-jtt-core.sh | 22 ++ deploy/macos/run-jtt-gateway.sh | 23 ++ docs/jtt-scoped-gateway.md | 79 ++++ package.json | 2 + src/functions/search.ts | 7 +- src/index.ts | 2 +- src/jtt/scoped-gateway.ts | 553 +++++++++++++++++++++++++++ src/state/memory-utils.ts | 1 + src/types.ts | 1 + test/jtt-scoped-gateway-http.test.ts | 109 ++++++ test/jtt-scoped-gateway.test.ts | 189 +++++++++ test/search.test.ts | 34 ++ tsdown.config.ts | 7 + 16 files changed, 1035 insertions(+), 6 deletions(-) create mode 100755 deploy/macos/run-jtt-core.sh create mode 100755 deploy/macos/run-jtt-gateway.sh create mode 100644 docs/jtt-scoped-gateway.md create mode 100644 src/jtt/scoped-gateway.ts create mode 100644 test/jtt-scoped-gateway-http.test.ts create mode 100644 test/jtt-scoped-gateway.test.ts diff --git a/.env.example b/.env.example index 77ca0f3a3..9eee4f85f 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,14 @@ # AGENTMEMORY_SECRET=your-secret-here +# JTT scoped MCP gateway (fork-only; keep the AgentMemory core on loopback) +# AGENTMEMORY_GATEWAY_SECRET=replace-with-a-long-random-secret +# AGENTMEMORY_ALLOWED_PROJECTS=agent-hub,global/reference +# AGENTMEMORY_GATEWAY_HOST=127.0.0.1 +# AGENTMEMORY_GATEWAY_PORT=3121 +# AGENTMEMORY_GATEWAY_TIMEOUT_MS=4000 +# AGENTMEMORY_UPSTREAM_URL=http://127.0.0.1:3111 + # ----------------------------------------------------------------------------- # 4. Search tuning # ----------------------------------------------------------------------------- diff --git a/AGENTS.md b/AGENTS.md index 6f64946fc..9a5a55467 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). ## Current Stats (v0.9.28) - 54 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) -- 129 REST endpoints +- 130 REST endpoints - 6 MCP resources, 3 MCP prompts - 12 hooks, 15 skills - 260+ iii functions diff --git a/README.md b/README.md index 332e2010c..655347994 100644 --- a/README.md +++ b/README.md @@ -1498,7 +1498,7 @@ Create `~/.agentmemory/.env`:

API

-129 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers. +130 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
Key endpoints diff --git a/deploy/macos/run-jtt-core.sh b/deploy/macos/run-jtt-core.sh new file mode 100755 index 000000000..ff8b7409d --- /dev/null +++ b/deploy/macos/run-jtt-core.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +env_file="${AGENT_HUB_ENV_FILE:-$HOME/.config/agent-hub/.env}" +repo_dir="${AGENTMEMORY_REPO_DIR:-$HOME/mcp-servers/agentmemory}" + +if [ ! -r "$env_file" ]; then + echo "agentmemory: secret env file is not readable: $env_file" >&2 + exit 1 +fi +if [ ! -x "$repo_dir/dist/cli.mjs" ]; then + echo "agentmemory: build output is missing: $repo_dir/dist/cli.mjs" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$env_file" +set +a + +cd "$repo_dir" +exec node dist/cli.mjs --tools core --data-dir "${AGENTMEMORY_DATA_DIR:-$HOME/.agentmemory/data}" diff --git a/deploy/macos/run-jtt-gateway.sh b/deploy/macos/run-jtt-gateway.sh new file mode 100755 index 000000000..df707565d --- /dev/null +++ b/deploy/macos/run-jtt-gateway.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +env_file="${AGENT_HUB_ENV_FILE:-$HOME/.config/agent-hub/.env}" +repo_dir="${AGENTMEMORY_REPO_DIR:-$HOME/mcp-servers/agentmemory}" + +if [ ! -r "$env_file" ]; then + echo "agentmemory gateway: secret env file is not readable: $env_file" >&2 + exit 1 +fi +if [ ! -x "$repo_dir/dist/jtt/scoped-gateway.mjs" ]; then + echo "agentmemory gateway: build output is missing: $repo_dir/dist/jtt/scoped-gateway.mjs" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$env_file" +set +a + +export AGENTMEMORY_UPSTREAM_URL="${AGENTMEMORY_UPSTREAM_URL:-http://127.0.0.1:3111}" +cd "$repo_dir" +exec node dist/jtt/scoped-gateway.mjs diff --git a/docs/jtt-scoped-gateway.md b/docs/jtt-scoped-gateway.md new file mode 100644 index 000000000..0201299cf --- /dev/null +++ b/docs/jtt-scoped-gateway.md @@ -0,0 +1,79 @@ +# JTT scoped AgentMemory gateway + +This fork adds a deliberately small MCP surface for JTT's multi-agent workflow. +The upstream AgentMemory daemon remains the storage and search engine. The JTT +gateway only enforces project binding and exposes four tools: + +- `agentmemory_save` +- `agentmemory_search` +- `agentmemory_handoff_save` +- `agentmemory_handoff_get` + +## Safety contract + +- The project comes from the `X-AgentMemory-Project` connection header. Tools + cannot silently replace it. +- `AGENTMEMORY_ALLOWED_PROJECTS` is a fail-closed allowlist. +- Cross-project search requires `referenceProjects` or + `includeGlobalReference: true`; every result includes its source project. +- `agentmemory_handoff_get` reads the exact current project only and returns + `fallbackUsed: false`. It never selects the latest session from another repo. +- `global/reference` is valid for Hermes conversations, but cannot store or + retrieve implementation handoffs. +- This gateway does not install AgentMemory's automatic conversation hooks or + the Hermes memory provider. Saving is explicit. +- If the central daemon is unavailable, a memory tool returns an error. It does + not create a second local memory store. The calling agent can continue its + normal work without memory. + +## Private deployment + +The AgentMemory core stays on `127.0.0.1:3111`. The JTT gateway may listen on a +private Tailscale-reachable address and forwards to the core over loopback. +It requires both a bearer secret and a project header on every MCP request. + +Required environment variables: + +```text +AGENTMEMORY_SECRET= +AGENTMEMORY_GATEWAY_SECRET= +AGENTMEMORY_ALLOWED_PROJECTS=agent-hub,global/reference +AGENTMEMORY_GATEWAY_HOST=0.0.0.0 +AGENTMEMORY_GATEWAY_PORT=3121 +AGENTMEMORY_UPSTREAM_URL=http://127.0.0.1:3111 +``` + +Keep real values in the machine's secret store. Do not commit them. + +Build and start: + +```text +npm install --omit=optional +npm run build +npm start +npm run start:jtt-gateway +``` + +`--omit=optional` keeps local image/embedding native packages out of this +BM25-first pilot. The fork pins the MCP SDK to an exact version. + +## Client headers + +Each project connection supplies: + +```text +Authorization: Bearer +X-AgentMemory-Project: agent-hub +X-AgentMemory-Agent: claude-code +``` + +Hermes' ordinary conversation connection uses +`X-AgentMemory-Project: global/reference`. When a conversation becomes an +implementation task, Hermes must use a separately project-bound connection; +if no canonical project is known, handoff saving stops with an error. + +## Internet exposure + +The private bearer gateway is not the Claude.ai endpoint. Do not put it behind +a public tunnel. Claude.ai requires a separate standards-based OAuth 2.1 + DCR +front door whose token is bound to an allowed project scope. diff --git a/package.json b/package.json index 77185ad5f..c1da22948 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "build": "tsdown && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/", "dev": "tsx src/index.ts", "start": "node dist/cli.mjs", + "start:jtt-gateway": "node dist/jtt/scoped-gateway.mjs", "migrate": "node dist/functions/migrate.js", "test": "vitest run --exclude test/integration.test.ts", "test:watch": "vitest --exclude test/integration.test.ts", @@ -63,6 +64,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.142", "@anthropic-ai/sdk": "^0.100.1", "@clack/prompts": "^1.2.0", + "@modelcontextprotocol/sdk": "1.30.0", "dotenv": "^17.4.2", "iii-sdk": "0.11.2", "picocolors": "^1.1.1", diff --git a/src/functions/search.ts b/src/functions/search.ts index 9bcda6ae0..652a56fc7 100644 --- a/src/functions/search.ts +++ b/src/functions/search.ts @@ -504,11 +504,12 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { // null and the entry passes through as unscoped. This is the safe // fallback: we lose the ability to filter but never incorrectly // block a result whose session we can no longer verify. - // In both cases, a null memProject means "project unknown — treat as - // unscoped and let it through" to preserve backward-compatibility. + // A project-filtered lookup must fail closed when ownership cannot + // be proven. Unscoped legacy rows remain available to unfiltered + // searches, but never cross a project boundary by accident. if (projectFilter) { const memProject = await loadMemoryProject(r.obsId) - if (memProject !== null && memProject !== projectFilter) continue + if (memProject !== projectFilter) continue } // cwd filter does not apply to unbound entries. } diff --git a/src/index.ts b/src/index.ts index 5f66d76c9..198a6dc3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -540,7 +540,7 @@ async function main() { `Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, ); bootLog( - `REST API: 129 endpoints at http://localhost:${config.restPort}/agentmemory/*`, + `REST API: 130 endpoints at http://localhost:${config.restPort}/agentmemory/*`, ); bootLog( `MCP surface (opt-in via \`npx @agentmemory/mcp\`): ${getAllTools().length} tools · 6 resources · 3 prompts`, diff --git a/src/jtt/scoped-gateway.ts b/src/jtt/scoped-gateway.ts new file mode 100644 index 000000000..9870d3e1b --- /dev/null +++ b/src/jtt/scoped-gateway.ts @@ -0,0 +1,553 @@ +#!/usr/bin/env node + +import { createHash, timingSafeEqual } from "node:crypto"; +import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { pathToFileURL } from "node:url"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { z } from "zod"; + +const STORED_SCHEMA = "jtt-agentmemory/v1" as const; +const GLOBAL_REFERENCE_PROJECT = "global/reference"; +const PROJECT_PATTERN = /^[a-z0-9][a-z0-9._/-]{0,127}$/; +const AGENT_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; +const MAX_BODY_BYTES = 256 * 1024; +const DEFAULT_TIMEOUT_MS = 4_000; + +type MemoryCategory = "reference" | "decision" | "fact" | "implementation_handoff"; + +export interface GatewayConfig { + host: string; + port: number; + gatewaySecret: string; + upstreamUrl: string; + upstreamSecret?: string; + allowedProjects: ReadonlySet; + requestTimeoutMs: number; +} + +export interface RequestScope { + project: string; + agent: string; +} + +interface StoredEnvelope { + schema: typeof STORED_SCHEMA; + project: string; + category: MemoryCategory; + sourceAgent: string; + content: string; + files: string[]; + createdAt: string; + handoff?: { + summary: string; + nextStep: string; + openQuestions: string[]; + gitRef?: string; + }; +} + +interface SearchHit { + observation?: { + id?: string; + timestamp?: string; + narrative?: string; + project?: string; + }; + score?: number; +} + +interface SearchResponse { + results?: SearchHit[]; +} + +export interface AgentMemoryBackend { + remember(input: { + content: string; + type: "workflow" | "architecture" | "fact"; + concepts: string[]; + files: string[]; + project: string; + }): Promise; + search(input: { + query: string; + limit: number; + project: string; + }): Promise; +} + +export class GatewayError extends Error { + constructor( + message: string, + readonly statusCode: number, + readonly code: string, + ) { + super(message); + } +} + +function positiveInt(value: string | undefined, fallback: number): number { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function canonicalProject(value: string): string { + const project = value.trim().toLowerCase(); + if (!PROJECT_PATTERN.test(project) || project.includes("..") || project.includes("//")) { + throw new GatewayError("Invalid project identifier", 400, "invalid_project"); + } + return project; +} + +export function loadGatewayConfig(env: NodeJS.ProcessEnv = process.env): GatewayConfig { + const gatewaySecret = env["AGENTMEMORY_GATEWAY_SECRET"]?.trim(); + if (!gatewaySecret) { + throw new Error("AGENTMEMORY_GATEWAY_SECRET is required"); + } + const rawProjects = env["AGENTMEMORY_ALLOWED_PROJECTS"] ?? ""; + const allowedProjects = new Set( + rawProjects + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + .map(canonicalProject), + ); + if (allowedProjects.size === 0) { + throw new Error("AGENTMEMORY_ALLOWED_PROJECTS must contain at least one canonical project id"); + } + return { + host: env["AGENTMEMORY_GATEWAY_HOST"]?.trim() || "127.0.0.1", + port: positiveInt(env["AGENTMEMORY_GATEWAY_PORT"], 3121), + gatewaySecret, + upstreamUrl: (env["AGENTMEMORY_UPSTREAM_URL"]?.trim() || "http://127.0.0.1:3111").replace(/\/+$/, ""), + upstreamSecret: env["AGENTMEMORY_SECRET"]?.trim() || undefined, + allowedProjects, + requestTimeoutMs: positiveInt(env["AGENTMEMORY_GATEWAY_TIMEOUT_MS"], DEFAULT_TIMEOUT_MS), + }; +} + +function sameSecret(actual: string, expected: string): boolean { + const actualDigest = createHash("sha256").update(actual).digest(); + const expectedDigest = createHash("sha256").update(expected).digest(); + return timingSafeEqual(actualDigest, expectedDigest); +} + +export function resolveRequestScope(headers: Headers, config: GatewayConfig): RequestScope { + const authorization = headers.get("authorization") ?? ""; + const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""; + if (!token || !sameSecret(token, config.gatewaySecret)) { + throw new GatewayError("Unauthorized", 401, "unauthorized"); + } + const rawProject = headers.get("x-agentmemory-project"); + if (!rawProject) { + throw new GatewayError("X-AgentMemory-Project is required", 400, "project_required"); + } + const project = canonicalProject(rawProject); + if (!config.allowedProjects.has(project)) { + throw new GatewayError("Project is not enabled for this gateway", 403, "project_not_allowed"); + } + const rawAgent = (headers.get("x-agentmemory-agent") || "unknown-agent").trim().toLowerCase(); + if (!AGENT_PATTERN.test(rawAgent)) { + throw new GatewayError("Invalid agent identifier", 400, "invalid_agent"); + } + return { project, agent: rawAgent }; +} + +export class RestAgentMemoryBackend implements AgentMemoryBackend { + constructor(private readonly config: GatewayConfig) {} + + private async post(path: string, body: unknown): Promise { + const response = await fetch(`${this.config.upstreamUrl}/agentmemory/${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(this.config.upstreamSecret + ? { authorization: `Bearer ${this.config.upstreamSecret}` } + : {}), + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(this.config.requestTimeoutMs), + }); + if (!response.ok) { + throw new GatewayError("AgentMemory backend is unavailable", 502, "backend_error"); + } + return response.json(); + } + + remember(input: Parameters[0]): Promise { + return this.post("remember", input); + } + + async search(input: Parameters[0]): Promise { + const result = await this.post("search", { + query: input.query, + limit: input.limit, + project: input.project, + format: "full", + }); + return result && typeof result === "object" ? (result as SearchResponse) : {}; + } +} + +function encodeEnvelope(envelope: StoredEnvelope): string { + return `JTT_AGENTMEMORY ${envelope.category} ${envelope.project}\n${JSON.stringify(envelope)}`; +} + +function decodeEnvelope(value: string | undefined): StoredEnvelope | null { + if (!value?.startsWith("JTT_AGENTMEMORY ")) return null; + const separator = value.indexOf("\n"); + if (separator < 0) return null; + try { + const parsed = JSON.parse(value.slice(separator + 1)) as Partial; + if ( + parsed.schema !== STORED_SCHEMA || + typeof parsed.project !== "string" || + typeof parsed.category !== "string" || + typeof parsed.sourceAgent !== "string" || + typeof parsed.content !== "string" || + !Array.isArray(parsed.files) || + typeof parsed.createdAt !== "string" + ) { + return null; + } + return parsed as StoredEnvelope; + } catch { + return null; + } +} + +function memoryType(category: MemoryCategory): "workflow" | "architecture" | "fact" { + if (category === "implementation_handoff") return "workflow"; + if (category === "decision") return "architecture"; + return "fact"; +} + +function textResult(value: unknown, isError = false) { + return { + isError, + content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], + structuredContent: value as Record, + }; +} + +export class ScopedMemoryService { + constructor( + private readonly backend: AgentMemoryBackend, + private readonly allowedProjects: ReadonlySet, + ) {} + + async save( + scope: RequestScope, + input: { content: string; category: Exclude; files?: string[] }, + ): Promise> { + const envelope: StoredEnvelope = { + schema: STORED_SCHEMA, + project: scope.project, + category: input.category, + sourceAgent: scope.agent, + content: input.content.trim(), + files: input.files ?? [], + createdAt: new Date().toISOString(), + }; + const saved = await this.backend.remember({ + content: encodeEnvelope(envelope), + type: memoryType(envelope.category), + concepts: ["jtt-agentmemory", envelope.category, scope.project], + files: envelope.files, + project: scope.project, + }); + return { success: true, project: scope.project, category: envelope.category, saved }; + } + + async saveHandoff( + scope: RequestScope, + input: { + summary: string; + nextStep: string; + openQuestions?: string[]; + files?: string[]; + gitRef?: string; + }, + ): Promise> { + if (scope.project === GLOBAL_REFERENCE_PROJECT) { + throw new GatewayError( + "An implementation handoff requires an exact project binding", + 400, + "handoff_project_required", + ); + } + const envelope: StoredEnvelope = { + schema: STORED_SCHEMA, + project: scope.project, + category: "implementation_handoff", + sourceAgent: scope.agent, + content: input.summary.trim(), + files: input.files ?? [], + createdAt: new Date().toISOString(), + handoff: { + summary: input.summary.trim(), + nextStep: input.nextStep.trim(), + openQuestions: input.openQuestions ?? [], + ...(input.gitRef?.trim() ? { gitRef: input.gitRef.trim() } : {}), + }, + }; + const saved = await this.backend.remember({ + content: encodeEnvelope(envelope), + type: "workflow", + concepts: ["jtt-agentmemory", "implementation_handoff", scope.project], + files: envelope.files, + project: scope.project, + }); + return { success: true, project: scope.project, handoff: envelope.handoff, saved }; + } + + async search( + scope: RequestScope, + input: { + query: string; + limit: number; + includeGlobalReference?: boolean; + referenceProjects?: string[]; + }, + ): Promise> { + const projects = new Set([scope.project]); + if (input.includeGlobalReference && scope.project !== GLOBAL_REFERENCE_PROJECT) { + projects.add(GLOBAL_REFERENCE_PROJECT); + } + for (const requested of input.referenceProjects ?? []) { + const project = canonicalProject(requested); + if (!this.allowedProjects.has(project)) { + throw new GatewayError("Referenced project is not enabled", 403, "reference_project_not_allowed"); + } + projects.add(project); + } + const perProjectLimit = Math.min(Math.max(input.limit * 3, 10), 60); + const responses = await Promise.all( + [...projects].map(async (project) => ({ + project, + response: await this.backend.search({ query: input.query, limit: perProjectLimit, project }), + })), + ); + const results = responses + .flatMap(({ project, response }) => + (response.results ?? []).flatMap((hit) => { + const envelope = decodeEnvelope(hit.observation?.narrative); + if (!envelope || envelope.project !== project) return []; + return [{ + id: hit.observation?.id, + project, + source: project === scope.project ? "current_project" : "explicit_reference", + category: envelope.category, + content: envelope.content, + files: envelope.files, + sourceAgent: envelope.sourceAgent, + createdAt: envelope.createdAt, + score: typeof hit.score === "number" ? hit.score : 0, + ...(envelope.handoff ? { handoff: envelope.handoff } : {}), + }]; + }), + ) + .sort((a, b) => b.score - a.score || b.createdAt.localeCompare(a.createdAt)) + .slice(0, input.limit); + return { query: input.query, currentProject: scope.project, searchedProjects: [...projects], results }; + } + + async getHandoff(scope: RequestScope): Promise> { + if (scope.project === GLOBAL_REFERENCE_PROJECT) { + throw new GatewayError( + "An implementation handoff requires an exact project binding", + 400, + "handoff_project_required", + ); + } + const result = await this.search(scope, { + query: `implementation_handoff ${scope.project}`, + limit: 20, + }); + const handoffs = Array.isArray(result["results"]) + ? (result["results"] as Array>).filter( + (item) => item["category"] === "implementation_handoff" && item["project"] === scope.project, + ).sort((a, b) => String(b["createdAt"] ?? "").localeCompare(String(a["createdAt"] ?? ""))) + : []; + return { + project: scope.project, + handoff: handoffs[0] ?? null, + fallbackUsed: false, + }; + } +} + +export function createScopedMcpServer(service: ScopedMemoryService, scope: RequestScope): McpServer { + const server = new McpServer({ name: "jtt-agentmemory", version: "0.1.0" }); + + server.registerTool( + "agentmemory_save", + { + title: "Save project memory", + description: "Save a concise project-bound reference, decision, or fact. The project is fixed by the MCP connection.", + inputSchema: { + content: z.string().min(1).max(20_000), + category: z.enum(["reference", "decision", "fact"]).default("reference"), + files: z.array(z.string().min(1).max(500)).max(50).optional(), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + async (input) => { + try { + return textResult(await service.save(scope, input)); + } catch (error) { + return textResult(toPublicError(error), true); + } + }, + ); + + server.registerTool( + "agentmemory_search", + { + title: "Search project memory", + description: "Search the current project by default. Other projects are searched only when explicitly listed, and every result includes its source project.", + inputSchema: { + query: z.string().min(1).max(2_000), + limit: z.number().int().min(1).max(20).default(8), + includeGlobalReference: z.boolean().default(false), + referenceProjects: z.array(z.string().min(1).max(128)).max(5).optional(), + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + async (input) => { + try { + return textResult(await service.search(scope, input)); + } catch (error) { + return textResult(toPublicError(error), true); + } + }, + ); + + server.registerTool( + "agentmemory_handoff_save", + { + title: "Save implementation handoff", + description: "Save an implementation handoff for the exact current project. Global or unknown project contexts are rejected.", + inputSchema: { + summary: z.string().min(1).max(12_000), + nextStep: z.string().min(1).max(4_000), + openQuestions: z.array(z.string().min(1).max(2_000)).max(20).optional(), + files: z.array(z.string().min(1).max(500)).max(50).optional(), + gitRef: z.string().min(1).max(200).optional(), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + async (input) => { + try { + return textResult(await service.saveHandoff(scope, input)); + } catch (error) { + return textResult(toPublicError(error), true); + } + }, + ); + + server.registerTool( + "agentmemory_handoff_get", + { + title: "Get implementation handoff", + description: "Return only the latest handoff belonging to the exact current project. It never falls back to another project.", + inputSchema: {}, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + async () => { + try { + return textResult(await service.getHandoff(scope)); + } catch (error) { + return textResult(toPublicError(error), true); + } + }, + ); + + return server; +} + +function toPublicError(error: unknown): Record { + if (error instanceof GatewayError) return { error: error.code, message: error.message }; + return { error: "internal_error", message: "AgentMemory operation failed" }; +} + +async function readBody(req: IncomingMessage): Promise { + const declared = Number(req.headers["content-length"] ?? 0); + if (declared > MAX_BODY_BYTES) { + throw new GatewayError("Request body too large", 413, "body_too_large"); + } + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += bytes.length; + if (total > MAX_BODY_BYTES) { + throw new GatewayError("Request body too large", 413, "body_too_large"); + } + chunks.push(bytes); + } + return Buffer.concat(chunks); +} + +function json(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(JSON.stringify(body)); +} + +function requestHeaders(req: IncomingMessage): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value) headers.set(name, Array.isArray(value) ? value.join(", ") : value); + } + return headers; +} + +export function startScopedGateway(config: GatewayConfig = loadGatewayConfig()) { + const backend = new RestAgentMemoryBackend(config); + const service = new ScopedMemoryService(backend, config.allowedProjects); + const server = createHttpServer((req, res) => { + void (async () => { + if (req.url === "/health" && req.method === "GET") { + return json(res, 200, { ok: true, service: "jtt-agentmemory-gateway" }); + } + if (req.url !== "/mcp") return json(res, 404, { error: "not_found" }); + if (req.method !== "POST") return json(res, 405, { error: "method_not_allowed" }); + const headers = requestHeaders(req); + const scope = resolveRequestScope(headers, config); + const payload = await readBody(req); + const request = new Request("http://127.0.0.1/mcp", { + method: "POST", + headers, + body: new Blob([Uint8Array.from(payload)]), + }); + const mcp = createScopedMcpServer(service, scope); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + try { + await mcp.connect(transport); + const response = await transport.handleRequest(request); + const responseHeaders: Record = { "cache-control": "no-store" }; + response.headers.forEach((value, name) => { + responseHeaders[name] = value; + }); + res.writeHead(response.status, responseHeaders); + res.end(Buffer.from(await response.arrayBuffer())); + } finally { + await mcp.close(); + } + })().catch((error: unknown) => { + const publicError = toPublicError(error); + const status = error instanceof GatewayError ? error.statusCode : 500; + if (!res.headersSent) json(res, status, publicError); + else res.end(); + }); + }); + return server.listen(config.port, config.host, () => { + process.stderr.write(`[jtt-agentmemory] scoped gateway listening on ${config.host}:${config.port}\n`); + }); +} + +const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; +if (import.meta.url === entrypoint) startScopedGateway(); diff --git a/src/state/memory-utils.ts b/src/state/memory-utils.ts index aa0bcc5b8..725aea4e4 100644 --- a/src/state/memory-utils.ts +++ b/src/state/memory-utils.ts @@ -20,5 +20,6 @@ export function memoryToObservation(memory: Memory): CompressedObservation { concepts: memory.concepts, files: memory.files, importance: memory.strength, + ...(memory.project ? { project: memory.project } : {}), }; } diff --git a/src/types.ts b/src/types.ts index 7cda80ffb..38285a40f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,7 @@ export interface CompressedObservation { imageDescription?: string; modality?: "text" | "image" | "mixed"; agentId?: string; + project?: string; } export type ObservationType = diff --git a/test/jtt-scoped-gateway-http.test.ts b/test/jtt-scoped-gateway-http.test.ts new file mode 100644 index 000000000..bad80b2fe --- /dev/null +++ b/test/jtt-scoped-gateway-http.test.ts @@ -0,0 +1,109 @@ +import { createServer } from "node:http"; +import { once } from "node:events"; +import { afterEach, describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { startScopedGateway, type GatewayConfig } from "../src/jtt/scoped-gateway.js"; + +const openServers: Array> = []; + +afterEach(async () => { + await Promise.all( + openServers.splice(0).map( + (server) => new Promise((resolve) => server.close(() => resolve())), + ), + ); +}); + +function portOf(server: ReturnType): number { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("server has no TCP port"); + return address.port; +} + +describe("JTT scoped gateway over Streamable HTTP", () => { + it("completes initialize, tools/list, and a project-bound save", async () => { + const remembered: Array> = []; + const upstream = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => { + const body = chunks.length ? (JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record) : {}; + if (req.url === "/agentmemory/remember") { + remembered.push(body); + res.writeHead(201, { "content-type": "application/json" }); + res.end(JSON.stringify({ success: true, memory: { id: "mem-http-1" } })); + return; + } + if (req.url === "/agentmemory/search") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ results: [] })); + return; + } + res.writeHead(404).end(); + }); + }); + openServers.push(upstream); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + + const config: GatewayConfig = { + host: "127.0.0.1", + port: 0, + gatewaySecret: "http-test-secret", + upstreamUrl: `http://127.0.0.1:${portOf(upstream)}`, + upstreamSecret: "upstream-test-secret", + allowedProjects: new Set(["agent-hub", "global/reference"]), + requestTimeoutMs: 1_000, + }; + const gateway = startScopedGateway(config); + openServers.push(gateway); + await once(gateway, "listening"); + + const headers = { + authorization: "Bearer http-test-secret", + "x-agentmemory-project": "agent-hub", + "x-agentmemory-agent": "warp", + }; + const client = new Client({ name: "http-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${portOf(gateway)}/mcp`), + { requestInit: { headers } }, + ); + await client.connect(transport); + try { + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(4); + const response = await client.callTool({ + name: "agentmemory_save", + arguments: { content: "Warp can save scoped memory", category: "reference" }, + }); + expect(response.isError).not.toBe(true); + expect(remembered).toHaveLength(1); + expect(remembered[0]?.project).toBe("agent-hub"); + expect(remembered[0]?.content).toContain('"sourceAgent":"warp"'); + } finally { + await client.close(); + } + }); + + it("rejects unauthenticated MCP discovery", async () => { + const config: GatewayConfig = { + host: "127.0.0.1", + port: 0, + gatewaySecret: "http-test-secret", + upstreamUrl: "http://127.0.0.1:9", + allowedProjects: new Set(["agent-hub"]), + requestTimeoutMs: 50, + }; + const gateway = startScopedGateway(config); + openServers.push(gateway); + await once(gateway, "listening"); + const response = await fetch(`http://127.0.0.1:${portOf(gateway)}/mcp`, { + method: "POST", + headers: { "content-type": "application/json", "x-agentmemory-project": "agent-hub" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }); + expect(response.status).toBe(401); + }); +}); diff --git a/test/jtt-scoped-gateway.test.ts b/test/jtt-scoped-gateway.test.ts new file mode 100644 index 000000000..fcd223c5c --- /dev/null +++ b/test/jtt-scoped-gateway.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { + GatewayError, + ScopedMemoryService, + createScopedMcpServer, + resolveRequestScope, + type AgentMemoryBackend, + type GatewayConfig, +} from "../src/jtt/scoped-gateway.js"; + +class FakeBackend implements AgentMemoryBackend { + readonly remembers: Array[0]> = []; + readonly searches: Array[0]> = []; + searchResponse: Awaited> = { results: [] }; + + async remember(input: Parameters[0]): Promise { + this.remembers.push(input); + return { success: true, memory: { id: `mem-${this.remembers.length}` } }; + } + + async search(input: Parameters[0]) { + this.searches.push(input); + return this.searchResponse; + } +} + +const config: GatewayConfig = { + host: "127.0.0.1", + port: 3121, + gatewaySecret: "test-secret", + upstreamUrl: "http://127.0.0.1:3111", + allowedProjects: new Set(["agent-hub", "jtt-cms", "global/reference"]), + requestTimeoutMs: 100, +}; + +function headers(project: string, agent = "hermes"): Headers { + return new Headers({ + authorization: "Bearer test-secret", + "x-agentmemory-project": project, + "x-agentmemory-agent": agent, + }); +} + +function encoded(project: string, category: string, content: string, createdAt: string): string { + return `JTT_AGENTMEMORY ${category} ${project}\n${JSON.stringify({ + schema: "jtt-agentmemory/v1", + project, + category, + sourceAgent: "hermes", + content, + files: [], + createdAt, + ...(category === "implementation_handoff" + ? { handoff: { summary: content, nextStep: "continue", openQuestions: [] } } + : {}), + })}`; +} + +describe("resolveRequestScope", () => { + it("binds the request to an allowlisted canonical project", () => { + expect(resolveRequestScope(headers("AGENT-HUB", "Claude-Code"), config)).toEqual({ + project: "agent-hub", + agent: "claude-code", + }); + }); + + it("rejects missing auth and unknown projects", () => { + expect(() => resolveRequestScope(new Headers({ "x-agentmemory-project": "agent-hub" }), config)).toThrow( + GatewayError, + ); + expect(() => resolveRequestScope(headers("other-project"), config)).toThrow(/not enabled/); + }); +}); + +describe("ScopedMemoryService", () => { + it("always writes the connection-bound project", async () => { + const backend = new FakeBackend(); + const service = new ScopedMemoryService(backend, config.allowedProjects); + await service.save( + { project: "agent-hub", agent: "codex" }, + { content: "Use manifest v2", category: "decision", files: ["registries/harness-manifest.yaml"] }, + ); + expect(backend.remembers).toHaveLength(1); + expect(backend.remembers[0]?.project).toBe("agent-hub"); + expect(backend.remembers[0]?.content).toContain('"project":"agent-hub"'); + }); + + it("refuses a handoff from the global Hermes reference scope", async () => { + const service = new ScopedMemoryService(new FakeBackend(), config.allowedProjects); + await expect( + service.saveHandoff( + { project: "global/reference", agent: "hermes" }, + { summary: "Discussed an idea", nextStep: "Choose a project" }, + ), + ).rejects.toMatchObject({ code: "handoff_project_required" }); + }); + + it("searches other projects only when explicitly requested and labels every result", async () => { + const backend = new FakeBackend(); + backend.searchResponse = { + results: [ + { + observation: { + id: "mem-1", + narrative: encoded("jtt-cms", "reference", "Coupon flow", "2026-08-03T00:00:00Z"), + }, + score: 0.8, + }, + ], + }; + const service = new ScopedMemoryService(backend, config.allowedProjects); + const result = await service.search( + { project: "agent-hub", agent: "codex" }, + { query: "coupon", limit: 5, referenceProjects: ["jtt-cms"] }, + ); + expect(backend.searches.map((call) => call.project)).toEqual(["agent-hub", "jtt-cms"]); + expect(result.results).toEqual([ + expect.objectContaining({ project: "jtt-cms", source: "explicit_reference" }), + ]); + }); + + it("never falls back to another project's handoff", async () => { + const backend = new FakeBackend(); + backend.searchResponse = { + results: [ + { + observation: { + id: "wrong", + narrative: encoded("jtt-cms", "implementation_handoff", "Wrong project", "2026-08-03T02:00:00Z"), + }, + score: 1, + }, + { + observation: { + id: "right", + narrative: encoded("agent-hub", "implementation_handoff", "Right project", "2026-08-03T01:00:00Z"), + }, + score: 0.9, + }, + { + observation: { + id: "newest", + narrative: encoded("agent-hub", "implementation_handoff", "Newest project handoff", "2026-08-03T03:00:00Z"), + }, + score: 0.1, + }, + ], + }; + const service = new ScopedMemoryService(backend, config.allowedProjects); + const result = await service.getHandoff({ project: "agent-hub", agent: "claude-code" }); + expect(result).toMatchObject({ + project: "agent-hub", + fallbackUsed: false, + handoff: { project: "agent-hub", content: "Newest project handoff" }, + }); + }); +}); + +describe("JTT scoped MCP surface", () => { + it("initializes, lists only the four safe tools, and performs a scoped save", async () => { + const backend = new FakeBackend(); + const service = new ScopedMemoryService(backend, config.allowedProjects); + const server = createScopedMcpServer(service, { project: "agent-hub", agent: "codex" }); + const client = new Client({ name: "test-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toEqual([ + "agentmemory_save", + "agentmemory_search", + "agentmemory_handoff_save", + "agentmemory_handoff_get", + ]); + const saved = await client.callTool({ + name: "agentmemory_save", + arguments: { content: "Manifest v2 is the distribution SSOT", category: "decision" }, + }); + expect(saved.isError).not.toBe(true); + expect(backend.remembers[0]?.project).toBe("agent-hub"); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/test/search.test.ts b/test/search.test.ts index 84b8134e3..1919c13a2 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -188,6 +188,40 @@ describe("mem::search", () => { expect(hit?.title).toBe("Pineapple belongs on pizza"); }); + it("fails closed for unscoped and other-project memories when project is filtered", async () => { + for (const [id, project] of [ + ["mem_agent_hub", "agent-hub"], + ["mem_jtt_cms", "jtt-cms"], + ["mem_legacy", undefined], + ] as const) { + await kv.set(KV.memories, id, { + id, + createdAt: "2026-08-03T00:00:00Z", + updatedAt: "2026-08-03T00:00:00Z", + type: "fact", + title: "Scoped handoff marker", + content: "Scoped handoff marker for project isolation testing.", + concepts: ["handoff"], + files: [], + sessionIds: [], + strength: 7, + version: 1, + isLatest: true, + ...(project ? { project } : {}), + }); + } + getSearchIndex().clear(); + await rebuildIndex(kv as never); + + const result = (await sdk.trigger("mem::search", { + query: "scoped handoff marker", + project: "agent-hub", + })) as { results: Array<{ observation: CompressedObservation }> }; + + expect(result.results.map((item) => item.observation.id)).toEqual(["mem_agent_hub"]); + expect(result.results[0]?.observation.project).toBe("agent-hub"); + }); + it("rebuildIndex populates the vector index", async () => { const mockEmbedder = { name: "test", diff --git a/tsdown.config.ts b/tsdown.config.ts index 390a4df6c..e1a77018b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -66,6 +66,13 @@ export default defineConfig([ clean: false, sourcemap: false, }, + { + entry: ["src/jtt/scoped-gateway.ts"], + outDir: "dist/jtt", + ...shared, + clean: false, + sourcemap: false, + }, // One entry per config block prevents tsdown from hoisting shared // helpers into hashed chunks across hooks. ...hookEntries.map((entry) => ({ From 424c224b222b54be0d04471d4a4620c304fb1e92 Mon Sep 17 00:00:00 2001 From: ShintaroKawakami Date: Mon, 3 Aug 2026 21:56:17 +0900 Subject: [PATCH 2/5] fix: resolve Node for macOS launchd (#2) Co-authored-by: ShintaroKawakami --- deploy/macos/run-jtt-core.sh | 15 ++++++++++++++- deploy/macos/run-jtt-gateway.sh | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/deploy/macos/run-jtt-core.sh b/deploy/macos/run-jtt-core.sh index ff8b7409d..a9871c25f 100755 --- a/deploy/macos/run-jtt-core.sh +++ b/deploy/macos/run-jtt-core.sh @@ -19,4 +19,17 @@ source "$env_file" set +a cd "$repo_dir" -exec node dist/cli.mjs --tools core --data-dir "${AGENTMEMORY_DATA_DIR:-$HOME/.agentmemory/data}" +node_bin="$(command -v node 2>/dev/null || true)" +if [ -z "$node_bin" ]; then + for candidate in /opt/homebrew/bin/node /usr/local/bin/node "$HOME/.local/bin/node"; do + if [ -x "$candidate" ]; then + node_bin="$candidate" + break + fi + done +fi +if [ -z "$node_bin" ]; then + echo "agentmemory: node binary not found" >&2 + exit 127 +fi +exec "$node_bin" dist/cli.mjs --tools core --data-dir "${AGENTMEMORY_DATA_DIR:-$HOME/.agentmemory/data}" diff --git a/deploy/macos/run-jtt-gateway.sh b/deploy/macos/run-jtt-gateway.sh index df707565d..a2ba7772d 100755 --- a/deploy/macos/run-jtt-gateway.sh +++ b/deploy/macos/run-jtt-gateway.sh @@ -20,4 +20,17 @@ set +a export AGENTMEMORY_UPSTREAM_URL="${AGENTMEMORY_UPSTREAM_URL:-http://127.0.0.1:3111}" cd "$repo_dir" -exec node dist/jtt/scoped-gateway.mjs +node_bin="$(command -v node 2>/dev/null || true)" +if [ -z "$node_bin" ]; then + for candidate in /opt/homebrew/bin/node /usr/local/bin/node "$HOME/.local/bin/node"; do + if [ -x "$candidate" ]; then + node_bin="$candidate" + break + fi + done +fi +if [ -z "$node_bin" ]; then + echo "agentmemory gateway: node binary not found" >&2 + exit 127 +fi +exec "$node_bin" dist/jtt/scoped-gateway.mjs From f45caaaed5ae76ff19db5502277063e345fff00f Mon Sep 17 00:00:00 2001 From: ShintaroKawakami Date: Tue, 4 Aug 2026 00:05:46 +0900 Subject: [PATCH 3/5] chore: rollout AgentMemory harness to all active clients (#3) Co-authored-by: ShintaroKawakami --- .agent-hub/harness-generation.json | 2689 +++++++++++++++++ .agent/rules/ai-model-selection.md | 78 + .agent/rules/branch-rule.md | 102 + .agent/rules/constructive-dissent.md | 61 + .agent/rules/hooks-structure-rule.md | 71 + .agent/rules/latest-stack-context7.md | 43 + .agent/rules/mandate-registry.md | 59 + .agent/rules/mcp-key-management.md | 97 + .agent/rules/memory-lookups.md | 67 + .agent/rules/plan-approval-gate.md | 47 + .agent/rules/plan-commitment-tracking.md | 45 + .agent/rules/reference-over-hardcode.md | 49 + .agent/rules/response-style.md | 56 + .agent/rules/responsive-both-viewports.md | 27 + .../rules/settings-protection-coexistence.md | 54 + .agent/rules/sub-agent-scope-contract.md | 75 + .agent/rules/ui-stitch-mandatory.md | 79 + .agent/rules/visual-progress-map.md | 124 + .agent/rules/worktree-rule.md | 114 + .../general/.agent-hub-materializations.json | 97 + .claude/rules/general/ai-model-selection.md | 78 + .claude/rules/general/branch-rule.md | 102 + .claude/rules/general/constructive-dissent.md | 61 + .claude/rules/general/hooks-structure-rule.md | 79 + .../rules/general/latest-stack-context7.md | 61 + .claude/rules/general/mandate-registry.md | 59 + .claude/rules/general/mcp-key-management.md | 97 + .claude/rules/general/memory-lookups.md | 67 + .claude/rules/general/plan-approval-gate.md | 47 + .../rules/general/plan-commitment-tracking.md | 45 + .../rules/general/reference-over-hardcode.md | 49 + .claude/rules/general/response-style.md | 56 + .../general/responsive-both-viewports.md | 44 + .../settings-protection-coexistence.md | 70 + .../rules/general/sub-agent-scope-contract.md | 75 + .claude/rules/general/ui-stitch-mandatory.md | 79 + .claude/rules/general/visual-progress-map.md | 124 + .claude/rules/general/worktree-rule.md | 114 + .codex/config.toml | 52 + .codex/hooks.json | 73 + .codex/hooks/.hook-library-version | 1 + .codex/hooks/lib/code-quality-check.md | 205 ++ .codex/hooks/lib/hook-io.sh | 121 + .codex/hooks/lib/quality-check-common.sh | 973 ++++++ .codex/hooks/lib/storage-url-common.py | 190 ++ .codex/hooks/scripts/block-destructive-git.sh | 1963 ++++++++++++ .../scripts/block-destructive-git.test.sh | 526 ++++ .codex/hooks/scripts/block-main-commit.sh | 956 ++++++ .../hooks/scripts/block-main-commit.test.sh | 517 ++++ .../scripts/block-unauthorized-docs-file.sh | 551 ++++ .../block-unauthorized-docs-file.test.sh | 329 ++ .codex/hooks/scripts/freshness-gate.sh | 266 ++ .codex/hooks/scripts/handover-preflight.sh | 353 +++ .../hooks/scripts/handover-preflight.test.sh | 156 + .codex/hooks/scripts/post-merge-gate.sh | 472 +++ .codex/hooks/scripts/post-merge-gate.test.sh | 166 + .../hooks/scripts/pre-implementation-check.sh | 50 + .codex/hooks/scripts/stop-quality-check.sh | 15 + .codex/hooks/scripts/storage-url-pr-gate.sh | 127 + .codex/hooks/scripts/takeover-preflight.sh | 6 + .../hooks/scripts/takeover-preflight.test.sh | 113 + .codex/hooks/scripts/telemetry-lib.sh | 167 + .codex/sync-state.json | 18 + .cursor/hooks.json | 98 + .cursor/hooks/.hook-library-version | 1 + .cursor/hooks/lib/code-quality-check.md | 205 ++ .cursor/hooks/lib/hook-io.sh | 121 + .cursor/hooks/lib/quality-check-common.sh | 973 ++++++ .cursor/hooks/lib/storage-url-common.py | 190 ++ .../hooks/scripts/block-destructive-git.sh | 1963 ++++++++++++ .../scripts/block-destructive-git.test.sh | 526 ++++ .cursor/hooks/scripts/block-main-commit.sh | 956 ++++++ .../hooks/scripts/block-main-commit.test.sh | 517 ++++ .../hooks/scripts/block-skill-reverse-edit.sh | 142 + .../scripts/block-skill-reverse-edit.test.sh | 137 + .../scripts/block-unauthorized-docs-file.sh | 551 ++++ .../block-unauthorized-docs-file.test.sh | 329 ++ .../hooks/scripts/cursor-command-bridge.sh | 198 ++ .cursor/hooks/scripts/freshness-gate.sh | 266 ++ .cursor/hooks/scripts/handover-preflight.sh | 353 +++ .../hooks/scripts/handover-preflight.test.sh | 156 + .cursor/hooks/scripts/post-merge-gate.sh | 472 +++ .cursor/hooks/scripts/post-merge-gate.test.sh | 166 + .../hooks/scripts/pre-implementation-check.sh | 50 + .cursor/hooks/scripts/stop-quality-check.sh | 15 + .cursor/hooks/scripts/storage-url-pr-gate.sh | 127 + .../hooks/scripts/subagent-quality-check.sh | 16 + .cursor/hooks/scripts/takeover-preflight.sh | 6 + .../hooks/scripts/takeover-preflight.test.sh | 113 + .cursor/hooks/scripts/telemetry-lib.sh | 167 + .cursor/hooks/scripts/telemetry-log.sh | 125 + .cursor/hooks/scripts/telemetry-log.test.sh | 104 + .cursor/mcp.json | 42 + .cursor/rules/10-runtime-sync.mdc | 168 + .cursor/rules/general/ai-model-selection.mdc | 85 + .cursor/rules/general/branch-rule.mdc | 109 + .../rules/general/constructive-dissent.mdc | 68 + .../rules/general/hooks-structure-rule.mdc | 81 + .../rules/general/latest-stack-context7.mdc | 63 + .cursor/rules/general/mandate-registry.mdc | 66 + .cursor/rules/general/mcp-key-management.mdc | 104 + .cursor/rules/general/memory-lookups.mdc | 74 + .cursor/rules/general/plan-approval-gate.mdc | 54 + .../general/plan-commitment-tracking.mdc | 52 + .../rules/general/reference-over-hardcode.mdc | 56 + .cursor/rules/general/response-style.mdc | 63 + .../general/responsive-both-viewports.mdc | 45 + .../settings-protection-coexistence.mdc | 71 + .../general/sub-agent-scope-contract.mdc | 82 + .cursor/rules/general/ui-stitch-mandatory.mdc | 86 + .cursor/rules/general/visual-progress-map.mdc | 131 + .cursor/rules/general/worktree-rule.mdc | 121 + .cursor/sync-state.json | 167 + .gemini/hooks/.hook-library-version | 1 + .gemini/hooks/lib/code-quality-check.md | 205 ++ .gemini/hooks/lib/hook-io.sh | 121 + .gemini/hooks/lib/quality-check-common.sh | 973 ++++++ .gemini/hooks/lib/storage-url-common.py | 190 ++ .../hooks/scripts/block-destructive-git.sh | 1963 ++++++++++++ .../scripts/block-destructive-git.test.sh | 526 ++++ .gemini/hooks/scripts/block-main-commit.sh | 956 ++++++ .../hooks/scripts/block-main-commit.test.sh | 517 ++++ .../hooks/scripts/block-skill-reverse-edit.sh | 142 + .../scripts/block-skill-reverse-edit.test.sh | 137 + .../scripts/block-unauthorized-docs-file.sh | 551 ++++ .../block-unauthorized-docs-file.test.sh | 329 ++ .gemini/hooks/scripts/freshness-gate.sh | 266 ++ .gemini/hooks/scripts/gemini-hook-bridge.py | 342 +++ .gemini/hooks/scripts/handover-preflight.sh | 353 +++ .../hooks/scripts/handover-preflight.test.sh | 156 + .gemini/hooks/scripts/post-merge-gate.sh | 472 +++ .gemini/hooks/scripts/post-merge-gate.test.sh | 166 + .../hooks/scripts/pre-implementation-check.sh | 50 + .gemini/hooks/scripts/stop-quality-check.sh | 15 + .gemini/hooks/scripts/storage-url-pr-gate.sh | 127 + .../hooks/scripts/subagent-quality-check.sh | 16 + .gemini/hooks/scripts/takeover-preflight.sh | 6 + .../hooks/scripts/takeover-preflight.test.sh | 113 + .gemini/hooks/scripts/telemetry-lib.sh | 167 + .gemini/hooks/scripts/telemetry-log.sh | 125 + .gemini/hooks/scripts/telemetry-log.test.sh | 104 + .gemini/sync-state.json | 113 + .gitignore | 25 +- .kimi-code/AGENTS.md | 31 + .kimi-code/hooks/.hook-library-version | 1 + .kimi-code/hooks/kimi-hook-bridge.py | 78 + .kimi-code/hooks/lib/code-quality-check.md | 205 ++ .kimi-code/hooks/lib/hook-io.sh | 127 + .kimi-code/hooks/lib/quality-check-common.sh | 1007 ++++++ .kimi-code/hooks/lib/storage-url-common.py | 190 ++ .kimi-code/hooks/managed-hooks.json | 69 + .../hooks/scripts/block-destructive-git.sh | 1963 ++++++++++++ .../scripts/block-destructive-git.test.sh | 526 ++++ .kimi-code/hooks/scripts/block-main-commit.sh | 956 ++++++ .../hooks/scripts/block-main-commit.test.sh | 517 ++++ .../hooks/scripts/block-skill-reverse-edit.sh | 142 + .../scripts/block-skill-reverse-edit.test.sh | 137 + .../scripts/block-unauthorized-docs-file.sh | 551 ++++ .../block-unauthorized-docs-file.test.sh | 329 ++ .kimi-code/hooks/scripts/freshness-gate.sh | 266 ++ .../hooks/scripts/handover-preflight.sh | 353 +++ .../hooks/scripts/handover-preflight.test.sh | 156 + .kimi-code/hooks/scripts/post-merge-gate.sh | 472 +++ .../hooks/scripts/post-merge-gate.test.sh | 166 + .../hooks/scripts/pre-implementation-check.sh | 50 + .../hooks/scripts/stop-quality-check.sh | 15 + .../hooks/scripts/storage-url-pr-gate.sh | 127 + .../hooks/scripts/subagent-quality-check.sh | 16 + .../hooks/scripts/takeover-preflight.sh | 6 + .../hooks/scripts/takeover-preflight.test.sh | 113 + .kimi-code/hooks/scripts/telemetry-lib.sh | 167 + .kimi-code/hooks/scripts/telemetry-log.sh | 125 + .../hooks/scripts/telemetry-log.test.sh | 104 + .kimi-code/mcp.json | 55 + .kimi-code/sync-state.json | 51 + .opencode/plugins/runtime-sync.js | 204 ++ .opencode/sync-state.json | 46 + AGENTS.md | 1301 ++++++++ CLAUDE.md | 155 + GEMINI.md | 1557 ++++++++++ opencode.json | 60 + tui.json | 4 + 182 files changed, 46162 insertions(+), 1 deletion(-) create mode 100644 .agent-hub/harness-generation.json create mode 100644 .agent/rules/ai-model-selection.md create mode 100644 .agent/rules/branch-rule.md create mode 100644 .agent/rules/constructive-dissent.md create mode 100644 .agent/rules/hooks-structure-rule.md create mode 100644 .agent/rules/latest-stack-context7.md create mode 100644 .agent/rules/mandate-registry.md create mode 100644 .agent/rules/mcp-key-management.md create mode 100644 .agent/rules/memory-lookups.md create mode 100644 .agent/rules/plan-approval-gate.md create mode 100644 .agent/rules/plan-commitment-tracking.md create mode 100644 .agent/rules/reference-over-hardcode.md create mode 100644 .agent/rules/response-style.md create mode 100644 .agent/rules/responsive-both-viewports.md create mode 100644 .agent/rules/settings-protection-coexistence.md create mode 100644 .agent/rules/sub-agent-scope-contract.md create mode 100644 .agent/rules/ui-stitch-mandatory.md create mode 100644 .agent/rules/visual-progress-map.md create mode 100644 .agent/rules/worktree-rule.md create mode 100644 .claude/rules/general/.agent-hub-materializations.json create mode 100644 .claude/rules/general/ai-model-selection.md create mode 100644 .claude/rules/general/branch-rule.md create mode 100644 .claude/rules/general/constructive-dissent.md create mode 100644 .claude/rules/general/hooks-structure-rule.md create mode 100644 .claude/rules/general/latest-stack-context7.md create mode 100644 .claude/rules/general/mandate-registry.md create mode 100644 .claude/rules/general/mcp-key-management.md create mode 100644 .claude/rules/general/memory-lookups.md create mode 100644 .claude/rules/general/plan-approval-gate.md create mode 100644 .claude/rules/general/plan-commitment-tracking.md create mode 100644 .claude/rules/general/reference-over-hardcode.md create mode 100644 .claude/rules/general/response-style.md create mode 100644 .claude/rules/general/responsive-both-viewports.md create mode 100644 .claude/rules/general/settings-protection-coexistence.md create mode 100644 .claude/rules/general/sub-agent-scope-contract.md create mode 100644 .claude/rules/general/ui-stitch-mandatory.md create mode 100644 .claude/rules/general/visual-progress-map.md create mode 100644 .claude/rules/general/worktree-rule.md create mode 100644 .codex/config.toml create mode 100644 .codex/hooks.json create mode 100644 .codex/hooks/.hook-library-version create mode 100644 .codex/hooks/lib/code-quality-check.md create mode 100755 .codex/hooks/lib/hook-io.sh create mode 100755 .codex/hooks/lib/quality-check-common.sh create mode 100644 .codex/hooks/lib/storage-url-common.py create mode 100755 .codex/hooks/scripts/block-destructive-git.sh create mode 100755 .codex/hooks/scripts/block-destructive-git.test.sh create mode 100755 .codex/hooks/scripts/block-main-commit.sh create mode 100755 .codex/hooks/scripts/block-main-commit.test.sh create mode 100755 .codex/hooks/scripts/block-unauthorized-docs-file.sh create mode 100755 .codex/hooks/scripts/block-unauthorized-docs-file.test.sh create mode 100755 .codex/hooks/scripts/freshness-gate.sh create mode 100755 .codex/hooks/scripts/handover-preflight.sh create mode 100755 .codex/hooks/scripts/handover-preflight.test.sh create mode 100755 .codex/hooks/scripts/post-merge-gate.sh create mode 100755 .codex/hooks/scripts/post-merge-gate.test.sh create mode 100755 .codex/hooks/scripts/pre-implementation-check.sh create mode 100755 .codex/hooks/scripts/stop-quality-check.sh create mode 100755 .codex/hooks/scripts/storage-url-pr-gate.sh create mode 100755 .codex/hooks/scripts/takeover-preflight.sh create mode 100755 .codex/hooks/scripts/takeover-preflight.test.sh create mode 100755 .codex/hooks/scripts/telemetry-lib.sh create mode 100644 .codex/sync-state.json create mode 100644 .cursor/hooks.json create mode 100644 .cursor/hooks/.hook-library-version create mode 100644 .cursor/hooks/lib/code-quality-check.md create mode 100755 .cursor/hooks/lib/hook-io.sh create mode 100755 .cursor/hooks/lib/quality-check-common.sh create mode 100644 .cursor/hooks/lib/storage-url-common.py create mode 100755 .cursor/hooks/scripts/block-destructive-git.sh create mode 100755 .cursor/hooks/scripts/block-destructive-git.test.sh create mode 100755 .cursor/hooks/scripts/block-main-commit.sh create mode 100755 .cursor/hooks/scripts/block-main-commit.test.sh create mode 100755 .cursor/hooks/scripts/block-skill-reverse-edit.sh create mode 100755 .cursor/hooks/scripts/block-skill-reverse-edit.test.sh create mode 100755 .cursor/hooks/scripts/block-unauthorized-docs-file.sh create mode 100755 .cursor/hooks/scripts/block-unauthorized-docs-file.test.sh create mode 100755 .cursor/hooks/scripts/cursor-command-bridge.sh create mode 100755 .cursor/hooks/scripts/freshness-gate.sh create mode 100755 .cursor/hooks/scripts/handover-preflight.sh create mode 100755 .cursor/hooks/scripts/handover-preflight.test.sh create mode 100755 .cursor/hooks/scripts/post-merge-gate.sh create mode 100755 .cursor/hooks/scripts/post-merge-gate.test.sh create mode 100755 .cursor/hooks/scripts/pre-implementation-check.sh create mode 100755 .cursor/hooks/scripts/stop-quality-check.sh create mode 100755 .cursor/hooks/scripts/storage-url-pr-gate.sh create mode 100755 .cursor/hooks/scripts/subagent-quality-check.sh create mode 100755 .cursor/hooks/scripts/takeover-preflight.sh create mode 100755 .cursor/hooks/scripts/takeover-preflight.test.sh create mode 100755 .cursor/hooks/scripts/telemetry-lib.sh create mode 100755 .cursor/hooks/scripts/telemetry-log.sh create mode 100755 .cursor/hooks/scripts/telemetry-log.test.sh create mode 100644 .cursor/mcp.json create mode 100644 .cursor/rules/10-runtime-sync.mdc create mode 100644 .cursor/rules/general/ai-model-selection.mdc create mode 100644 .cursor/rules/general/branch-rule.mdc create mode 100644 .cursor/rules/general/constructive-dissent.mdc create mode 100644 .cursor/rules/general/hooks-structure-rule.mdc create mode 100644 .cursor/rules/general/latest-stack-context7.mdc create mode 100644 .cursor/rules/general/mandate-registry.mdc create mode 100644 .cursor/rules/general/mcp-key-management.mdc create mode 100644 .cursor/rules/general/memory-lookups.mdc create mode 100644 .cursor/rules/general/plan-approval-gate.mdc create mode 100644 .cursor/rules/general/plan-commitment-tracking.mdc create mode 100644 .cursor/rules/general/reference-over-hardcode.mdc create mode 100644 .cursor/rules/general/response-style.mdc create mode 100644 .cursor/rules/general/responsive-both-viewports.mdc create mode 100644 .cursor/rules/general/settings-protection-coexistence.mdc create mode 100644 .cursor/rules/general/sub-agent-scope-contract.mdc create mode 100644 .cursor/rules/general/ui-stitch-mandatory.mdc create mode 100644 .cursor/rules/general/visual-progress-map.mdc create mode 100644 .cursor/rules/general/worktree-rule.mdc create mode 100644 .cursor/sync-state.json create mode 100644 .gemini/hooks/.hook-library-version create mode 100644 .gemini/hooks/lib/code-quality-check.md create mode 100755 .gemini/hooks/lib/hook-io.sh create mode 100755 .gemini/hooks/lib/quality-check-common.sh create mode 100644 .gemini/hooks/lib/storage-url-common.py create mode 100755 .gemini/hooks/scripts/block-destructive-git.sh create mode 100755 .gemini/hooks/scripts/block-destructive-git.test.sh create mode 100755 .gemini/hooks/scripts/block-main-commit.sh create mode 100755 .gemini/hooks/scripts/block-main-commit.test.sh create mode 100755 .gemini/hooks/scripts/block-skill-reverse-edit.sh create mode 100755 .gemini/hooks/scripts/block-skill-reverse-edit.test.sh create mode 100755 .gemini/hooks/scripts/block-unauthorized-docs-file.sh create mode 100755 .gemini/hooks/scripts/block-unauthorized-docs-file.test.sh create mode 100755 .gemini/hooks/scripts/freshness-gate.sh create mode 100755 .gemini/hooks/scripts/gemini-hook-bridge.py create mode 100755 .gemini/hooks/scripts/handover-preflight.sh create mode 100755 .gemini/hooks/scripts/handover-preflight.test.sh create mode 100755 .gemini/hooks/scripts/post-merge-gate.sh create mode 100755 .gemini/hooks/scripts/post-merge-gate.test.sh create mode 100755 .gemini/hooks/scripts/pre-implementation-check.sh create mode 100755 .gemini/hooks/scripts/stop-quality-check.sh create mode 100755 .gemini/hooks/scripts/storage-url-pr-gate.sh create mode 100755 .gemini/hooks/scripts/subagent-quality-check.sh create mode 100755 .gemini/hooks/scripts/takeover-preflight.sh create mode 100755 .gemini/hooks/scripts/takeover-preflight.test.sh create mode 100755 .gemini/hooks/scripts/telemetry-lib.sh create mode 100755 .gemini/hooks/scripts/telemetry-log.sh create mode 100755 .gemini/hooks/scripts/telemetry-log.test.sh create mode 100644 .gemini/sync-state.json create mode 100644 .kimi-code/AGENTS.md create mode 100644 .kimi-code/hooks/.hook-library-version create mode 100755 .kimi-code/hooks/kimi-hook-bridge.py create mode 100644 .kimi-code/hooks/lib/code-quality-check.md create mode 100755 .kimi-code/hooks/lib/hook-io.sh create mode 100755 .kimi-code/hooks/lib/quality-check-common.sh create mode 100644 .kimi-code/hooks/lib/storage-url-common.py create mode 100644 .kimi-code/hooks/managed-hooks.json create mode 100755 .kimi-code/hooks/scripts/block-destructive-git.sh create mode 100755 .kimi-code/hooks/scripts/block-destructive-git.test.sh create mode 100755 .kimi-code/hooks/scripts/block-main-commit.sh create mode 100755 .kimi-code/hooks/scripts/block-main-commit.test.sh create mode 100755 .kimi-code/hooks/scripts/block-skill-reverse-edit.sh create mode 100755 .kimi-code/hooks/scripts/block-skill-reverse-edit.test.sh create mode 100755 .kimi-code/hooks/scripts/block-unauthorized-docs-file.sh create mode 100755 .kimi-code/hooks/scripts/block-unauthorized-docs-file.test.sh create mode 100755 .kimi-code/hooks/scripts/freshness-gate.sh create mode 100755 .kimi-code/hooks/scripts/handover-preflight.sh create mode 100755 .kimi-code/hooks/scripts/handover-preflight.test.sh create mode 100755 .kimi-code/hooks/scripts/post-merge-gate.sh create mode 100755 .kimi-code/hooks/scripts/post-merge-gate.test.sh create mode 100755 .kimi-code/hooks/scripts/pre-implementation-check.sh create mode 100755 .kimi-code/hooks/scripts/stop-quality-check.sh create mode 100755 .kimi-code/hooks/scripts/storage-url-pr-gate.sh create mode 100755 .kimi-code/hooks/scripts/subagent-quality-check.sh create mode 100755 .kimi-code/hooks/scripts/takeover-preflight.sh create mode 100755 .kimi-code/hooks/scripts/takeover-preflight.test.sh create mode 100755 .kimi-code/hooks/scripts/telemetry-lib.sh create mode 100755 .kimi-code/hooks/scripts/telemetry-log.sh create mode 100755 .kimi-code/hooks/scripts/telemetry-log.test.sh create mode 100644 .kimi-code/mcp.json create mode 100644 .kimi-code/sync-state.json create mode 100644 .opencode/plugins/runtime-sync.js create mode 100644 .opencode/sync-state.json create mode 100644 CLAUDE.md create mode 100644 GEMINI.md create mode 100644 opencode.json create mode 100644 tui.json diff --git a/.agent-hub/harness-generation.json b/.agent-hub/harness-generation.json new file mode 100644 index 000000000..a3c405b68 --- /dev/null +++ b/.agent-hub/harness-generation.json @@ -0,0 +1,2689 @@ +{ + "absent_surfaces": [ + ".augment" + ], + "canonical_project": "agentmemory", + "effective_hashes": { + "antigravity": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", + "claude": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", + "codex": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", + "cursor": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", + "kimi": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", + "opencode": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", + "warp": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f" + }, + "harness_type": "mcp-server", + "ledger_writer": { + "writer_id": "sync-agents", + "writer_version": "sha256:4000a0f869bf95a58f0acf31e3c806ae896d72766d080fba7575031c99abf8c3" + }, + "resolver_version": "1.1.0", + "schema_version": 2, + "source_fingerprint": "sha256:1f2c35236a329a1c3635abee5175ac2b0e29a2313e42ce72b2c7946afc50b13c", + "surfaces": [ + { + "asset_revisions": [ + { + "asset_id": "antigravity", + "source_revision": "bcaed648876d9d4586466ccf5b236875d36ee62c3ef5b04fd3c0d9a1237f04e7" + }, + { + "asset_id": "claude", + "source_revision": "09530a052046fdca9046f56ae8bed4b4efbb4bb8ca52880e96c45eab60ef4736" + }, + { + "asset_id": "codex", + "source_revision": "c14e66b00ebfbaabd91a71c2f18d215c9224e7fd88900bc8468d65033aaac2ab" + }, + { + "asset_id": "cursor", + "source_revision": "20598fb9822e208075c234a5c4c8a9437e21c51dc1ba4bec87d20af43951bea1" + }, + { + "asset_id": "kimi", + "source_revision": "de404fbd95a41e4edef577673fd432ea78f10959d464f8ba77d26c73758e28d8" + }, + { + "asset_id": "opencode", + "source_revision": "ee4333fd657cfc51766289e4064241151636f6475bc15f4e7184a799215849d0" + }, + { + "asset_id": "warp", + "source_revision": "6d4d9867e18c1f6624ebe8a45131992dd57770c258ef85b1520072c2060ed679" + } + ], + "client": "antigravity", + "content_digest": "sha256:33f8c8ccd6a2e1f3e2a91f5dfc38102211e7d9cca3aa1f402f4098199d60d8a0", + "kind": "ai_client", + "materialization_digest": "sha256:74b7a4cfd30c3ee59f6473d82f73cface756f10e858309dfc9c6a209fbffc905", + "probe": "stable-sync-state", + "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", + "symlink_policy": "forbidden", + "target": ".gemini/sync-state.json", + "writer_id": "sync-antigravity-from-cc", + "writer_version": "sha256:241caef00d7cbaeb9bef6e3c52ff35d56077ef23ae39b753a8ef34631f805f44" + }, + { + "asset_revisions": [ + { + "asset_id": "agent-publish", + "source_revision": "3f75ea730a21f1877715be1b76cd493815f7a5b28461ed7284225cb66066ea1f" + }, + { + "asset_id": "attach", + "source_revision": "8687b2fa81a92f40c3636526d5138d286b9ceec7e769d0f9e20214637d99847c" + }, + { + "asset_id": "audit-agents", + "source_revision": "2a043eb72d458bba50a845f87853a0e494d3f83bfa3cebc5fa3cb4115271dc04" + }, + { + "asset_id": "brainstorm", + "source_revision": "9aea19bd88594cdb4aaadbfdfc77e96e576364099f674b623035d1bdfb03a3e9" + }, + { + "asset_id": "command-check", + "source_revision": "0e5f497a545788e2d8f9da6b4209753a5559cc55d1123069309fb11787e01210" + }, + { + "asset_id": "command-publish", + "source_revision": "b7036f59ce6c7677996030c2a5cc2d8ab665b695ec6677c25cf50ef5553c3b50" + }, + { + "asset_id": "detach", + "source_revision": "262ec4b2d493919280325b007b437f66c90bef3ce0aef7adfe92e7b1e97ded18" + }, + { + "asset_id": "document", + "source_revision": "16b04f6bc35e1fe208036a999af17fb59ee7492e9967a8cd5e4a0be679d08ba6" + }, + { + "asset_id": "explain", + "source_revision": "63cc6279c9d16dd8b6891d9dbf746023643ff21b6db24796e52e6fbf33d2ef08" + }, + { + "asset_id": "new-project", + "source_revision": "aaa7a120691172be985ef7c55a7e9b43f603650ae91df15d5be6d49eae7b623d" + }, + { + "asset_id": "parallel-run", + "source_revision": "eecc9409e9091b758023d0e5d0dc07250bc190635adf74b46dc75e383d0ae7a4" + }, + { + "asset_id": "publish-deploy", + "source_revision": "2bf9b70f871f1821859a211efd9d3a83daf5d9db39a300b4eba20a05ddc1d12c" + }, + { + "asset_id": "rules-publish", + "source_revision": "5fa9927079d380209e7afbc80a6de6e114cbc71acf84eb50155361d4f895c666" + }, + { + "asset_id": "set-agent-hub-secret", + "source_revision": "7a4371df9d48e1398b0f6afd2f2f2ca75e3c8e146c279f8abb0a24fbb50eeccf" + }, + { + "asset_id": "skill-check", + "source_revision": "981f07479b24d412b297dede7b8002250cbf28b9a7a723d894dec7560148db07" + }, + { + "asset_id": "skill-publish", + "source_revision": "42ffb47268cab24ce6e10ddd8a4b6ce3aa6f2c98a00c6d77d3fd7c701818d767" + }, + { + "asset_id": "skill-selector", + "source_revision": "bdca03ebf69a64e346dc5d4dedf360a486bc49ac25cac14a7461687999102cc5" + }, + { + "asset_id": "skills-publish", + "source_revision": "d01c9d85b8cd3749754f1b6e474dd12a46ae987869902e61d7265dac79b56429" + }, + { + "asset_id": "sync-agents", + "source_revision": "ecb86170609c8100868cb1d619e2e32f6a27093f9edb42d903ae44d51a8d0887" + }, + { + "asset_id": "sync-antigravity-from-cc", + "source_revision": "f465978577529c777d9ad85117164cc2b046addb4effc821c2f34c1264005b6e" + }, + { + "asset_id": "sync-codex-from-cc", + "source_revision": "338a2ed245bf14718204237c8d597a6ae1c5de38a09c8269dec782ad021943e9" + }, + { + "asset_id": "sync-cursor-from-cc", + "source_revision": "7cd3c28bd74998a7a0dc012269d5166ee9cb59f96dfec9fdb2a4c61209f78323" + }, + { + "asset_id": "sync-opencode-from-cc", + "source_revision": "e6afc76afeb41073a7acad7f73739b1fba3234de1931d18a91cdf96d6871671e" + }, + { + "asset_id": "sync-reconcile", + "source_revision": "ce16daf7d2f880b2d24f413120c0af471c2ee9f31dad50ba550cd60d8e670635" + }, + { + "asset_id": "typinator-prompt-check", + "source_revision": "e1eda58bd6f344be73c6dde3ca077702ab5599d78bcdc817d8ca6f81a9239059" + } + ], + "client": "antigravity", + "content_digest": "sha256:40a5ecb0799ba5fe42193d457eefec8eac73e744178c1deb8c265251c5e86d9e", + "kind": "command", + "materialization_digest": "sha256:994aa8fb3c00ad7deaced4566f1a08f3414d3287e751cc5a660244728b1fe0a2", + "probe": "fresh-command-discovery", + "source_revision": "sha256:f262e701cd117ebfc294b96e40580eab67d29d903dc416e095b7fe92a6a76594", + "symlink_policy": "forbidden", + "target": ".agent/workflows", + "writer_id": "sync-project-commands", + "writer_version": "sha256:fbf5d36c472ac0ec7d506e7bf6a2ef717ab0e0ea3e53d6e2e73ad6d0a0bb1ab0" + }, + { + "asset_revisions": [ + { + "asset_id": "agents-md", + "source_revision": "026ba32dd8315d9193477efa84574f7516ac46b31da3415a817cbaaf53ffc91a" + }, + { + "asset_id": "claude-md", + "source_revision": "2b9ab1966be6b5a37b99e9601448535ba11f6c619c0b66f9601c63a4e1e51331" + }, + { + "asset_id": "gbrain-md", + "source_revision": "17f3ff14b835d5d1930b190b294c299f9904dff3a4aba13b4c3e88e7199300a1" + }, + { + "asset_id": "gemini-md", + "source_revision": "ccc69bc96b91498eeab2c076aa8ac75aa85d9c3d3892d30d021356e56d689078" + } + ], + "client": "antigravity", + "content_digest": "sha256:011b8d16a8c32bf0cd72f82978afabec826ec621565739912e1cf4c867fa2d59", + "kind": "constitution", + "materialization_digest": "sha256:cd22c2beaf219f864c344ec60c69b6260f264aaec59ebfd198318f815d724c42", + "probe": "fresh-instruction-discovery", + "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", + "symlink_policy": "forbidden", + "target": "GEMINI.md", + "writer_id": "sync-antigravity-from-cc", + "writer_version": "sha256:241caef00d7cbaeb9bef6e3c52ff35d56077ef23ae39b753a8ef34631f805f44" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "antigravity", + "content_digest": "sha256:7cd0521c9566755b20f2d6419db8b5ffad0afea5cc367dcf773882cf1817ed56", + "kind": "hook", + "materialization_digest": "sha256:2c223cda4b0f9d51d9f646c395b74d78da12be4f02d3fe2513a579ea33695098", + "probe": "safe-hook-fixture", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".gemini/hooks", + "writer_id": "sync-antigravity-from-cc", + "writer_version": "sha256:241caef00d7cbaeb9bef6e3c52ff35d56077ef23ae39b753a8ef34631f805f44" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "antigravity", + "content_digest": "sha256:0ef03123354a948ba6004238db9a72add0d38f45982e2022f2278932ccf678d2", + "kind": "hook", + "materialization_digest": "sha256:d10a63b68ab9ab67aab3e7bf3dbee35f718eda3b478c6198d7837e6a43a75df3", + "probe": "stable-client-config", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".gemini/settings.json", + "writer_id": "sync-antigravity-from-cc", + "writer_version": "sha256:241caef00d7cbaeb9bef6e3c52ff35d56077ef23ae39b753a8ef34631f805f44" + }, + { + "asset_revisions": [ + { + "asset_id": "agentmemory-agentmemory", + "source_revision": "c90c9306c67e8b8e0b6727022a534610aef60c5ea3ba77a50278ae228de37e79" + }, + { + "asset_id": "ai-worker-mcp", + "source_revision": "61d86bd651b69d2860e98ec47bfb287f85125c67d5feba2fdeda655f0d132248" + }, + { + "asset_id": "codebase-context-engine-agentmemory", + "source_revision": "169cc62b154ca0997a376701dd0d08a0df3e7651ab86f346c73b960a1c1b08e0" + }, + { + "asset_id": "context7", + "source_revision": "579c97f2c4323321f2e4920f12e985bccfb565ddb2505211d2e520128871ca89" + }, + { + "asset_id": "shintaro-gbrain", + "source_revision": "720bd8b73e21854541086b91dc19e43c431b8c06b73ce84587c80af02b8cb394" + }, + { + "asset_id": "stitch", + "source_revision": "26bc55af145c09fc6ccf24e6fd9738baf331fbca7bc210f7dbd35444a6639559" + }, + { + "asset_id": "tech-gbrain", + "source_revision": "41e5b97c53d1dec100f4d78ea7d9532c81a3daf8f80c18181c59242972c227fc" + } + ], + "client": "antigravity", + "content_digest": "sha256:0ef03123354a948ba6004238db9a72add0d38f45982e2022f2278932ccf678d2", + "kind": "mcp", + "materialization_digest": "sha256:94adbe556c76796603a4fda7abf48484b973a23212deb47143f62c1c34ef90af", + "probe": "mcp-initialize-tools-list", + "source_revision": "sha256:7c73bf290e8e9272183652fea348c04b5200cffcbd655b67e56c953d53fb07ac", + "symlink_policy": "forbidden", + "target": ".gemini/settings.json", + "writer_id": "sync-antigravity-from-cc", + "writer_version": "sha256:241caef00d7cbaeb9bef6e3c52ff35d56077ef23ae39b753a8ef34631f805f44" + }, + { + "asset_revisions": [ + { + "asset_id": "ai-model-selection", + "source_revision": "0205f80f354e153bc240cac68db2bb607e4d18ed367983efab63d1aa52ec056a" + }, + { + "asset_id": "branch-rule", + "source_revision": "138c6646648abacb3ec607e9453a3ece796efc2fa2465849f0d5822e75216ba3" + }, + { + "asset_id": "constructive-dissent", + "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" + }, + { + "asset_id": "hooks-structure-rule", + "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" + }, + { + "asset_id": "latest-stack-context7", + "source_revision": "4832808009de0fe9b7866ad4916ebeebe60969b2a88bd097cc6df477f0ee3c74" + }, + { + "asset_id": "mandate-registry", + "source_revision": "9a96532c78b028b91958d09150d2902935d083fec5b2eb61ac87c9f36c89c080" + }, + { + "asset_id": "mcp-key-management", + "source_revision": "0a684743271ef7127923312dd62a53e0fdde03d73abc5361764f033a89eab246" + }, + { + "asset_id": "memory-lookups", + "source_revision": "4db569b053c02df2c4b1758b94770ab05a089082495a865a5e3033abb2aa50d6" + }, + { + "asset_id": "plan-approval-gate", + "source_revision": "d5f5b17d45d2855807f5267cea6f94c21b2782546e02bab9c97f04d2d490b5ec" + }, + { + "asset_id": "plan-commitment-tracking", + "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + }, + { + "asset_id": "reference-over-hardcode", + "source_revision": "e5b5404290509541726651c79906a629942a55f3e4864e02db1a1c81a370e7ec" + }, + { + "asset_id": "response-style", + "source_revision": "0392ece7de1d0b5335269d4fb98d96af9890acccfeb35891797110e33d64ca3f" + }, + { + "asset_id": "responsive-both-viewports", + "source_revision": "c7be824deb9960711360bafb24e1d868f3c5d698e3dfd985c1c32b248594c357" + }, + { + "asset_id": "settings-protection-coexistence", + "source_revision": "125465ad3abc97fdd1ab513274ff4b72b20be2888afe5ebe3f9df64178378cfd" + }, + { + "asset_id": "sub-agent-scope-contract", + "source_revision": "fe3ff9fb58c105ce1a423feedd1ddbc52f85319a02252a905429988c2f77391d" + }, + { + "asset_id": "ui-stitch-mandatory", + "source_revision": "1ec7a6117c056bf8e1920330ccb2316d57617d3ba4928e7ac85ea7e48ff99eba" + }, + { + "asset_id": "visual-progress-map", + "source_revision": "4b433438661e45bf858a847081d1383980543d440d44845e162500ae418f4fb6" + }, + { + "asset_id": "worktree-rule", + "source_revision": "e4976caa636d23a7459a2610bf396feac476385008cab8c20cafa6842b0ef4e5" + } + ], + "client": "antigravity", + "content_digest": "sha256:2bd73eec63e653b80bfe425511d70fd05602c1bd946a458747e23be5967f698b", + "kind": "rule", + "materialization_digest": "sha256:68cde6a6f68b5d5178ee997f7212490a3ced1f8d72c7bb6c7128c81903913c94", + "probe": "fresh-rule-discovery", + "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "symlink_policy": "forbidden", + "target": ".agent/rules", + "writer_id": "sync-antigravity-from-cc", + "writer_version": "sha256:241caef00d7cbaeb9bef6e3c52ff35d56077ef23ae39b753a8ef34631f805f44" + }, + { + "asset_revisions": [ + { + "asset_id": "backend-architect", + "source_revision": "a239f150fc7bb802f6e4a778591ac95697eaf03c8e321571096b82ab103b0955" + }, + { + "asset_id": "backend-developer", + "source_revision": "c8290052bc6f0109d1a924ae4d44158fa4a90370a9dc97a017a828e8dd478687" + }, + { + "asset_id": "chatgpt-image-creator", + "source_revision": "b7367778a8cc1e6cea1163f865915b259633ff4a9ec0a70b5ea1f2e6cec49120" + }, + { + "asset_id": "document-writer", + "source_revision": "29735c440814ea7680bb14a9e4fa06c023f63ddf13cb806d1a5160f6e46ea109" + }, + { + "asset_id": "frontend-developer", + "source_revision": "7a5df8a88b009790d644939d824ab5fe0b00f78e2ac4dd0f5e63a81a18332fec" + }, + { + "asset_id": "qa-reviewer", + "source_revision": "1a7adf79ce2533a289f953d65b25c49cbb1a6927d3e7d983d008ebfd4a4d335a" + }, + { + "asset_id": "quality-engineer", + "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" + }, + { + "asset_id": "stitch-screen-creator", + "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" + }, + { + "asset_id": "technical-writer", + "source_revision": "9f2bfbb6dcf1b2827af21e577c7642895c4e0e2ba0e95eb8298a48c7300350f6" + }, + { + "asset_id": "templates/implementation-auditor", + "source_revision": "9fe2d3ecefbdaf1a8f03b2afb2e7c357701d77330363d84fd929a80970d5e6a6" + }, + { + "asset_id": "test-runner", + "source_revision": "47219a9ea2b442f4c7fd8e6c63d0554ae6db41cddd7b15920dcb603ec87973d2" + } + ], + "client": "antigravity", + "content_digest": "sha256:2599abb455d1f16d1cfa1b341e92289fcaf5378483ce8717722b06ee2bac8c70", + "kind": "subagent", + "materialization_digest": "sha256:b3ec8d58fa654536c726c8d9af96cb9243f2664cd9881c2d1639a496a2384880", + "probe": "fresh-agent-discovery", + "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "symlink_policy": "forbidden", + "target": ".gemini/agents", + "writer_id": "sync-antigravity-from-cc", + "writer_version": "sha256:241caef00d7cbaeb9bef6e3c52ff35d56077ef23ae39b753a8ef34631f805f44" + }, + { + "asset_revisions": [ + { + "asset_id": "antigravity", + "source_revision": "bcaed648876d9d4586466ccf5b236875d36ee62c3ef5b04fd3c0d9a1237f04e7" + }, + { + "asset_id": "claude", + "source_revision": "09530a052046fdca9046f56ae8bed4b4efbb4bb8ca52880e96c45eab60ef4736" + }, + { + "asset_id": "codex", + "source_revision": "c14e66b00ebfbaabd91a71c2f18d215c9224e7fd88900bc8468d65033aaac2ab" + }, + { + "asset_id": "cursor", + "source_revision": "20598fb9822e208075c234a5c4c8a9437e21c51dc1ba4bec87d20af43951bea1" + }, + { + "asset_id": "kimi", + "source_revision": "de404fbd95a41e4edef577673fd432ea78f10959d464f8ba77d26c73758e28d8" + }, + { + "asset_id": "opencode", + "source_revision": "ee4333fd657cfc51766289e4064241151636f6475bc15f4e7184a799215849d0" + }, + { + "asset_id": "warp", + "source_revision": "6d4d9867e18c1f6624ebe8a45131992dd57770c258ef85b1520072c2060ed679" + } + ], + "client": "claude", + "content_digest": "sha256:c7e89ef03177632214dc388d0cabff9cd974baf550325f8ed907ead571ba96e7", + "kind": "ai_client", + "materialization_digest": "sha256:df80fbc403cab512032ad6bc7a6046ac5089435a1fc1b31829f8842556f0828b", + "probe": "generated-surface-ignore", + "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", + "symlink_policy": "forbidden", + "target": ".gitignore", + "writer_id": "sync-harness-gitignore", + "writer_version": "sha256:838fef4c19d3ecd10da777b71cb8aa90343b73dc44acdc672015191d34def732" + }, + { + "asset_revisions": [ + { + "asset_id": "agent-publish", + "source_revision": "3f75ea730a21f1877715be1b76cd493815f7a5b28461ed7284225cb66066ea1f" + }, + { + "asset_id": "attach", + "source_revision": "8687b2fa81a92f40c3636526d5138d286b9ceec7e769d0f9e20214637d99847c" + }, + { + "asset_id": "audit-agents", + "source_revision": "2a043eb72d458bba50a845f87853a0e494d3f83bfa3cebc5fa3cb4115271dc04" + }, + { + "asset_id": "brainstorm", + "source_revision": "9aea19bd88594cdb4aaadbfdfc77e96e576364099f674b623035d1bdfb03a3e9" + }, + { + "asset_id": "command-check", + "source_revision": "0e5f497a545788e2d8f9da6b4209753a5559cc55d1123069309fb11787e01210" + }, + { + "asset_id": "command-publish", + "source_revision": "b7036f59ce6c7677996030c2a5cc2d8ab665b695ec6677c25cf50ef5553c3b50" + }, + { + "asset_id": "detach", + "source_revision": "262ec4b2d493919280325b007b437f66c90bef3ce0aef7adfe92e7b1e97ded18" + }, + { + "asset_id": "document", + "source_revision": "16b04f6bc35e1fe208036a999af17fb59ee7492e9967a8cd5e4a0be679d08ba6" + }, + { + "asset_id": "explain", + "source_revision": "63cc6279c9d16dd8b6891d9dbf746023643ff21b6db24796e52e6fbf33d2ef08" + }, + { + "asset_id": "new-project", + "source_revision": "aaa7a120691172be985ef7c55a7e9b43f603650ae91df15d5be6d49eae7b623d" + }, + { + "asset_id": "parallel-run", + "source_revision": "eecc9409e9091b758023d0e5d0dc07250bc190635adf74b46dc75e383d0ae7a4" + }, + { + "asset_id": "publish-deploy", + "source_revision": "2bf9b70f871f1821859a211efd9d3a83daf5d9db39a300b4eba20a05ddc1d12c" + }, + { + "asset_id": "rules-publish", + "source_revision": "5fa9927079d380209e7afbc80a6de6e114cbc71acf84eb50155361d4f895c666" + }, + { + "asset_id": "set-agent-hub-secret", + "source_revision": "7a4371df9d48e1398b0f6afd2f2f2ca75e3c8e146c279f8abb0a24fbb50eeccf" + }, + { + "asset_id": "skill-check", + "source_revision": "981f07479b24d412b297dede7b8002250cbf28b9a7a723d894dec7560148db07" + }, + { + "asset_id": "skill-publish", + "source_revision": "42ffb47268cab24ce6e10ddd8a4b6ce3aa6f2c98a00c6d77d3fd7c701818d767" + }, + { + "asset_id": "skill-selector", + "source_revision": "bdca03ebf69a64e346dc5d4dedf360a486bc49ac25cac14a7461687999102cc5" + }, + { + "asset_id": "skills-publish", + "source_revision": "d01c9d85b8cd3749754f1b6e474dd12a46ae987869902e61d7265dac79b56429" + }, + { + "asset_id": "sync-agents", + "source_revision": "ecb86170609c8100868cb1d619e2e32f6a27093f9edb42d903ae44d51a8d0887" + }, + { + "asset_id": "sync-antigravity-from-cc", + "source_revision": "f465978577529c777d9ad85117164cc2b046addb4effc821c2f34c1264005b6e" + }, + { + "asset_id": "sync-codex-from-cc", + "source_revision": "338a2ed245bf14718204237c8d597a6ae1c5de38a09c8269dec782ad021943e9" + }, + { + "asset_id": "sync-cursor-from-cc", + "source_revision": "7cd3c28bd74998a7a0dc012269d5166ee9cb59f96dfec9fdb2a4c61209f78323" + }, + { + "asset_id": "sync-opencode-from-cc", + "source_revision": "e6afc76afeb41073a7acad7f73739b1fba3234de1931d18a91cdf96d6871671e" + }, + { + "asset_id": "sync-reconcile", + "source_revision": "ce16daf7d2f880b2d24f413120c0af471c2ee9f31dad50ba550cd60d8e670635" + }, + { + "asset_id": "typinator-prompt-check", + "source_revision": "e1eda58bd6f344be73c6dde3ca077702ab5599d78bcdc817d8ca6f81a9239059" + } + ], + "client": "claude", + "content_digest": "sha256:40a5ecb0799ba5fe42193d457eefec8eac73e744178c1deb8c265251c5e86d9e", + "kind": "command", + "materialization_digest": "sha256:05faafd9993eef37a7cee88c6f1c5c7c6631ad6f4367a8f211e053c5a911acee", + "probe": "fresh-command-discovery", + "source_revision": "sha256:f262e701cd117ebfc294b96e40580eab67d29d903dc416e095b7fe92a6a76594", + "symlink_policy": "forbidden", + "target": ".claude/commands", + "writer_id": "sync-project-commands", + "writer_version": "sha256:fbf5d36c472ac0ec7d506e7bf6a2ef717ab0e0ea3e53d6e2e73ad6d0a0bb1ab0" + }, + { + "asset_revisions": [ + { + "asset_id": "agents-md", + "source_revision": "026ba32dd8315d9193477efa84574f7516ac46b31da3415a817cbaaf53ffc91a" + }, + { + "asset_id": "claude-md", + "source_revision": "2b9ab1966be6b5a37b99e9601448535ba11f6c619c0b66f9601c63a4e1e51331" + }, + { + "asset_id": "gbrain-md", + "source_revision": "17f3ff14b835d5d1930b190b294c299f9904dff3a4aba13b4c3e88e7199300a1" + }, + { + "asset_id": "gemini-md", + "source_revision": "ccc69bc96b91498eeab2c076aa8ac75aa85d9c3d3892d30d021356e56d689078" + } + ], + "client": "claude", + "content_digest": "sha256:fd92da11f875d8f87bc062bac69ec0510f5b45b24d658e400bc0acb759ca2431", + "kind": "constitution", + "materialization_digest": "sha256:c0c417626020c9e02be813ceb29e64d68195d624439003ed56d184de4328e11d", + "probe": "fresh-instruction-discovery", + "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", + "symlink_policy": "forbidden", + "target": "CLAUDE.md", + "writer_id": "generate-project-constitution", + "writer_version": "sha256:a6c7122a992869f2b3e47f0a3dab5b9ebd4df28366632e4cf90a7625e5a6fa93" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "claude", + "content_digest": "sha256:263676b5ea2c822fe6b1161d43d19e408de0d6bad5cf30134dddfc7e0d77749a", + "kind": "hook", + "materialization_digest": "sha256:20aee19d4ca96ef4229ac238534360e34d92a34cdcf5d970000577d696d9847b", + "probe": "safe-hook-fixture", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".claude/hooks", + "writer_id": "deploy-hooks", + "writer_version": "sha256:33cd4aacd83aa8f3346169149cc92016e4a5b062e9ac25bacada8c4c76f2cffa" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "claude", + "content_digest": "sha256:8df8eeeb827e48739e3feca687a85d37cdde5e79fc47db58b9734b8f0f8e0936", + "kind": "hook", + "materialization_digest": "sha256:a11154e79a29f7a0ae2e96d768d8c87269b7e764376ac556b92ec7a6d9ad1805", + "probe": "safe-hook-fixture", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".claude/settings.json", + "writer_id": "deploy-hooks", + "writer_version": "sha256:33cd4aacd83aa8f3346169149cc92016e4a5b062e9ac25bacada8c4c76f2cffa" + }, + { + "asset_revisions": [ + { + "asset_id": "agentmemory-agentmemory", + "source_revision": "c90c9306c67e8b8e0b6727022a534610aef60c5ea3ba77a50278ae228de37e79" + }, + { + "asset_id": "ai-worker-mcp", + "source_revision": "61d86bd651b69d2860e98ec47bfb287f85125c67d5feba2fdeda655f0d132248" + }, + { + "asset_id": "codebase-context-engine-agentmemory", + "source_revision": "169cc62b154ca0997a376701dd0d08a0df3e7651ab86f346c73b960a1c1b08e0" + }, + { + "asset_id": "context7", + "source_revision": "579c97f2c4323321f2e4920f12e985bccfb565ddb2505211d2e520128871ca89" + }, + { + "asset_id": "shintaro-gbrain", + "source_revision": "720bd8b73e21854541086b91dc19e43c431b8c06b73ce84587c80af02b8cb394" + }, + { + "asset_id": "stitch", + "source_revision": "26bc55af145c09fc6ccf24e6fd9738baf331fbca7bc210f7dbd35444a6639559" + }, + { + "asset_id": "tech-gbrain", + "source_revision": "41e5b97c53d1dec100f4d78ea7d9532c81a3daf8f80c18181c59242972c227fc" + } + ], + "client": "claude", + "content_digest": "sha256:00c6634b321abc7c0fdeb6dae64c656288ed50a11b06991aa9fd06f4e183f105", + "kind": "mcp", + "materialization_digest": "sha256:4eb8437f2296ab6c83250c3e041720a68f4bb3d3d0ab3a3c1fb440e7ba563b50", + "probe": "mcp-initialize-tools-list", + "source_revision": "sha256:7c73bf290e8e9272183652fea348c04b5200cffcbd655b67e56c953d53fb07ac", + "symlink_policy": "preserve-entry-no-follow", + "target": ".mcp.json", + "writer_id": "sync-claude-project-mcp", + "writer_version": "sha256:7d98ac0b6a47586b782e93f4df4a6a750f35029d6d1df3b00f3d2da5102f5723" + }, + { + "asset_revisions": [ + { + "asset_id": "ai-model-selection", + "source_revision": "0205f80f354e153bc240cac68db2bb607e4d18ed367983efab63d1aa52ec056a" + }, + { + "asset_id": "branch-rule", + "source_revision": "138c6646648abacb3ec607e9453a3ece796efc2fa2465849f0d5822e75216ba3" + }, + { + "asset_id": "constructive-dissent", + "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" + }, + { + "asset_id": "hooks-structure-rule", + "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" + }, + { + "asset_id": "latest-stack-context7", + "source_revision": "4832808009de0fe9b7866ad4916ebeebe60969b2a88bd097cc6df477f0ee3c74" + }, + { + "asset_id": "mandate-registry", + "source_revision": "9a96532c78b028b91958d09150d2902935d083fec5b2eb61ac87c9f36c89c080" + }, + { + "asset_id": "mcp-key-management", + "source_revision": "0a684743271ef7127923312dd62a53e0fdde03d73abc5361764f033a89eab246" + }, + { + "asset_id": "memory-lookups", + "source_revision": "4db569b053c02df2c4b1758b94770ab05a089082495a865a5e3033abb2aa50d6" + }, + { + "asset_id": "plan-approval-gate", + "source_revision": "d5f5b17d45d2855807f5267cea6f94c21b2782546e02bab9c97f04d2d490b5ec" + }, + { + "asset_id": "plan-commitment-tracking", + "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + }, + { + "asset_id": "reference-over-hardcode", + "source_revision": "e5b5404290509541726651c79906a629942a55f3e4864e02db1a1c81a370e7ec" + }, + { + "asset_id": "response-style", + "source_revision": "0392ece7de1d0b5335269d4fb98d96af9890acccfeb35891797110e33d64ca3f" + }, + { + "asset_id": "responsive-both-viewports", + "source_revision": "c7be824deb9960711360bafb24e1d868f3c5d698e3dfd985c1c32b248594c357" + }, + { + "asset_id": "settings-protection-coexistence", + "source_revision": "125465ad3abc97fdd1ab513274ff4b72b20be2888afe5ebe3f9df64178378cfd" + }, + { + "asset_id": "sub-agent-scope-contract", + "source_revision": "fe3ff9fb58c105ce1a423feedd1ddbc52f85319a02252a905429988c2f77391d" + }, + { + "asset_id": "ui-stitch-mandatory", + "source_revision": "1ec7a6117c056bf8e1920330ccb2316d57617d3ba4928e7ac85ea7e48ff99eba" + }, + { + "asset_id": "visual-progress-map", + "source_revision": "4b433438661e45bf858a847081d1383980543d440d44845e162500ae418f4fb6" + }, + { + "asset_id": "worktree-rule", + "source_revision": "e4976caa636d23a7459a2610bf396feac476385008cab8c20cafa6842b0ef4e5" + } + ], + "client": "claude", + "content_digest": "sha256:7508e5a2f7dd28d6da810444fb654b8ec84ece7d2e7f8fb9e5658138e4497ad9", + "kind": "rule", + "materialization_digest": "sha256:437a488a5d5329ec66d51fb5e3c5aa0ee41983161c2517562a161860f6c89fde", + "probe": "fresh-rule-discovery", + "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "symlink_policy": "forbidden", + "target": ".claude/rules", + "writer_id": "deploy-rules", + "writer_version": "sha256:ac36008bc140ef0e5604e4f88a27961da8f7983c1a6a9647144946436b69379e" + }, + { + "asset_revisions": [ + { + "asset_id": "adversarial-review", + "source_revision": "ce41fe079117d6e6bb2a77566a5d25d239dc2386b7e1f31a62368a91899bbcdd" + }, + { + "asset_id": "agent-dispatch", + "source_revision": "68209f182ae0d3da281c1600c2d532fe151d02696a4f4ebcfe106ad25282b109" + }, + { + "asset_id": "agentmemory-routing", + "source_revision": "3c477764f40da2dcf8a7219227113e67a81c9697d2152b05ab35cfe6ef4ac9ec" + }, + { + "asset_id": "ai-project-rules", + "source_revision": "859192d9a501e9d942c457a42cfd7886fae01007c4a25b5e7c809d31a6b463fc" + }, + { + "asset_id": "cc-context-optimizer", + "source_revision": "de26de606d14cc0b8b2336432f28f9b248744921d9faa8d3809bb39bed7b46fb" + }, + { + "asset_id": "ci-credit-optimizer", + "source_revision": "18f11db5f67e20a16a1773e46a4e69e2baaca12a98fef71d0bdf2bb8891ff643" + }, + { + "asset_id": "codex-review", + "source_revision": "c44b6a3175b19296ab8aa292b4672047b5a937a55c02d55cc3ea7d5089eeac84" + }, + { + "asset_id": "create-pr", + "source_revision": "a9b91d159d309d8b626595896bc645f1a565f7c6a1d4cc082bfd7283167d1c16" + }, + { + "asset_id": "dev-guardrails", + "source_revision": "6652989322196ed82cd61c36eae2b8bbfef365b35270237620fbb069a3932141" + }, + { + "asset_id": "handover-manual", + "source_revision": "fa229b12cbd3e0b5fd2015808d30b75612e9f5a4c7da267ad29db2893980f05b" + }, + { + "asset_id": "mcp-dev-kit", + "source_revision": "b9eb97460ed5be2c799b3c8bff54825efb3422f4db43652a2ce466a6075c5f4f" + }, + { + "asset_id": "plan-approval", + "source_revision": "ccca7f1eb7f228d6df67223bb5895f05f1f73f08c1e23e7edf40f0a0345ce419" + }, + { + "asset_id": "post-merge", + "source_revision": "713daf4a34a279b6296b1e721a4637152531ec8c61bc094b38d2754491f6b9b4" + }, + { + "asset_id": "skill-audit", + "source_revision": "89cf836efafe59ec44edf055866ff3887374658c55998f23362f08947d17cddc" + } + ], + "client": "claude", + "content_digest": "sha256:39a906d45df71bdab9d60f23d9deb60ac610391ea261327ab175c924ac46ceba", + "kind": "skill", + "materialization_digest": "sha256:fe03997f1274f0da22cefcf9c9df582f2dca017649cf69212db503cda5c7e6e3", + "probe": "fresh-skill-discovery", + "source_revision": "sha256:b583437e2d84e00e897e359fa84f1b69c6b7b620c5ea37f2f6b0ed3ef7e03b5b", + "symlink_policy": "forbidden", + "target": ".claude/skills", + "writer_id": "sync-runtime-skills", + "writer_version": "sha256:e65ecb05fa99f9b44e5fd68ef5ae29027677f0b1de7ce6a90e4dc48263475d5f" + }, + { + "asset_revisions": [ + { + "asset_id": "backend-architect", + "source_revision": "a239f150fc7bb802f6e4a778591ac95697eaf03c8e321571096b82ab103b0955" + }, + { + "asset_id": "backend-developer", + "source_revision": "c8290052bc6f0109d1a924ae4d44158fa4a90370a9dc97a017a828e8dd478687" + }, + { + "asset_id": "chatgpt-image-creator", + "source_revision": "b7367778a8cc1e6cea1163f865915b259633ff4a9ec0a70b5ea1f2e6cec49120" + }, + { + "asset_id": "document-writer", + "source_revision": "29735c440814ea7680bb14a9e4fa06c023f63ddf13cb806d1a5160f6e46ea109" + }, + { + "asset_id": "frontend-developer", + "source_revision": "7a5df8a88b009790d644939d824ab5fe0b00f78e2ac4dd0f5e63a81a18332fec" + }, + { + "asset_id": "qa-reviewer", + "source_revision": "1a7adf79ce2533a289f953d65b25c49cbb1a6927d3e7d983d008ebfd4a4d335a" + }, + { + "asset_id": "quality-engineer", + "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" + }, + { + "asset_id": "stitch-screen-creator", + "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" + }, + { + "asset_id": "technical-writer", + "source_revision": "9f2bfbb6dcf1b2827af21e577c7642895c4e0e2ba0e95eb8298a48c7300350f6" + }, + { + "asset_id": "templates/implementation-auditor", + "source_revision": "9fe2d3ecefbdaf1a8f03b2afb2e7c357701d77330363d84fd929a80970d5e6a6" + }, + { + "asset_id": "test-runner", + "source_revision": "47219a9ea2b442f4c7fd8e6c63d0554ae6db41cddd7b15920dcb603ec87973d2" + } + ], + "client": "claude", + "content_digest": "sha256:ada572493c395b78b110b22a1059e49d98f7de834951bd27b09add91f728a48a", + "kind": "subagent", + "materialization_digest": "sha256:efe40478647ecf4a768ca7564c8d0119a61e42f9b16b66e2a476b55e725f412a", + "probe": "fresh-agent-discovery", + "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "symlink_policy": "forbidden", + "target": ".claude/agents", + "writer_id": "harness-link-planner", + "writer_version": "sha256:c1793eb413bc5b90b62a96017a8dfdaba7057c7beee3d7c20277a7ca04eb8d92" + }, + { + "asset_revisions": [ + { + "asset_id": "antigravity", + "source_revision": "bcaed648876d9d4586466ccf5b236875d36ee62c3ef5b04fd3c0d9a1237f04e7" + }, + { + "asset_id": "claude", + "source_revision": "09530a052046fdca9046f56ae8bed4b4efbb4bb8ca52880e96c45eab60ef4736" + }, + { + "asset_id": "codex", + "source_revision": "c14e66b00ebfbaabd91a71c2f18d215c9224e7fd88900bc8468d65033aaac2ab" + }, + { + "asset_id": "cursor", + "source_revision": "20598fb9822e208075c234a5c4c8a9437e21c51dc1ba4bec87d20af43951bea1" + }, + { + "asset_id": "kimi", + "source_revision": "de404fbd95a41e4edef577673fd432ea78f10959d464f8ba77d26c73758e28d8" + }, + { + "asset_id": "opencode", + "source_revision": "ee4333fd657cfc51766289e4064241151636f6475bc15f4e7184a799215849d0" + }, + { + "asset_id": "warp", + "source_revision": "6d4d9867e18c1f6624ebe8a45131992dd57770c258ef85b1520072c2060ed679" + } + ], + "client": "codex", + "content_digest": "sha256:2aff514f55d6d1c8af6904fca45404ea5b4a5053368656d6879c643d08f3a799", + "kind": "ai_client", + "materialization_digest": "sha256:4c829882ce95a3e98c902829e23823de3b5116e338cfa0e2240209f3a7b8108a", + "probe": "stable-sync-state", + "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", + "symlink_policy": "forbidden", + "target": ".codex/sync-state.json", + "writer_id": "sync-codex-mcp-configs", + "writer_version": "sha256:03066899e421637f59278722a811fdc135fc433313451d5671731726f48790a4" + }, + { + "asset_revisions": [ + { + "asset_id": "agents-md", + "source_revision": "026ba32dd8315d9193477efa84574f7516ac46b31da3415a817cbaaf53ffc91a" + }, + { + "asset_id": "claude-md", + "source_revision": "2b9ab1966be6b5a37b99e9601448535ba11f6c619c0b66f9601c63a4e1e51331" + }, + { + "asset_id": "gbrain-md", + "source_revision": "17f3ff14b835d5d1930b190b294c299f9904dff3a4aba13b4c3e88e7199300a1" + }, + { + "asset_id": "gemini-md", + "source_revision": "ccc69bc96b91498eeab2c076aa8ac75aa85d9c3d3892d30d021356e56d689078" + } + ], + "client": "codex", + "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "kind": "constitution", + "materialization_digest": "sha256:dd8b68d8fc567db7bcf0167347d3d40d047020b5154d2084c8cb14982a0ae0de", + "probe": "fresh-instruction-discovery", + "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", + "symlink_policy": "forbidden", + "target": "AGENTS.md", + "writer_id": "generate-agents-md", + "writer_version": "sha256:42b4458121a39cbbbb72351a563b390a06c3453e8aa438998e768677935294da" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "codex", + "content_digest": "sha256:14b4e69dd6e8ed85abb0895f5cf3a0f8f1c7aea6a657677bbcc1822a0b103b49", + "kind": "hook", + "materialization_digest": "sha256:e56136f3f58f846b8b42a4dc68a73e68db05f65fb57b2955e057f65200680e83", + "probe": "safe-hook-fixture", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".codex/hooks", + "writer_id": "deploy-hooks", + "writer_version": "sha256:33cd4aacd83aa8f3346169149cc92016e4a5b062e9ac25bacada8c4c76f2cffa" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "codex", + "content_digest": "sha256:482c2947686b93254b0b6a90130b5d8f2d19efb2d561dbb5b06c26aedc4674b1", + "kind": "hook", + "materialization_digest": "sha256:6df5a0a6f5776c0a42aa007a98681be1195a2d5fd9cafab0fb10527c351e259a", + "probe": "safe-hook-fixture", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".codex/hooks.json", + "writer_id": "deploy-hooks", + "writer_version": "sha256:33cd4aacd83aa8f3346169149cc92016e4a5b062e9ac25bacada8c4c76f2cffa" + }, + { + "asset_revisions": [ + { + "asset_id": "agentmemory-agentmemory", + "source_revision": "c90c9306c67e8b8e0b6727022a534610aef60c5ea3ba77a50278ae228de37e79" + }, + { + "asset_id": "ai-worker-mcp", + "source_revision": "61d86bd651b69d2860e98ec47bfb287f85125c67d5feba2fdeda655f0d132248" + }, + { + "asset_id": "codebase-context-engine-agentmemory", + "source_revision": "169cc62b154ca0997a376701dd0d08a0df3e7651ab86f346c73b960a1c1b08e0" + }, + { + "asset_id": "context7", + "source_revision": "579c97f2c4323321f2e4920f12e985bccfb565ddb2505211d2e520128871ca89" + }, + { + "asset_id": "shintaro-gbrain", + "source_revision": "720bd8b73e21854541086b91dc19e43c431b8c06b73ce84587c80af02b8cb394" + }, + { + "asset_id": "stitch", + "source_revision": "26bc55af145c09fc6ccf24e6fd9738baf331fbca7bc210f7dbd35444a6639559" + }, + { + "asset_id": "tech-gbrain", + "source_revision": "41e5b97c53d1dec100f4d78ea7d9532c81a3daf8f80c18181c59242972c227fc" + } + ], + "client": "codex", + "content_digest": "sha256:9fd93e00c7304cee94fcca376a0aed418ed00ea0a55f52b6428c926244f51503", + "kind": "mcp", + "materialization_digest": "sha256:fdeca470dc6cf0dd067550554a5dc9062f8b857f9d7d34f0a7fbd73c4f82c479", + "probe": "mcp-initialize-tools-list", + "source_revision": "sha256:7c73bf290e8e9272183652fea348c04b5200cffcbd655b67e56c953d53fb07ac", + "symlink_policy": "forbidden", + "target": ".codex/config.toml", + "writer_id": "sync-codex-mcp-configs", + "writer_version": "sha256:03066899e421637f59278722a811fdc135fc433313451d5671731726f48790a4" + }, + { + "asset_revisions": [ + { + "asset_id": "ai-model-selection", + "source_revision": "0205f80f354e153bc240cac68db2bb607e4d18ed367983efab63d1aa52ec056a" + }, + { + "asset_id": "branch-rule", + "source_revision": "138c6646648abacb3ec607e9453a3ece796efc2fa2465849f0d5822e75216ba3" + }, + { + "asset_id": "constructive-dissent", + "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" + }, + { + "asset_id": "hooks-structure-rule", + "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" + }, + { + "asset_id": "latest-stack-context7", + "source_revision": "4832808009de0fe9b7866ad4916ebeebe60969b2a88bd097cc6df477f0ee3c74" + }, + { + "asset_id": "mandate-registry", + "source_revision": "9a96532c78b028b91958d09150d2902935d083fec5b2eb61ac87c9f36c89c080" + }, + { + "asset_id": "mcp-key-management", + "source_revision": "0a684743271ef7127923312dd62a53e0fdde03d73abc5361764f033a89eab246" + }, + { + "asset_id": "memory-lookups", + "source_revision": "4db569b053c02df2c4b1758b94770ab05a089082495a865a5e3033abb2aa50d6" + }, + { + "asset_id": "plan-approval-gate", + "source_revision": "d5f5b17d45d2855807f5267cea6f94c21b2782546e02bab9c97f04d2d490b5ec" + }, + { + "asset_id": "plan-commitment-tracking", + "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + }, + { + "asset_id": "reference-over-hardcode", + "source_revision": "e5b5404290509541726651c79906a629942a55f3e4864e02db1a1c81a370e7ec" + }, + { + "asset_id": "response-style", + "source_revision": "0392ece7de1d0b5335269d4fb98d96af9890acccfeb35891797110e33d64ca3f" + }, + { + "asset_id": "responsive-both-viewports", + "source_revision": "c7be824deb9960711360bafb24e1d868f3c5d698e3dfd985c1c32b248594c357" + }, + { + "asset_id": "settings-protection-coexistence", + "source_revision": "125465ad3abc97fdd1ab513274ff4b72b20be2888afe5ebe3f9df64178378cfd" + }, + { + "asset_id": "sub-agent-scope-contract", + "source_revision": "fe3ff9fb58c105ce1a423feedd1ddbc52f85319a02252a905429988c2f77391d" + }, + { + "asset_id": "ui-stitch-mandatory", + "source_revision": "1ec7a6117c056bf8e1920330ccb2316d57617d3ba4928e7ac85ea7e48ff99eba" + }, + { + "asset_id": "visual-progress-map", + "source_revision": "4b433438661e45bf858a847081d1383980543d440d44845e162500ae418f4fb6" + }, + { + "asset_id": "worktree-rule", + "source_revision": "e4976caa636d23a7459a2610bf396feac476385008cab8c20cafa6842b0ef4e5" + } + ], + "client": "codex", + "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "kind": "rule", + "materialization_digest": "sha256:d6c2893db1c06fc0988a3d64f503cacb924156cce38e2ed644313ca884109342", + "probe": "fresh-rule-discovery", + "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "symlink_policy": "forbidden", + "target": "AGENTS.md", + "writer_id": "generate-agents-md", + "writer_version": "sha256:42b4458121a39cbbbb72351a563b390a06c3453e8aa438998e768677935294da" + }, + { + "asset_revisions": [ + { + "asset_id": "adversarial-review", + "source_revision": "ce41fe079117d6e6bb2a77566a5d25d239dc2386b7e1f31a62368a91899bbcdd" + }, + { + "asset_id": "agent-dispatch", + "source_revision": "68209f182ae0d3da281c1600c2d532fe151d02696a4f4ebcfe106ad25282b109" + }, + { + "asset_id": "agentmemory-routing", + "source_revision": "3c477764f40da2dcf8a7219227113e67a81c9697d2152b05ab35cfe6ef4ac9ec" + }, + { + "asset_id": "ai-project-rules", + "source_revision": "859192d9a501e9d942c457a42cfd7886fae01007c4a25b5e7c809d31a6b463fc" + }, + { + "asset_id": "cc-context-optimizer", + "source_revision": "de26de606d14cc0b8b2336432f28f9b248744921d9faa8d3809bb39bed7b46fb" + }, + { + "asset_id": "ci-credit-optimizer", + "source_revision": "18f11db5f67e20a16a1773e46a4e69e2baaca12a98fef71d0bdf2bb8891ff643" + }, + { + "asset_id": "codex-review", + "source_revision": "c44b6a3175b19296ab8aa292b4672047b5a937a55c02d55cc3ea7d5089eeac84" + }, + { + "asset_id": "create-pr", + "source_revision": "a9b91d159d309d8b626595896bc645f1a565f7c6a1d4cc082bfd7283167d1c16" + }, + { + "asset_id": "dev-guardrails", + "source_revision": "6652989322196ed82cd61c36eae2b8bbfef365b35270237620fbb069a3932141" + }, + { + "asset_id": "handover-manual", + "source_revision": "fa229b12cbd3e0b5fd2015808d30b75612e9f5a4c7da267ad29db2893980f05b" + }, + { + "asset_id": "mcp-dev-kit", + "source_revision": "b9eb97460ed5be2c799b3c8bff54825efb3422f4db43652a2ce466a6075c5f4f" + }, + { + "asset_id": "plan-approval", + "source_revision": "ccca7f1eb7f228d6df67223bb5895f05f1f73f08c1e23e7edf40f0a0345ce419" + }, + { + "asset_id": "post-merge", + "source_revision": "713daf4a34a279b6296b1e721a4637152531ec8c61bc094b38d2754491f6b9b4" + }, + { + "asset_id": "skill-audit", + "source_revision": "89cf836efafe59ec44edf055866ff3887374658c55998f23362f08947d17cddc" + } + ], + "client": "codex", + "content_digest": "sha256:3ab51293f56ca7a584d9489b0c91aa743888d50cfb18776a87f4e5bae09f8035", + "kind": "skill", + "materialization_digest": "sha256:aa07206bfbb1bc88f5ce426f01d64ecfb5ccaa7828eecd314e9c462bb702ea5f", + "probe": "fresh-skill-discovery", + "source_revision": "sha256:b583437e2d84e00e897e359fa84f1b69c6b7b620c5ea37f2f6b0ed3ef7e03b5b", + "symlink_policy": "forbidden", + "target": ".agents/skills", + "writer_id": "sync-runtime-skills", + "writer_version": "sha256:e65ecb05fa99f9b44e5fd68ef5ae29027677f0b1de7ce6a90e4dc48263475d5f" + }, + { + "asset_revisions": [ + { + "asset_id": "backend-architect", + "source_revision": "a239f150fc7bb802f6e4a778591ac95697eaf03c8e321571096b82ab103b0955" + }, + { + "asset_id": "backend-developer", + "source_revision": "c8290052bc6f0109d1a924ae4d44158fa4a90370a9dc97a017a828e8dd478687" + }, + { + "asset_id": "chatgpt-image-creator", + "source_revision": "b7367778a8cc1e6cea1163f865915b259633ff4a9ec0a70b5ea1f2e6cec49120" + }, + { + "asset_id": "document-writer", + "source_revision": "29735c440814ea7680bb14a9e4fa06c023f63ddf13cb806d1a5160f6e46ea109" + }, + { + "asset_id": "frontend-developer", + "source_revision": "7a5df8a88b009790d644939d824ab5fe0b00f78e2ac4dd0f5e63a81a18332fec" + }, + { + "asset_id": "qa-reviewer", + "source_revision": "1a7adf79ce2533a289f953d65b25c49cbb1a6927d3e7d983d008ebfd4a4d335a" + }, + { + "asset_id": "quality-engineer", + "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" + }, + { + "asset_id": "stitch-screen-creator", + "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" + }, + { + "asset_id": "technical-writer", + "source_revision": "9f2bfbb6dcf1b2827af21e577c7642895c4e0e2ba0e95eb8298a48c7300350f6" + }, + { + "asset_id": "templates/implementation-auditor", + "source_revision": "9fe2d3ecefbdaf1a8f03b2afb2e7c357701d77330363d84fd929a80970d5e6a6" + }, + { + "asset_id": "test-runner", + "source_revision": "47219a9ea2b442f4c7fd8e6c63d0554ae6db41cddd7b15920dcb603ec87973d2" + } + ], + "client": "codex", + "content_digest": "sha256:ada572493c395b78b110b22a1059e49d98f7de834951bd27b09add91f728a48a", + "kind": "subagent", + "materialization_digest": "sha256:df69c9a8bdc45f4017b7e7c01e1f16e7bf6ec802fa0876df509a34777e118962", + "probe": "fresh-agent-discovery", + "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "symlink_policy": "forbidden", + "target": ".codex/agents", + "writer_id": "harness-link-planner", + "writer_version": "sha256:c1793eb413bc5b90b62a96017a8dfdaba7057c7beee3d7c20277a7ca04eb8d92" + }, + { + "asset_revisions": [ + { + "asset_id": "antigravity", + "source_revision": "bcaed648876d9d4586466ccf5b236875d36ee62c3ef5b04fd3c0d9a1237f04e7" + }, + { + "asset_id": "claude", + "source_revision": "09530a052046fdca9046f56ae8bed4b4efbb4bb8ca52880e96c45eab60ef4736" + }, + { + "asset_id": "codex", + "source_revision": "c14e66b00ebfbaabd91a71c2f18d215c9224e7fd88900bc8468d65033aaac2ab" + }, + { + "asset_id": "cursor", + "source_revision": "20598fb9822e208075c234a5c4c8a9437e21c51dc1ba4bec87d20af43951bea1" + }, + { + "asset_id": "kimi", + "source_revision": "de404fbd95a41e4edef577673fd432ea78f10959d464f8ba77d26c73758e28d8" + }, + { + "asset_id": "opencode", + "source_revision": "ee4333fd657cfc51766289e4064241151636f6475bc15f4e7184a799215849d0" + }, + { + "asset_id": "warp", + "source_revision": "6d4d9867e18c1f6624ebe8a45131992dd57770c258ef85b1520072c2060ed679" + } + ], + "client": "cursor", + "content_digest": "sha256:b4d146393c754baa379691508e63b0215e3bff466f21c5b48cd6f37fde1cac7b", + "kind": "ai_client", + "materialization_digest": "sha256:e662150f6941c3312374de425f0c124df6b6013a2ffee514b098607009aa31b8", + "probe": "stable-sync-state", + "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", + "symlink_policy": "forbidden", + "target": ".cursor/sync-state.json", + "writer_id": "sync-cursor-from-cc", + "writer_version": "sha256:9b0fe0c7f2de9543b54d77b7131ef23464be0abf903d5f2f7fa31683dea8a910" + }, + { + "asset_revisions": [ + { + "asset_id": "agents-md", + "source_revision": "026ba32dd8315d9193477efa84574f7516ac46b31da3415a817cbaaf53ffc91a" + }, + { + "asset_id": "claude-md", + "source_revision": "2b9ab1966be6b5a37b99e9601448535ba11f6c619c0b66f9601c63a4e1e51331" + }, + { + "asset_id": "gbrain-md", + "source_revision": "17f3ff14b835d5d1930b190b294c299f9904dff3a4aba13b4c3e88e7199300a1" + }, + { + "asset_id": "gemini-md", + "source_revision": "ccc69bc96b91498eeab2c076aa8ac75aa85d9c3d3892d30d021356e56d689078" + } + ], + "client": "cursor", + "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "kind": "constitution", + "materialization_digest": "sha256:78cd0da6af7203b2309f31b6a0ce1646a7e238288dfb34c825b9af4537188ede", + "probe": "fresh-instruction-discovery", + "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", + "symlink_policy": "forbidden", + "target": "AGENTS.md", + "writer_id": "generate-agents-md", + "writer_version": "sha256:42b4458121a39cbbbb72351a563b390a06c3453e8aa438998e768677935294da" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "cursor", + "content_digest": "sha256:d684a7503d880d1d1ee3c816569a02f45992f09147976a8824b82e99ad274e94", + "kind": "hook", + "materialization_digest": "sha256:a63a0e6558222760b2914d56dbe965a8ec914d2fac79999a4b89f853beabd599", + "probe": "safe-hook-fixture", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".cursor/hooks", + "writer_id": "sync-cursor-from-cc", + "writer_version": "sha256:9b0fe0c7f2de9543b54d77b7131ef23464be0abf903d5f2f7fa31683dea8a910" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "cursor", + "content_digest": "sha256:d338f8bf318235f882a6fa92c1092665891b88cfaf83351020282158f11c888c", + "kind": "hook", + "materialization_digest": "sha256:ed3e34eeabddf0a924fc9cc86734d7ed6427d2d757afc5fd4ba3223ba1b7f9e8", + "probe": "safe-hook-fixture", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".cursor/hooks.json", + "writer_id": "sync-cursor-from-cc", + "writer_version": "sha256:9b0fe0c7f2de9543b54d77b7131ef23464be0abf903d5f2f7fa31683dea8a910" + }, + { + "asset_revisions": [ + { + "asset_id": "agentmemory-agentmemory", + "source_revision": "c90c9306c67e8b8e0b6727022a534610aef60c5ea3ba77a50278ae228de37e79" + }, + { + "asset_id": "ai-worker-mcp", + "source_revision": "61d86bd651b69d2860e98ec47bfb287f85125c67d5feba2fdeda655f0d132248" + }, + { + "asset_id": "codebase-context-engine-agentmemory", + "source_revision": "169cc62b154ca0997a376701dd0d08a0df3e7651ab86f346c73b960a1c1b08e0" + }, + { + "asset_id": "context7", + "source_revision": "579c97f2c4323321f2e4920f12e985bccfb565ddb2505211d2e520128871ca89" + }, + { + "asset_id": "shintaro-gbrain", + "source_revision": "720bd8b73e21854541086b91dc19e43c431b8c06b73ce84587c80af02b8cb394" + }, + { + "asset_id": "stitch", + "source_revision": "26bc55af145c09fc6ccf24e6fd9738baf331fbca7bc210f7dbd35444a6639559" + }, + { + "asset_id": "tech-gbrain", + "source_revision": "41e5b97c53d1dec100f4d78ea7d9532c81a3daf8f80c18181c59242972c227fc" + } + ], + "client": "cursor", + "content_digest": "sha256:e77b638ec7a3709d6c07c4342b0a2cd2555e20fbb2221893b599752b54c821b1", + "kind": "mcp", + "materialization_digest": "sha256:1697e2ffb4220f60577a5c2ae5341c8c245f81d54531c9ce861da9bacc76ecdb", + "probe": "mcp-initialize-tools-list", + "source_revision": "sha256:7c73bf290e8e9272183652fea348c04b5200cffcbd655b67e56c953d53fb07ac", + "symlink_policy": "forbidden", + "target": ".cursor/mcp.json", + "writer_id": "sync-cursor-mcp-configs", + "writer_version": "sha256:6d94e226154ec11622189fc62d365b0f99d35724f0cf74a7996502808b186ba0" + }, + { + "asset_revisions": [ + { + "asset_id": "ai-model-selection", + "source_revision": "0205f80f354e153bc240cac68db2bb607e4d18ed367983efab63d1aa52ec056a" + }, + { + "asset_id": "branch-rule", + "source_revision": "138c6646648abacb3ec607e9453a3ece796efc2fa2465849f0d5822e75216ba3" + }, + { + "asset_id": "constructive-dissent", + "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" + }, + { + "asset_id": "hooks-structure-rule", + "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" + }, + { + "asset_id": "latest-stack-context7", + "source_revision": "4832808009de0fe9b7866ad4916ebeebe60969b2a88bd097cc6df477f0ee3c74" + }, + { + "asset_id": "mandate-registry", + "source_revision": "9a96532c78b028b91958d09150d2902935d083fec5b2eb61ac87c9f36c89c080" + }, + { + "asset_id": "mcp-key-management", + "source_revision": "0a684743271ef7127923312dd62a53e0fdde03d73abc5361764f033a89eab246" + }, + { + "asset_id": "memory-lookups", + "source_revision": "4db569b053c02df2c4b1758b94770ab05a089082495a865a5e3033abb2aa50d6" + }, + { + "asset_id": "plan-approval-gate", + "source_revision": "d5f5b17d45d2855807f5267cea6f94c21b2782546e02bab9c97f04d2d490b5ec" + }, + { + "asset_id": "plan-commitment-tracking", + "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + }, + { + "asset_id": "reference-over-hardcode", + "source_revision": "e5b5404290509541726651c79906a629942a55f3e4864e02db1a1c81a370e7ec" + }, + { + "asset_id": "response-style", + "source_revision": "0392ece7de1d0b5335269d4fb98d96af9890acccfeb35891797110e33d64ca3f" + }, + { + "asset_id": "responsive-both-viewports", + "source_revision": "c7be824deb9960711360bafb24e1d868f3c5d698e3dfd985c1c32b248594c357" + }, + { + "asset_id": "settings-protection-coexistence", + "source_revision": "125465ad3abc97fdd1ab513274ff4b72b20be2888afe5ebe3f9df64178378cfd" + }, + { + "asset_id": "sub-agent-scope-contract", + "source_revision": "fe3ff9fb58c105ce1a423feedd1ddbc52f85319a02252a905429988c2f77391d" + }, + { + "asset_id": "ui-stitch-mandatory", + "source_revision": "1ec7a6117c056bf8e1920330ccb2316d57617d3ba4928e7ac85ea7e48ff99eba" + }, + { + "asset_id": "visual-progress-map", + "source_revision": "4b433438661e45bf858a847081d1383980543d440d44845e162500ae418f4fb6" + }, + { + "asset_id": "worktree-rule", + "source_revision": "e4976caa636d23a7459a2610bf396feac476385008cab8c20cafa6842b0ef4e5" + } + ], + "client": "cursor", + "content_digest": "sha256:48d0001d0c3747eed4988620cc5387d7993811e3d5709d8bd350ba6e0d368202", + "kind": "rule", + "materialization_digest": "sha256:5058046f66e3a00fdae9874a37cc5768408337951070ad30f0e8b930a9261478", + "probe": "fresh-rule-discovery", + "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "symlink_policy": "forbidden", + "target": ".cursor/rules", + "writer_id": "sync-cursor-from-cc", + "writer_version": "sha256:9b0fe0c7f2de9543b54d77b7131ef23464be0abf903d5f2f7fa31683dea8a910" + }, + { + "asset_revisions": [ + { + "asset_id": "adversarial-review", + "source_revision": "ce41fe079117d6e6bb2a77566a5d25d239dc2386b7e1f31a62368a91899bbcdd" + }, + { + "asset_id": "agent-dispatch", + "source_revision": "68209f182ae0d3da281c1600c2d532fe151d02696a4f4ebcfe106ad25282b109" + }, + { + "asset_id": "agentmemory-routing", + "source_revision": "3c477764f40da2dcf8a7219227113e67a81c9697d2152b05ab35cfe6ef4ac9ec" + }, + { + "asset_id": "ai-project-rules", + "source_revision": "859192d9a501e9d942c457a42cfd7886fae01007c4a25b5e7c809d31a6b463fc" + }, + { + "asset_id": "cc-context-optimizer", + "source_revision": "de26de606d14cc0b8b2336432f28f9b248744921d9faa8d3809bb39bed7b46fb" + }, + { + "asset_id": "ci-credit-optimizer", + "source_revision": "18f11db5f67e20a16a1773e46a4e69e2baaca12a98fef71d0bdf2bb8891ff643" + }, + { + "asset_id": "codex-review", + "source_revision": "c44b6a3175b19296ab8aa292b4672047b5a937a55c02d55cc3ea7d5089eeac84" + }, + { + "asset_id": "create-pr", + "source_revision": "a9b91d159d309d8b626595896bc645f1a565f7c6a1d4cc082bfd7283167d1c16" + }, + { + "asset_id": "dev-guardrails", + "source_revision": "6652989322196ed82cd61c36eae2b8bbfef365b35270237620fbb069a3932141" + }, + { + "asset_id": "handover-manual", + "source_revision": "fa229b12cbd3e0b5fd2015808d30b75612e9f5a4c7da267ad29db2893980f05b" + }, + { + "asset_id": "mcp-dev-kit", + "source_revision": "b9eb97460ed5be2c799b3c8bff54825efb3422f4db43652a2ce466a6075c5f4f" + }, + { + "asset_id": "plan-approval", + "source_revision": "ccca7f1eb7f228d6df67223bb5895f05f1f73f08c1e23e7edf40f0a0345ce419" + }, + { + "asset_id": "post-merge", + "source_revision": "713daf4a34a279b6296b1e721a4637152531ec8c61bc094b38d2754491f6b9b4" + }, + { + "asset_id": "skill-audit", + "source_revision": "89cf836efafe59ec44edf055866ff3887374658c55998f23362f08947d17cddc" + } + ], + "client": "cursor", + "content_digest": "sha256:3ab51293f56ca7a584d9489b0c91aa743888d50cfb18776a87f4e5bae09f8035", + "kind": "skill", + "materialization_digest": "sha256:1cf41ec4e5f1e624d3b544519bf91dc67ae62db05b73f34ebd931c56628d025b", + "probe": "fresh-skill-discovery", + "source_revision": "sha256:b583437e2d84e00e897e359fa84f1b69c6b7b620c5ea37f2f6b0ed3ef7e03b5b", + "symlink_policy": "forbidden", + "target": ".cursor/skills", + "writer_id": "sync-runtime-skills", + "writer_version": "sha256:e65ecb05fa99f9b44e5fd68ef5ae29027677f0b1de7ce6a90e4dc48263475d5f" + }, + { + "asset_revisions": [ + { + "asset_id": "backend-architect", + "source_revision": "a239f150fc7bb802f6e4a778591ac95697eaf03c8e321571096b82ab103b0955" + }, + { + "asset_id": "backend-developer", + "source_revision": "c8290052bc6f0109d1a924ae4d44158fa4a90370a9dc97a017a828e8dd478687" + }, + { + "asset_id": "chatgpt-image-creator", + "source_revision": "b7367778a8cc1e6cea1163f865915b259633ff4a9ec0a70b5ea1f2e6cec49120" + }, + { + "asset_id": "document-writer", + "source_revision": "29735c440814ea7680bb14a9e4fa06c023f63ddf13cb806d1a5160f6e46ea109" + }, + { + "asset_id": "frontend-developer", + "source_revision": "7a5df8a88b009790d644939d824ab5fe0b00f78e2ac4dd0f5e63a81a18332fec" + }, + { + "asset_id": "qa-reviewer", + "source_revision": "1a7adf79ce2533a289f953d65b25c49cbb1a6927d3e7d983d008ebfd4a4d335a" + }, + { + "asset_id": "quality-engineer", + "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" + }, + { + "asset_id": "stitch-screen-creator", + "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" + }, + { + "asset_id": "technical-writer", + "source_revision": "9f2bfbb6dcf1b2827af21e577c7642895c4e0e2ba0e95eb8298a48c7300350f6" + }, + { + "asset_id": "templates/implementation-auditor", + "source_revision": "9fe2d3ecefbdaf1a8f03b2afb2e7c357701d77330363d84fd929a80970d5e6a6" + }, + { + "asset_id": "test-runner", + "source_revision": "47219a9ea2b442f4c7fd8e6c63d0554ae6db41cddd7b15920dcb603ec87973d2" + } + ], + "client": "cursor", + "content_digest": "sha256:3b05494f026eab180d54a8da37f83b3a80f7ccd858f82e1606ccb08899b7d3cf", + "kind": "subagent", + "materialization_digest": "sha256:b2ae07c4cd52c730954a53962089513552472286ce9a0c31b36883db68a4db4a", + "probe": "fresh-agent-discovery", + "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "symlink_policy": "forbidden", + "target": ".cursor/agents", + "writer_id": "sync-cursor-from-cc", + "writer_version": "sha256:9b0fe0c7f2de9543b54d77b7131ef23464be0abf903d5f2f7fa31683dea8a910" + }, + { + "asset_revisions": [ + { + "asset_id": "antigravity", + "source_revision": "bcaed648876d9d4586466ccf5b236875d36ee62c3ef5b04fd3c0d9a1237f04e7" + }, + { + "asset_id": "claude", + "source_revision": "09530a052046fdca9046f56ae8bed4b4efbb4bb8ca52880e96c45eab60ef4736" + }, + { + "asset_id": "codex", + "source_revision": "c14e66b00ebfbaabd91a71c2f18d215c9224e7fd88900bc8468d65033aaac2ab" + }, + { + "asset_id": "cursor", + "source_revision": "20598fb9822e208075c234a5c4c8a9437e21c51dc1ba4bec87d20af43951bea1" + }, + { + "asset_id": "kimi", + "source_revision": "de404fbd95a41e4edef577673fd432ea78f10959d464f8ba77d26c73758e28d8" + }, + { + "asset_id": "opencode", + "source_revision": "ee4333fd657cfc51766289e4064241151636f6475bc15f4e7184a799215849d0" + }, + { + "asset_id": "warp", + "source_revision": "6d4d9867e18c1f6624ebe8a45131992dd57770c258ef85b1520072c2060ed679" + } + ], + "client": "kimi", + "content_digest": "sha256:b6d1568b0c737e790b17d2058cbe957949de38159dc291aa70994d66dec7e072", + "kind": "ai_client", + "materialization_digest": "sha256:8d7245003f29e7d446e0cfd220f5d681e353310ff4421ad9474b4c5986026139", + "probe": "stable-client-instruction", + "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", + "symlink_policy": "forbidden", + "target": ".kimi-code/AGENTS.md", + "writer_id": "sync-kimi-from-cc", + "writer_version": "sha256:647f2a67bd59ef6b854d177c1e444163467d1985e2c3fa42ffcc5959585cfb7f" + }, + { + "asset_revisions": [ + { + "asset_id": "antigravity", + "source_revision": "bcaed648876d9d4586466ccf5b236875d36ee62c3ef5b04fd3c0d9a1237f04e7" + }, + { + "asset_id": "claude", + "source_revision": "09530a052046fdca9046f56ae8bed4b4efbb4bb8ca52880e96c45eab60ef4736" + }, + { + "asset_id": "codex", + "source_revision": "c14e66b00ebfbaabd91a71c2f18d215c9224e7fd88900bc8468d65033aaac2ab" + }, + { + "asset_id": "cursor", + "source_revision": "20598fb9822e208075c234a5c4c8a9437e21c51dc1ba4bec87d20af43951bea1" + }, + { + "asset_id": "kimi", + "source_revision": "de404fbd95a41e4edef577673fd432ea78f10959d464f8ba77d26c73758e28d8" + }, + { + "asset_id": "opencode", + "source_revision": "ee4333fd657cfc51766289e4064241151636f6475bc15f4e7184a799215849d0" + }, + { + "asset_id": "warp", + "source_revision": "6d4d9867e18c1f6624ebe8a45131992dd57770c258ef85b1520072c2060ed679" + } + ], + "client": "kimi", + "content_digest": "sha256:730277f80738d3f0f5bc40cd2d279dcf9cba192b54f86f97df15eef359a6a056", + "kind": "ai_client", + "materialization_digest": "sha256:d7531ecf6d52f108831dba5ec8c500a3eaf82b0bfe056e978866b24fc6b7998d", + "probe": "stable-sync-state", + "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", + "symlink_policy": "forbidden", + "target": ".kimi-code/sync-state.json", + "writer_id": "sync-kimi-from-cc", + "writer_version": "sha256:647f2a67bd59ef6b854d177c1e444163467d1985e2c3fa42ffcc5959585cfb7f" + }, + { + "asset_revisions": [ + { + "asset_id": "agents-md", + "source_revision": "026ba32dd8315d9193477efa84574f7516ac46b31da3415a817cbaaf53ffc91a" + }, + { + "asset_id": "claude-md", + "source_revision": "2b9ab1966be6b5a37b99e9601448535ba11f6c619c0b66f9601c63a4e1e51331" + }, + { + "asset_id": "gbrain-md", + "source_revision": "17f3ff14b835d5d1930b190b294c299f9904dff3a4aba13b4c3e88e7199300a1" + }, + { + "asset_id": "gemini-md", + "source_revision": "ccc69bc96b91498eeab2c076aa8ac75aa85d9c3d3892d30d021356e56d689078" + } + ], + "client": "kimi", + "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "kind": "constitution", + "materialization_digest": "sha256:8e606f514fbd54143d8822196843d025a1f75e963066ecd9a97f00871022498b", + "probe": "fresh-instruction-discovery", + "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", + "symlink_policy": "forbidden", + "target": "AGENTS.md", + "writer_id": "generate-agents-md", + "writer_version": "sha256:42b4458121a39cbbbb72351a563b390a06c3453e8aa438998e768677935294da" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "kimi", + "content_digest": "sha256:005c0a4f6753239b8c38d0b6e08e1f1d9b92d6f9595d43778d3ae4a1f519f1b6", + "kind": "hook", + "materialization_digest": "sha256:28588e565254ff76e782a8f646e75caf642a4fb21ef9a3ad638b0621b66bc326", + "probe": "safe-hook-fixture", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".kimi-code/hooks", + "writer_id": "sync-kimi-from-cc", + "writer_version": "sha256:647f2a67bd59ef6b854d177c1e444163467d1985e2c3fa42ffcc5959585cfb7f" + }, + { + "asset_revisions": [ + { + "asset_id": "agentmemory-agentmemory", + "source_revision": "c90c9306c67e8b8e0b6727022a534610aef60c5ea3ba77a50278ae228de37e79" + }, + { + "asset_id": "ai-worker-mcp", + "source_revision": "61d86bd651b69d2860e98ec47bfb287f85125c67d5feba2fdeda655f0d132248" + }, + { + "asset_id": "codebase-context-engine-agentmemory", + "source_revision": "169cc62b154ca0997a376701dd0d08a0df3e7651ab86f346c73b960a1c1b08e0" + }, + { + "asset_id": "context7", + "source_revision": "579c97f2c4323321f2e4920f12e985bccfb565ddb2505211d2e520128871ca89" + }, + { + "asset_id": "shintaro-gbrain", + "source_revision": "720bd8b73e21854541086b91dc19e43c431b8c06b73ce84587c80af02b8cb394" + }, + { + "asset_id": "stitch", + "source_revision": "26bc55af145c09fc6ccf24e6fd9738baf331fbca7bc210f7dbd35444a6639559" + }, + { + "asset_id": "tech-gbrain", + "source_revision": "41e5b97c53d1dec100f4d78ea7d9532c81a3daf8f80c18181c59242972c227fc" + } + ], + "client": "kimi", + "content_digest": "sha256:4a2c5b357298be8532d9640f53715a1fd0b794df385294031f4a959abfb04388", + "kind": "mcp", + "materialization_digest": "sha256:10c33cd60a1a5c43569c8d155bcf095b2f4d1db2f89f6705cb9fa48e338df151", + "probe": "mcp-initialize-tools-list", + "source_revision": "sha256:7c73bf290e8e9272183652fea348c04b5200cffcbd655b67e56c953d53fb07ac", + "symlink_policy": "forbidden", + "target": ".kimi-code/mcp.json", + "writer_id": "sync-kimi-from-cc", + "writer_version": "sha256:647f2a67bd59ef6b854d177c1e444163467d1985e2c3fa42ffcc5959585cfb7f" + }, + { + "asset_revisions": [ + { + "asset_id": "ai-model-selection", + "source_revision": "0205f80f354e153bc240cac68db2bb607e4d18ed367983efab63d1aa52ec056a" + }, + { + "asset_id": "branch-rule", + "source_revision": "138c6646648abacb3ec607e9453a3ece796efc2fa2465849f0d5822e75216ba3" + }, + { + "asset_id": "constructive-dissent", + "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" + }, + { + "asset_id": "hooks-structure-rule", + "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" + }, + { + "asset_id": "latest-stack-context7", + "source_revision": "4832808009de0fe9b7866ad4916ebeebe60969b2a88bd097cc6df477f0ee3c74" + }, + { + "asset_id": "mandate-registry", + "source_revision": "9a96532c78b028b91958d09150d2902935d083fec5b2eb61ac87c9f36c89c080" + }, + { + "asset_id": "mcp-key-management", + "source_revision": "0a684743271ef7127923312dd62a53e0fdde03d73abc5361764f033a89eab246" + }, + { + "asset_id": "memory-lookups", + "source_revision": "4db569b053c02df2c4b1758b94770ab05a089082495a865a5e3033abb2aa50d6" + }, + { + "asset_id": "plan-approval-gate", + "source_revision": "d5f5b17d45d2855807f5267cea6f94c21b2782546e02bab9c97f04d2d490b5ec" + }, + { + "asset_id": "plan-commitment-tracking", + "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + }, + { + "asset_id": "reference-over-hardcode", + "source_revision": "e5b5404290509541726651c79906a629942a55f3e4864e02db1a1c81a370e7ec" + }, + { + "asset_id": "response-style", + "source_revision": "0392ece7de1d0b5335269d4fb98d96af9890acccfeb35891797110e33d64ca3f" + }, + { + "asset_id": "responsive-both-viewports", + "source_revision": "c7be824deb9960711360bafb24e1d868f3c5d698e3dfd985c1c32b248594c357" + }, + { + "asset_id": "settings-protection-coexistence", + "source_revision": "125465ad3abc97fdd1ab513274ff4b72b20be2888afe5ebe3f9df64178378cfd" + }, + { + "asset_id": "sub-agent-scope-contract", + "source_revision": "fe3ff9fb58c105ce1a423feedd1ddbc52f85319a02252a905429988c2f77391d" + }, + { + "asset_id": "ui-stitch-mandatory", + "source_revision": "1ec7a6117c056bf8e1920330ccb2316d57617d3ba4928e7ac85ea7e48ff99eba" + }, + { + "asset_id": "visual-progress-map", + "source_revision": "4b433438661e45bf858a847081d1383980543d440d44845e162500ae418f4fb6" + }, + { + "asset_id": "worktree-rule", + "source_revision": "e4976caa636d23a7459a2610bf396feac476385008cab8c20cafa6842b0ef4e5" + } + ], + "client": "kimi", + "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "kind": "rule", + "materialization_digest": "sha256:09d015eb98c76d8437d3214fdf0bf2dbda0f075018ccbf4cbd1f4531761025f2", + "probe": "fresh-rule-discovery", + "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "symlink_policy": "forbidden", + "target": "AGENTS.md", + "writer_id": "generate-agents-md", + "writer_version": "sha256:42b4458121a39cbbbb72351a563b390a06c3453e8aa438998e768677935294da" + }, + { + "asset_revisions": [ + { + "asset_id": "adversarial-review", + "source_revision": "ce41fe079117d6e6bb2a77566a5d25d239dc2386b7e1f31a62368a91899bbcdd" + }, + { + "asset_id": "agent-dispatch", + "source_revision": "68209f182ae0d3da281c1600c2d532fe151d02696a4f4ebcfe106ad25282b109" + }, + { + "asset_id": "agentmemory-routing", + "source_revision": "3c477764f40da2dcf8a7219227113e67a81c9697d2152b05ab35cfe6ef4ac9ec" + }, + { + "asset_id": "ai-project-rules", + "source_revision": "859192d9a501e9d942c457a42cfd7886fae01007c4a25b5e7c809d31a6b463fc" + }, + { + "asset_id": "cc-context-optimizer", + "source_revision": "de26de606d14cc0b8b2336432f28f9b248744921d9faa8d3809bb39bed7b46fb" + }, + { + "asset_id": "ci-credit-optimizer", + "source_revision": "18f11db5f67e20a16a1773e46a4e69e2baaca12a98fef71d0bdf2bb8891ff643" + }, + { + "asset_id": "codex-review", + "source_revision": "c44b6a3175b19296ab8aa292b4672047b5a937a55c02d55cc3ea7d5089eeac84" + }, + { + "asset_id": "create-pr", + "source_revision": "a9b91d159d309d8b626595896bc645f1a565f7c6a1d4cc082bfd7283167d1c16" + }, + { + "asset_id": "dev-guardrails", + "source_revision": "6652989322196ed82cd61c36eae2b8bbfef365b35270237620fbb069a3932141" + }, + { + "asset_id": "handover-manual", + "source_revision": "fa229b12cbd3e0b5fd2015808d30b75612e9f5a4c7da267ad29db2893980f05b" + }, + { + "asset_id": "mcp-dev-kit", + "source_revision": "b9eb97460ed5be2c799b3c8bff54825efb3422f4db43652a2ce466a6075c5f4f" + }, + { + "asset_id": "plan-approval", + "source_revision": "ccca7f1eb7f228d6df67223bb5895f05f1f73f08c1e23e7edf40f0a0345ce419" + }, + { + "asset_id": "post-merge", + "source_revision": "713daf4a34a279b6296b1e721a4637152531ec8c61bc094b38d2754491f6b9b4" + }, + { + "asset_id": "skill-audit", + "source_revision": "89cf836efafe59ec44edf055866ff3887374658c55998f23362f08947d17cddc" + } + ], + "client": "kimi", + "content_digest": "sha256:3ab51293f56ca7a584d9489b0c91aa743888d50cfb18776a87f4e5bae09f8035", + "kind": "skill", + "materialization_digest": "sha256:5491abe65f5197e053bd4c67e440fc334f74f708ecaadf4aa9791944bc991543", + "probe": "fresh-skill-discovery", + "source_revision": "sha256:b583437e2d84e00e897e359fa84f1b69c6b7b620c5ea37f2f6b0ed3ef7e03b5b", + "symlink_policy": "forbidden", + "target": ".kimi-code/skills", + "writer_id": "sync-runtime-skills", + "writer_version": "sha256:e65ecb05fa99f9b44e5fd68ef5ae29027677f0b1de7ce6a90e4dc48263475d5f" + }, + { + "asset_revisions": [ + { + "asset_id": "antigravity", + "source_revision": "bcaed648876d9d4586466ccf5b236875d36ee62c3ef5b04fd3c0d9a1237f04e7" + }, + { + "asset_id": "claude", + "source_revision": "09530a052046fdca9046f56ae8bed4b4efbb4bb8ca52880e96c45eab60ef4736" + }, + { + "asset_id": "codex", + "source_revision": "c14e66b00ebfbaabd91a71c2f18d215c9224e7fd88900bc8468d65033aaac2ab" + }, + { + "asset_id": "cursor", + "source_revision": "20598fb9822e208075c234a5c4c8a9437e21c51dc1ba4bec87d20af43951bea1" + }, + { + "asset_id": "kimi", + "source_revision": "de404fbd95a41e4edef577673fd432ea78f10959d464f8ba77d26c73758e28d8" + }, + { + "asset_id": "opencode", + "source_revision": "ee4333fd657cfc51766289e4064241151636f6475bc15f4e7184a799215849d0" + }, + { + "asset_id": "warp", + "source_revision": "6d4d9867e18c1f6624ebe8a45131992dd57770c258ef85b1520072c2060ed679" + } + ], + "client": "opencode", + "content_digest": "sha256:09ed231df8154c7d11ed816b78d42e46718cc5e45cc7344032602e0a82e26c61", + "kind": "ai_client", + "materialization_digest": "sha256:56ac247dc39b2e18acd1fc57eee00f0a82cac89e158c30acdb91d9682f51f624", + "probe": "stable-sync-state", + "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", + "symlink_policy": "forbidden", + "target": ".opencode/sync-state.json", + "writer_id": "sync-opencode-from-cc", + "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + }, + { + "asset_revisions": [ + { + "asset_id": "antigravity", + "source_revision": "bcaed648876d9d4586466ccf5b236875d36ee62c3ef5b04fd3c0d9a1237f04e7" + }, + { + "asset_id": "claude", + "source_revision": "09530a052046fdca9046f56ae8bed4b4efbb4bb8ca52880e96c45eab60ef4736" + }, + { + "asset_id": "codex", + "source_revision": "c14e66b00ebfbaabd91a71c2f18d215c9224e7fd88900bc8468d65033aaac2ab" + }, + { + "asset_id": "cursor", + "source_revision": "20598fb9822e208075c234a5c4c8a9437e21c51dc1ba4bec87d20af43951bea1" + }, + { + "asset_id": "kimi", + "source_revision": "de404fbd95a41e4edef577673fd432ea78f10959d464f8ba77d26c73758e28d8" + }, + { + "asset_id": "opencode", + "source_revision": "ee4333fd657cfc51766289e4064241151636f6475bc15f4e7184a799215849d0" + }, + { + "asset_id": "warp", + "source_revision": "6d4d9867e18c1f6624ebe8a45131992dd57770c258ef85b1520072c2060ed679" + } + ], + "client": "opencode", + "content_digest": "sha256:9fc9420210f8a37eaed4f07ac0d9cda00cb97a894e65c420356c01f488b7fbbc", + "kind": "ai_client", + "materialization_digest": "sha256:368fbc49c1c73f11046e69908ec16a289430a33b3a55f40d437463fa457b6020", + "probe": "stable-client-config", + "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", + "symlink_policy": "forbidden", + "target": "tui.json", + "writer_id": "sync-opencode-from-cc", + "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + }, + { + "asset_revisions": [ + { + "asset_id": "agents-md", + "source_revision": "026ba32dd8315d9193477efa84574f7516ac46b31da3415a817cbaaf53ffc91a" + }, + { + "asset_id": "claude-md", + "source_revision": "2b9ab1966be6b5a37b99e9601448535ba11f6c619c0b66f9601c63a4e1e51331" + }, + { + "asset_id": "gbrain-md", + "source_revision": "17f3ff14b835d5d1930b190b294c299f9904dff3a4aba13b4c3e88e7199300a1" + }, + { + "asset_id": "gemini-md", + "source_revision": "ccc69bc96b91498eeab2c076aa8ac75aa85d9c3d3892d30d021356e56d689078" + } + ], + "client": "opencode", + "content_digest": "sha256:2bb5d1317b3ac3f792da0241e75b2cb9154f53a231db4dd88b5d70e16c15d6d5", + "kind": "constitution", + "materialization_digest": "sha256:253a42ec72228e28ae97bf1ff76948328ddab319c42e472954cebb84ddc4cca2", + "probe": "fresh-instruction-discovery", + "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", + "symlink_policy": "forbidden", + "target": "opencode.json", + "writer_id": "sync-opencode-from-cc", + "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + }, + { + "asset_revisions": [ + { + "asset_id": "block-destructive-git", + "source_revision": "98a4f2ef02acfd33bad7baa28c29d818976595c6987353928896e802b31f2b3f" + }, + { + "asset_id": "block-main-commit", + "source_revision": "22f0dde5868da010f66badd0d0fb6ddad3d4236d41f1b14b774ba6cdfe320470" + }, + { + "asset_id": "block-skill-reverse-edit", + "source_revision": "08f806c123911055dce1133e87deaa522aa90c5cd8e841b15b4e5e0d9cad3c1e" + }, + { + "asset_id": "block-unauthorized-docs-file", + "source_revision": "079906c1c35944d6d04c01b9348430469b80769172c4d11452f6a4a46aad2400" + }, + { + "asset_id": "freshness-gate", + "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" + }, + { + "asset_id": "handover-preflight", + "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" + }, + { + "asset_id": "post-merge-gate", + "source_revision": "02bdf22c15e1b98230512da05ab35d5f70e2ba1886b944df35cfb5e0c08a1d60" + }, + { + "asset_id": "pre-implementation-check", + "source_revision": "51bb28dd0f694b92e6c28a13710fcd252d559dccf9130f99eef038a32815cf47" + }, + { + "asset_id": "quality-gate", + "source_revision": "915572ca23036300251bfa9fb64e2d047bfd0edd16b7c0151131c529e06438e3" + }, + { + "asset_id": "telemetry-log", + "source_revision": "8ed29cf107fd6ea9b1f382c240faeb03e19f2ffd4ef860c8aa82b656befaa124" + } + ], + "client": "opencode", + "content_digest": "sha256:2dd96af747129231c15ff849ec6cc36513813bdbafd6a7e858b8dc65e52c9470", + "kind": "hook", + "materialization_digest": "sha256:4415009cbb90ee27638ab9580e8dde91ca82288c77342b275d3dd7aed91a4292", + "probe": "safe-hook-fixture", + "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "symlink_policy": "forbidden", + "target": ".opencode/plugins", + "writer_id": "sync-opencode-from-cc", + "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + }, + { + "asset_revisions": [ + { + "asset_id": "agentmemory-agentmemory", + "source_revision": "c90c9306c67e8b8e0b6727022a534610aef60c5ea3ba77a50278ae228de37e79" + }, + { + "asset_id": "ai-worker-mcp", + "source_revision": "61d86bd651b69d2860e98ec47bfb287f85125c67d5feba2fdeda655f0d132248" + }, + { + "asset_id": "codebase-context-engine-agentmemory", + "source_revision": "169cc62b154ca0997a376701dd0d08a0df3e7651ab86f346c73b960a1c1b08e0" + }, + { + "asset_id": "context7", + "source_revision": "579c97f2c4323321f2e4920f12e985bccfb565ddb2505211d2e520128871ca89" + }, + { + "asset_id": "shintaro-gbrain", + "source_revision": "720bd8b73e21854541086b91dc19e43c431b8c06b73ce84587c80af02b8cb394" + }, + { + "asset_id": "stitch", + "source_revision": "26bc55af145c09fc6ccf24e6fd9738baf331fbca7bc210f7dbd35444a6639559" + }, + { + "asset_id": "tech-gbrain", + "source_revision": "41e5b97c53d1dec100f4d78ea7d9532c81a3daf8f80c18181c59242972c227fc" + } + ], + "client": "opencode", + "content_digest": "sha256:2bb5d1317b3ac3f792da0241e75b2cb9154f53a231db4dd88b5d70e16c15d6d5", + "kind": "mcp", + "materialization_digest": "sha256:52388d05dbbe9a039a4ea4f8ee5d735f184ca9612d376a2ac1b32563487d1b66", + "probe": "mcp-initialize-tools-list", + "source_revision": "sha256:7c73bf290e8e9272183652fea348c04b5200cffcbd655b67e56c953d53fb07ac", + "symlink_policy": "forbidden", + "target": "opencode.json", + "writer_id": "sync-opencode-from-cc", + "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + }, + { + "asset_revisions": [ + { + "asset_id": "ai-model-selection", + "source_revision": "0205f80f354e153bc240cac68db2bb607e4d18ed367983efab63d1aa52ec056a" + }, + { + "asset_id": "branch-rule", + "source_revision": "138c6646648abacb3ec607e9453a3ece796efc2fa2465849f0d5822e75216ba3" + }, + { + "asset_id": "constructive-dissent", + "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" + }, + { + "asset_id": "hooks-structure-rule", + "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" + }, + { + "asset_id": "latest-stack-context7", + "source_revision": "4832808009de0fe9b7866ad4916ebeebe60969b2a88bd097cc6df477f0ee3c74" + }, + { + "asset_id": "mandate-registry", + "source_revision": "9a96532c78b028b91958d09150d2902935d083fec5b2eb61ac87c9f36c89c080" + }, + { + "asset_id": "mcp-key-management", + "source_revision": "0a684743271ef7127923312dd62a53e0fdde03d73abc5361764f033a89eab246" + }, + { + "asset_id": "memory-lookups", + "source_revision": "4db569b053c02df2c4b1758b94770ab05a089082495a865a5e3033abb2aa50d6" + }, + { + "asset_id": "plan-approval-gate", + "source_revision": "d5f5b17d45d2855807f5267cea6f94c21b2782546e02bab9c97f04d2d490b5ec" + }, + { + "asset_id": "plan-commitment-tracking", + "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + }, + { + "asset_id": "reference-over-hardcode", + "source_revision": "e5b5404290509541726651c79906a629942a55f3e4864e02db1a1c81a370e7ec" + }, + { + "asset_id": "response-style", + "source_revision": "0392ece7de1d0b5335269d4fb98d96af9890acccfeb35891797110e33d64ca3f" + }, + { + "asset_id": "responsive-both-viewports", + "source_revision": "c7be824deb9960711360bafb24e1d868f3c5d698e3dfd985c1c32b248594c357" + }, + { + "asset_id": "settings-protection-coexistence", + "source_revision": "125465ad3abc97fdd1ab513274ff4b72b20be2888afe5ebe3f9df64178378cfd" + }, + { + "asset_id": "sub-agent-scope-contract", + "source_revision": "fe3ff9fb58c105ce1a423feedd1ddbc52f85319a02252a905429988c2f77391d" + }, + { + "asset_id": "ui-stitch-mandatory", + "source_revision": "1ec7a6117c056bf8e1920330ccb2316d57617d3ba4928e7ac85ea7e48ff99eba" + }, + { + "asset_id": "visual-progress-map", + "source_revision": "4b433438661e45bf858a847081d1383980543d440d44845e162500ae418f4fb6" + }, + { + "asset_id": "worktree-rule", + "source_revision": "e4976caa636d23a7459a2610bf396feac476385008cab8c20cafa6842b0ef4e5" + } + ], + "client": "opencode", + "content_digest": "sha256:2bb5d1317b3ac3f792da0241e75b2cb9154f53a231db4dd88b5d70e16c15d6d5", + "kind": "rule", + "materialization_digest": "sha256:9d6c7e2276f3341eb777edc2068c8b67ca834abe18830de7cc21a98dc2b7b6e3", + "probe": "fresh-rule-discovery", + "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "symlink_policy": "forbidden", + "target": "opencode.json", + "writer_id": "sync-opencode-from-cc", + "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + }, + { + "asset_revisions": [ + { + "asset_id": "backend-architect", + "source_revision": "a239f150fc7bb802f6e4a778591ac95697eaf03c8e321571096b82ab103b0955" + }, + { + "asset_id": "backend-developer", + "source_revision": "c8290052bc6f0109d1a924ae4d44158fa4a90370a9dc97a017a828e8dd478687" + }, + { + "asset_id": "chatgpt-image-creator", + "source_revision": "b7367778a8cc1e6cea1163f865915b259633ff4a9ec0a70b5ea1f2e6cec49120" + }, + { + "asset_id": "document-writer", + "source_revision": "29735c440814ea7680bb14a9e4fa06c023f63ddf13cb806d1a5160f6e46ea109" + }, + { + "asset_id": "frontend-developer", + "source_revision": "7a5df8a88b009790d644939d824ab5fe0b00f78e2ac4dd0f5e63a81a18332fec" + }, + { + "asset_id": "qa-reviewer", + "source_revision": "1a7adf79ce2533a289f953d65b25c49cbb1a6927d3e7d983d008ebfd4a4d335a" + }, + { + "asset_id": "quality-engineer", + "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" + }, + { + "asset_id": "stitch-screen-creator", + "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" + }, + { + "asset_id": "technical-writer", + "source_revision": "9f2bfbb6dcf1b2827af21e577c7642895c4e0e2ba0e95eb8298a48c7300350f6" + }, + { + "asset_id": "templates/implementation-auditor", + "source_revision": "9fe2d3ecefbdaf1a8f03b2afb2e7c357701d77330363d84fd929a80970d5e6a6" + }, + { + "asset_id": "test-runner", + "source_revision": "47219a9ea2b442f4c7fd8e6c63d0554ae6db41cddd7b15920dcb603ec87973d2" + } + ], + "client": "opencode", + "content_digest": "sha256:f946e35c266cf69249cc9604380ac29fb820ad594c3b400f91565d598a83ef52", + "kind": "subagent", + "materialization_digest": "sha256:bff75cc2488205154b7c8f139c9a04352ad8b808310d8df6c486eb128fa1f8b6", + "probe": "fresh-agent-discovery", + "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "symlink_policy": "forbidden", + "target": ".opencode/agents", + "writer_id": "sync-opencode-from-cc", + "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + }, + { + "asset_revisions": [ + { + "asset_id": "agentmemory-agentmemory", + "source_revision": "c90c9306c67e8b8e0b6727022a534610aef60c5ea3ba77a50278ae228de37e79" + }, + { + "asset_id": "ai-worker-mcp", + "source_revision": "61d86bd651b69d2860e98ec47bfb287f85125c67d5feba2fdeda655f0d132248" + }, + { + "asset_id": "codebase-context-engine-agentmemory", + "source_revision": "169cc62b154ca0997a376701dd0d08a0df3e7651ab86f346c73b960a1c1b08e0" + }, + { + "asset_id": "context7", + "source_revision": "579c97f2c4323321f2e4920f12e985bccfb565ddb2505211d2e520128871ca89" + }, + { + "asset_id": "shintaro-gbrain", + "source_revision": "720bd8b73e21854541086b91dc19e43c431b8c06b73ce84587c80af02b8cb394" + }, + { + "asset_id": "stitch", + "source_revision": "26bc55af145c09fc6ccf24e6fd9738baf331fbca7bc210f7dbd35444a6639559" + }, + { + "asset_id": "tech-gbrain", + "source_revision": "41e5b97c53d1dec100f4d78ea7d9532c81a3daf8f80c18181c59242972c227fc" + } + ], + "client": "warp", + "content_digest": "sha256:0140a9b18265070e6a0cf8f547ee3eb996b9164acebd365facf0e44b5b75debc", + "kind": "mcp", + "materialization_digest": "sha256:109c692f737da848b599970597b8d89fd403ff379c6328592778860a560a3091", + "probe": "mcp-initialize-tools-list", + "source_revision": "sha256:7c73bf290e8e9272183652fea348c04b5200cffcbd655b67e56c953d53fb07ac", + "symlink_policy": "forbidden", + "target": ".warp/.mcp.json", + "writer_id": "sync-warp-project-mcp-configs", + "writer_version": "sha256:15a778f1363707aa8feb8773f210e879181f8b4a5e1e0c7bfbdc7a60b2cfaca9" + } + ] +} diff --git a/.agent/rules/ai-model-selection.md b/.agent/rules/ai-model-selection.md new file mode 100644 index 000000000..3761020ba --- /dev/null +++ b/.agent/rules/ai-model-selection.md @@ -0,0 +1,78 @@ + + +# AI モデル選定指標(GLM 5.2 / Kimi K2.7・K3) + +全 PJ 共通。コード実装をAIエージェントに任せる際の初期ヒューリスティック。 + +> ⚠️ これは**法則ではなく初期判断**。母数が小さい(初期 n=4 + 追加観測・人が見ながら実行)。矛盾する観測が出たら現状を優先し、実測ログ(references)を更新すること。 + +--- + +## 0-bis. Codex 指名時の固定ルール + +- **`codexで実装` / `Codexで実装` / `codex実装` / `Codex実装`** と言われた時だけ、Codex 実装として扱う。 +- Codex 実装の正式設定名は **model = `gpt-5.3-codex-spark`**, **model_reasoning_effort = `high`**(既定。旧既定 `medium`。SWE-Bench Pro 実測で high→xhigh の上げ幅は1pt未満のため常時 xhigh は費用対効果が低い)。 +- 起動例は `codex exec -m gpt-5.3-codex-spark -c model_reasoning_effort=high`。 +- **`xhigh` はユーザーが明示指定した時だけ使う**。軽微タスクは `medium` を明示指定する。AI が自動・既定・推測で `xhigh` を選ばない。 +- **「実装」だけでは Codex 固定にしない**。Cursor / Kimi / GLM / Claude / Codex のどれで進めるかを文脈で判断し、不明なら確認する。 +- **Spark は AI Worker MCP の auto routing 候補に対等参加する**(適材適所+残量バランス・絶対優先ではない)。原因不明バグ・設計判断・DB移行・大規模リファクタ・コンテキストが大きい仕事は Spark に固執せず、auto が適材適所で他 worker(GLM/Kimi/Gemini)へ回避する。 +- **「レビュー」または「codexでレビュー」** は既存の `codex-review` 導線を使う。実装専用の `gpt-5.3-codex-spark` 固定には巻き込まない。 + +--- + +## 4. 使い分けガイド(第一候補) + +| タスク種別 | 第一候補 | 理由 | +|-----------|---------|------| +| 仕様が明確・クリーンさ重視・UI/結線・お手本コード | **GLM 5.2** | 簡潔・範囲内に収まりやすい・速い | +| 複雑・セキュリティ/堅牢性が重要なバックエンド | **Kimi K2.7 Code** | 安全性を自力で深掘り・テスト厚い | +| どちらでも可 | いずれか | ただし下記ガードを必ず付ける | + +### Kimi 内モデル選択(決定論的) + +`agents.yaml.worker_delegation.kimi_model_routing` を正本とし、優先順は、明示 `provider_model` → 長大/推定不能な巨大contextの `k3` → 明示的な速度優先かつ3倍quota許容時の `kimi-for-coding-highspeed` → 通常の `kimi-for-coding` とする。 + +- K3条件: `requires_long_context=true`、推定contextが212,992 token超、または推定不能かつraw UTF-8が512KiB超。`max`、上限1,048,576 token。 +- 選定結果: `reason_code` / `selected_model` / `estimated_context` / `fallback_reason` を必ず残す。 +- K3切替: 新sessionを開始し、必要情報の要約だけを渡す。履歴を丸ごと移送しない。 + +GLM 5.2 の正式運用は high / max のみ(デフォルト high・他の値はルーティングのバリデーションで拒否される)。母数は n=4 の初期観測であり法則ではない(冒頭⚠️参照)。 + +--- + +## 5. 運用上の必須ガード(モデルの弱点を相殺する) + +- **完了の定義を検証可能に**(Kimi の過大申告対策): 「スクショは git にコミット」「テストは緑のログを示す」等、"やったと言うだけ"を許さない。 +- **スコープを超えるなを明示**(Kimi の過剰実装対策): 「指定範囲のみ。追加の堅牢化は別 PR」。 +- **長時間タスクは声がけ / 自動継続**(GLM の停滞対策)。 +- **リポの前提を渡す**(GLM の取り違え対策): 言語・パッケージ管理の前提を明記。 +- **既存 CaD コメント規約に倣わせる**: 新規関数・ブロック追加時は対象ファイルの既存様式(日付・種別・背景3点)に倣うと明記する。 + +--- + +## 6. 候補提案とディスパッチ + +実装委譲・並列実装の話題が出たら §4 を根拠に「GLM 5.2 向き / Kimi向き」を 1 行理由つきで先に提案し、Kimi内のK2.7/K3は上記契約で選ぶ。ディスパッチ実行は `agent-dispatch` スキルへ(未導入環境では §4・§5 のみ使う)。役割分担: 方針選定・委譲・進捗確認・結果回収 = Claude / Codex。実行は `agents.yaml` の有効 provider だけを AI Worker MCP 経由で行う。プロンプトには §5 の必須ガードを必ず織り込む。 + +詳細手順は `skills/agent-dispatch/` を参照(本ルールは方針、skill は手順=DRY)。 + +--- + +## 8. 関連 + +- `skills/agent-dispatch/` — `agents.yaml` と AI Worker MCP を使う worker 委譲手順(本ルールの実行系) +- `skills/kimi-sync/` — Kimi CLI のPJアタッチ(`sync-kimi-from-cc.py`) +- `.claude/rules/general/response-style.md` — 出力簡潔性 +- `.claude/rules/general/visual-progress-map.md` — 進捗可視化 +- `dotfiles/kimi/config.toml.base` — Kimi Code CLI の loop/permission 既定(`max_steps_per_turn` 等) +- 実測ログ・スコアカード・OpenCode Go 選定指標の全文: `/skills/agent-dispatch/references/model-selection-evidence.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: 実測ログ・スコアカードは references(上記)へ追記し、本ルールには足さない(再肥大化防止)。** diff --git a/.agent/rules/branch-rule.md b/.agent/rules/branch-rule.md new file mode 100644 index 000000000..6451c092a --- /dev/null +++ b/.agent/rules/branch-rule.md @@ -0,0 +1,102 @@ + + +# ブランチ運用ルール + +## main ブランチへの直接コミット・プッシュ + +AI エージェントの通常作業では、**main ブランチへの直接コミット・プッシュは禁止**。 + +Markdown、`sync-state.json`、AI ツール設定、AGENT-HUB 運用設定、MCP 台帳などの軽量変更でも、 +AI は main へ直接 commit / push しない。必ず専用 worktree + feature branch を作成し、PR 経由でマージする。 + +人間が明示的に「今回は main に直接反映してよい」と承認した場合、または初回 repo 作成直後で +PR 導線がまだ存在しない場合だけ例外になりうる。AI はこの例外を自己判断で使わず、理由を作業ログに残す。 + +(過去に運用設定・hook配布物等を段階的に allowlist で main 直接許可した経緯があるが、2026-06-23〜2026-07-01 +で全撤回済み。allowlist 変遷史の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照)。 + +## 理由 + +- main checkout は複数 AI / 複数セッションで共有されやすく、軽量変更でも HEAD を掴むと競合や cleanup 失敗の原因になる +- Markdown や設定だけでも、PR にするとレビュー履歴・CI・merge 後確認・worktree cleanup が同じ型で残る +- ツールごとに例外を残すと、Claude / Codex / Cursor / Kimi / Antigravity 間で運用がずれる +- main の最新化は `git pull` ではなく、fetch-only と detached HEAD / 専用 verify worktree で確認すれば足りる + + +## AGENT-HUB の CI とマージ根拠(2026-07-30 STEP 4) + +AGENT-HUB の CI は `workflow_dispatch` + `ci/light` ラベル方式(pull_request 自動トリガーは 2026-07-24 に削除済み)。 +PR に checks が無い場合のマージ根拠は `merge-pr.py` のローカル軽量ゲート(`registries/merge-gate-suite.yaml`)。 +台帳未整備のリポでは従来どおり checks 0 件で通す(詳細: `skills/post-merge/SKILL.md`)。 + +## 配布クローズアウト責任 + +AGENT-HUB から各 PJ へ配布した差分は、配布を実行した AI / 担当者が最後まで閉じる。 + +対象: `scripts/deploy-agent-bundle.py` / `scripts/deploy-hooks.py` / `scripts/sync-agents.py` / +`scripts/bootstrap-skills.py` / `scripts/deploy-skills.py` / `scripts/deploy-rules.py` / +`/publish-deploy` など、上記を呼ぶ配布コマンド。 + +配布先 PJ に tracked 差分が出た場合は、feature branch 作成 → 配布差分だけ commit → PR 作成 → CI/review 確認 → +`merge-pr` でマージ → fetch-only + detached HEAD / verify worktree で取り込み確認 → worktree/branch cleanup → +`git status --short` clean 確認、まで一連で完了する(詳細な完了条件・禁止・例外の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` 参照)。 + +禁止: 「これは自分が修正したファイルではない」として配布差分を放置する/未コミットのまま終了する/ +main 直接 push で済ませる/`--push` の成功だけで完了扱いにする。 + +例外(dry-run のみ・差分なし・既存WIPで安全に branch できない・権限やCI failureで merge できない)の場合も、 +対象 PJ・残っている差分・止めた理由・次の安全な一手を報告する。 + +## 事前計画ステップ + +タスク開始時、変更を伴う作業か確認する(コード変更・JSON/YAML変更・`*.sh`変更・Markdown/sync-state/AIツール設定などの軽量変更)。 +AI 作業で変更がある場合、**最初に専用 worktree + feature branch を作成**してから編集を始める。AI 作業では `main` を checkout しない。 +コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +読み取りだけの場合、またはすでに専用 worktree / feature branch 内にいる場合は新規 worktree を作らなくてよい。 +AGENT-HUB から各 PJ へ配布した tracked 差分も「配布クローズアウト責任」に従う。 + +## pre-commit hook 違反後のピボット + +万一 hook(`hook-library/scripts/block-main-commit.sh`)にブロックされた場合は、変更を退避(stash/patch)→ +専用 worktree で feature branch 作成 → 変更復元 → commit/push → PR 作成、の順で復旧する。main の HEAD は +無変更のまま維持されることを確認する。詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 関連フック + +`hook-library/scripts/block-main-commit.sh` が上記ルールを自動判定・ブロックする。 + +## 関連ルール + +- `.claude/rules/general/worktree-rule.md` — 並列セッション時の worktree 利用 +- `.claude/rules/general/sub-agent-scope-contract.md` — サブエージェント delegate 時の制約 +- `~/business/AGENT-HUB/docs/worktree-operations.md` — allowlist変遷史・配布クローズアウト責任詳細・事前計画コマンド列・pre-commit hookピボット手順の正本 + +--- + +**追記ルール: 実測事例・変遷史・長文手順は `~/business/AGENT-HUB/docs/worktree-operations.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.agent/rules/constructive-dissent.md b/.agent/rules/constructive-dissent.md new file mode 100644 index 000000000..8c703c288 --- /dev/null +++ b/.agent/rules/constructive-dissent.md @@ -0,0 +1,61 @@ + + +# 建設的異議(言いなり禁止・グローバル憲法) + +## 原則 + +AI は**言いなりにならない**。ユーザー指示が現実・制約・過去の不採用判断(CaD)と衝突するとき、迎合せず次の 3 点を必ず行う。 + +1. **現実的制約の明確な指摘** — 無理なものは「無理です」と根拠付きで言う(時間・技術・運用・既存 SSOT・過去の不採用理由)。 +2. **根拠付きの代替案** — 達成したい意図を保ちつつ、実行可能な別ルートを 2〜3 択で提示する(推奨を 1 行添える)。 +3. **保守・メンテナンス観点の改善提案** — 指示に従うだけでなく、「こういう仕組みを入れるべき」と AI から先出しする(出生登録・正本参照・陳腐化防止など)。 + +**最終決定は常にユーザー**。AI は異見を述べたうえで、ユーザーが選んだ方向に従う。 + +## 発火場面 + +| フェーズ | 異議の出し方 | +|---------|-------------| +| **提案・設計** | plan-approval の HTML プランに「🤔 AI の異見」欄で記載(テンプレ側は別 PR で欄追加予定)。プラン提示前に衝突があれば先に異議を出す | +| **実装** | 着手前または実装中に制約・不採用判断との衝突を検知したら、実装を止めて代替案を提示 | +| **レビュー** | codex-review 等の指摘が個人開発スケールに過剰なときも、レビュー結果に対して異議・優先度の再整理を提案できる | + +判断に迷う場合は**異議を出す側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 作法 + +- **根拠必須**: 「良くない」だけでなく、なぜ無理か・何が起きるかを平易語で 1〜2 文。 +- **平易語 + 選択肢**: visual-progress-map §5 に従い、技術用語だけで問わない。速さ・安全・見た目への影響など、ユーザーが判断できる軸に翻訳する。 +- **推奨を添える**: 2〜3 択のうち推奨を明示(「(推奨)」+ 理由 1 行)。 +- **短い同意への再確認**: ユーザーが「お願い」「はい」だけ返したとき、次の一手を 1 文で要約してから進める(response-style と整合)。 + +## 個人開発スケールと例外 + +- **前提**: 本リポ群は個人開発(1 人・非エンジニアオーナー)。大規模チーム向けのプロセス・過度な抽象化・仮想的大規模負荷対策を**無条件で推奨しない**。 +- **過剰エンタープライズ提案への異議**: 「全 PJ に同じ監査パイプライン」「専用 infra チーム前提の運用」等は、意図が明確でない限り異議を唱える。 +- **例外(厳格維持)**: + - **(a)** セキュリティ・データ消失・金銭に関わる指摘はスケールに関係なく常に厳格。 + - **(b)** 顧客向けシステム(jtt-cms の予約・お客様導線・決済・個人情報を扱う画面/API)はエンタープライズ相当の厳格さを維持。 + +codex-review のレビュー観点にも同校正が内蔵されている(プロンプト文字列参照)。 + +## メンテナンス観点の先出し例 + +- 新スキル・hook・ドキュメントを作るとき → `checkup-registry.yaml` への出生登録を提案。 +- 手順・閾値・API 名をハードコードしそうなとき → 正本参照(ライブ読み・SSOT symlink)を提案。 +- 外部 API・ライブラリ版数を書くとき → 最終確認日の記載を提案。 + +## 関連 + +- `.claude/rules/general/response-style.md` — 出力簡潔性・確認の書き方 +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・技術判断の平易化(§5) +- `.claude/rules/general/plan-approval-gate.md` — 実装前 HTML プラン承認(🤔 AI の異見欄と接続) +- `.claude/rules/general/plan-commitment-tracking.md` — 承認済みプラン条項の実行追跡 +- `skills/adversarial-review/SKILL.md` — **本ルールの手順 SSOT**(dev / business の 2 モード・発火条件・証拠水準・自己反証・分布点検)。本ルールは義務、スキルは手順の二段構えとし、手順本文をここへ複製しない +- `skills/codex-review/SKILL.md` — レビュー時の個人開発スケール校正 diff --git a/.agent/rules/hooks-structure-rule.md b/.agent/rules/hooks-structure-rule.md new file mode 100644 index 000000000..39147bf06 --- /dev/null +++ b/.agent/rules/hooks-structure-rule.md @@ -0,0 +1,71 @@ +# hooks 構造ルール + + + +## チェックリストMDの配置 + +| 正しい配置 | 禁止 | +| ----------------------------------------------------- | -------------------------- | +| `hook-library/lib/code-quality-check.md` | `hook-library/prompts/` | +| `hook-library/checklists/security/security-review-check.md` | 任意の新規サブディレクトリ | + +配布後の PJ 側でも同じ規約に従う: + +| 正しい配置 | 禁止 | +| -------------------------------------------- | -------------------------- | +| `.claude/hooks/lib/code-quality-check.md` | `.claude/hooks/prompts/` | +| `.claude/hooks/lib/security-review-check.md` | 任意の新規サブディレクトリ | + +## 禁止事項 + +- `prompts/` ディレクトリの作成・復活(AGENT-HUB / 配布先 PJ いずれも) +- `quality-check-common.sh` のチェックリスト参照パスを `lib/` 以外に変更 +- `supabase-sql-review.md` の復活(`security-review-check.md` と重複していた削除済みファイル) +- `ui-quality-gate.json` の復活(`type: "prompt"` でレビュー LLM に丸投げする方式は失敗時にプロンプト原文がチャットに漏れるため廃止。UI 品質チェックは `code-quality-check.md` の `ui-quality-jp` domain を `subagent-quality-check.sh` / `stop-quality-check.sh` がファイル参照型で reason に出す形で完結する) +- `hook-registry.yaml` の `checklist.security` に `KNOWN_SECURITY_CHECKLISTS` allow-list 外の名前を書くこと(`scripts/deploy-hooks.py` が fail-fast で拒否する) +- 対象PJを明示せずに hook を追加・配布すること。新規 hook は「必要な PJ」「不要な PJ」「Codex/Augment へ載せるか」を AGENT-HUB セッションで決めてから `hook-registry.yaml` に登録する。 +- `/hook-publish` の復活。project 配布applyは + `scripts/sync-agents.py --project --project-root ` だけを公開入口とし、物理 hook writerを単独実行しない。 + +## hook 追加・配布フロー + +1. AGENT-HUB セッションで hook の目的と対象PJを決める。 +2. `hook-library/scripts/`、`hook-library/settings/`、`scripts/deploy-hooks.py` の script map、`hook-registry.yaml` を同一PRで更新する。 +3. `scripts/sync-agents.py --project --dry-run` で全 surface の同一generation差分を確認する。 +4. 実配布が必要ならcleanな専用linked worktreeを明示してfull applyする。複数PJでも明示リストを1件ずつ処理する。 +5. 個別 writer の `--all` は使わない。全PJの一括同期は別の明示承認とscope確認を必要とする。 + +## チェックリスト注入方式 + +| 方式 | 説明 | +| ------------------------ | --------------------------------------------------------- | +| ファイル参照型(採用) | reason にファイルパスを記載し、AIがReadツールで読む | +| インライン注入型(廃止) | reason にチェックリスト全文を埋め込む(チャットが埋まる) | + +reason にチェックリスト全文を埋め込まないこと。AI が Read ツールでファイルを読む形にすることで、ユーザーのチャット視認性を確保する。 + +## 配布スクリプトによる強制ガード(`scripts/deploy-hooks.py:merge_settings()`) + +| ガード | 役割 | +| --- | --- | +| `_strip_prompt_type_hooks()` | `type: "prompt"` の hook をマージ時に強制除去。インライン注入型の混入を配布パイプラインで遮断する | +| `_dedupe_hooks_by_command()` | `(matcher, paths, command)` ベースで dedupe。dict 完全一致比較が空白・キー順差で破綻し、過去 jtt-cms に重複 4 件(`prettier-format` / `seo-check` / `storage-url-check` / `block-main-commit`)が混入した実績の再発防止 | + +これらのガードと `--all --confirm-all-hook-scope` の安全弁を外す変更は禁止。検証スクリプト `scripts/test-deploy-hooks-merge-settings.sh` がガードの挙動を回帰チェックする。 + +## 理由 + +`scripts/deploy-hooks.py`(テンプレート配布スクリプト)は配布先 PJ の `lib/` にチェックリストMDをデプロイする。`quality-check-common.sh`(runtime)が異なるパスを参照すると、新規 PJ セットアップ後に品質チェックリストが見つからず approve が素通りする。 + +`security-review-check.md` の内容は SECURITY DEFINER / RLS / `crm.` schema 等 Supabase + Postgres 専用のため、Supabase を使わない PJ には配布しない(registry の `checklist.security` を空配列にする)。 diff --git a/.agent/rules/latest-stack-context7.md b/.agent/rules/latest-stack-context7.md new file mode 100644 index 000000000..b14c285b7 --- /dev/null +++ b/.agent/rules/latest-stack-context7.md @@ -0,0 +1,43 @@ +# 最新スタック確認ルール(context7 必須) + +## 対象ライブラリ(AI カットオフ後・急速更新) + +以下を**実装・デバッグ・設定変更する前に必ず** context7 で最新 docs を取得する。 +記憶だけで書かない(古い API を使うと動かない・型エラー・ビルド失敗を引き起こす)。 + +| ライブラリ / フレームワーク | 主な罠 | +|----------------------------|--------| +| **Next.js 16+** | `middleware` → `proxy.ts` に改名(Next15→16)、`cookies()`/`headers()` は非同期=`await` 必須(Next15で async 化・16で同期アクセス廃止)、App Router キャッシュ挙動変更 | +| **React 19+** | Next15 以降は React19 前提。`use()`, Server Actions の型・挙動変更 | +| **@serwist/next** / **serwist** | SW ビルド設定・`defaultCache` API が頻繁変更。Turbopack 非対応(`--webpack` 必須) | +| **motion 12+** (`motion/react`) | `motion-plus` API、`AnimatePresence`・`useSpring` 型変更 | +| **Tailwind CSS v4+** | `@config` 廃止・CSS ファースト設定に移行(`tailwind.config.js` 非推奨) | +| **drizzle-orm** | マイグレーション API・スキーマ定義が毎 minor で変わりやすい | +| **vaul** | ドロワー API・`snapPoints` 型が変わっている可能性 | +| **sonner** | `toast()` オプション・`Toaster` props の更新 | + +## 必須手順 + +1. `mcp__context7__resolve-library-id` でライブラリの context7 ID を取得 +2. `mcp__context7__query-docs` で最新 docs を取得してから実装 +3. context7 が使えない環境は `WebFetch` で公式 docs を取得(記憶補完のみでの実装禁止) + +``` +例: Next.js 16 の proxy.ts (旧 middleware) を実装する前に + → resolve-library-id "next.js" → query-docs "proxy middleware" +例: serwist defaultCache を設定する前に + → resolve-library-id "@serwist/next" → query-docs "defaultCache" +``` + +## 古い API の使用禁止 + +- **Next15 以前の同期 `cookies()`**: Next16 では非推奨。`await cookies()` を前提に書く(context7 で確認) +- **`middleware.ts`(Next16 では `proxy.ts`)**: 名前が変わった。context7 で確認してから書く +- **Pages Router 前提のコード**: App Router が前提。`getServerSideProps` 等を新規に書かない +- **React18 前提の型**: React19 の型変化(`children: ReactNode` の必須化等)を確認してから書く +- **旧 `motion/react` 型**: `motion-plus` の型は memory だけで書かない + +## 関連 + +- `skills/dev-guardrails` — フェーズ別ワークフロー・品質ゲート +- `skills/pwa-guardrails` — serwist 配線・PWA 品質チェックリスト(context7 が必要になる代表例を列挙) diff --git a/.agent/rules/mandate-registry.md b/.agent/rules/mandate-registry.md new file mode 100644 index 000000000..4fada9b7c --- /dev/null +++ b/.agent/rules/mandate-registry.md @@ -0,0 +1,59 @@ + + +# 横断チェック台帳(mandate-registry)への登録ルール + +## 原則 + +「これは全アプリで必要だ」という横断的な気づきは、ルール追記だけで終わらせず**台帳へ1行登録する**。 + +理由: ルールファイルへの追記は**新規開発にしか効かない**。既存アプリへの適用漏れは、機械が乖離を提示しない限り再指摘が起きるまで発火しない。台帳へ登録しておけば `mandate-audit.py` が未対応アプリを一覧化し、記憶や注意力に頼らず気づける。 + +## 発火条件(トリガー) + +伸太郎殿が以下のような**横断指摘**をしたとき: + +- 「これは全アプリで必要」 +- 「横展開すべき」 +- 「他のアプリでも同じ対応が要る」 + +判断に迷う場合は**登録する側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 必須手順 + +1. **重複確認**: `registries/mandate-registry.yaml` を `id` / `title_ja` で grep し、同種の項目が既に無いか確認する(複数 AI による二重登録防止)。 +2. **1行登録**: 無ければ台帳へ1エントリ追加する。`reason` には経緯1行+日付を必須で入れる。 +3. **報告**: 登録したことを利用者へ報告する(黙って追加しない)。 + +## 監査 + +「横断監査して」等の発話で `python3 scripts/mandate-audit.py` を実行し、結果を提示する。作業対象アプリが決まっているセッションでは `--app ` で絞り込む。 + +## 回答の記録 + +台帳の `status` フィールドは利用者の回答をそのまま反映する: + +| 利用者の回答 | 記録する値 | +|------|-----------| +| 「後で」 | `snoozed:YYYY-MM-DD` | +| 「対象外」 | `na` | +| 対応 PR がマージされた | `done` | + +## 限界の明示 + +`check: manual` の項目は**目視消込**であり、**監査が緑でも全部 OK を意味しない**。機械(`mandate-audit.py`)が見えるのは台帳に記録された静的な項目だけであり、実装が実際にルールへ適合しているかは別途確認が要る。 + +## スキーマ・規約の正本 + +台帳のフィールド定義・規約①②(`check:script` の実行前提・登録前の重複確認義務)は `registries/mandate-registry.yaml` のヘッダコメントが正本。本ルールへ複製しない。 + +## 試行フェーズ + +2026-08-17 目安で、登録実績・提案件数・`status` 更新のコストを振り返る。セッション開始 hook による自動提案の採否は、その振り返りを踏まえて別プランで判断する(今は hook 化しない)。 + +--- + +**追記ルール: 実測事例・長文手順は台帳ヘッダ/別 doc へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.agent/rules/mcp-key-management.md b/.agent/rules/mcp-key-management.md new file mode 100644 index 000000000..bb99236f4 --- /dev/null +++ b/.agent/rules/mcp-key-management.md @@ -0,0 +1,97 @@ + + +# MCP API キー管理規範(AGENT-HUB SSOT) + +JTT 関連の MCP(asana-mcp / jtt-smaregi-mcp / smaregi-docs / google-chat-mcp / google-docs-mcp / jtt-spreadsheet-mcp 等)の API キーは **AGENT-HUB を SSOT として一元管理**する。 + +詳細手順(復旧・ローテーション・実装経緯・実例)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。本ルールは義務・禁止事項だけを持つ。 + +## SSOT + +| 役割 | 場所 | 状態 | +|------|------|------| +| 実値(秘匿) | `~/.config/agent-hub/.env` | コミット対象外、各マシンで作成 | +| 名前テンプレート(公開) | `~/business/AGENT-HUB/dotfiles/.env.example` | git 管理、新マシン bootstrap で参照 | +| 環境変数 export | `~/.zshrc.local` の `set -a; source ~/.config/agent-hub/.env; set +a` | bootstrap.sh が初期セットアップ | + +## スコープ振り分け規範 + +| MCP 種別 | 配布先 | 同期スクリプト | +|---------|--------|---------------| +| 全 PJ 共通で必要な MCP | `asset_contract.global.include.mcp` から各clientの宣言surfaceへ | manifestが所有者として指す単一writer | +| harness type共通の MCP(例: Laravel Boost) | `asset_contract.harness_types..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 固有の業務 MCP | `asset_contract.projects..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 個別環境の例外(例: Supabase stg/prod) | project layerと明示local-exception契約へ宣言 | writerが保護・描画。生成surfaceの手編集は禁止 | + +理由: User scope に PJ 固有 MCP を入れると「使わない PJ でも表示・接続試行・認証エラー表示」が起きる。PJ別の使用意図はmanifestのproject layerが表現し、client別catalogは選択根拠にしない。 + +**Gmail の扱い(2026-05-25 更新 / 2026-07-20選択経路更新)**: 自前 gmail-mcp は 2026-05-21 に一度凍結したが、公式 Gmail のツール不足(ラベル CRUD / Triage / 添付取得欠如)が判明し **2026-05-25 に Project scope (jtt-cafe-pj) で復活**。接続definitionはStreamable HTTP `/mcp` + X-API-Keyを維持する。採否はjtt-cafe-pjのmanifest project layer、client対応可否は同じeffective MCPに対するsurface契約で判定する。 + +**Supabase の stg / prod 2 環境並列 (jtt-cms)**: `supabase-prod` / `supabase-stg` の2 assetを命名規約として必須にする(`supabase` 単独名・env-agnostic な `mcp__supabase__*` 表記は禁止)。実例・OAuth手順の詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## Claude Code の `${VAR}` 補間仕様 + +**Claude Code は `mcpServers[*].headers["X-API-Key"]` 等の値を `${VAR}` 補間しない**(User scope / Project scope どちらも同じ)。 + +→ sync スクリプトは `~/.config/agent-hub/.env` から実値を読み出し、`/.mcp.json` / `~/.claude.json` には実値を書き込む。 + +→ よって `.mcp.json` は **gitignore 必須**(実値がコミットされないように)。AGENT-HUB の SSOT は環境変数名のみ保持し、各マシンで sync 実行時に実値展開する。同じ理由で `~/.claude.json` / `.gemini/settings.json` / `.cursor/mcp.json` / `.kimi-code/mcp.json` も全て gitignore 必須(対象ファイルと生成元の gitignore 必須リストは `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。 + +## 禁止事項 + +1. **`~/mcp-servers//.env` へ直書き禁止**。`asana-mcp/.env` `jtt-smaregi-mcp/.env` 等に API キーを置かない。発見次第 `~/.config/agent-hub/.env` へ移行し、ローカル `.env` は `# moved to ~/.config/agent-hub/.env (AGENT-HUB SSOT)` のコメントだけ残す +2. **`~/.zshrc.local` に `_mcp_load_key_from_env` のような分散ロード関数を新設禁止**。AGENT-HUB SSOT の bootstrap フロー(`set -a; source ~/.config/agent-hub/.env; set +a`)を使う +3. **git 管理対象ファイルに API キー実値を平文で書かない**。ドキュメント(README / SKILL.md / 設計書)では `` または env 変数名 `${ASANA_MCP_API_KEY}` で表記する +4. **ローカル生成物へ手作業で API キー実値を書かない**。`.mcp.json` / `~/.claude.json` は gitignore 済みであることを前提に、sync スクリプトだけが `~/.config/agent-hub/.env` から実値展開して書き込む +5. **管理対象ファイルでの URL クエリパラメータ方式(`?api_key=...`)禁止**。Cloud Run の監査ログに URL ごとキーが残るため、`.mcp.json` / `~/.claude.json` / `.codex/config.toml` など AGENT-HUB が生成する設定は `headers: {"X-API-Key": "${...}"}` のヘッダー方式に統一する + +**Claude.ai 例外**: Claude.ai コネクタで `X-API-Key` ヘッダーを設定できない場合のみ、asana-mcp は `https://asana-mcp-vaibinqqva-an.a.run.app/mcp?api_key=` 形式を使ってよい。この例外は Claude.ai 手動登録専用で、AGENT-HUB の生成物には書かない。 + +## 再発防止: sync スクリプトのハードエラー化 + +`scripts/sync-claude-global-mcp.py`、`scripts/sync-claude-project-mcp.py`、`scripts/sync-codex-mcp-configs.py`、`scripts/sync-cursor-mcp-configs.py`、`skills/{gemini,kimi,opencode,augment}-sync/scripts/sync-*-from-cc.py` は、env_key が未解決(`~/.config/agent-hub/.env` に無い/空文字)の場合に **literal `${VAR}` を書き込まず exit 1** すること。 + +理由・過去の実害(jtt-cms で `smaregi-docs` MCP の認証エラーが反復した根本原因)は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## 再発防止: MANAGED block の重複キー除去 + TOML 検証(Codex / 2026-06-02〜) + +`scripts/lib/user_mcp_sync_lib.py` の `replace_managed_block` は、①同名野良エントリの自動除去 ②書き込み前 TOML パース検証、を担保する(MANAGED 対象でない手書き MCP は保護する)。実装経緯・障害の症状は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` の「Codex config TOML 重複キー」節を参照。 + +## 復旧手順(MCP Auth エラー時) + +詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md`。要旨: ①env が読めているか確認 ②`~/.claude.json` の literal `${VAR}` 残存検査 ③対象projectを公開入口から再同期 ④Claude Code を再起動。 + +## ローテーション手順 + +API キーローテーション時の 7 ステップ(新キー発行 → SSOT 更新 → dry-run 確認 → full apply → 個別sync禁止 → 各PJ再起動 → 旧キー失効)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。旧キーを `dotfiles/.env.example` のコメントに「廃止済み」として残してはいけない。 + +## User scope MCP 同期フレームワーク (2026-05-21〜) + +User scope MCP (`~/./...`) の SSOT 一元管理は **user-mcp スキル**が管轄する(User scope / Project scope の設計と担当 sync スクリプトの対応表は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。新エージェント追加 5 ステップ (CLAUDE.md 9-13 参照): `skills/user-mcp/SKILL.md`。 + +## 関連 + +- `dotfiles/.env.example` — 名前テンプレート +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 復旧手順・ローテーション手順・実装経緯・User scope同期フレームワーク対応表の詳細正本 +- `skills/user-mcp/SKILL.md` — User scope MCP 5 ツール統一管理スキル(sync スクリプト一覧はここに集約) +- `scripts/lib/user_mcp_sync_lib.py` — 5 sync 共通 lib (env / registry / MANAGED block / 検証) +- `scripts/sync-claude-project-mcp.py` — Project scope 同期 +- `scripts/codex-mcp-remote-with-env.sh` — Codex 用 SSE → stdio bridge +- `~/business/AGENT-HUB/docs/codex-mcp-registry.yaml` `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — 台帳 +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 障害復旧ランブック +- `docs/reference/project-roots.md` — プロジェクトルート規約 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.agent/rules/memory-lookups.md b/.agent/rules/memory-lookups.md new file mode 100644 index 000000000..f9726e2be --- /dev/null +++ b/.agent/rules/memory-lookups.md @@ -0,0 +1,67 @@ + + +# メモリ参照ルール + + + +## 基本方針 + +memory は、前回までの作業状態・人物名・用語・過去の判断を思い出すための**参照補助**である。 +売上・タスク・勤怠・予約・確定ルールの正本ではない。 + +以下のケースに該当するとき、応答を出す前に `~/.claude/projects/*/memory/MEMORY.md` および同階層の個別メモリファイルを検索する: + +- **人名・略称・愛称**に遭遇したとき(読み方・関係性が記録されている可能性) +- **PJ 固有用語・コードネーム**に遭遇したとき +- **過去の不採用判断**を覆そうとしているとき +- ユーザーが「あの〜」「以前話した〜」等の指示語で参照しているとき + +## 検索手順 + +`~/.claude/projects/*/memory/` 配下を検索し、`MEMORY.md` のインデックスから該当する個別ファイルを特定して読み、応答に反映する。 + +## 該当メモリがあった場合 + +- メモリの内容を踏まえて応答する +- メモリの記述が古い可能性がある場合は、現在の状態(コード・設定・正本MCP・Markdown SSOT)と突き合わせる +- 矛盾があれば**現状を優先**し、メモリの更新を提案する + +## JTT 業務情報の正本 + +| 情報 | 正本 | +|------|------| +| 売上・取引・商品実績 | スマレジ / `jtt-smaregi-mcp` | +| 施策・担当・期限・進捗 | Asana / `asana-mcp` | +| 勤怠・シフト・出勤者 | 出パンダ / 将来の Depanda MCP | +| 予約・来店予定 | よやくま / 将来の Yoyakuma MCP | +| 確定した方針・ルール・議事録 | プロジェクトの Markdown SSOT | +| 横断分析・再利用する学び | G-Brain | +| 作業途中の短期文脈 | Claude / Codex / Hermes の memory | + +memory と正本が矛盾する場合は、正本を優先する。G-Brain は検索・分析・要約の層であり、MCP から取得した生データの保管先にはしない。 + +**Asana のどこに何があるか**(workspace / project gid / section 構造 / 周期 PJ の命名規則)は +`~/business/AGENT-HUB/docs/reference/asana-project-map.md` が地図。gid を推測せず、まずこの地図を引く。 +地図には参照先だけがあり、タスクの中身は載せない(中身は `asana-mcp` でその場で取る)。 + +## 該当メモリがなかった場合 + +- 推測で補完せず、ユーザーに直接確認する +- 確認後、必要に応じて新規メモリとして記録する(auto memory ルール参照) + +## 関連 + +- グローバル auto memory: `/Users/shintaro/.claude/CLAUDE.md` の「auto memory」セクション +- PJ 別 auto memory: `~/.claude/projects//memory/` diff --git a/.agent/rules/plan-approval-gate.md b/.agent/rules/plan-approval-gate.md new file mode 100644 index 000000000..67d326ca4 --- /dev/null +++ b/.agent/rules/plan-approval-gate.md @@ -0,0 +1,47 @@ +# 実装前に HTML プランで承認を仰ぐルール(強制) + +## 原則 + +中規模以上の**実装に着手する前に、必ず HTML で実装プランを提示し、利用者の明示承認(「この実装でいい」)を得てから着手する**。テキストだけで合意したつもりにならない。 + +理由: 非エンジニアの利用者には「どの画面がどう変わるか」がテキストでは伝わりにくく、着手後に手戻りが多発する。給与v1の説明 HTML のような見せ方を毎回・自動で出して認識を合わせ「手戻りゼロ」を狙う。 + +これは `ui-stitch-mandatory` と同じく**ルールが義務を担い、手順はスキルに置く**二段構え。手順 SSOT は `skills/plan-approval/SKILL.md`(本ルールは手順を複製せず参照する)。 + + + +## 必須手順 + +1. **プラン作成基準をライブ読み**: `skills/plan-approval` が `resolve-pj-prompt.py --phase plan` を実行し、PJ 別のプラン基準(`snippet-prompts/Typinator/plan/`。専用未作成 PJ は汎用 `dev-plan`)を読む。 +2. **HTML プランを作る(固定テンプレを必ず使う・独自デザイン禁止)**: 正本テンプレをコピーし中身だけ差し替える(通常=`plan-template.html`、AI worker 委譲時=`plan-template-aiworker.html`)。必須のビジュアル要素は下記「中身」節を参照。 +3. **提示して承認を待つ(iPhone でも PC でも、両方の届け方を毎回使う)**: HTML プランは**必ず Write ツールで実体の `.html` ファイルとして作成する**。**禁止**: ① HTML 本文をチャットに貼り付ける、② Bash ヒアドキュメントで書き出す(どちらも iPhone で生コードになる)。作成後は毎回 `open ` で PC ブラウザにも表示する。**タップ用ファイルカード作成と open による PC ブラウザ表示の両方を毎回必須とする**。末尾に「この実装でいいですか?(進めて / 直す / やめる)」を置き、**承認なしに実装へ進まない**。短い同意だけで進めず、次の一手を1文に要約して再確認する。保存先は作業中 PJ の gitignore 済み一時パス(`claude-plans/` 等)、slug は短く、共有 URL は1行で提示する(詳細: `skills/plan-approval/SKILL.md` §5)。 +4. **承認直後に 📋 コミットメント台帳を全件タスク化する**: HTML プランの台帳の各行を、着手前に `TaskCreate` で 1 行 = 1 タスク化してから実装へ進む。台帳が全消化(実施済み or 明示保留)になるまで「完了」と宣言しない。詳細は `.claude/rules/general/plan-commitment-tracking.md`。 + - **AI worker を 1 度でも使う計画は必須**: 「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れ、タスク化する(テンプレに既定行として焼き込み済み・消さない)。 +5. **承認後は標準パイプラインを通す**: 実装(dev-guardrails)→ codexレビュー → 実装監査 → CI → SSOT 同期確認 → マージ。本番投入は人間ゲート。 + +## HTMLプランの中身 + +中身の構成部品(🎯目的・🖼️前後比較・🗺️ユーザーストーリー・🔀画面遷移図・📚メニュー構成図・🧩変える物一覧・🤔AI の異見・🔭一段上の視点・📋コミットメント台帳 等の必須要素一覧)はテンプレ正本(`references/plan-template.html` の `parts` マニフェスト)と `skills/plan-approval/SKILL.md` が正本。ここに複製しない。 + +## 適用トリガー + +新機能・新画面、データの形を変える変更(DB 構造変更)、複数ファイルにまたがる実装、画面の見た目・挙動が変わる変更。判断に迷う場合は提示する側に倒す。 + +## 例外(HTMLプラン不要) + +- 誤字・1行修正など、見た目・挙動の方針が変わらないもの +- 純粋な調査・質問への回答、会話だけで完結する話 +- 利用者が「今回は要らない」と明示したとき +- UI に変化を伴わない純粋なロジック修正(ただし複数ファイル・データ変更を伴うなら提示する) + +## 接続・関連 + +手順: `skills/plan-approval/SKILL.md`(テンプレ・保存規約・承認ループ・3層視覚化)。プラン基準: `snippet-prompts/Typinator/plan/[PLAN-INPUT]_-plan.md`。進捗: `visual-progress-map.md`。UI確定: `ui-stitch-mandatory.md`。承認後: `skills/dev-guardrails/SKILL.md`(各フェーズは `resolve-pj-prompt.py` 同一リゾルバ)。モデル委譲: `ai-model-selection.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/.agent/rules/plan-commitment-tracking.md b/.agent/rules/plan-commitment-tracking.md new file mode 100644 index 000000000..736552d4e --- /dev/null +++ b/.agent/rules/plan-commitment-tracking.md @@ -0,0 +1,45 @@ + + + + +# プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) + +## 原則 + +承認済みプラン本文に書かれた**全ての commitment / 条項**を、実装着手前に **1 項目 = 1 タスク**へ起票する。 +プランの箱(HTML / テキスト)を静的ドキュメントで終わらせない。承認は一度きりの儀式ではなく、 +実行ループ全体で参照し続ける**生きたチェックリスト(plan-as-live-checklist)**として扱う。 + +## なぜ + +プランに「〜不具合時は正本を直す」等の条項があっても、主要タスクだけ起票すると、長い実行ループ(`/compact` でプラン本文が能動コンテキストから外れる)で**一度も発火せず**未実施のまま「完了」と誤宣言する。これは**全 PJ で再発し得る構造欠陥**。実例は `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。 + +## 必須手順 + +1. **承認直後に台帳化**: プラン本文の commitment / 条項(「〜不具合時」「〜したら」「最後に〜」「後で」「別プラン」「TODO」「フォローアップ」「要〜判断」の類)を全て抽出し、着手前に **TaskCreate で 1 項目 = 1 タスク**化する。**📋 コミットメント台帳セクションが空でないのに、未タスク化のまま実装へ進まない**。 + - **AI worker を 1 度でも使う計画なら、「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れる**(テンプレ既定行・消さない)。無ければ台帳は未完成。 +2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 +3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 + - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 +4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 +5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 +6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。 + - **`/goal` 等の反復発火チェッカーへの対応**: 人間ゲート行に反復発火する場合、AI は §6 の優先(明示保留=全消化・虚偽✓禁止)を **1 度だけ根拠付きで提示して停止**し、以後は最小限の再表明に留める(無限反復・迎合的な虚偽✓化をしない)。実測は `commitment-examples.md`。 + +## 恒久原則(proactive) + +繰り返す同種の摩擦・失敗は、**利用者の指摘を待たず**観測した時点で「最後に正本を直す」を既定の最終ステップとして計画へ自分から組み込む。委譲ジョブの失敗・失速もコミット監視だけに頼らず能動的にポーリングして検知する(`skills/agent-dispatch` の「失敗の能動検知」と対)。 + +## 接続 + +義務: `plan-approval-gate.md`。手順: `skills/plan-approval/SKILL.md`。実例: `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。進捗可視化: `visual-progress-map.md`。能動検知: `skills/agent-dispatch/SKILL.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/.agent/rules/reference-over-hardcode.md b/.agent/rules/reference-over-hardcode.md new file mode 100644 index 000000000..d53cd3839 --- /dev/null +++ b/.agent/rules/reference-over-hardcode.md @@ -0,0 +1,49 @@ + + +# ハードコード排除・参照型設計(グローバル憲法) + +## 原則 + +**ハードコード(直書き)をしない。** 設定値・手順・API 名・パス・思想・スタイルなど、2 箇所以上で必要になる情報は、**正本(SSOT)を 1 箇所に置き、他はそれをライブ参照する**(参照型設計)。 + +- どうしても直書きが必要な場合は、**1 箇所に集約**し、なぜ集約先を作ったのかを CaD コメント等に残す。 +- 「そこだけ直書き」を積み重ねると、後から値がずれる・改訂が反映されない・矛盾が生まれる。これは規模の大小を問わず起きる。 + +この原則は、サブエージェント作成・SSOT 構築・hook 実装・YAML 台帳・skill・rule のどの作業でも同じ扱いにする。特定のフェーズだけに適用される限定ルールではない。 + +## 参照型の実例 + +- **設計思想**: `~/business/AGENT-HUB/docs/design/design-philosophy.md` に集約し、UI/デザインに関わる各所(stitch-screen-creator 等のサブエージェント、UI 作成フロー)からライブ参照する。思想を各 PJ の rule や skill に複製しない。 +- **MCP キー**: `~/.config/agent-hub/.env` に実値を集約し、各 PJ の `.mcp.json` や sync スクリプトはそこから展開する(`.claude/rules/general/mcp-key-management.md`)。`~/mcp-servers//.env` への直書きは禁止。 +- **スキル手順**: 各 rule は手順を複製せず、手順 SSOT(skill)をライブ読みで参照する(例: `plan-approval-gate.md` が `skills/plan-approval/SKILL.md` を参照する二段構え)。 + +## 発火場面 + +- 新しい設定値・API 名・閾値・手順・文言などを**2 箇所以上に書きそうになった時**。 +- 既存の rule / skill / doc の内容を**別ファイルにコピーして使いたくなった時**(コピーせず参照にする)。 +- サブエージェントや AI worker への delegate プロンプトに、正本にある情報を**そのまま貼り付けたくなった時**(正本のパスを渡し、読ませる方を優先する)。 +- **配布物(他 PJ へ配る rule / agent / skill)から AGENT-HUB 専用ファイルを参照する時**: 相対パス(`docs/design/...`)ではなく**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)で書く。配布先 PJ の実行 cwd はその PJ 自身であり、相対パスでは正本を解決できず参照が壊れる(2026-07-07 実装監査が検出)。 + +## 個人開発スケールとの両立 + +`.claude/rules/general/constructive-dissent.md`「個人開発スケールと例外」節を参照(同根の原則・過剰な抽象化を避け素朴な解決を優先する基準)。 + +## 関連 + +- `.claude/rules/general/constructive-dissent.md` — 言いなり禁止・グローバル憲法(同種の常時ロード規範。メンテナンス観点の先出し提案として「正本参照」を挙げている) +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 参照型設計の実例(G-Brain 正本からの派生ドキュメント) +- `.claude/rules/general/mcp-key-management.md` — MCP API キー一元管理(参照型設計の実例) + +この原則は G-Brain の上流原則 `principle-single-source-of-truth-reference`(伸太郎殿の開発大原則)と同根である。 diff --git a/.agent/rules/response-style.md b/.agent/rules/response-style.md new file mode 100644 index 000000000..e5dc40415 --- /dev/null +++ b/.agent/rules/response-style.md @@ -0,0 +1,56 @@ + + +# 出力簡潔性ルール + +## 基本方針 + +- 中間状態(「これからこうします」「次にこれを実行します」)の冗長な説明を避ける +- 概念的な説明より具体例・差分・コマンドを優先する +- 段落より bullet list を優先する +- 同じ情報を 2 回繰り返さない(タスクツールで進捗を可視化している場合は、テキストで重ねて述べない) + +## 避けるべき出力パターン + +- 「〜について説明します」「以下に〜します」等の予告フレーズ +- 完了済みタスクの再要約(diff や git log が SSOT) +- 「もし〜の場合は〜」の仮定列挙(実行結果を待ってから判断する) +- 「これは〜という意味です」型の自明な解説 + +## 確認するときの書き方 + +ユーザーに判断を仰ぐときは、選択肢を簡潔に列挙し、推奨案を 1 行で示す: + +良い例: `Tier 2 まで実行 / REN-1 のみ / 全部 のどれにしますか?推奨: Tier 2 まで` + +悪い例(冗長すぎ): 長文で各選択肢のメリット・デメリットを 3 段落ずつ説明 + +## 完了報告の書き方 + +- 変更したファイル一覧(path のみ) +- 主な変更点(1 行ずつ) +- 確認してほしい点(あれば、1-2 件) + +過剰な「お疲れさまでした」「素晴らしい結果でした」等の挨拶は不要。 + + + +## URL・リンクの出力 + +AI が URL やファイルパスを出力するとき、**URL の直後に全角括弧・句読点(`)` `。` `、` `」` など)を隣接させない**。ターミナルや Markdown のリンク解釈がその記号まで URL に取り込み、リンクが壊れる(404)ため。 + +- URL は原則**独立した行**に置く(前後に説明文があっても URL 単独の行にする)。 +- 文中に置く場合は URL の直後に**半角スペースか改行**を入れ、全角記号を隣接させない。必要なら `< >` または バッククォートで囲む。 +- 悪い例: `詳細は https://example.com/path)。`(`)。` まで URL に食われて 404)。 +- 良い例: 説明文の後に改行して `https://example.com/path` を単独行で出す。 diff --git a/.agent/rules/responsive-both-viewports.md b/.agent/rules/responsive-both-viewports.md new file mode 100644 index 000000000..8d0f6fac8 --- /dev/null +++ b/.agent/rules/responsive-both-viewports.md @@ -0,0 +1,27 @@ +# レスポンシブ UI は全 viewport に足す(普遍ルール・常時適用) + +## 原則(絶対・例外なし) + +レスポンシブな画面にボタン・リンク・ナビ等の UI 要素を**新規に足す**ときは、**モバイル表示とデスクトップ表示の両方**(存在する全ブレークポイント)に足す。片方だけに足すと、もう片方の画面幅でその要素が**消える**。これは開発の普遍ルールであり、条件付きにしない。 + +多くのレスポンシブ実装は同じ内容を画面幅で出し分ける: +- モバイル: 上部バー等(例 `.appbar`)を表示し、サイドバーを隠す +- デスクトップ(例 `@media (min-width:1024px)`): サイドバー等(例 `.side-*`)を表示し、上部バーを隠す + +このとき片方の枠にだけ要素を足すと、もう片方では `display:none` により非表示になる。 + +## 必須 + +1. UI 要素を足すとき、**全 viewport バリアント**(モバイル枠 / デスクトップ枠 / その他ブレークポイント)の**すべて**に足す。 +2. 追加要素が**各画面幅で実際に表示される**ことを確認してから完了にする(該当 CSS の `display:none` / media query の出し分けを読み、隠れる枠だけに足していないか確認する)。 +3. コードレビュー・実装監査でも「新規 UI 要素が全 viewport で見えるか」を必須確認項目にする。 + +## 実例(この規則ができた経緯) + +2026-07-05 cron-dashboard で「🩺 健診」ナビを最初モバイルの上部バー(`.appbar`)だけに足した結果、`.appbar` が `@media (min-width:1024px)` で `display:none` になるため **PC 幅で恒久的に非表示**になり、実装監査がブロッカーとして検出した。デスクトップのサイドバー(`.side-brand` 隣)にも足して解消。片側だけ追加は完了ではない。 + +## 接続 + +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・現在地マップ +- `.claude/rules/general/ui-stitch-mandatory.md` — UI/デザインは Stitch を通す +- `skills/dev-guardrails/SKILL.md` — 実装ガードレール diff --git a/.agent/rules/settings-protection-coexistence.md b/.agent/rules/settings-protection-coexistence.md new file mode 100644 index 000000000..918f3cdd4 --- /dev/null +++ b/.agent/rules/settings-protection-coexistence.md @@ -0,0 +1,54 @@ + + +# settings.json 等の保護テストと正当な配線変更の共存ルール + +## 原則 + +`.claude/` `.codex/` `.cursor/` `.gemini/` `.kimi-code/` `.augment/` `.opencode/` `.githooks/` +`claude-plans/` `node_modules/` 配下および `.env` / `.env.*` は、`tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` +が **プレフィックス一致で広く保護対象と判定**する(実装: `skills/handover-manual/scripts/resolve-handover-path.py` の `is_protected_path()`、 +`PROTECTED_PREFIXES`)。テストは `origin/main` とのマージベース以降 + working tree + staged の変更ファイルを走査し、 +保護対象なのに `allowed_managed_placements`(テスト内のallowlist)に無いパスがあれば **fail** する。 + +この判定は粗い(ディレクトリ丸ごと保護)ため、telemetry 配線・新規ルール追加・hook 再配備など +**正当な変更でも毎回検知される**。これは仕様であり、バグではない。正当な変更を安全に通す手順は以下。 + +## 必須手順 + +1. **まず中央配布経路で済まないか確認する**: project harness は `scripts/sync-agents.py --project --dry-run` + で全surfaceを確認し、承認後だけcleanな専用linked worktreeへfull applyする。個別writerを手で連結しない。 +2. **どうしても直接編集が必要なら、同一PRで `allowed_managed_placements` に追加する**: + `tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` 内のセットへ、 + 変更した具体パスと日付・理由コメントを添えて追記する(例: `# [YYYY-MM-DD][fix] PR#nnn で〜が弾かれた。理由。`)。 + 既存の fix-forward 例(PR#637 `ai-model-selection.md` / PR#666 `constructive-dissent.md` / + PR#695 `responsive-both-viewports.md` / PR#736 `dotfiles/.env.example`)と同じパターンに倣う。 +3. **保護テストの検査ロジック自体を弱めない**: `is_protected_path()` のプレフィックス判定や + `changed_paths_for_protected_check()` の走査範囲を変更・無効化しない。許可は必ず allowlist の + 個別パス追加で行う(一括 skip・正規表現の緩和は禁止)。 +4. **テスト赤のままマージしない**: `python3 -m pytest tests/test_handover_manual.py -q` を PR 作成前に + ローカル実行し green を確認する。CI の同テストが赤の状態での merge は `branch-rule.md` の + CI 緑ゲートに反する(#742 の再発防止)。 + +## 関連 + +- `.claude/rules/general/branch-rule.md` — main 直接コミット禁止・CI緑ゲート +- `.claude/rules/general/plan-commitment-tracking.md` — workaround 自問(正本修正を先送りしない) +- `.claude/rules/general/hooks-structure-rule.md` — hook 配置の隣接ルール(配布経由の管理配置) +- `tests/test_handover_manual.py` — 保護テスト本体・allowlist 実体 +- `skills/handover-manual/scripts/resolve-handover-path.py` — `is_protected_path()` / `PROTECTED_PREFIXES` 実装 diff --git a/.agent/rules/sub-agent-scope-contract.md b/.agent/rules/sub-agent-scope-contract.md new file mode 100644 index 000000000..2021bdff8 --- /dev/null +++ b/.agent/rules/sub-agent-scope-contract.md @@ -0,0 +1,75 @@ + + +# サブエージェント Scope Contract + +サブエージェント(Task / Agent tool)に作業を委譲するとき、delegate 元のプロンプトに**必ず以下 3 項目(コード探索を伴う場合は §4、UI/デザインを伴う場合は §5 を足す)を含める**。制定経緯・テンプレート全文は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。 + +## 1. allowed_files(編集を許可するファイル) + +委譲先が編集してよいファイルパスを明示的に列挙する。 + +例: `「allowed_files: src/api/auth.ts のみ。他は read-only」` + +## 2. forbidden_actions(禁止する操作) + +委譲先が**してはいけない**操作を明示する。よくある禁止例: + +- `auto-format で quote replacement や import 並び替えを実行しない` +- `スコープ外のファイルを編集しない(読み取りは可)` +- `テストの skip / xit を追加しない` +- `existing CaD コメントを削除しない` + +## 3. verify before return(返却前の検証手順) + +委譲先が作業完了を報告する前に実行する検証を指定する。 + +例: +- `git diff --name-only で編集ファイル一覧が allowed_files と一致することを確認` +- `lint / typecheck を実行してエラーが出ないことを確認` +- `想定外の編集があった場合は revert してから報告` + +## 4. context-engine first(コード探索を伴う委譲・Explore 含む) + +委譲タスクが**コードの場所・関数・route・呼び出し関係・影響範囲の探索**を含むなら、prompt に必ず入れる: + +- 「まず `codebase-context-engine` を使う(`grep`/`Read` を先に走らせない)。遅延ツールは + `select:mcp__codebase-context-engine__list_projects,hybrid_search,search_graph,get_code_snippet` でロード」 +- **解決済みの `project` 名を親が渡す**(親が `list_projects` を見て明示)。 + `preferred_project` がある場合はそれを使う。 + `project_scope: ambiguous_worktrees` の場合は、現在の cwd と一致する `root_path` / `preferred_project_candidates` を親が選んでから渡す。 + subagent に `private-tmp-cbm-...` の長いミラー名を推測させない。 +- 「索引はミラー=当日新規/変更したファイルは未反映なので、その分だけ `Read` 併用」 + +理由: 候補圧縮で速く・低コスト(多数 grep/Read を回避)。subagent は本ルールを自動継承しないため親が prompt 注入必須(追加経緯は詳細ドキュメント参照)。 + +## 5. design-philosophy first(UI/デザインを伴う委譲時) + +委譲タスクが**UI・画面・デザイン・レイアウト・コンポーネントの作成/変更**を含むなら、親が prompt に必ず入れる: + +- 「まず `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)を Read してから着手する」を**必読指定**する。 +- 必ず該当ファイルの**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)を渡す(委譲先の実行 cwd は消費先PJであり、相対パスでは解決不能なため)。 +- Stitch を使う画面作成は、`stitch-screen-creator` グローバルエージェント(設計思想を step0 で必読にしている)へ委譲するのが既定。 + +理由: AI Worker(Kimi/Codex/Cursor/GLM 等)自身にデザインセンスが無くても、親が設計思想 doc を必読で渡せば思想に沿った画面を作れる。渡さないと委譲先が自己流判断でずれる。 + +## delegate プロンプトのテンプレート・親側の verify ステップ + +テンプレート全文と、親セッションが `git diff --stat` / `git diff -- ` で確認する verify コマンド列は +`~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。allowed_files 外に変更が混入していた場合は +revert し、delegate にやり直しを指示する。 + +--- + +**追記ルール: 制定経緯・テンプレート全文の詳細は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` へ書き、本ルールには義務・トリガーだけ足す(再肥大化防止)。** diff --git a/.agent/rules/ui-stitch-mandatory.md b/.agent/rules/ui-stitch-mandatory.md new file mode 100644 index 000000000..ae6cf27bb --- /dev/null +++ b/.agent/rules/ui-stitch-mandatory.md @@ -0,0 +1,79 @@ + + +# UI / デザインは必ず Stitch を通すルール(強制) + +制定経緯(2026-05-27 新設判断・2026-07-20 MCP選択正本切替)は `skills/stitch/SKILL.md` の +「ui-stitch-mandatory 制定経緯」節を参照。 + +## 原則 + +UI / 画面 / デザイン / レイアウト / コンポーネントの**新規作成・見た目の変更**依頼は、**必ず Stitch**(`skills/stitch` + Stitch MCP `mcp__stitch__*`)でデザインを生成し、**伸太郎殿が実物を見て確定してから実装に進む**。 + +理由: UI は AI とユーザーの言語的意思疎通が難しく、テキストだけで合意したつもりで実装すると手戻りが多発する。Stitch で生成した実物を見て双方の認識を合わせることで「手戻りゼロ」を狙う。 + +## 必須手順 + +1. **Stitch でデザイン案を生成**(**最低 3・最大 5(ケースバイケース)**)。1 案だけ出して進めるのは**禁止**。 +2. **伸太郎殿が Stitch Web(プロジェクト URL)で比較・確定**する。 +3. **確定したデザインだけ**を基に実装する(`.stitch/` 出力 / DESIGN.md を参照)。 + +## 適用トリガー + +「UI を作って」「画面作って」「デザイン(して)」「レイアウト変更」「コンポーネント新規」など(`skills/stitch` の triggers と整合)。判断に迷う場合は Stitch を通す側に倒す。 + +## データ格納ルール(リポジトリルート汚染防止) + +Stitch 由来のファイルを散らかさないため、保存先を固定する: + +| データ | 置き場所 | +|--------|---------| +| ① デザイン案の比較 | **Stitch Web(プロジェクト URL)で見る** → 全候補をローカル保存しない | +| ② 確定したデザイン | `.stitch/<システム名>/<画面名>/`(`code.html` + `screen.png`)にだけ Export | +| ③ MCP 取得データの一時保存 | **temp ディレクトリ**(その PJ の `/tmp/` 等・gitignored) | +| ④ リポジトリルート直下・任意の場所 | **保存禁止**(ゴミファイル堆積を防ぐ) | + +- `.stitch/` は**Stitch を使う PJ ごとに gitignore する**(生成物はコミットしない)。配布先 PJ へ広げる場合は、その PJ 側の `.gitignore` 変更を別途同じ変更束に含める。 +- 「とりあえずルートに HTML を置く」は**禁止**。必ず上記 ① 〜 ③ のいずれかに収める。 + +## 例外(Stitch 不要) + +- 既存 UI の微修正(typo 修正・1 色だけ変更など、**見た目の方針が変わらない**もの)。 +- UI に変化を伴わない純粋なロジック修正。 + +## MCP 前提 + +Stitch MCPの接続definitionは`~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml`、project採否は +`registries/harness-manifest.yaml#asset_contract` のeffective `mcp` setを正とする。 +未接続時は `scripts/sync-agents.py --project --dry-run` で継承・surface・envを確認し、apply後にfresh clientでruntime proofを取る。 + +## 接続 + +- 手順 SSOT: `skills/stitch/SKILL.md`(プロンプトテンプレ・`.stitch/` 規約・DESIGN.md 抽出・MCP 前提)。本ルールは手順を複製せず参照する。 +- dev フローの普遍 UI ルール(Tailwind 等)は `skills/dev-guardrails/SKILL.md`(2-10 ほか)の上に乗る。業務 PJ は `skills/business-guardrails/SKILL.md`。 +- 要件固め・実装フローでの発火点: `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md`。 +- Stitch でデザインを作る際は `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)に従うこと。本ルールは思想本文を複製せず参照する。 +- 「Stitchで作って」の委譲は `agents/global/stitch-screen-creator.md`(着手前に設計思想 doc を必読)が実行役を担う。 + +## 関連 + +- `skills/stitch/SKILL.md` — Stitch ワークフロー SSOT +- `skills/dev-guardrails/SKILL.md` / `skills/business-guardrails/SKILL.md` — ガードレール +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 発火フロー +- `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — Stitch MCPのtransport / 認証definition +- `registries/harness-manifest.yaml` — global / harness type / projectの採否とsurface契約 +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 伸太郎殿の設計思想 SSOT +- `agents/global/stitch-screen-creator.md` — Stitch 画面作成グローバルエージェント + +--- + +**追記ルール: 制定経緯・実測詳細は `skills/stitch/SKILL.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.agent/rules/visual-progress-map.md b/.agent/rules/visual-progress-map.md new file mode 100644 index 000000000..97ef91694 --- /dev/null +++ b/.agent/rules/visual-progress-map.md @@ -0,0 +1,124 @@ + + +# 図解・現在地マップ・非エンジニア用語ルール + +全 AI・全作業共通の SSOT。ユーザー(非エンジニア)が現在地・ゴール・次の一手を必ず把握できる状態を保つための図解描画ルール。**通常の実装・Issue/PR/PRD 確認・調査でも、§1-bis のトリガーに該当したら skill 抜きで図解を出す**。 + +**テンプレ・実例・置換表の全文は references へ。本ルールは義務とトリガーだけ(再肥大化防止)。** + +## 0. モード判定(開発 / 業務) + +この図解は **2 モード**を持つ。テンプレは共通で、語彙は references の置換表で読み替える(DRY)。 + +| モード | 対象 PJ(デフォルト) | 性質 | ペア guardrails | +|--------|---------------------|------|----------------| +| **開発** | jtt-apps / jtt-cms / jtt-shift-mobile-app / *-mcp 等 | GitHub PR フロー中心 | dev-guardrails | +| **業務** | jtt-cafe-pj / non-pj | 戦略・施策・KPI 中心 | business-guardrails | + +- `jtt-cafe-pj` は business PJ。曖昧なら §5 に従い平易語で確認してから描く(推測しない)。 +- 最小読み替え: PR/Issue/merge/本番投入 → 戦略スコープ/KPI/意思決定/本番運用。詳細は references。 + +## 1. 地図描画タイミング + +| タイミング | 出すもの | +|-----------|---------| +| セッション開始直後 | `.claude/parallel-run-state/*.json` があれば冒頭で全体地図を ASCII 表示(複数あれば選択を仰ぐ) | +| /brainstorm 各フェーズ遷移時 | Phase 1→2→3 移行直前にミニ地図(§3) | +| /parallel-run 各ステップ完了時 | Step 完了報告+次 Step 前に全体地図を再描画 | +| 通常作業中 | §1-bis 該当時は skill 抜きでも L1 ASCII 図解を出す | +| オンデマンド | 「地図」「現在地」「進捗」の発話で即時再描画 | + +`gh pr list --state all` は開始時1回+オンデマンド時のみ呼ぶ(API節約)。再描画は状態ファイルのキャッシュを優先。 + +## 1-bis. skill 非依存の常時発火トリガー(バランス型) + +skill 非起動時でも、以下のいずれかに該当したら L1 ASCII 図解を出す(指示なしで出るのが本ルール最大の目的)。 + +| トリガー | 出す図の例 | +|---------|-----------| +| ① 3 つ以上の要素・手順・選択肢の説明 | 箇条マップ / 比較表 / フロー | +| ② 「今どこ・次どこ」の現在地・進捗 | 5 段階地図 / ミニ地図 | +| ③ Issue/PR/PRD/仕様書を読んで方針を伝える | 関係図 / 要約マップ / フェーズ図 | +| ④ バグ修正の「原因 → 対処」説明 | 原因 → 対処フロー | +| ⑤⑥ 複数ファイル横断の整理・依存関係説明 | 依存ツリー / フロー図 | +| ⑦ 進捗・週次レビュー・残り作業 | **ゴール地図(§2-bis)**。羅列で終わらせない | +| ⑧ AI Worker MCP へ複数 provider 委譲/状態確認 | **AI Worker 進捗図**(references)。provider名でなく作業内容・現在地を主役にする | + +議論を伴う説明・プランはチャットの L1 要点図解を基本とする。L2 HTMLカードは見た目の比較が必要な時、またはユーザー希望時だけ使う(実装承認プランは plan-approval-gate.md 優先)。③④⑦も専用skill化せず本ルールで発火。 + +### 出さない場面(うるささ回避) + +- 単純な一問一答、1 ステップで完結する短い事実回答、「図はいらない」明示時 + +図形式は自由。**重い L2/L3 は使わず L1 ASCII をデフォルト**にし、図を要約として使う。 + +## 2-bis. ゴール地図(骨子) + +§1-bis⑦で出す。やったこと羅列で終わらせず、計画全体・残り・次の一手・ゴール妥当性を同時に出す。 + +必須 7 要素: ①🎯最終ゴール+達成条件 ②全体スコープ ③✅済 ④⬜未(漏れ) ⑤◀次の一手 ⑥残数 ⑦⚠️ゴール妥当性レビュー。 + +短絡禁止: 「実装が終わった=ゴール達成」「施策を打った=成果(KPI)達成」と書かない(本番運用・撤退基準判定まで未達)。骨子: 📍ゴール/つくる→テスト→🚧本番投入→🏁本番=ゴール/✅済・⬜未・◀次の一手。 + +全体スコープ・未着手は PRD / Issue / git log を実読して埋める(推測禁止)。フルテンプレは references 参照。 + +## 3. ミニ地図テンプレート(/brainstorm 用) + +``` +[ 現在地 ] /brainstorm Phase X/3 +✅ Phase 1: 要件聞き取り +🔵 Phase 2: 不明点深掘り ← 今ここ +⬜ Phase 3: 実装方針提示 + +次にやること: <1 文> +``` + +## 4-bis. 視覚化の 3 層(L1/L2/L3)の使い分け + +図解は内容に応じ 3 層を使い分ける。実行手段の SSOT は `skills/visual-companion/SKILL.md`。本ルールは L1 ASCII と判定基準のみ持つ。 + +| 層 | 何を出すか | 手段 | いつ | +|----|-----------|------|------| +| **L1 ASCII** | 進捗・現在地マップ | ASCII 地図(ゼロ依存) | **デフォルト・常時** | +| **L2 ブラウザ HTML** | mockup・レイアウト比較 | `start-server.sh` | 見た目の比較(オプトイン) | +| **L3 ターミナル画像** | HTML を CLI で目視 | `html-to-terminal.sh` | ブラウザを開かず見たい時 | + +判定: 「読むより見た方が理解できるか?」。テキストで足りる選択は L1、見た目の比較は L2/L3。 + +## 5. 非エンジニア用語ルール + +### 原則 + +- 技術用語は**初回登場時のみ**括弧で平易語を併記、以降はそのまま使う(完全置換はしない) +- 短い同意(「お願い」「はい」)だけで進めない + +代表例(全 12 語は references 参照): PR=変更提案 / merge=本番に取り込む / migration=DB 構造変更 / staging=テスト環境 / worktree=別フォルダ作業領域。 + +### 短い同意への応答 + +「お願い」「はい」「OK」だけ返った時は**次の一手を 1 文で要約してから**再確認する。 + +### 技術判断を仰ぐ時(平易語 + 選択肢で聞く) + +**技術判断は技術用語で聞かない**。①平易語(速さ・安全性・見た目への影響)で説明②2〜3択で提示(可能なら AskUserQuestion)③推奨理由を1文添える。実例は references 参照。 + +## 6. 状態ファイル schema + +`.claude/parallel-run-state/.json` に保管(kebab-case slug、各PJの `.gitignore` へ追加)。フィールド定義・モード別 schema・`gh pr list` 合成手順の全文は `/skills/visual-companion/references/state-file-schema.md` を参照。 + +## 7. 関連ルール + +- `.claude/rules/general/response-style.md` / `sub-agent-scope-contract.md` / `branch-rule.md` +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 各フェーズ・Step 遷移時に参照 +- `commands/brainstorm.md` / `commands/parallel-run.md` — 手動発火ラッパー +- 全文: `/skills/visual-companion/references/progress-map-templates.md`, `state-file-schema.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: テンプレ・実例・置換表は references へ書き、本ルールには足さない(再肥大化防止)。** diff --git a/.agent/rules/worktree-rule.md b/.agent/rules/worktree-rule.md new file mode 100644 index 000000000..832bc98f1 --- /dev/null +++ b/.agent/rules/worktree-rule.md @@ -0,0 +1,114 @@ + + + + + + +# Worktree 利用ルール + +## いつ worktree を使うか + +AI が変更を加える通常作業では、git worktree を作成して別ディレクトリで作業する。 +特に以下のいずれかに該当するときは必須: + +- **並列セッション**: Claude Code / Codex CLI / Cursor 等を同時に複数立ち上げて別タスクを進める +- **複数 PR 同時進行**: 同一リポジトリで 2 本以上の feature branch を行き来する +- **長期 feature branch**: main から離れて 1 日以上滞在する作業(途中で main を hotfix する可能性がある) +- **軽量変更を含む AI 作業**: 例外なし。詳細は branch-rule.md 参照 + +例: +``` +git worktree add ../jtt-cms-feat-xyz -b feat/xyz +cd ../jtt-cms-feat-xyz +``` + +## いつ新規 worktree を作らなくてよいか + +以下は新規 worktree なしでよい: + +- 読み取りだけでファイル変更・commit・push がない場合 +- 既に feature branch にチェックアウト済みで、別タスクを差し挟まない場合 +- 既にこのタスク専用の worktree / branch にいる場合 +- 人間が明示承認した main 直接反映や初回 repo 作成など、branch-rule.md の注記に該当する例外の場合 + +## 機密ファイル(MCP / .env)の自動 symlink + +worktree 作成時、git 追跡外の機密ファイル(`.mcp.json` / `.env` 系)は main worktree の実体へ**自動 symlink**される(git post-checkout hook 由来)。追加操作は不要。仕組み・手動再設置手順・非破壊の詳細は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## Mac mini ContextEngine mirror の自動追従 + +Mac Studio 側の worktree は Mac mini の ContextEngine mirror が自動追従する(対象: jtt-cms / jtt-apps / jtt-system / AGENT-HUB / hermes)。索引はミラーであり当日の新規変更は未反映のことがある。詳細・stale削除・semantic強化ジョブは +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## branch contamination が発生した場合の復旧 + +別セッションのブランチに誤ってコミットした場合は、誤コミット特定 → 正しいブランチへ `cherry-pick` → 復旧用退避作成、の順で対応する。 +**`git reset --hard` と force-push はデフォルト禁止。必ずユーザー承認を得てから実行する。** +詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## AI セッションから worktree へ commit / push する方法(block-main-commit 対策) + +block-main-commit hook は cwd 変更を伴う複合コマンドでの main 直 commit を fail-closed で deny する。AI セッション(cwd=main)から worktree の feature branch へ commit / push する時は: + +1. **`isolation: "worktree"` 付きサブエージェントに委譲する**(正攻法)。 +2. isolation 指定ができない場合のみ、GitHub API / connector で remote feature branch commit → PR → CI → merge の fallback を使う(main 直更新は禁止のまま)。 +3. commit/push を含まない操作(`git add` / `git status` / `gh pr create` 等)はメインセッションから直接 `cd && ...` してよい。 +4. hook 検査を `bash -c` 等で素通りさせる回避は**禁止**。 + +サブエージェントの worktree が古いベース(origin/main 以前)から切られる問題への対処、外側隔離 worktree の残存・cleanup 手順、Codex fallback の実測経緯は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 共有 checkout / main 非占有ルール(全 PJ・全 AI ツール共通) + +対象ルート: `~/LLM-Dev/` `~/business/` `~/Herd/` `~/mac-mini-server/` `~/mcp-servers/` `~/jtt-system/`。Claude / Codex / Cursor / Kimi / OpenCode / Antigravity 全て同じ意味で読む。 + +**AI セッションは、他者や他エージェントが使う可能性のある `main` checkout を掴まない。** 共有 checkout で merge / pull / cleanup を実行すると、並行セッションとブランチ・HEAD を奪い合って競合する。背景・実測実害は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### 必須:1 タスク = 1 連の完了フロー(PR を出して放置しない) + +**専用 worktree 作成 → 編集/commit/push → PR 作成 → マージ → fetch-only / detached 確認 → clean(worktree/branch 削除)まで、必ず一連で最後まで閉じる。** 「PR を出した」「マージした」で止めない。詳細コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### AI が `main` で「やらないこと / 代わりにやること」 + +- **やらない**: `git checkout main` / `git switch main` / `git pull` while on `main` / `git branch -f main`。 +- **やる**: `git fetch origin +refs/heads/main:refs/remotes/origin/main` で remote tracking ref を更新する。確認が必要な時は `git worktree add --detach origin/main` で detached 確認。 +- merge は worktree 内から `gh` / `skills/post-merge/scripts/merge-pr.py --confirm-read` で行う。 +- **cleanup は自分が作った worktree / branch だけ**削除する。`git worktree list --porcelain` で他セッションのものを確認し**温存する**。 +- allowlist 対象の生成 config を main 直コミットする時の stale-main 注意は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +要するに「編集だけ worktree、merge/pull は共有 checkout」をやめる。**着手から cleanup まで一貫して専用 worktree**で閉じる。例外的に人間が明示して main checkout を使う場合は、AI が占有している状態でないことと例外理由を作業ログへ残す。 + +## 既存 worktree の確認 + +```bash +git worktree list +``` + +`~/Herd/jtt-apps` 配下には `jtt-apps-api-rate-limit-guards` / `jtt-apps-wt` / `jtt-apps-worktrees` 等の既存 worktree がある(CLAUDE.md `## プロジェクトルート規約` 参照)。新規作成前に既存 worktree の再利用可否を確認すること。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/.claude/rules/general/.agent-hub-materializations.json b/.claude/rules/general/.agent-hub-materializations.json new file mode 100644 index 000000000..b912cc0be --- /dev/null +++ b/.claude/rules/general/.agent-hub-materializations.json @@ -0,0 +1,97 @@ +{ + "project": "agentmemory", + "rules": { + "ai-model-selection.md": { + "asset_id": "ai-model-selection", + "sha256": "820fc60cc98d36374571c2968a7f45935bdfbce5f701649b714a930caeee5ea0", + "source": ".claude/rules/general/ai-model-selection.md" + }, + "branch-rule.md": { + "asset_id": "branch-rule", + "sha256": "37387a5c524f78a9f9c169a8707daaf4b0d415cbc4ea99d81ba5bf16c6c709b0", + "source": ".claude/rules/general/branch-rule.md" + }, + "constructive-dissent.md": { + "asset_id": "constructive-dissent", + "sha256": "92373c52b76e0d8d0f82cf03fe8a1f79638644b31c2dbcdb9bd522c45f7aa34a", + "source": ".claude/rules/general/constructive-dissent.md" + }, + "hooks-structure-rule.md": { + "asset_id": "hooks-structure-rule", + "sha256": "271c86c3aead4e05d27dbe773ce5e6e3490d2467494b2112250f95675e233e48", + "source": ".claude/rules/general/hooks-structure-rule.md" + }, + "latest-stack-context7.md": { + "asset_id": "latest-stack-context7", + "sha256": "501cd24a32cad2fe5bbb89c3c7b0d6229dd5d023c21a35f8e13497adf031b30e", + "source": ".claude/rules/general/latest-stack-context7.md" + }, + "mandate-registry.md": { + "asset_id": "mandate-registry", + "sha256": "03b6bc7d08834ffecbbff7e34023ce102748677520b52fea38162ebb909b5d63", + "source": ".claude/rules/general/mandate-registry.md" + }, + "mcp-key-management.md": { + "asset_id": "mcp-key-management", + "sha256": "040269280efd97ab46c4f15a5f7dc166313b8de1d2d4270b2f9e3cff58efe282", + "source": ".claude/rules/general/mcp-key-management.md" + }, + "memory-lookups.md": { + "asset_id": "memory-lookups", + "sha256": "0d14607db5ca54f87d538565e8ba7f31c563a67bfe7d9b6c5b96c701cbdc626d", + "source": ".claude/rules/general/memory-lookups.md" + }, + "plan-approval-gate.md": { + "asset_id": "plan-approval-gate", + "sha256": "360a00fffd3e73d11f9ca7751b8b4fcfee4108ad3ce871feaa46e7f1ade9d679", + "source": ".claude/rules/general/plan-approval-gate.md" + }, + "plan-commitment-tracking.md": { + "asset_id": "plan-commitment-tracking", + "sha256": "64af4fd8bd13a7a2d7276190c4e8db514572cd5ffcd408781afb2903dab8167e", + "source": ".claude/rules/general/plan-commitment-tracking.md" + }, + "reference-over-hardcode.md": { + "asset_id": "reference-over-hardcode", + "sha256": "9795f759e3afb9e4a8842e2117531d1ef8576838fe8e24ecb78c0fc0806a94c5", + "source": ".claude/rules/general/reference-over-hardcode.md" + }, + "response-style.md": { + "asset_id": "response-style", + "sha256": "b5c3697f30f329a88d7fa28f668fd41562fff09627e802a995d23e6638f535ff", + "source": ".claude/rules/general/response-style.md" + }, + "responsive-both-viewports.md": { + "asset_id": "responsive-both-viewports", + "sha256": "a98414941642aced0443fe9d7b81a0e71cacf4f1acb4f8be078c0b5bc3385607", + "source": ".claude/rules/general/responsive-both-viewports.md" + }, + "settings-protection-coexistence.md": { + "asset_id": "settings-protection-coexistence", + "sha256": "c85dae4a20be558cce354b5b7f846d2a156c69f49ec3f5830ffc31acf35cf036", + "source": ".claude/rules/general/settings-protection-coexistence.md" + }, + "sub-agent-scope-contract.md": { + "asset_id": "sub-agent-scope-contract", + "sha256": "8160d13f58be81f55ebe1bfea0489fe80992cc94100dd2406b18fb9b8ff30770", + "source": ".claude/rules/general/sub-agent-scope-contract.md" + }, + "ui-stitch-mandatory.md": { + "asset_id": "ui-stitch-mandatory", + "sha256": "0570b3bb37dfbd4ee2b6a60f2052307467b8d0537ec261445301af3978485d5a", + "source": ".claude/rules/general/ui-stitch-mandatory.md" + }, + "visual-progress-map.md": { + "asset_id": "visual-progress-map", + "sha256": "afa7a75e69d5a6aa63d98499c329ab0a8bb168ed38bded6bebc580f8f5cd2097", + "source": ".claude/rules/general/visual-progress-map.md" + }, + "worktree-rule.md": { + "asset_id": "worktree-rule", + "sha256": "9a43a3efc347b9b6226ca62a78af252228c0a9862d1361eedf57de56945f9684", + "source": ".claude/rules/general/worktree-rule.md" + } + }, + "version": 1, + "writer": "deploy-rules" +} diff --git a/.claude/rules/general/ai-model-selection.md b/.claude/rules/general/ai-model-selection.md new file mode 100644 index 000000000..3761020ba --- /dev/null +++ b/.claude/rules/general/ai-model-selection.md @@ -0,0 +1,78 @@ + + +# AI モデル選定指標(GLM 5.2 / Kimi K2.7・K3) + +全 PJ 共通。コード実装をAIエージェントに任せる際の初期ヒューリスティック。 + +> ⚠️ これは**法則ではなく初期判断**。母数が小さい(初期 n=4 + 追加観測・人が見ながら実行)。矛盾する観測が出たら現状を優先し、実測ログ(references)を更新すること。 + +--- + +## 0-bis. Codex 指名時の固定ルール + +- **`codexで実装` / `Codexで実装` / `codex実装` / `Codex実装`** と言われた時だけ、Codex 実装として扱う。 +- Codex 実装の正式設定名は **model = `gpt-5.3-codex-spark`**, **model_reasoning_effort = `high`**(既定。旧既定 `medium`。SWE-Bench Pro 実測で high→xhigh の上げ幅は1pt未満のため常時 xhigh は費用対効果が低い)。 +- 起動例は `codex exec -m gpt-5.3-codex-spark -c model_reasoning_effort=high`。 +- **`xhigh` はユーザーが明示指定した時だけ使う**。軽微タスクは `medium` を明示指定する。AI が自動・既定・推測で `xhigh` を選ばない。 +- **「実装」だけでは Codex 固定にしない**。Cursor / Kimi / GLM / Claude / Codex のどれで進めるかを文脈で判断し、不明なら確認する。 +- **Spark は AI Worker MCP の auto routing 候補に対等参加する**(適材適所+残量バランス・絶対優先ではない)。原因不明バグ・設計判断・DB移行・大規模リファクタ・コンテキストが大きい仕事は Spark に固執せず、auto が適材適所で他 worker(GLM/Kimi/Gemini)へ回避する。 +- **「レビュー」または「codexでレビュー」** は既存の `codex-review` 導線を使う。実装専用の `gpt-5.3-codex-spark` 固定には巻き込まない。 + +--- + +## 4. 使い分けガイド(第一候補) + +| タスク種別 | 第一候補 | 理由 | +|-----------|---------|------| +| 仕様が明確・クリーンさ重視・UI/結線・お手本コード | **GLM 5.2** | 簡潔・範囲内に収まりやすい・速い | +| 複雑・セキュリティ/堅牢性が重要なバックエンド | **Kimi K2.7 Code** | 安全性を自力で深掘り・テスト厚い | +| どちらでも可 | いずれか | ただし下記ガードを必ず付ける | + +### Kimi 内モデル選択(決定論的) + +`agents.yaml.worker_delegation.kimi_model_routing` を正本とし、優先順は、明示 `provider_model` → 長大/推定不能な巨大contextの `k3` → 明示的な速度優先かつ3倍quota許容時の `kimi-for-coding-highspeed` → 通常の `kimi-for-coding` とする。 + +- K3条件: `requires_long_context=true`、推定contextが212,992 token超、または推定不能かつraw UTF-8が512KiB超。`max`、上限1,048,576 token。 +- 選定結果: `reason_code` / `selected_model` / `estimated_context` / `fallback_reason` を必ず残す。 +- K3切替: 新sessionを開始し、必要情報の要約だけを渡す。履歴を丸ごと移送しない。 + +GLM 5.2 の正式運用は high / max のみ(デフォルト high・他の値はルーティングのバリデーションで拒否される)。母数は n=4 の初期観測であり法則ではない(冒頭⚠️参照)。 + +--- + +## 5. 運用上の必須ガード(モデルの弱点を相殺する) + +- **完了の定義を検証可能に**(Kimi の過大申告対策): 「スクショは git にコミット」「テストは緑のログを示す」等、"やったと言うだけ"を許さない。 +- **スコープを超えるなを明示**(Kimi の過剰実装対策): 「指定範囲のみ。追加の堅牢化は別 PR」。 +- **長時間タスクは声がけ / 自動継続**(GLM の停滞対策)。 +- **リポの前提を渡す**(GLM の取り違え対策): 言語・パッケージ管理の前提を明記。 +- **既存 CaD コメント規約に倣わせる**: 新規関数・ブロック追加時は対象ファイルの既存様式(日付・種別・背景3点)に倣うと明記する。 + +--- + +## 6. 候補提案とディスパッチ + +実装委譲・並列実装の話題が出たら §4 を根拠に「GLM 5.2 向き / Kimi向き」を 1 行理由つきで先に提案し、Kimi内のK2.7/K3は上記契約で選ぶ。ディスパッチ実行は `agent-dispatch` スキルへ(未導入環境では §4・§5 のみ使う)。役割分担: 方針選定・委譲・進捗確認・結果回収 = Claude / Codex。実行は `agents.yaml` の有効 provider だけを AI Worker MCP 経由で行う。プロンプトには §5 の必須ガードを必ず織り込む。 + +詳細手順は `skills/agent-dispatch/` を参照(本ルールは方針、skill は手順=DRY)。 + +--- + +## 8. 関連 + +- `skills/agent-dispatch/` — `agents.yaml` と AI Worker MCP を使う worker 委譲手順(本ルールの実行系) +- `skills/kimi-sync/` — Kimi CLI のPJアタッチ(`sync-kimi-from-cc.py`) +- `.claude/rules/general/response-style.md` — 出力簡潔性 +- `.claude/rules/general/visual-progress-map.md` — 進捗可視化 +- `dotfiles/kimi/config.toml.base` — Kimi Code CLI の loop/permission 既定(`max_steps_per_turn` 等) +- 実測ログ・スコアカード・OpenCode Go 選定指標の全文: `/skills/agent-dispatch/references/model-selection-evidence.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: 実測ログ・スコアカードは references(上記)へ追記し、本ルールには足さない(再肥大化防止)。** diff --git a/.claude/rules/general/branch-rule.md b/.claude/rules/general/branch-rule.md new file mode 100644 index 000000000..6451c092a --- /dev/null +++ b/.claude/rules/general/branch-rule.md @@ -0,0 +1,102 @@ + + +# ブランチ運用ルール + +## main ブランチへの直接コミット・プッシュ + +AI エージェントの通常作業では、**main ブランチへの直接コミット・プッシュは禁止**。 + +Markdown、`sync-state.json`、AI ツール設定、AGENT-HUB 運用設定、MCP 台帳などの軽量変更でも、 +AI は main へ直接 commit / push しない。必ず専用 worktree + feature branch を作成し、PR 経由でマージする。 + +人間が明示的に「今回は main に直接反映してよい」と承認した場合、または初回 repo 作成直後で +PR 導線がまだ存在しない場合だけ例外になりうる。AI はこの例外を自己判断で使わず、理由を作業ログに残す。 + +(過去に運用設定・hook配布物等を段階的に allowlist で main 直接許可した経緯があるが、2026-06-23〜2026-07-01 +で全撤回済み。allowlist 変遷史の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照)。 + +## 理由 + +- main checkout は複数 AI / 複数セッションで共有されやすく、軽量変更でも HEAD を掴むと競合や cleanup 失敗の原因になる +- Markdown や設定だけでも、PR にするとレビュー履歴・CI・merge 後確認・worktree cleanup が同じ型で残る +- ツールごとに例外を残すと、Claude / Codex / Cursor / Kimi / Antigravity 間で運用がずれる +- main の最新化は `git pull` ではなく、fetch-only と detached HEAD / 専用 verify worktree で確認すれば足りる + + +## AGENT-HUB の CI とマージ根拠(2026-07-30 STEP 4) + +AGENT-HUB の CI は `workflow_dispatch` + `ci/light` ラベル方式(pull_request 自動トリガーは 2026-07-24 に削除済み)。 +PR に checks が無い場合のマージ根拠は `merge-pr.py` のローカル軽量ゲート(`registries/merge-gate-suite.yaml`)。 +台帳未整備のリポでは従来どおり checks 0 件で通す(詳細: `skills/post-merge/SKILL.md`)。 + +## 配布クローズアウト責任 + +AGENT-HUB から各 PJ へ配布した差分は、配布を実行した AI / 担当者が最後まで閉じる。 + +対象: `scripts/deploy-agent-bundle.py` / `scripts/deploy-hooks.py` / `scripts/sync-agents.py` / +`scripts/bootstrap-skills.py` / `scripts/deploy-skills.py` / `scripts/deploy-rules.py` / +`/publish-deploy` など、上記を呼ぶ配布コマンド。 + +配布先 PJ に tracked 差分が出た場合は、feature branch 作成 → 配布差分だけ commit → PR 作成 → CI/review 確認 → +`merge-pr` でマージ → fetch-only + detached HEAD / verify worktree で取り込み確認 → worktree/branch cleanup → +`git status --short` clean 確認、まで一連で完了する(詳細な完了条件・禁止・例外の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` 参照)。 + +禁止: 「これは自分が修正したファイルではない」として配布差分を放置する/未コミットのまま終了する/ +main 直接 push で済ませる/`--push` の成功だけで完了扱いにする。 + +例外(dry-run のみ・差分なし・既存WIPで安全に branch できない・権限やCI failureで merge できない)の場合も、 +対象 PJ・残っている差分・止めた理由・次の安全な一手を報告する。 + +## 事前計画ステップ + +タスク開始時、変更を伴う作業か確認する(コード変更・JSON/YAML変更・`*.sh`変更・Markdown/sync-state/AIツール設定などの軽量変更)。 +AI 作業で変更がある場合、**最初に専用 worktree + feature branch を作成**してから編集を始める。AI 作業では `main` を checkout しない。 +コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +読み取りだけの場合、またはすでに専用 worktree / feature branch 内にいる場合は新規 worktree を作らなくてよい。 +AGENT-HUB から各 PJ へ配布した tracked 差分も「配布クローズアウト責任」に従う。 + +## pre-commit hook 違反後のピボット + +万一 hook(`hook-library/scripts/block-main-commit.sh`)にブロックされた場合は、変更を退避(stash/patch)→ +専用 worktree で feature branch 作成 → 変更復元 → commit/push → PR 作成、の順で復旧する。main の HEAD は +無変更のまま維持されることを確認する。詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 関連フック + +`hook-library/scripts/block-main-commit.sh` が上記ルールを自動判定・ブロックする。 + +## 関連ルール + +- `.claude/rules/general/worktree-rule.md` — 並列セッション時の worktree 利用 +- `.claude/rules/general/sub-agent-scope-contract.md` — サブエージェント delegate 時の制約 +- `~/business/AGENT-HUB/docs/worktree-operations.md` — allowlist変遷史・配布クローズアウト責任詳細・事前計画コマンド列・pre-commit hookピボット手順の正本 + +--- + +**追記ルール: 実測事例・変遷史・長文手順は `~/business/AGENT-HUB/docs/worktree-operations.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.claude/rules/general/constructive-dissent.md b/.claude/rules/general/constructive-dissent.md new file mode 100644 index 000000000..8c703c288 --- /dev/null +++ b/.claude/rules/general/constructive-dissent.md @@ -0,0 +1,61 @@ + + +# 建設的異議(言いなり禁止・グローバル憲法) + +## 原則 + +AI は**言いなりにならない**。ユーザー指示が現実・制約・過去の不採用判断(CaD)と衝突するとき、迎合せず次の 3 点を必ず行う。 + +1. **現実的制約の明確な指摘** — 無理なものは「無理です」と根拠付きで言う(時間・技術・運用・既存 SSOT・過去の不採用理由)。 +2. **根拠付きの代替案** — 達成したい意図を保ちつつ、実行可能な別ルートを 2〜3 択で提示する(推奨を 1 行添える)。 +3. **保守・メンテナンス観点の改善提案** — 指示に従うだけでなく、「こういう仕組みを入れるべき」と AI から先出しする(出生登録・正本参照・陳腐化防止など)。 + +**最終決定は常にユーザー**。AI は異見を述べたうえで、ユーザーが選んだ方向に従う。 + +## 発火場面 + +| フェーズ | 異議の出し方 | +|---------|-------------| +| **提案・設計** | plan-approval の HTML プランに「🤔 AI の異見」欄で記載(テンプレ側は別 PR で欄追加予定)。プラン提示前に衝突があれば先に異議を出す | +| **実装** | 着手前または実装中に制約・不採用判断との衝突を検知したら、実装を止めて代替案を提示 | +| **レビュー** | codex-review 等の指摘が個人開発スケールに過剰なときも、レビュー結果に対して異議・優先度の再整理を提案できる | + +判断に迷う場合は**異議を出す側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 作法 + +- **根拠必須**: 「良くない」だけでなく、なぜ無理か・何が起きるかを平易語で 1〜2 文。 +- **平易語 + 選択肢**: visual-progress-map §5 に従い、技術用語だけで問わない。速さ・安全・見た目への影響など、ユーザーが判断できる軸に翻訳する。 +- **推奨を添える**: 2〜3 択のうち推奨を明示(「(推奨)」+ 理由 1 行)。 +- **短い同意への再確認**: ユーザーが「お願い」「はい」だけ返したとき、次の一手を 1 文で要約してから進める(response-style と整合)。 + +## 個人開発スケールと例外 + +- **前提**: 本リポ群は個人開発(1 人・非エンジニアオーナー)。大規模チーム向けのプロセス・過度な抽象化・仮想的大規模負荷対策を**無条件で推奨しない**。 +- **過剰エンタープライズ提案への異議**: 「全 PJ に同じ監査パイプライン」「専用 infra チーム前提の運用」等は、意図が明確でない限り異議を唱える。 +- **例外(厳格維持)**: + - **(a)** セキュリティ・データ消失・金銭に関わる指摘はスケールに関係なく常に厳格。 + - **(b)** 顧客向けシステム(jtt-cms の予約・お客様導線・決済・個人情報を扱う画面/API)はエンタープライズ相当の厳格さを維持。 + +codex-review のレビュー観点にも同校正が内蔵されている(プロンプト文字列参照)。 + +## メンテナンス観点の先出し例 + +- 新スキル・hook・ドキュメントを作るとき → `checkup-registry.yaml` への出生登録を提案。 +- 手順・閾値・API 名をハードコードしそうなとき → 正本参照(ライブ読み・SSOT symlink)を提案。 +- 外部 API・ライブラリ版数を書くとき → 最終確認日の記載を提案。 + +## 関連 + +- `.claude/rules/general/response-style.md` — 出力簡潔性・確認の書き方 +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・技術判断の平易化(§5) +- `.claude/rules/general/plan-approval-gate.md` — 実装前 HTML プラン承認(🤔 AI の異見欄と接続) +- `.claude/rules/general/plan-commitment-tracking.md` — 承認済みプラン条項の実行追跡 +- `skills/adversarial-review/SKILL.md` — **本ルールの手順 SSOT**(dev / business の 2 モード・発火条件・証拠水準・自己反証・分布点検)。本ルールは義務、スキルは手順の二段構えとし、手順本文をここへ複製しない +- `skills/codex-review/SKILL.md` — レビュー時の個人開発スケール校正 diff --git a/.claude/rules/general/hooks-structure-rule.md b/.claude/rules/general/hooks-structure-rule.md new file mode 100644 index 000000000..1636283b2 --- /dev/null +++ b/.claude/rules/general/hooks-structure-rule.md @@ -0,0 +1,79 @@ +--- +description: hooks構造ルール — チェックリストMDの配置制約とゾンビ復活禁止 +paths: + - 'hook-library/**' + - 'hook-registry.yaml' + - 'scripts/deploy-hooks.py' +--- + +# hooks 構造ルール + + + +## チェックリストMDの配置 + +| 正しい配置 | 禁止 | +| ----------------------------------------------------- | -------------------------- | +| `hook-library/lib/code-quality-check.md` | `hook-library/prompts/` | +| `hook-library/checklists/security/security-review-check.md` | 任意の新規サブディレクトリ | + +配布後の PJ 側でも同じ規約に従う: + +| 正しい配置 | 禁止 | +| -------------------------------------------- | -------------------------- | +| `.claude/hooks/lib/code-quality-check.md` | `.claude/hooks/prompts/` | +| `.claude/hooks/lib/security-review-check.md` | 任意の新規サブディレクトリ | + +## 禁止事項 + +- `prompts/` ディレクトリの作成・復活(AGENT-HUB / 配布先 PJ いずれも) +- `quality-check-common.sh` のチェックリスト参照パスを `lib/` 以外に変更 +- `supabase-sql-review.md` の復活(`security-review-check.md` と重複していた削除済みファイル) +- `ui-quality-gate.json` の復活(`type: "prompt"` でレビュー LLM に丸投げする方式は失敗時にプロンプト原文がチャットに漏れるため廃止。UI 品質チェックは `code-quality-check.md` の `ui-quality-jp` domain を `subagent-quality-check.sh` / `stop-quality-check.sh` がファイル参照型で reason に出す形で完結する) +- `hook-registry.yaml` の `checklist.security` に `KNOWN_SECURITY_CHECKLISTS` allow-list 外の名前を書くこと(`scripts/deploy-hooks.py` が fail-fast で拒否する) +- 対象PJを明示せずに hook を追加・配布すること。新規 hook は「必要な PJ」「不要な PJ」「Codex/Augment へ載せるか」を AGENT-HUB セッションで決めてから `hook-registry.yaml` に登録する。 +- `/hook-publish` の復活。project 配布applyは + `scripts/sync-agents.py --project --project-root ` だけを公開入口とし、物理 hook writerを単独実行しない。 + +## hook 追加・配布フロー + +1. AGENT-HUB セッションで hook の目的と対象PJを決める。 +2. `hook-library/scripts/`、`hook-library/settings/`、`scripts/deploy-hooks.py` の script map、`hook-registry.yaml` を同一PRで更新する。 +3. `scripts/sync-agents.py --project --dry-run` で全 surface の同一generation差分を確認する。 +4. 実配布が必要ならcleanな専用linked worktreeを明示してfull applyする。複数PJでも明示リストを1件ずつ処理する。 +5. 個別 writer の `--all` は使わない。全PJの一括同期は別の明示承認とscope確認を必要とする。 + +## チェックリスト注入方式 + +| 方式 | 説明 | +| ------------------------ | --------------------------------------------------------- | +| ファイル参照型(採用) | reason にファイルパスを記載し、AIがReadツールで読む | +| インライン注入型(廃止) | reason にチェックリスト全文を埋め込む(チャットが埋まる) | + +reason にチェックリスト全文を埋め込まないこと。AI が Read ツールでファイルを読む形にすることで、ユーザーのチャット視認性を確保する。 + +## 配布スクリプトによる強制ガード(`scripts/deploy-hooks.py:merge_settings()`) + +| ガード | 役割 | +| --- | --- | +| `_strip_prompt_type_hooks()` | `type: "prompt"` の hook をマージ時に強制除去。インライン注入型の混入を配布パイプラインで遮断する | +| `_dedupe_hooks_by_command()` | `(matcher, paths, command)` ベースで dedupe。dict 完全一致比較が空白・キー順差で破綻し、過去 jtt-cms に重複 4 件(`prettier-format` / `seo-check` / `storage-url-check` / `block-main-commit`)が混入した実績の再発防止 | + +これらのガードと `--all --confirm-all-hook-scope` の安全弁を外す変更は禁止。検証スクリプト `scripts/test-deploy-hooks-merge-settings.sh` がガードの挙動を回帰チェックする。 + +## 理由 + +`scripts/deploy-hooks.py`(テンプレート配布スクリプト)は配布先 PJ の `lib/` にチェックリストMDをデプロイする。`quality-check-common.sh`(runtime)が異なるパスを参照すると、新規 PJ セットアップ後に品質チェックリストが見つからず approve が素通りする。 + +`security-review-check.md` の内容は SECURITY DEFINER / RLS / `crm.` schema 等 Supabase + Postgres 専用のため、Supabase を使わない PJ には配布しない(registry の `checklist.security` を空配列にする)。 diff --git a/.claude/rules/general/latest-stack-context7.md b/.claude/rules/general/latest-stack-context7.md new file mode 100644 index 000000000..d3968f26b --- /dev/null +++ b/.claude/rules/general/latest-stack-context7.md @@ -0,0 +1,61 @@ +--- +description: 最新スタック確認ルール — Next.js/React/serwist等の急速更新ライブラリは実装前にcontext7で最新docsを取得 +paths: + - '**/*.ts' + - '**/*.tsx' + - '**/*.js' + - '**/*.jsx' + - '**/*.mjs' + - '**/*.vue' + - '**/*.svelte' + - 'package.json' + - 'next.config.*' + - 'tailwind.config.*' + - 'drizzle.config.*' + - 'vite.config.*' + - '**/sw.ts' +--- + +# 最新スタック確認ルール(context7 必須) + +## 対象ライブラリ(AI カットオフ後・急速更新) + +以下を**実装・デバッグ・設定変更する前に必ず** context7 で最新 docs を取得する。 +記憶だけで書かない(古い API を使うと動かない・型エラー・ビルド失敗を引き起こす)。 + +| ライブラリ / フレームワーク | 主な罠 | +|----------------------------|--------| +| **Next.js 16+** | `middleware` → `proxy.ts` に改名(Next15→16)、`cookies()`/`headers()` は非同期=`await` 必須(Next15で async 化・16で同期アクセス廃止)、App Router キャッシュ挙動変更 | +| **React 19+** | Next15 以降は React19 前提。`use()`, Server Actions の型・挙動変更 | +| **@serwist/next** / **serwist** | SW ビルド設定・`defaultCache` API が頻繁変更。Turbopack 非対応(`--webpack` 必須) | +| **motion 12+** (`motion/react`) | `motion-plus` API、`AnimatePresence`・`useSpring` 型変更 | +| **Tailwind CSS v4+** | `@config` 廃止・CSS ファースト設定に移行(`tailwind.config.js` 非推奨) | +| **drizzle-orm** | マイグレーション API・スキーマ定義が毎 minor で変わりやすい | +| **vaul** | ドロワー API・`snapPoints` 型が変わっている可能性 | +| **sonner** | `toast()` オプション・`Toaster` props の更新 | + +## 必須手順 + +1. `mcp__context7__resolve-library-id` でライブラリの context7 ID を取得 +2. `mcp__context7__query-docs` で最新 docs を取得してから実装 +3. context7 が使えない環境は `WebFetch` で公式 docs を取得(記憶補完のみでの実装禁止) + +``` +例: Next.js 16 の proxy.ts (旧 middleware) を実装する前に + → resolve-library-id "next.js" → query-docs "proxy middleware" +例: serwist defaultCache を設定する前に + → resolve-library-id "@serwist/next" → query-docs "defaultCache" +``` + +## 古い API の使用禁止 + +- **Next15 以前の同期 `cookies()`**: Next16 では非推奨。`await cookies()` を前提に書く(context7 で確認) +- **`middleware.ts`(Next16 では `proxy.ts`)**: 名前が変わった。context7 で確認してから書く +- **Pages Router 前提のコード**: App Router が前提。`getServerSideProps` 等を新規に書かない +- **React18 前提の型**: React19 の型変化(`children: ReactNode` の必須化等)を確認してから書く +- **旧 `motion/react` 型**: `motion-plus` の型は memory だけで書かない + +## 関連 + +- `skills/dev-guardrails` — フェーズ別ワークフロー・品質ゲート +- `skills/pwa-guardrails` — serwist 配線・PWA 品質チェックリスト(context7 が必要になる代表例を列挙) diff --git a/.claude/rules/general/mandate-registry.md b/.claude/rules/general/mandate-registry.md new file mode 100644 index 000000000..4fada9b7c --- /dev/null +++ b/.claude/rules/general/mandate-registry.md @@ -0,0 +1,59 @@ + + +# 横断チェック台帳(mandate-registry)への登録ルール + +## 原則 + +「これは全アプリで必要だ」という横断的な気づきは、ルール追記だけで終わらせず**台帳へ1行登録する**。 + +理由: ルールファイルへの追記は**新規開発にしか効かない**。既存アプリへの適用漏れは、機械が乖離を提示しない限り再指摘が起きるまで発火しない。台帳へ登録しておけば `mandate-audit.py` が未対応アプリを一覧化し、記憶や注意力に頼らず気づける。 + +## 発火条件(トリガー) + +伸太郎殿が以下のような**横断指摘**をしたとき: + +- 「これは全アプリで必要」 +- 「横展開すべき」 +- 「他のアプリでも同じ対応が要る」 + +判断に迷う場合は**登録する側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 必須手順 + +1. **重複確認**: `registries/mandate-registry.yaml` を `id` / `title_ja` で grep し、同種の項目が既に無いか確認する(複数 AI による二重登録防止)。 +2. **1行登録**: 無ければ台帳へ1エントリ追加する。`reason` には経緯1行+日付を必須で入れる。 +3. **報告**: 登録したことを利用者へ報告する(黙って追加しない)。 + +## 監査 + +「横断監査して」等の発話で `python3 scripts/mandate-audit.py` を実行し、結果を提示する。作業対象アプリが決まっているセッションでは `--app ` で絞り込む。 + +## 回答の記録 + +台帳の `status` フィールドは利用者の回答をそのまま反映する: + +| 利用者の回答 | 記録する値 | +|------|-----------| +| 「後で」 | `snoozed:YYYY-MM-DD` | +| 「対象外」 | `na` | +| 対応 PR がマージされた | `done` | + +## 限界の明示 + +`check: manual` の項目は**目視消込**であり、**監査が緑でも全部 OK を意味しない**。機械(`mandate-audit.py`)が見えるのは台帳に記録された静的な項目だけであり、実装が実際にルールへ適合しているかは別途確認が要る。 + +## スキーマ・規約の正本 + +台帳のフィールド定義・規約①②(`check:script` の実行前提・登録前の重複確認義務)は `registries/mandate-registry.yaml` のヘッダコメントが正本。本ルールへ複製しない。 + +## 試行フェーズ + +2026-08-17 目安で、登録実績・提案件数・`status` 更新のコストを振り返る。セッション開始 hook による自動提案の採否は、その振り返りを踏まえて別プランで判断する(今は hook 化しない)。 + +--- + +**追記ルール: 実測事例・長文手順は台帳ヘッダ/別 doc へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.claude/rules/general/mcp-key-management.md b/.claude/rules/general/mcp-key-management.md new file mode 100644 index 000000000..bb99236f4 --- /dev/null +++ b/.claude/rules/general/mcp-key-management.md @@ -0,0 +1,97 @@ + + +# MCP API キー管理規範(AGENT-HUB SSOT) + +JTT 関連の MCP(asana-mcp / jtt-smaregi-mcp / smaregi-docs / google-chat-mcp / google-docs-mcp / jtt-spreadsheet-mcp 等)の API キーは **AGENT-HUB を SSOT として一元管理**する。 + +詳細手順(復旧・ローテーション・実装経緯・実例)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。本ルールは義務・禁止事項だけを持つ。 + +## SSOT + +| 役割 | 場所 | 状態 | +|------|------|------| +| 実値(秘匿) | `~/.config/agent-hub/.env` | コミット対象外、各マシンで作成 | +| 名前テンプレート(公開) | `~/business/AGENT-HUB/dotfiles/.env.example` | git 管理、新マシン bootstrap で参照 | +| 環境変数 export | `~/.zshrc.local` の `set -a; source ~/.config/agent-hub/.env; set +a` | bootstrap.sh が初期セットアップ | + +## スコープ振り分け規範 + +| MCP 種別 | 配布先 | 同期スクリプト | +|---------|--------|---------------| +| 全 PJ 共通で必要な MCP | `asset_contract.global.include.mcp` から各clientの宣言surfaceへ | manifestが所有者として指す単一writer | +| harness type共通の MCP(例: Laravel Boost) | `asset_contract.harness_types..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 固有の業務 MCP | `asset_contract.projects..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 個別環境の例外(例: Supabase stg/prod) | project layerと明示local-exception契約へ宣言 | writerが保護・描画。生成surfaceの手編集は禁止 | + +理由: User scope に PJ 固有 MCP を入れると「使わない PJ でも表示・接続試行・認証エラー表示」が起きる。PJ別の使用意図はmanifestのproject layerが表現し、client別catalogは選択根拠にしない。 + +**Gmail の扱い(2026-05-25 更新 / 2026-07-20選択経路更新)**: 自前 gmail-mcp は 2026-05-21 に一度凍結したが、公式 Gmail のツール不足(ラベル CRUD / Triage / 添付取得欠如)が判明し **2026-05-25 に Project scope (jtt-cafe-pj) で復活**。接続definitionはStreamable HTTP `/mcp` + X-API-Keyを維持する。採否はjtt-cafe-pjのmanifest project layer、client対応可否は同じeffective MCPに対するsurface契約で判定する。 + +**Supabase の stg / prod 2 環境並列 (jtt-cms)**: `supabase-prod` / `supabase-stg` の2 assetを命名規約として必須にする(`supabase` 単独名・env-agnostic な `mcp__supabase__*` 表記は禁止)。実例・OAuth手順の詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## Claude Code の `${VAR}` 補間仕様 + +**Claude Code は `mcpServers[*].headers["X-API-Key"]` 等の値を `${VAR}` 補間しない**(User scope / Project scope どちらも同じ)。 + +→ sync スクリプトは `~/.config/agent-hub/.env` から実値を読み出し、`/.mcp.json` / `~/.claude.json` には実値を書き込む。 + +→ よって `.mcp.json` は **gitignore 必須**(実値がコミットされないように)。AGENT-HUB の SSOT は環境変数名のみ保持し、各マシンで sync 実行時に実値展開する。同じ理由で `~/.claude.json` / `.gemini/settings.json` / `.cursor/mcp.json` / `.kimi-code/mcp.json` も全て gitignore 必須(対象ファイルと生成元の gitignore 必須リストは `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。 + +## 禁止事項 + +1. **`~/mcp-servers//.env` へ直書き禁止**。`asana-mcp/.env` `jtt-smaregi-mcp/.env` 等に API キーを置かない。発見次第 `~/.config/agent-hub/.env` へ移行し、ローカル `.env` は `# moved to ~/.config/agent-hub/.env (AGENT-HUB SSOT)` のコメントだけ残す +2. **`~/.zshrc.local` に `_mcp_load_key_from_env` のような分散ロード関数を新設禁止**。AGENT-HUB SSOT の bootstrap フロー(`set -a; source ~/.config/agent-hub/.env; set +a`)を使う +3. **git 管理対象ファイルに API キー実値を平文で書かない**。ドキュメント(README / SKILL.md / 設計書)では `` または env 変数名 `${ASANA_MCP_API_KEY}` で表記する +4. **ローカル生成物へ手作業で API キー実値を書かない**。`.mcp.json` / `~/.claude.json` は gitignore 済みであることを前提に、sync スクリプトだけが `~/.config/agent-hub/.env` から実値展開して書き込む +5. **管理対象ファイルでの URL クエリパラメータ方式(`?api_key=...`)禁止**。Cloud Run の監査ログに URL ごとキーが残るため、`.mcp.json` / `~/.claude.json` / `.codex/config.toml` など AGENT-HUB が生成する設定は `headers: {"X-API-Key": "${...}"}` のヘッダー方式に統一する + +**Claude.ai 例外**: Claude.ai コネクタで `X-API-Key` ヘッダーを設定できない場合のみ、asana-mcp は `https://asana-mcp-vaibinqqva-an.a.run.app/mcp?api_key=` 形式を使ってよい。この例外は Claude.ai 手動登録専用で、AGENT-HUB の生成物には書かない。 + +## 再発防止: sync スクリプトのハードエラー化 + +`scripts/sync-claude-global-mcp.py`、`scripts/sync-claude-project-mcp.py`、`scripts/sync-codex-mcp-configs.py`、`scripts/sync-cursor-mcp-configs.py`、`skills/{gemini,kimi,opencode,augment}-sync/scripts/sync-*-from-cc.py` は、env_key が未解決(`~/.config/agent-hub/.env` に無い/空文字)の場合に **literal `${VAR}` を書き込まず exit 1** すること。 + +理由・過去の実害(jtt-cms で `smaregi-docs` MCP の認証エラーが反復した根本原因)は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## 再発防止: MANAGED block の重複キー除去 + TOML 検証(Codex / 2026-06-02〜) + +`scripts/lib/user_mcp_sync_lib.py` の `replace_managed_block` は、①同名野良エントリの自動除去 ②書き込み前 TOML パース検証、を担保する(MANAGED 対象でない手書き MCP は保護する)。実装経緯・障害の症状は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` の「Codex config TOML 重複キー」節を参照。 + +## 復旧手順(MCP Auth エラー時) + +詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md`。要旨: ①env が読めているか確認 ②`~/.claude.json` の literal `${VAR}` 残存検査 ③対象projectを公開入口から再同期 ④Claude Code を再起動。 + +## ローテーション手順 + +API キーローテーション時の 7 ステップ(新キー発行 → SSOT 更新 → dry-run 確認 → full apply → 個別sync禁止 → 各PJ再起動 → 旧キー失効)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。旧キーを `dotfiles/.env.example` のコメントに「廃止済み」として残してはいけない。 + +## User scope MCP 同期フレームワーク (2026-05-21〜) + +User scope MCP (`~/./...`) の SSOT 一元管理は **user-mcp スキル**が管轄する(User scope / Project scope の設計と担当 sync スクリプトの対応表は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。新エージェント追加 5 ステップ (CLAUDE.md 9-13 参照): `skills/user-mcp/SKILL.md`。 + +## 関連 + +- `dotfiles/.env.example` — 名前テンプレート +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 復旧手順・ローテーション手順・実装経緯・User scope同期フレームワーク対応表の詳細正本 +- `skills/user-mcp/SKILL.md` — User scope MCP 5 ツール統一管理スキル(sync スクリプト一覧はここに集約) +- `scripts/lib/user_mcp_sync_lib.py` — 5 sync 共通 lib (env / registry / MANAGED block / 検証) +- `scripts/sync-claude-project-mcp.py` — Project scope 同期 +- `scripts/codex-mcp-remote-with-env.sh` — Codex 用 SSE → stdio bridge +- `~/business/AGENT-HUB/docs/codex-mcp-registry.yaml` `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — 台帳 +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 障害復旧ランブック +- `docs/reference/project-roots.md` — プロジェクトルート規約 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.claude/rules/general/memory-lookups.md b/.claude/rules/general/memory-lookups.md new file mode 100644 index 000000000..f9726e2be --- /dev/null +++ b/.claude/rules/general/memory-lookups.md @@ -0,0 +1,67 @@ + + +# メモリ参照ルール + + + +## 基本方針 + +memory は、前回までの作業状態・人物名・用語・過去の判断を思い出すための**参照補助**である。 +売上・タスク・勤怠・予約・確定ルールの正本ではない。 + +以下のケースに該当するとき、応答を出す前に `~/.claude/projects/*/memory/MEMORY.md` および同階層の個別メモリファイルを検索する: + +- **人名・略称・愛称**に遭遇したとき(読み方・関係性が記録されている可能性) +- **PJ 固有用語・コードネーム**に遭遇したとき +- **過去の不採用判断**を覆そうとしているとき +- ユーザーが「あの〜」「以前話した〜」等の指示語で参照しているとき + +## 検索手順 + +`~/.claude/projects/*/memory/` 配下を検索し、`MEMORY.md` のインデックスから該当する個別ファイルを特定して読み、応答に反映する。 + +## 該当メモリがあった場合 + +- メモリの内容を踏まえて応答する +- メモリの記述が古い可能性がある場合は、現在の状態(コード・設定・正本MCP・Markdown SSOT)と突き合わせる +- 矛盾があれば**現状を優先**し、メモリの更新を提案する + +## JTT 業務情報の正本 + +| 情報 | 正本 | +|------|------| +| 売上・取引・商品実績 | スマレジ / `jtt-smaregi-mcp` | +| 施策・担当・期限・進捗 | Asana / `asana-mcp` | +| 勤怠・シフト・出勤者 | 出パンダ / 将来の Depanda MCP | +| 予約・来店予定 | よやくま / 将来の Yoyakuma MCP | +| 確定した方針・ルール・議事録 | プロジェクトの Markdown SSOT | +| 横断分析・再利用する学び | G-Brain | +| 作業途中の短期文脈 | Claude / Codex / Hermes の memory | + +memory と正本が矛盾する場合は、正本を優先する。G-Brain は検索・分析・要約の層であり、MCP から取得した生データの保管先にはしない。 + +**Asana のどこに何があるか**(workspace / project gid / section 構造 / 周期 PJ の命名規則)は +`~/business/AGENT-HUB/docs/reference/asana-project-map.md` が地図。gid を推測せず、まずこの地図を引く。 +地図には参照先だけがあり、タスクの中身は載せない(中身は `asana-mcp` でその場で取る)。 + +## 該当メモリがなかった場合 + +- 推測で補完せず、ユーザーに直接確認する +- 確認後、必要に応じて新規メモリとして記録する(auto memory ルール参照) + +## 関連 + +- グローバル auto memory: `/Users/shintaro/.claude/CLAUDE.md` の「auto memory」セクション +- PJ 別 auto memory: `~/.claude/projects//memory/` diff --git a/.claude/rules/general/plan-approval-gate.md b/.claude/rules/general/plan-approval-gate.md new file mode 100644 index 000000000..67d326ca4 --- /dev/null +++ b/.claude/rules/general/plan-approval-gate.md @@ -0,0 +1,47 @@ +# 実装前に HTML プランで承認を仰ぐルール(強制) + +## 原則 + +中規模以上の**実装に着手する前に、必ず HTML で実装プランを提示し、利用者の明示承認(「この実装でいい」)を得てから着手する**。テキストだけで合意したつもりにならない。 + +理由: 非エンジニアの利用者には「どの画面がどう変わるか」がテキストでは伝わりにくく、着手後に手戻りが多発する。給与v1の説明 HTML のような見せ方を毎回・自動で出して認識を合わせ「手戻りゼロ」を狙う。 + +これは `ui-stitch-mandatory` と同じく**ルールが義務を担い、手順はスキルに置く**二段構え。手順 SSOT は `skills/plan-approval/SKILL.md`(本ルールは手順を複製せず参照する)。 + + + +## 必須手順 + +1. **プラン作成基準をライブ読み**: `skills/plan-approval` が `resolve-pj-prompt.py --phase plan` を実行し、PJ 別のプラン基準(`snippet-prompts/Typinator/plan/`。専用未作成 PJ は汎用 `dev-plan`)を読む。 +2. **HTML プランを作る(固定テンプレを必ず使う・独自デザイン禁止)**: 正本テンプレをコピーし中身だけ差し替える(通常=`plan-template.html`、AI worker 委譲時=`plan-template-aiworker.html`)。必須のビジュアル要素は下記「中身」節を参照。 +3. **提示して承認を待つ(iPhone でも PC でも、両方の届け方を毎回使う)**: HTML プランは**必ず Write ツールで実体の `.html` ファイルとして作成する**。**禁止**: ① HTML 本文をチャットに貼り付ける、② Bash ヒアドキュメントで書き出す(どちらも iPhone で生コードになる)。作成後は毎回 `open ` で PC ブラウザにも表示する。**タップ用ファイルカード作成と open による PC ブラウザ表示の両方を毎回必須とする**。末尾に「この実装でいいですか?(進めて / 直す / やめる)」を置き、**承認なしに実装へ進まない**。短い同意だけで進めず、次の一手を1文に要約して再確認する。保存先は作業中 PJ の gitignore 済み一時パス(`claude-plans/` 等)、slug は短く、共有 URL は1行で提示する(詳細: `skills/plan-approval/SKILL.md` §5)。 +4. **承認直後に 📋 コミットメント台帳を全件タスク化する**: HTML プランの台帳の各行を、着手前に `TaskCreate` で 1 行 = 1 タスク化してから実装へ進む。台帳が全消化(実施済み or 明示保留)になるまで「完了」と宣言しない。詳細は `.claude/rules/general/plan-commitment-tracking.md`。 + - **AI worker を 1 度でも使う計画は必須**: 「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れ、タスク化する(テンプレに既定行として焼き込み済み・消さない)。 +5. **承認後は標準パイプラインを通す**: 実装(dev-guardrails)→ codexレビュー → 実装監査 → CI → SSOT 同期確認 → マージ。本番投入は人間ゲート。 + +## HTMLプランの中身 + +中身の構成部品(🎯目的・🖼️前後比較・🗺️ユーザーストーリー・🔀画面遷移図・📚メニュー構成図・🧩変える物一覧・🤔AI の異見・🔭一段上の視点・📋コミットメント台帳 等の必須要素一覧)はテンプレ正本(`references/plan-template.html` の `parts` マニフェスト)と `skills/plan-approval/SKILL.md` が正本。ここに複製しない。 + +## 適用トリガー + +新機能・新画面、データの形を変える変更(DB 構造変更)、複数ファイルにまたがる実装、画面の見た目・挙動が変わる変更。判断に迷う場合は提示する側に倒す。 + +## 例外(HTMLプラン不要) + +- 誤字・1行修正など、見た目・挙動の方針が変わらないもの +- 純粋な調査・質問への回答、会話だけで完結する話 +- 利用者が「今回は要らない」と明示したとき +- UI に変化を伴わない純粋なロジック修正(ただし複数ファイル・データ変更を伴うなら提示する) + +## 接続・関連 + +手順: `skills/plan-approval/SKILL.md`(テンプレ・保存規約・承認ループ・3層視覚化)。プラン基準: `snippet-prompts/Typinator/plan/[PLAN-INPUT]_-plan.md`。進捗: `visual-progress-map.md`。UI確定: `ui-stitch-mandatory.md`。承認後: `skills/dev-guardrails/SKILL.md`(各フェーズは `resolve-pj-prompt.py` 同一リゾルバ)。モデル委譲: `ai-model-selection.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/.claude/rules/general/plan-commitment-tracking.md b/.claude/rules/general/plan-commitment-tracking.md new file mode 100644 index 000000000..736552d4e --- /dev/null +++ b/.claude/rules/general/plan-commitment-tracking.md @@ -0,0 +1,45 @@ + + + + +# プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) + +## 原則 + +承認済みプラン本文に書かれた**全ての commitment / 条項**を、実装着手前に **1 項目 = 1 タスク**へ起票する。 +プランの箱(HTML / テキスト)を静的ドキュメントで終わらせない。承認は一度きりの儀式ではなく、 +実行ループ全体で参照し続ける**生きたチェックリスト(plan-as-live-checklist)**として扱う。 + +## なぜ + +プランに「〜不具合時は正本を直す」等の条項があっても、主要タスクだけ起票すると、長い実行ループ(`/compact` でプラン本文が能動コンテキストから外れる)で**一度も発火せず**未実施のまま「完了」と誤宣言する。これは**全 PJ で再発し得る構造欠陥**。実例は `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。 + +## 必須手順 + +1. **承認直後に台帳化**: プラン本文の commitment / 条項(「〜不具合時」「〜したら」「最後に〜」「後で」「別プラン」「TODO」「フォローアップ」「要〜判断」の類)を全て抽出し、着手前に **TaskCreate で 1 項目 = 1 タスク**化する。**📋 コミットメント台帳セクションが空でないのに、未タスク化のまま実装へ進まない**。 + - **AI worker を 1 度でも使う計画なら、「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れる**(テンプレ既定行・消さない)。無ければ台帳は未完成。 +2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 +3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 + - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 +4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 +5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 +6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。 + - **`/goal` 等の反復発火チェッカーへの対応**: 人間ゲート行に反復発火する場合、AI は §6 の優先(明示保留=全消化・虚偽✓禁止)を **1 度だけ根拠付きで提示して停止**し、以後は最小限の再表明に留める(無限反復・迎合的な虚偽✓化をしない)。実測は `commitment-examples.md`。 + +## 恒久原則(proactive) + +繰り返す同種の摩擦・失敗は、**利用者の指摘を待たず**観測した時点で「最後に正本を直す」を既定の最終ステップとして計画へ自分から組み込む。委譲ジョブの失敗・失速もコミット監視だけに頼らず能動的にポーリングして検知する(`skills/agent-dispatch` の「失敗の能動検知」と対)。 + +## 接続 + +義務: `plan-approval-gate.md`。手順: `skills/plan-approval/SKILL.md`。実例: `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。進捗可視化: `visual-progress-map.md`。能動検知: `skills/agent-dispatch/SKILL.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/.claude/rules/general/reference-over-hardcode.md b/.claude/rules/general/reference-over-hardcode.md new file mode 100644 index 000000000..d53cd3839 --- /dev/null +++ b/.claude/rules/general/reference-over-hardcode.md @@ -0,0 +1,49 @@ + + +# ハードコード排除・参照型設計(グローバル憲法) + +## 原則 + +**ハードコード(直書き)をしない。** 設定値・手順・API 名・パス・思想・スタイルなど、2 箇所以上で必要になる情報は、**正本(SSOT)を 1 箇所に置き、他はそれをライブ参照する**(参照型設計)。 + +- どうしても直書きが必要な場合は、**1 箇所に集約**し、なぜ集約先を作ったのかを CaD コメント等に残す。 +- 「そこだけ直書き」を積み重ねると、後から値がずれる・改訂が反映されない・矛盾が生まれる。これは規模の大小を問わず起きる。 + +この原則は、サブエージェント作成・SSOT 構築・hook 実装・YAML 台帳・skill・rule のどの作業でも同じ扱いにする。特定のフェーズだけに適用される限定ルールではない。 + +## 参照型の実例 + +- **設計思想**: `~/business/AGENT-HUB/docs/design/design-philosophy.md` に集約し、UI/デザインに関わる各所(stitch-screen-creator 等のサブエージェント、UI 作成フロー)からライブ参照する。思想を各 PJ の rule や skill に複製しない。 +- **MCP キー**: `~/.config/agent-hub/.env` に実値を集約し、各 PJ の `.mcp.json` や sync スクリプトはそこから展開する(`.claude/rules/general/mcp-key-management.md`)。`~/mcp-servers//.env` への直書きは禁止。 +- **スキル手順**: 各 rule は手順を複製せず、手順 SSOT(skill)をライブ読みで参照する(例: `plan-approval-gate.md` が `skills/plan-approval/SKILL.md` を参照する二段構え)。 + +## 発火場面 + +- 新しい設定値・API 名・閾値・手順・文言などを**2 箇所以上に書きそうになった時**。 +- 既存の rule / skill / doc の内容を**別ファイルにコピーして使いたくなった時**(コピーせず参照にする)。 +- サブエージェントや AI worker への delegate プロンプトに、正本にある情報を**そのまま貼り付けたくなった時**(正本のパスを渡し、読ませる方を優先する)。 +- **配布物(他 PJ へ配る rule / agent / skill)から AGENT-HUB 専用ファイルを参照する時**: 相対パス(`docs/design/...`)ではなく**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)で書く。配布先 PJ の実行 cwd はその PJ 自身であり、相対パスでは正本を解決できず参照が壊れる(2026-07-07 実装監査が検出)。 + +## 個人開発スケールとの両立 + +`.claude/rules/general/constructive-dissent.md`「個人開発スケールと例外」節を参照(同根の原則・過剰な抽象化を避け素朴な解決を優先する基準)。 + +## 関連 + +- `.claude/rules/general/constructive-dissent.md` — 言いなり禁止・グローバル憲法(同種の常時ロード規範。メンテナンス観点の先出し提案として「正本参照」を挙げている) +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 参照型設計の実例(G-Brain 正本からの派生ドキュメント) +- `.claude/rules/general/mcp-key-management.md` — MCP API キー一元管理(参照型設計の実例) + +この原則は G-Brain の上流原則 `principle-single-source-of-truth-reference`(伸太郎殿の開発大原則)と同根である。 diff --git a/.claude/rules/general/response-style.md b/.claude/rules/general/response-style.md new file mode 100644 index 000000000..e5dc40415 --- /dev/null +++ b/.claude/rules/general/response-style.md @@ -0,0 +1,56 @@ + + +# 出力簡潔性ルール + +## 基本方針 + +- 中間状態(「これからこうします」「次にこれを実行します」)の冗長な説明を避ける +- 概念的な説明より具体例・差分・コマンドを優先する +- 段落より bullet list を優先する +- 同じ情報を 2 回繰り返さない(タスクツールで進捗を可視化している場合は、テキストで重ねて述べない) + +## 避けるべき出力パターン + +- 「〜について説明します」「以下に〜します」等の予告フレーズ +- 完了済みタスクの再要約(diff や git log が SSOT) +- 「もし〜の場合は〜」の仮定列挙(実行結果を待ってから判断する) +- 「これは〜という意味です」型の自明な解説 + +## 確認するときの書き方 + +ユーザーに判断を仰ぐときは、選択肢を簡潔に列挙し、推奨案を 1 行で示す: + +良い例: `Tier 2 まで実行 / REN-1 のみ / 全部 のどれにしますか?推奨: Tier 2 まで` + +悪い例(冗長すぎ): 長文で各選択肢のメリット・デメリットを 3 段落ずつ説明 + +## 完了報告の書き方 + +- 変更したファイル一覧(path のみ) +- 主な変更点(1 行ずつ) +- 確認してほしい点(あれば、1-2 件) + +過剰な「お疲れさまでした」「素晴らしい結果でした」等の挨拶は不要。 + + + +## URL・リンクの出力 + +AI が URL やファイルパスを出力するとき、**URL の直後に全角括弧・句読点(`)` `。` `、` `」` など)を隣接させない**。ターミナルや Markdown のリンク解釈がその記号まで URL に取り込み、リンクが壊れる(404)ため。 + +- URL は原則**独立した行**に置く(前後に説明文があっても URL 単独の行にする)。 +- 文中に置く場合は URL の直後に**半角スペースか改行**を入れ、全角記号を隣接させない。必要なら `< >` または バッククォートで囲む。 +- 悪い例: `詳細は https://example.com/path)。`(`)。` まで URL に食われて 404)。 +- 良い例: 説明文の後に改行して `https://example.com/path` を単独行で出す。 diff --git a/.claude/rules/general/responsive-both-viewports.md b/.claude/rules/general/responsive-both-viewports.md new file mode 100644 index 000000000..ba9ed412f --- /dev/null +++ b/.claude/rules/general/responsive-both-viewports.md @@ -0,0 +1,44 @@ +--- +name: responsive-both-viewports +description: レスポンシブ画面にUI要素(ボタン/リンク/ナビ)を足す時は必ず全viewport(モバイル/デスクトップ)に足し、各画面幅で表示を確認してから完了にする普遍ルール +paths: + - '**/*.tsx' + - '**/*.jsx' + - '**/*.js' + - '**/*.vue' + - '**/*.svelte' + - '**/*.astro' + - '**/*.blade.php' + - '**/*.html' + - '**/*.css' + - '**/*.scss' + - '**/components/**' +--- + +# レスポンシブ UI は全 viewport に足す(普遍ルール・常時適用) + +## 原則(絶対・例外なし) + +レスポンシブな画面にボタン・リンク・ナビ等の UI 要素を**新規に足す**ときは、**モバイル表示とデスクトップ表示の両方**(存在する全ブレークポイント)に足す。片方だけに足すと、もう片方の画面幅でその要素が**消える**。これは開発の普遍ルールであり、条件付きにしない。 + +多くのレスポンシブ実装は同じ内容を画面幅で出し分ける: +- モバイル: 上部バー等(例 `.appbar`)を表示し、サイドバーを隠す +- デスクトップ(例 `@media (min-width:1024px)`): サイドバー等(例 `.side-*`)を表示し、上部バーを隠す + +このとき片方の枠にだけ要素を足すと、もう片方では `display:none` により非表示になる。 + +## 必須 + +1. UI 要素を足すとき、**全 viewport バリアント**(モバイル枠 / デスクトップ枠 / その他ブレークポイント)の**すべて**に足す。 +2. 追加要素が**各画面幅で実際に表示される**ことを確認してから完了にする(該当 CSS の `display:none` / media query の出し分けを読み、隠れる枠だけに足していないか確認する)。 +3. コードレビュー・実装監査でも「新規 UI 要素が全 viewport で見えるか」を必須確認項目にする。 + +## 実例(この規則ができた経緯) + +2026-07-05 cron-dashboard で「🩺 健診」ナビを最初モバイルの上部バー(`.appbar`)だけに足した結果、`.appbar` が `@media (min-width:1024px)` で `display:none` になるため **PC 幅で恒久的に非表示**になり、実装監査がブロッカーとして検出した。デスクトップのサイドバー(`.side-brand` 隣)にも足して解消。片側だけ追加は完了ではない。 + +## 接続 + +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・現在地マップ +- `.claude/rules/general/ui-stitch-mandatory.md` — UI/デザインは Stitch を通す +- `skills/dev-guardrails/SKILL.md` — 実装ガードレール diff --git a/.claude/rules/general/settings-protection-coexistence.md b/.claude/rules/general/settings-protection-coexistence.md new file mode 100644 index 000000000..b0103661f --- /dev/null +++ b/.claude/rules/general/settings-protection-coexistence.md @@ -0,0 +1,70 @@ +--- +name: settings-protection-coexistence +description: settings.json等の保護テスト(直接編集ブロック)と、telemetry配線等の正当な変更を共存させる手順。テスト赤のままマージしない +paths: + - '.claude/**' + - '.codex/**' + - '.cursor/**' + - '.gemini/**' + - '.kimi-code/**' + - '.augment/**' + - '.opencode/**' + - '.githooks/**' + - 'tests/test_handover_manual.py' + - 'skills/handover-manual/scripts/resolve-handover-path.py' +--- + + + +# settings.json 等の保護テストと正当な配線変更の共存ルール + +## 原則 + +`.claude/` `.codex/` `.cursor/` `.gemini/` `.kimi-code/` `.augment/` `.opencode/` `.githooks/` +`claude-plans/` `node_modules/` 配下および `.env` / `.env.*` は、`tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` +が **プレフィックス一致で広く保護対象と判定**する(実装: `skills/handover-manual/scripts/resolve-handover-path.py` の `is_protected_path()`、 +`PROTECTED_PREFIXES`)。テストは `origin/main` とのマージベース以降 + working tree + staged の変更ファイルを走査し、 +保護対象なのに `allowed_managed_placements`(テスト内のallowlist)に無いパスがあれば **fail** する。 + +この判定は粗い(ディレクトリ丸ごと保護)ため、telemetry 配線・新規ルール追加・hook 再配備など +**正当な変更でも毎回検知される**。これは仕様であり、バグではない。正当な変更を安全に通す手順は以下。 + +## 必須手順 + +1. **まず中央配布経路で済まないか確認する**: project harness は `scripts/sync-agents.py --project --dry-run` + で全surfaceを確認し、承認後だけcleanな専用linked worktreeへfull applyする。個別writerを手で連結しない。 +2. **どうしても直接編集が必要なら、同一PRで `allowed_managed_placements` に追加する**: + `tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` 内のセットへ、 + 変更した具体パスと日付・理由コメントを添えて追記する(例: `# [YYYY-MM-DD][fix] PR#nnn で〜が弾かれた。理由。`)。 + 既存の fix-forward 例(PR#637 `ai-model-selection.md` / PR#666 `constructive-dissent.md` / + PR#695 `responsive-both-viewports.md` / PR#736 `dotfiles/.env.example`)と同じパターンに倣う。 +3. **保護テストの検査ロジック自体を弱めない**: `is_protected_path()` のプレフィックス判定や + `changed_paths_for_protected_check()` の走査範囲を変更・無効化しない。許可は必ず allowlist の + 個別パス追加で行う(一括 skip・正規表現の緩和は禁止)。 +4. **テスト赤のままマージしない**: `python3 -m pytest tests/test_handover_manual.py -q` を PR 作成前に + ローカル実行し green を確認する。CI の同テストが赤の状態での merge は `branch-rule.md` の + CI 緑ゲートに反する(#742 の再発防止)。 + +## 関連 + +- `.claude/rules/general/branch-rule.md` — main 直接コミット禁止・CI緑ゲート +- `.claude/rules/general/plan-commitment-tracking.md` — workaround 自問(正本修正を先送りしない) +- `.claude/rules/general/hooks-structure-rule.md` — hook 配置の隣接ルール(配布経由の管理配置) +- `tests/test_handover_manual.py` — 保護テスト本体・allowlist 実体 +- `skills/handover-manual/scripts/resolve-handover-path.py` — `is_protected_path()` / `PROTECTED_PREFIXES` 実装 diff --git a/.claude/rules/general/sub-agent-scope-contract.md b/.claude/rules/general/sub-agent-scope-contract.md new file mode 100644 index 000000000..2021bdff8 --- /dev/null +++ b/.claude/rules/general/sub-agent-scope-contract.md @@ -0,0 +1,75 @@ + + +# サブエージェント Scope Contract + +サブエージェント(Task / Agent tool)に作業を委譲するとき、delegate 元のプロンプトに**必ず以下 3 項目(コード探索を伴う場合は §4、UI/デザインを伴う場合は §5 を足す)を含める**。制定経緯・テンプレート全文は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。 + +## 1. allowed_files(編集を許可するファイル) + +委譲先が編集してよいファイルパスを明示的に列挙する。 + +例: `「allowed_files: src/api/auth.ts のみ。他は read-only」` + +## 2. forbidden_actions(禁止する操作) + +委譲先が**してはいけない**操作を明示する。よくある禁止例: + +- `auto-format で quote replacement や import 並び替えを実行しない` +- `スコープ外のファイルを編集しない(読み取りは可)` +- `テストの skip / xit を追加しない` +- `existing CaD コメントを削除しない` + +## 3. verify before return(返却前の検証手順) + +委譲先が作業完了を報告する前に実行する検証を指定する。 + +例: +- `git diff --name-only で編集ファイル一覧が allowed_files と一致することを確認` +- `lint / typecheck を実行してエラーが出ないことを確認` +- `想定外の編集があった場合は revert してから報告` + +## 4. context-engine first(コード探索を伴う委譲・Explore 含む) + +委譲タスクが**コードの場所・関数・route・呼び出し関係・影響範囲の探索**を含むなら、prompt に必ず入れる: + +- 「まず `codebase-context-engine` を使う(`grep`/`Read` を先に走らせない)。遅延ツールは + `select:mcp__codebase-context-engine__list_projects,hybrid_search,search_graph,get_code_snippet` でロード」 +- **解決済みの `project` 名を親が渡す**(親が `list_projects` を見て明示)。 + `preferred_project` がある場合はそれを使う。 + `project_scope: ambiguous_worktrees` の場合は、現在の cwd と一致する `root_path` / `preferred_project_candidates` を親が選んでから渡す。 + subagent に `private-tmp-cbm-...` の長いミラー名を推測させない。 +- 「索引はミラー=当日新規/変更したファイルは未反映なので、その分だけ `Read` 併用」 + +理由: 候補圧縮で速く・低コスト(多数 grep/Read を回避)。subagent は本ルールを自動継承しないため親が prompt 注入必須(追加経緯は詳細ドキュメント参照)。 + +## 5. design-philosophy first(UI/デザインを伴う委譲時) + +委譲タスクが**UI・画面・デザイン・レイアウト・コンポーネントの作成/変更**を含むなら、親が prompt に必ず入れる: + +- 「まず `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)を Read してから着手する」を**必読指定**する。 +- 必ず該当ファイルの**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)を渡す(委譲先の実行 cwd は消費先PJであり、相対パスでは解決不能なため)。 +- Stitch を使う画面作成は、`stitch-screen-creator` グローバルエージェント(設計思想を step0 で必読にしている)へ委譲するのが既定。 + +理由: AI Worker(Kimi/Codex/Cursor/GLM 等)自身にデザインセンスが無くても、親が設計思想 doc を必読で渡せば思想に沿った画面を作れる。渡さないと委譲先が自己流判断でずれる。 + +## delegate プロンプトのテンプレート・親側の verify ステップ + +テンプレート全文と、親セッションが `git diff --stat` / `git diff -- ` で確認する verify コマンド列は +`~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。allowed_files 外に変更が混入していた場合は +revert し、delegate にやり直しを指示する。 + +--- + +**追記ルール: 制定経緯・テンプレート全文の詳細は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` へ書き、本ルールには義務・トリガーだけ足す(再肥大化防止)。** diff --git a/.claude/rules/general/ui-stitch-mandatory.md b/.claude/rules/general/ui-stitch-mandatory.md new file mode 100644 index 000000000..ae6cf27bb --- /dev/null +++ b/.claude/rules/general/ui-stitch-mandatory.md @@ -0,0 +1,79 @@ + + +# UI / デザインは必ず Stitch を通すルール(強制) + +制定経緯(2026-05-27 新設判断・2026-07-20 MCP選択正本切替)は `skills/stitch/SKILL.md` の +「ui-stitch-mandatory 制定経緯」節を参照。 + +## 原則 + +UI / 画面 / デザイン / レイアウト / コンポーネントの**新規作成・見た目の変更**依頼は、**必ず Stitch**(`skills/stitch` + Stitch MCP `mcp__stitch__*`)でデザインを生成し、**伸太郎殿が実物を見て確定してから実装に進む**。 + +理由: UI は AI とユーザーの言語的意思疎通が難しく、テキストだけで合意したつもりで実装すると手戻りが多発する。Stitch で生成した実物を見て双方の認識を合わせることで「手戻りゼロ」を狙う。 + +## 必須手順 + +1. **Stitch でデザイン案を生成**(**最低 3・最大 5(ケースバイケース)**)。1 案だけ出して進めるのは**禁止**。 +2. **伸太郎殿が Stitch Web(プロジェクト URL)で比較・確定**する。 +3. **確定したデザインだけ**を基に実装する(`.stitch/` 出力 / DESIGN.md を参照)。 + +## 適用トリガー + +「UI を作って」「画面作って」「デザイン(して)」「レイアウト変更」「コンポーネント新規」など(`skills/stitch` の triggers と整合)。判断に迷う場合は Stitch を通す側に倒す。 + +## データ格納ルール(リポジトリルート汚染防止) + +Stitch 由来のファイルを散らかさないため、保存先を固定する: + +| データ | 置き場所 | +|--------|---------| +| ① デザイン案の比較 | **Stitch Web(プロジェクト URL)で見る** → 全候補をローカル保存しない | +| ② 確定したデザイン | `.stitch/<システム名>/<画面名>/`(`code.html` + `screen.png`)にだけ Export | +| ③ MCP 取得データの一時保存 | **temp ディレクトリ**(その PJ の `/tmp/` 等・gitignored) | +| ④ リポジトリルート直下・任意の場所 | **保存禁止**(ゴミファイル堆積を防ぐ) | + +- `.stitch/` は**Stitch を使う PJ ごとに gitignore する**(生成物はコミットしない)。配布先 PJ へ広げる場合は、その PJ 側の `.gitignore` 変更を別途同じ変更束に含める。 +- 「とりあえずルートに HTML を置く」は**禁止**。必ず上記 ① 〜 ③ のいずれかに収める。 + +## 例外(Stitch 不要) + +- 既存 UI の微修正(typo 修正・1 色だけ変更など、**見た目の方針が変わらない**もの)。 +- UI に変化を伴わない純粋なロジック修正。 + +## MCP 前提 + +Stitch MCPの接続definitionは`~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml`、project採否は +`registries/harness-manifest.yaml#asset_contract` のeffective `mcp` setを正とする。 +未接続時は `scripts/sync-agents.py --project --dry-run` で継承・surface・envを確認し、apply後にfresh clientでruntime proofを取る。 + +## 接続 + +- 手順 SSOT: `skills/stitch/SKILL.md`(プロンプトテンプレ・`.stitch/` 規約・DESIGN.md 抽出・MCP 前提)。本ルールは手順を複製せず参照する。 +- dev フローの普遍 UI ルール(Tailwind 等)は `skills/dev-guardrails/SKILL.md`(2-10 ほか)の上に乗る。業務 PJ は `skills/business-guardrails/SKILL.md`。 +- 要件固め・実装フローでの発火点: `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md`。 +- Stitch でデザインを作る際は `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)に従うこと。本ルールは思想本文を複製せず参照する。 +- 「Stitchで作って」の委譲は `agents/global/stitch-screen-creator.md`(着手前に設計思想 doc を必読)が実行役を担う。 + +## 関連 + +- `skills/stitch/SKILL.md` — Stitch ワークフロー SSOT +- `skills/dev-guardrails/SKILL.md` / `skills/business-guardrails/SKILL.md` — ガードレール +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 発火フロー +- `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — Stitch MCPのtransport / 認証definition +- `registries/harness-manifest.yaml` — global / harness type / projectの採否とsurface契約 +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 伸太郎殿の設計思想 SSOT +- `agents/global/stitch-screen-creator.md` — Stitch 画面作成グローバルエージェント + +--- + +**追記ルール: 制定経緯・実測詳細は `skills/stitch/SKILL.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.claude/rules/general/visual-progress-map.md b/.claude/rules/general/visual-progress-map.md new file mode 100644 index 000000000..97ef91694 --- /dev/null +++ b/.claude/rules/general/visual-progress-map.md @@ -0,0 +1,124 @@ + + +# 図解・現在地マップ・非エンジニア用語ルール + +全 AI・全作業共通の SSOT。ユーザー(非エンジニア)が現在地・ゴール・次の一手を必ず把握できる状態を保つための図解描画ルール。**通常の実装・Issue/PR/PRD 確認・調査でも、§1-bis のトリガーに該当したら skill 抜きで図解を出す**。 + +**テンプレ・実例・置換表の全文は references へ。本ルールは義務とトリガーだけ(再肥大化防止)。** + +## 0. モード判定(開発 / 業務) + +この図解は **2 モード**を持つ。テンプレは共通で、語彙は references の置換表で読み替える(DRY)。 + +| モード | 対象 PJ(デフォルト) | 性質 | ペア guardrails | +|--------|---------------------|------|----------------| +| **開発** | jtt-apps / jtt-cms / jtt-shift-mobile-app / *-mcp 等 | GitHub PR フロー中心 | dev-guardrails | +| **業務** | jtt-cafe-pj / non-pj | 戦略・施策・KPI 中心 | business-guardrails | + +- `jtt-cafe-pj` は business PJ。曖昧なら §5 に従い平易語で確認してから描く(推測しない)。 +- 最小読み替え: PR/Issue/merge/本番投入 → 戦略スコープ/KPI/意思決定/本番運用。詳細は references。 + +## 1. 地図描画タイミング + +| タイミング | 出すもの | +|-----------|---------| +| セッション開始直後 | `.claude/parallel-run-state/*.json` があれば冒頭で全体地図を ASCII 表示(複数あれば選択を仰ぐ) | +| /brainstorm 各フェーズ遷移時 | Phase 1→2→3 移行直前にミニ地図(§3) | +| /parallel-run 各ステップ完了時 | Step 完了報告+次 Step 前に全体地図を再描画 | +| 通常作業中 | §1-bis 該当時は skill 抜きでも L1 ASCII 図解を出す | +| オンデマンド | 「地図」「現在地」「進捗」の発話で即時再描画 | + +`gh pr list --state all` は開始時1回+オンデマンド時のみ呼ぶ(API節約)。再描画は状態ファイルのキャッシュを優先。 + +## 1-bis. skill 非依存の常時発火トリガー(バランス型) + +skill 非起動時でも、以下のいずれかに該当したら L1 ASCII 図解を出す(指示なしで出るのが本ルール最大の目的)。 + +| トリガー | 出す図の例 | +|---------|-----------| +| ① 3 つ以上の要素・手順・選択肢の説明 | 箇条マップ / 比較表 / フロー | +| ② 「今どこ・次どこ」の現在地・進捗 | 5 段階地図 / ミニ地図 | +| ③ Issue/PR/PRD/仕様書を読んで方針を伝える | 関係図 / 要約マップ / フェーズ図 | +| ④ バグ修正の「原因 → 対処」説明 | 原因 → 対処フロー | +| ⑤⑥ 複数ファイル横断の整理・依存関係説明 | 依存ツリー / フロー図 | +| ⑦ 進捗・週次レビュー・残り作業 | **ゴール地図(§2-bis)**。羅列で終わらせない | +| ⑧ AI Worker MCP へ複数 provider 委譲/状態確認 | **AI Worker 進捗図**(references)。provider名でなく作業内容・現在地を主役にする | + +議論を伴う説明・プランはチャットの L1 要点図解を基本とする。L2 HTMLカードは見た目の比較が必要な時、またはユーザー希望時だけ使う(実装承認プランは plan-approval-gate.md 優先)。③④⑦も専用skill化せず本ルールで発火。 + +### 出さない場面(うるささ回避) + +- 単純な一問一答、1 ステップで完結する短い事実回答、「図はいらない」明示時 + +図形式は自由。**重い L2/L3 は使わず L1 ASCII をデフォルト**にし、図を要約として使う。 + +## 2-bis. ゴール地図(骨子) + +§1-bis⑦で出す。やったこと羅列で終わらせず、計画全体・残り・次の一手・ゴール妥当性を同時に出す。 + +必須 7 要素: ①🎯最終ゴール+達成条件 ②全体スコープ ③✅済 ④⬜未(漏れ) ⑤◀次の一手 ⑥残数 ⑦⚠️ゴール妥当性レビュー。 + +短絡禁止: 「実装が終わった=ゴール達成」「施策を打った=成果(KPI)達成」と書かない(本番運用・撤退基準判定まで未達)。骨子: 📍ゴール/つくる→テスト→🚧本番投入→🏁本番=ゴール/✅済・⬜未・◀次の一手。 + +全体スコープ・未着手は PRD / Issue / git log を実読して埋める(推測禁止)。フルテンプレは references 参照。 + +## 3. ミニ地図テンプレート(/brainstorm 用) + +``` +[ 現在地 ] /brainstorm Phase X/3 +✅ Phase 1: 要件聞き取り +🔵 Phase 2: 不明点深掘り ← 今ここ +⬜ Phase 3: 実装方針提示 + +次にやること: <1 文> +``` + +## 4-bis. 視覚化の 3 層(L1/L2/L3)の使い分け + +図解は内容に応じ 3 層を使い分ける。実行手段の SSOT は `skills/visual-companion/SKILL.md`。本ルールは L1 ASCII と判定基準のみ持つ。 + +| 層 | 何を出すか | 手段 | いつ | +|----|-----------|------|------| +| **L1 ASCII** | 進捗・現在地マップ | ASCII 地図(ゼロ依存) | **デフォルト・常時** | +| **L2 ブラウザ HTML** | mockup・レイアウト比較 | `start-server.sh` | 見た目の比較(オプトイン) | +| **L3 ターミナル画像** | HTML を CLI で目視 | `html-to-terminal.sh` | ブラウザを開かず見たい時 | + +判定: 「読むより見た方が理解できるか?」。テキストで足りる選択は L1、見た目の比較は L2/L3。 + +## 5. 非エンジニア用語ルール + +### 原則 + +- 技術用語は**初回登場時のみ**括弧で平易語を併記、以降はそのまま使う(完全置換はしない) +- 短い同意(「お願い」「はい」)だけで進めない + +代表例(全 12 語は references 参照): PR=変更提案 / merge=本番に取り込む / migration=DB 構造変更 / staging=テスト環境 / worktree=別フォルダ作業領域。 + +### 短い同意への応答 + +「お願い」「はい」「OK」だけ返った時は**次の一手を 1 文で要約してから**再確認する。 + +### 技術判断を仰ぐ時(平易語 + 選択肢で聞く) + +**技術判断は技術用語で聞かない**。①平易語(速さ・安全性・見た目への影響)で説明②2〜3択で提示(可能なら AskUserQuestion)③推奨理由を1文添える。実例は references 参照。 + +## 6. 状態ファイル schema + +`.claude/parallel-run-state/.json` に保管(kebab-case slug、各PJの `.gitignore` へ追加)。フィールド定義・モード別 schema・`gh pr list` 合成手順の全文は `/skills/visual-companion/references/state-file-schema.md` を参照。 + +## 7. 関連ルール + +- `.claude/rules/general/response-style.md` / `sub-agent-scope-contract.md` / `branch-rule.md` +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 各フェーズ・Step 遷移時に参照 +- `commands/brainstorm.md` / `commands/parallel-run.md` — 手動発火ラッパー +- 全文: `/skills/visual-companion/references/progress-map-templates.md`, `state-file-schema.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: テンプレ・実例・置換表は references へ書き、本ルールには足さない(再肥大化防止)。** diff --git a/.claude/rules/general/worktree-rule.md b/.claude/rules/general/worktree-rule.md new file mode 100644 index 000000000..832bc98f1 --- /dev/null +++ b/.claude/rules/general/worktree-rule.md @@ -0,0 +1,114 @@ + + + + + + +# Worktree 利用ルール + +## いつ worktree を使うか + +AI が変更を加える通常作業では、git worktree を作成して別ディレクトリで作業する。 +特に以下のいずれかに該当するときは必須: + +- **並列セッション**: Claude Code / Codex CLI / Cursor 等を同時に複数立ち上げて別タスクを進める +- **複数 PR 同時進行**: 同一リポジトリで 2 本以上の feature branch を行き来する +- **長期 feature branch**: main から離れて 1 日以上滞在する作業(途中で main を hotfix する可能性がある) +- **軽量変更を含む AI 作業**: 例外なし。詳細は branch-rule.md 参照 + +例: +``` +git worktree add ../jtt-cms-feat-xyz -b feat/xyz +cd ../jtt-cms-feat-xyz +``` + +## いつ新規 worktree を作らなくてよいか + +以下は新規 worktree なしでよい: + +- 読み取りだけでファイル変更・commit・push がない場合 +- 既に feature branch にチェックアウト済みで、別タスクを差し挟まない場合 +- 既にこのタスク専用の worktree / branch にいる場合 +- 人間が明示承認した main 直接反映や初回 repo 作成など、branch-rule.md の注記に該当する例外の場合 + +## 機密ファイル(MCP / .env)の自動 symlink + +worktree 作成時、git 追跡外の機密ファイル(`.mcp.json` / `.env` 系)は main worktree の実体へ**自動 symlink**される(git post-checkout hook 由来)。追加操作は不要。仕組み・手動再設置手順・非破壊の詳細は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## Mac mini ContextEngine mirror の自動追従 + +Mac Studio 側の worktree は Mac mini の ContextEngine mirror が自動追従する(対象: jtt-cms / jtt-apps / jtt-system / AGENT-HUB / hermes)。索引はミラーであり当日の新規変更は未反映のことがある。詳細・stale削除・semantic強化ジョブは +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## branch contamination が発生した場合の復旧 + +別セッションのブランチに誤ってコミットした場合は、誤コミット特定 → 正しいブランチへ `cherry-pick` → 復旧用退避作成、の順で対応する。 +**`git reset --hard` と force-push はデフォルト禁止。必ずユーザー承認を得てから実行する。** +詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## AI セッションから worktree へ commit / push する方法(block-main-commit 対策) + +block-main-commit hook は cwd 変更を伴う複合コマンドでの main 直 commit を fail-closed で deny する。AI セッション(cwd=main)から worktree の feature branch へ commit / push する時は: + +1. **`isolation: "worktree"` 付きサブエージェントに委譲する**(正攻法)。 +2. isolation 指定ができない場合のみ、GitHub API / connector で remote feature branch commit → PR → CI → merge の fallback を使う(main 直更新は禁止のまま)。 +3. commit/push を含まない操作(`git add` / `git status` / `gh pr create` 等)はメインセッションから直接 `cd && ...` してよい。 +4. hook 検査を `bash -c` 等で素通りさせる回避は**禁止**。 + +サブエージェントの worktree が古いベース(origin/main 以前)から切られる問題への対処、外側隔離 worktree の残存・cleanup 手順、Codex fallback の実測経緯は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 共有 checkout / main 非占有ルール(全 PJ・全 AI ツール共通) + +対象ルート: `~/LLM-Dev/` `~/business/` `~/Herd/` `~/mac-mini-server/` `~/mcp-servers/` `~/jtt-system/`。Claude / Codex / Cursor / Kimi / OpenCode / Antigravity 全て同じ意味で読む。 + +**AI セッションは、他者や他エージェントが使う可能性のある `main` checkout を掴まない。** 共有 checkout で merge / pull / cleanup を実行すると、並行セッションとブランチ・HEAD を奪い合って競合する。背景・実測実害は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### 必須:1 タスク = 1 連の完了フロー(PR を出して放置しない) + +**専用 worktree 作成 → 編集/commit/push → PR 作成 → マージ → fetch-only / detached 確認 → clean(worktree/branch 削除)まで、必ず一連で最後まで閉じる。** 「PR を出した」「マージした」で止めない。詳細コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### AI が `main` で「やらないこと / 代わりにやること」 + +- **やらない**: `git checkout main` / `git switch main` / `git pull` while on `main` / `git branch -f main`。 +- **やる**: `git fetch origin +refs/heads/main:refs/remotes/origin/main` で remote tracking ref を更新する。確認が必要な時は `git worktree add --detach origin/main` で detached 確認。 +- merge は worktree 内から `gh` / `skills/post-merge/scripts/merge-pr.py --confirm-read` で行う。 +- **cleanup は自分が作った worktree / branch だけ**削除する。`git worktree list --porcelain` で他セッションのものを確認し**温存する**。 +- allowlist 対象の生成 config を main 直コミットする時の stale-main 注意は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +要するに「編集だけ worktree、merge/pull は共有 checkout」をやめる。**着手から cleanup まで一貫して専用 worktree**で閉じる。例外的に人間が明示して main checkout を使う場合は、AI が占有している状態でないことと例外理由を作業ログへ残す。 + +## 既存 worktree の確認 + +```bash +git worktree list +``` + +`~/Herd/jtt-apps` 配下には `jtt-apps-api-rate-limit-guards` / `jtt-apps-wt` / `jtt-apps-worktrees` 等の既存 worktree がある(CLAUDE.md `## プロジェクトルート規約` 参照)。新規作成前に既存 worktree の再利用可否を確認すること。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 000000000..dc945d94d --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,52 @@ +# >>> AGENT-HUB MANAGED CODEX MCP START +# Generated by scripts/sync-codex-mcp-configs.py +# Project: agentmemory +# Do not edit this block by hand. Update AGENT-HUB SSOT instead. +# [2026-04-08][feat] +# 背景: +# - ユーザー依頼意図: Codex 用 MCP を PJ ごとに同期しつつ、各PJの手書き設定は残したい。 +# - 守るべき業務ルール: 接続定義は AGENT-HUB の SSOT から生成し、project 側では managed block だけを置き換える。 +# - 他案不採用理由: project ごとに設定を手編集すると、SSOT との乖離と同期漏れが再発するため不採用。 +# 対応: AGENT-HUB 管理ブロックとして生成元と更新ルールをコメントで残す。 + +[mcp] +enabled = true + +[mcp_servers.agentmemory] +command = "/bin/bash" +args = ["/Users/shintaro/business/AGENT-HUB/scripts/agentmemory-mcp-remote.sh", "agentmemory"] +enabled = true + +[mcp_servers.ai-worker-mcp] +command = "/Users/shintaro/business/AGENT-HUB/tools/ai-worker-mcp/bin/ai-worker-mcp" +args = [] +enabled = true + +[mcp_servers.codebase-context-engine] +command = "/bin/bash" +args = ["/Users/shintaro/business/AGENT-HUB/scripts/codex-mcp-remote-with-env.sh", "http://shintaros-mac-mini:8847/mcp", "CODEBASE_CONTEXT_ENGINE_MCP_API_KEY"] +enabled = true + +[mcp_servers.context7] +command = "npx" +args = ["-y", "@upstash/context7-mcp@3.2.0"] +enabled = true + +[mcp_servers.shintaro-gbrain] +command = "/bin/bash" +args = ["/Users/shintaro/business/AGENT-HUB/scripts/mcp-remote-oauth.sh", "https://gbrain-mcp.jtt.cafe/mcp", "shintaro-gbrain"] +enabled = true + +[mcp_servers.stitch] +url = "https://stitch.googleapis.com/mcp" +enabled = true + +[mcp_servers.stitch.env_http_headers] +X-Goog-Api-Key = "STITCH_API_KEY" + +[mcp_servers.tech-gbrain] +command = "/bin/bash" +args = ["/Users/shintaro/business/AGENT-HUB/scripts/mcp-remote-oauth.sh", "https://gbrain-mcp.jtt.cafe/mcp", "tech-gbrain"] +enabled = true + +# <<< AGENT-HUB MANAGED CODEX MCP END diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 000000000..444152b53 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,73 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; bash \"$PROJECT_DIR/.codex/hooks/scripts/block-destructive-git.sh\"", + "timeout": 10 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; bash \"$PROJECT_DIR/.codex/hooks/scripts/block-main-commit.sh\"", + "timeout": 10 + }, + { + "type": "command", + "command": "PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; bash \"$PROJECT_DIR/.codex/hooks/scripts/storage-url-pr-gate.sh\"", + "timeout": 15 + } + ] + }, + { + "matcher": "Bash|Edit|MultiEdit|Shell|StrReplaceFile|Write|WriteFile", + "hooks": [ + { + "type": "command", + "command": "PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; CLAUDE_PROJECT_DIR=\"$PROJECT_DIR\" bash \"$PROJECT_DIR/.codex/hooks/scripts/block-unauthorized-docs-file.sh\"", + "timeout": 10 + } + ] + }, + { + "matcher": "Bash|Shell", + "hooks": [ + { + "type": "command", + "command": "PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; bash \"$PROJECT_DIR/.codex/hooks/scripts/post-merge-gate.sh\"", + "timeout": 10 + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; bash \"$PROJECT_DIR/.codex/hooks/scripts/freshness-gate.sh\"", + "timeout": 10 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; bash \"$PROJECT_DIR/.codex/hooks/scripts/handover-preflight.sh\"", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/.codex/hooks/.hook-library-version b/.codex/hooks/.hook-library-version new file mode 100644 index 000000000..1cecfb131 --- /dev/null +++ b/.codex/hooks/.hook-library-version @@ -0,0 +1 @@ +v3.6.37 | profile: agentmemory (codex) diff --git a/.codex/hooks/lib/code-quality-check.md b/.codex/hooks/lib/code-quality-check.md new file mode 100644 index 000000000..384cad9b1 --- /dev/null +++ b/.codex/hooks/lib/code-quality-check.md @@ -0,0 +1,205 @@ +# Code Quality Checklist(SubagentStop / Stop hook用) + + + +サブエージェントの作業完了時に、以下の観点で品質チェックを実施する。 +対象: 直前のサブエージェントが**新規作成・変更した**ファイルのみ。 + +--- + + +## コメント品質(Code as ドキュメント) + +### 必須コメント + +| 対象 | ルール | +|------|--------| +| 関数・メソッド | JSDoc / PHPDoc で @param, @returns を記載 | +| 複雑なロジック | 条件分岐3つ以上、正規表現 → 「なぜ」のコメント | +| マジックナンバー | 定数化 or コメントで意味を説明 | +| TODO / FIXME | 理由と期限を記載(// TODO(2025-03): ○○対応後に削除) | + +### 変更コメントの必須フォーマット + +既存コードに意味のある変更を加えた場合、以下のフォーマットでコメントを残すこと。 +**目的:** 次にAIがこの領域を修正する際に同じ過ちを繰り返さないための判断基準を残す。 + +``` +// [YYYY-MM-DD][fix|feat|refactor] +// 背景: ユーザーがその修正を依頼した理由・意図 +// 守るべき業務ルール・ブランド基準 +// 他の実装方法ではダメな理由の判断根拠 +// 対応: 実施した変更内容 +``` + +**背景に含めるべき3要素:** +1. ユーザーがその修正を依頼した理由・意図 +2. その領域で守るべき業務ルール・ブランド基準 +3. なぜ他の実装方法ではダメなのかの判断根拠 + +- [ ] 変更箇所に `[YYYY-MM-DD][fix|feat|refactor]` コメントがあるか +- [ ] 背景に「ユーザー意図」「業務ルール」「不採用理由」が含まれるか +- [ ] 次のAIが同じ判断ミスをしない情報が残っているか + + +--- + + +## 重複機能の禁止(DRY原則) + +| チェック項目 | 基準 | +|-------------|------| +| 既存検索義務 | 新コンポーネント・関数作成前に既存コードベースを検索したか | +| 適用範囲 | ロジック・スタイル定義・色・文言すべてに適用 | +| 類似機能の扱い | 新規作成ではなく既存を拡張・共通化すること | +| パラメータ化 | 同目的のコンポーネントは1つに統合しprops/パラメータで切替 | + +- [ ] 新規関数・コンポーネント作成前にGrep検索で既存を確認したか +- [ ] 同様のロジック・スタイル・文言が既に存在しないか +- [ ] 類似機能がある場合、新規作成ではなく既存を拡張したか + + +--- + + +## ハードコード防止 + +DB由来データ(店舗名、ロール、ステータス等)がコード内にリテラルで直書きされていないかチェックする。 + +### データ管理の優先順位 + +| 優先度 | 方法 | 対象 | +|--------|------|------| +| 1(最優先) | DBから取得 | 変更頻度があるもの: 店舗名、ブランドカラー、設定値、営業時間等 | +| 2 | 定数ファイルに定義 | 環境に依存しない固定値: ステータスEnum、カテゴリ種別等 | +| 3(最終手段) | ハードコード | ①②が不可能な場合のみ。理由をコメントに明記すること | + +**追加ルール:** 同じ値が2箇所以上に出現する場合、必ず①または②で一元管理すること。 + +### チェック項目 + +| 対象 | ルール | +|------|--------| +| ビジネスデータ直書き | 店舗名・ロール名・ステータス等がリテラル文字列で記述されていないか | +| 既存定数の未使用 | プロジェクトにModel定数・Enum・ValueObjectがあるのに文字列比較していないか | +| フロントのマスタデータ | コンポーネント内に選択肢リストがハードコードされていないか(propsまたはAPI経由にする) | +| 固有名詞の条件分岐 | `name.includes('固有名詞')` のような分岐がないか(IDまたはフラグで判定する) | +| TODO_DB / PLACEHOLDER | DB由来データを暫定的に書く場合、`// TODO_DB(YYYY-MM\|ISSUE-123): テーブル名.カラム名` または `// PLACEHOLDER(YYYY-MM\|ISSUE-123): 理由` が付いているか | + +### 許可パターン(チェック対象外) + +- Model / Enum / ValueObject 内の定数定義 +- テストファイル・Seeder・Factory +- config/ 配下の設定ファイル +- 定数ファイル(constants.ts 等) + +- [ ] 定数・設定値がDB or 定数ファイルから取得されているか +- [ ] 同じ値が2箇所以上にハードコードされていないか +- [ ] やむを得ないハードコードに理由コメントがあるか + + +--- + + +## 破壊的変更の事前確認 + +| チェック項目 | 基準 | +|-------------|------| +| 参照洗い出し | 関数・コンポーネント・スタイルの変更/削除前にgrep等で全参照箇所を特定 | +| 整合性修正 | 参照箇所が見つかった場合、全箇所を整合性を保って修正 | +| 報告義務 | 変更した全ファイルと箇所のサマリーをユーザーに報告 | + +- [ ] 変更・削除した関数の全参照箇所をGrepで確認したか +- [ ] 参照箇所を整合性を保って全て修正したか +- [ ] 変更ファイルと箇所のサマリーを報告したか + + +--- + + +## メタ情報コメント(Serena MCP検索対応) + +新規作成ファイルの冒頭に、検索可能なメタ情報コメントがあるか確認する。 +**既存ファイルへの軽微な修正(1-2行の変更)は対象外。** + +### TypeScript / JavaScript / React / React Native / Next.js + +```ts +/** + * @module モジュール名(PascalCase) + * @description 日本語で1行の概要。Serenaのsearch_for_patternで引っかかるキーワードを含める + * @related 関連モジュール名をカンマ区切り + * @stack react-native | react | nextjs ← プロジェクトのスタックを明記 + */ +``` + +### PHP / Laravel + +```php +/** + * @module モジュール名 + * @description 日本語で1行の概要 + * @related 関連クラス・モデル名 + * @stack laravel + */ +``` + +### 対象外(メタ情報コメント不要) +- 設定ファイル(.env, tailwind.config.*, tsconfig.json, composer.json等) +- テストファイル(テスト名が十分なドキュメント) +- 自動生成ファイル(migration以外のartisan generate等) +- package.json, Gemfile, requirements.txt等の依存定義 + +### 命名・配置 + +| チェック項目 | 基準 | +|-------------|------| +| シンボル命名 | 検索しやすい名前か(略語を避ける。ResCtrl → ReservationController) | +| ファイル配置 | プロジェクトの標準ディレクトリに配置されているか | + + +--- + + +## 型チェック(Code as Documentの土台) + +| スタック | ツール | 基準 | +|---------|--------|------| +| TypeScript | `tsc --noEmit` | strict mode必須。any禁止 | +| Laravel | PHPStan | Level 8以上(目標: Level 10) | +| React Native | `tsc --noEmit` | strict mode必須 | + +### チェック項目 + +| 対象 | ルール | +|------|--------| +| 関数の引数・戻り値 | 型アノテーション必須(any / mixed 禁止) | +| API レスポンス | Zod / FormRequest で型を定義 | +| Props | TypeScript interface / PHPDoc @param で明示 | +| 状態管理 | useState / typed Collection で型付け | + +**型が曖昧なコード = ドキュメントとして読めないコード**。AIが推測に頼る原因になるため、型は厳格に。 + + +--- + + +--- + +## 判定基準 + +- 全項目OK → {"decision": "approve", "reason": "品質基準を満たしています"} +- 1つでもNG → {"decision": "block", "reason": "【具体的な指摘と修正指示をここに書く】"} +- stop_hook_activeがtrueの場合 → 無限ループ防止のため必ずapprove + + +- Codex Stop hook の全項目OK / stop_hook_active=true → {"continue": true} diff --git a/.codex/hooks/lib/hook-io.sh b/.codex/hooks/lib/hook-io.sh new file mode 100755 index 000000000..101b8a1c4 --- /dev/null +++ b/.codex/hooks/lib/hook-io.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: jtt-cms Gen 3 のhook-io.shをAGENT-HUBのhook-libraryにポート。 +# PreToolUse/PostToolUse共通のJSON解析・出力関数を一元管理。 +# 3PJで同一ロジックが重複しており、修正時の漏れを防止するためコンポーネント化。 +# 対応: jtt-cms hook-io.sh をそのままポート。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「フック判定が環境差(Node有無)で揺れず、同じ入力なら同じ結果になること」。 +# 業務ルールとして、JSON抽出はエスケープ文字や改行を含む実データでも破綻してはならない。 +# 代替案として sed ベースの簡易抽出を維持すると、文字列中の引用符で誤抽出が起きるため不採用。 +# 対応: Node未導入時は Python JSON パースを使う安全フォールバックへ変更。 + +# --- stdin読み込み --- +# stdinからJSON入力を読み込み、HOOK_INPUT変数に格納する。 +# 各フックのエントリポイントで最初に呼ぶこと。 +read_stdin() { + HOOK_INPUT="$(cat)" +} + +# --- JSON フィールド抽出 (PreToolUse用) --- +# tool_input内の文字列フィールドを抽出する。Node.js優先、sed fallback。 +# 使用例: COMMAND=$(extract_field command) +extract_field() { + local field="$1" + if command -v node >/dev/null 2>&1; then + printf '%s' "$HOOK_INPUT" | node -e ' + const fs = require("fs"); + const field = process.argv[1]; + const raw = fs.readFileSync(0, "utf8"); + let value = ""; + try { + const parsed = JSON.parse(raw); + const source = + parsed && typeof parsed.tool_input === "object" && parsed.tool_input !== null + ? parsed.tool_input + : parsed && typeof parsed.toolInput === "object" && parsed.toolInput !== null + ? parsed.toolInput + : parsed; + if (source && typeof source[field] === "string") { + value = source[field]; + } + } catch {} + process.stdout.write(value); + ' "$field" 2>/dev/null || true + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + PY_FIELD="$field" HOOK_JSON="$HOOK_INPUT" python3 - <<'PY' 2>/dev/null || true +import json +import os + +field = os.environ.get("PY_FIELD", "") +raw = os.environ.get("HOOK_JSON", "") +value = "" +try: + parsed = json.loads(raw) + source = ( + parsed.get("tool_input") + or parsed.get("toolInput") + or parsed + if isinstance(parsed, dict) + else {} + ) + candidate = source.get(field, "") if isinstance(source, dict) else "" + if isinstance(candidate, str): + value = candidate +except Exception: + pass +print(value, end="") +PY + return 0 + fi + + # Node / Python が未導入の場合のみ簡易フォールバック(誤抽出リスクあり) + echo "$HOOK_INPUT" | sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -1 || true +} + +# --- file_path抽出 (PostToolUse用) --- +# tool_inputからfile_path(またはpath)を抽出する。Python3使用。 +# 使用例: filepath=$(extract_file_path) +extract_file_path() { + printf '%s' "$HOOK_INPUT" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + ti = data.get('tool_input') or data.get('toolInput') or {} + print(ti.get('file_path', ti.get('path', ''))) +except Exception: + print('') +" 2>/dev/null || echo "" +} + +# --- deny JSON出力 (PreToolUse用) --- +# hookSpecificOutput形式のdeny JSONを出力し、exit 0で終了する。 +# 使用例: emit_deny "ブロック理由メッセージ" +# [2026-06-19][fix] +# 背景: +# - Claude/Codex/Kimi の hook deny 出力で旧 `reason` キーが混在すると、 +# 新しい権限UIで理由が表示されない環境がある。 +# - 守るべき業務ルール: deny 理由は `permissionDecisionReason` に統一し、 +# JSON 文字列は Python で escape して壊れた hook 出力を防ぐ。 +# - 他案不採用理由: 各 hook で個別に printf する案は schema 差分と escape 漏れが再発するため不採用。 +emit_deny() { + local reason="$1" + HOOK_REASON="$reason" python3 - <<'PY' +import json +import os + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": os.environ.get("HOOK_REASON", ""), + } +}, ensure_ascii=False, separators=(",", ":"))) +PY + exit 0 +} diff --git a/.codex/hooks/lib/quality-check-common.sh b/.codex/hooks/lib/quality-check-common.sh new file mode 100755 index 000000000..acb337b4b --- /dev/null +++ b/.codex/hooks/lib/quality-check-common.sh @@ -0,0 +1,973 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: jtt-cms Gen 3 (403行) をAGENT-HUBのhook-libraryにポート。 +# 3PJで独立進化したhookを統一するため、最先端のGen 3をSSOTとして抽出。 +# 各PJ個別実装だと変更が伝播せず重複が増え続けるため、コンポーネント化して +# deploy-hooks.pyで全PJに配布する設計。 +# 対応: jtt-cms quality-check-common.sh をhook-library/lib/にポート。 +# パス解決をscripts/サブディレクトリ構成に対応させ、 +# チェックリストパスをproject_dir起点に変更。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「transcript解析がPython 3.8環境でも失敗せず動くこと」。 +# 業務ルールとして、品質ゲート共通ライブラリはPJ間で同一挙動を保つ必要がある。 +# 代替案として `set[str]` 型注釈を維持すると、3.8で構文エラーになり判定が抜けるため不採用。 +# 対応: 埋め込みPythonの型注釈を `typing.Set` ベースへ変更。 + +set -euo pipefail + +# telemetry(harness-checkup): quality-gate 系(stop/subagent)の deny を記録。 +# 本 lib は hook-library/lib/ に在り、telemetry-lib.sh は hook-library/scripts/ にある。 +# 配布先でも同じ相対構成(.claude/hooks/lib/ と .claude/hooks/scripts/)のため ../scripts/ で解決できる。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも quality-gate 本体を絶対に壊さない。 +_quality_common_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$_quality_common_dir/../scripts/telemetry-lib.sh" ]; then + . "$_quality_common_dir/../scripts/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: jtt-apps の /brainstorm 質問のみセッションで Stop hook が誤発火する事故 (B1) を、git diff fallback がバックグラウンド同期で書き換わった untracked 派生物 (.opencode/sync-state.json 等) を「変更ファイル」と誤認することで起きる問題として根治したい。 +# - 守るべき業務ルール: 配布先 PJ 側で sync スクリプトが書き換える派生物 (.opencode/, .cursor/, .gemini/, .augment/, .codex/hooks/, .agent/, sync-state.json) は AI のツール呼び出し由来ではないため品質ゲートの対象外にする。 +# - 他案不採用理由: +# 1) .gitignore に追加して回避する案 → 検出ロジックの欠陥は残ったまま、新しい派生物ディレクトリが増えるたびに各 PJ で .gitignore を直す必要があり SSOT 原則違反。 +# 2) git diff fallback を完全廃止する案 → Bash 経由 (sed -i / cat > / tee 等) の書き換えを救う最後の砦が消える。 +# 対応: DOC_SKIP_PATTERNS に sync 派生物パターンを追加し多重防御。主防御は run_quality_check_hook の transcript 判定変更で行う。 +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: business profile の PJ (jtt-cafe-pj / non-pj) は議事録・PRD・戦略などの .md/docs が +# 成果物そのもの。従来は全 PJ 共通で .md/docs を skip していたため、business PJ がローカルで DOC_SKIP を +# 書き換える drift が発生していた (hook-library v3.4.11 配布で露見・Codex 指摘)。SSOT で一元解決したい。 +# - 守るべき業務ルール: 同期派生物 (.opencode/ 等・ツール生成物) は全 profile で skip。文書 (.md/docs 等) は +# code profile では skip、business profile では品質チェック対象にする。配布時に deploy-hooks.py が +# business profile のみ DOC_TYPE_SKIP を外す。配布物のローカル編集 (drift) は禁止のため SSOT 側で分岐させる。 +# - 他案不採用理由: (1) 各 business PJ で DOC_SKIP をローカル編集 → 配布物改変禁止に反し再 drift。 +# (2) runtime で checklist md の文言から profile 推定 → 文言変更で静かに壊れる。 +# 対応: パターンを DOC_TYPE_SKIP (文書) と SYNC_DERIVATIVE_SKIP (同期派生物) に分割。deploy-hooks.py は +# business profile 配布時に下の結合行を `readonly DOC_SKIP_PATTERNS="${SYNC_DERIVATIVE_SKIP}"` へ置換する。 +# [2026-07-09][fix] +# 背景: +# - ユーザー依頼意図: `.brv/` と Kimi 系生成物が同期派生物なのに品質チェック対象へ入り、実作業の本質と +# 無関係な検出ノイズになるのを防ぎたい。 +# - 守るべき業務ルール: AI ツール CLI 派生物は SSOT から再生成・同期されるため、quality check の本文対象ではなく +# SYNC_DERIVATIVE_SKIP に集約する。文書本文の品質チェック分岐は既存の DOC_TYPE_SKIP と分けたまま維持する。 +# - 他案不採用理由: 各 PJ の `.gitignore` へ個別追加する案は配布先ごとの drift を増やすため不採用。 +# DOC_TYPE_SKIP 側へ混ぜる案は business profile の文書チェック分岐を壊すため不採用。 +# 対応: SYNC_DERIVATIVE_SKIP に `.brv/`、`.kimi-code/`、`.kimi/` を追加する。 +# [2026-07-30][fix] +# 背景: +# - ユーザー依頼意図: jtt-apps の実装セッション(シフト確定 v2.5.2)で、Stop hook が +# `.claude/hooks/.hook-library-version` を「変更されたコードファイル」として毎回検知し、 +# 品質チェック済みでも完了報告のたびに block を繰り返した。セッション由来でない配布物で止めたくない。 +# - 守るべき業務ルール: `.claude/hooks/**` は deploy-hooks.py が hook-library 正本から生成する配布物であり、 +# 配布先での直接編集は禁止(settings-protection-coexistence)。よって配布先 PJ で品質チェックの +# 対象にする意味がなく、正本側(AGENT-HUB `hook-library/`)でチェックすべき対象である。 +# 既に `^\.codex/hooks/` は除外済みで、Claude 側だけが抜けていた非対称性が原因。 +# - 他案不採用理由: +# 1) git diff fallback で追跡変更を拾うのを止める案 → Bash 経由(sed -i / cat >)の実コード変更を +# 見逃し品質ゲートが弱くなるため不採用(2026-05-26 の判断を維持)。 +# 2) `.hook-library-version` だけをファイル名で除外する案 → 同じ配布物である `lib/*.sh` や +# `scripts/*.sh` のドリフトで再発するため対症療法。ディレクトリ単位で `.codex/hooks/` と揃える。 +# 3) 配布先 PJ の drift をその都度コミットして消す案 → 配布のたびに日付スタンプで再発するため恒久解にならない。 +# 対応: SYNC_DERIVATIVE_SKIP に `^\.claude/hooks/|/\.claude/hooks/` を追加し、`.codex/hooks/` と対称にする。 +readonly DOC_TYPE_SKIP='\.md$|\.prd$|\.txt$|^docs/|/docs/|\.template$|CLAUDE\.md|README|CHANGELOG' +readonly SYNC_DERIVATIVE_SKIP='^\.opencode/|/\.opencode/|^\.cursor/|/\.cursor/|^\.gemini/|/\.gemini/|^\.augment/|/\.augment/|^\.claude/hooks/|/\.claude/hooks/|^\.codex/hooks/|/\.codex/hooks/|^\.agent/|/\.agent/|^\.brv/|/\.brv/|^\.kimi-code/|/\.kimi-code/|^\.kimi/|/\.kimi/|sync-state\.json$' +# DEPLOY-MARKER(business): deploy-hooks.py は business profile でこの行を SYNC_DERIVATIVE_SKIP のみへ置換する。 +readonly DOC_SKIP_PATTERNS="${DOC_TYPE_SKIP}|${SYNC_DERIVATIVE_SKIP}" +# [2026-03-17][refactor] +# 背景: +# - ユーザー依頼意図: hookのblock reasonにチェックリスト全文(395行)が毎回チャットに出力され、 +# 視認性が悪くコンテキストウィンドウを圧迫するため、最小限の出力に変更したい。 +# - 守るべき業務ルール: dev-guardrails SKILL.md Section 9「発火フロー」に記載の +# 「ファイルパス参照指示を block reason に記載 → AIが Read ツールで code-quality-check.md を +# 読み込み品質チェック実施」方式をランタイムで実現すること。 +# - 他案不採用理由: (1) チェックリスト全文のインライン注入は視認性を壊す(現状の問題そのもの)。 +# (2) 要約版を別ファイルで管理する案はDRY違反で同期漏れを再発させるため不採用。 +# (3) block reasonを完全に空にする案はAIが何をすべきか分からなくなるため不採用。 +# 対応: block reasonにはファイルパス+変更ファイル一覧のみ出力し、 +# AIにReadツールでチェックリストを読ませる方式に変更。 +readonly BLOCK_PREFIX='作業完了前に品質チェックを実施してください。指定されたチェックリストファイルを Read ツールで読み込み、各項目を確認してください。問題があれば修正してから再度完了を報告してください。' +readonly CODE_FILE_PATTERNS='\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|sql|php|py|sh|yaml|yml|toml|ini|mdx?)$' + +emit_json() { + local decision="$1" + local reason="$2" + + PY_DECISION="$decision" PY_REASON="$reason" python3 - <<'PY' +import json +import os + +print( + json.dumps( + {"decision": os.environ["PY_DECISION"], "reason": os.environ["PY_REASON"]}, + ensure_ascii=False, + ) +) +PY +} + +is_codex_hook_root() { + local hook_root="$1" + local normalized_hook_root + + normalized_hook_root="$(cd "$hook_root" 2>/dev/null && pwd || printf '%s\n' "$hook_root")" + + case "$normalized_hook_root" in + */.codex/hooks|*/.codex/hooks/) return 0 ;; + *) return 1 ;; + esac +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: Codex Stop hook が2回目停止時に +# "hook returned invalid stop hook JSON output" で失敗する問題を、配布元の正本で直したい。 +# - 守るべき業務ルール: hook-library は Claude Code / Codex CLI の共通正本なので、 +# Codex だけに必要な出力差分は配布先 hook_root で分岐し、Claude 側の既存応答を維持する。 +# - 他案不採用理由: 共通ライブラリ全体を `decision: approve` のままにする案は Codex Stop で再発する。 +# 逆に全環境を `continue: true` に変える案は Claude Code 側の既存運用に不要な互換リスクを持ち込むため不採用。 +# 対応: `.codex/hooks` 配下で動く approve 相当分岐だけ `{"continue": true}` を返す。 +emit_approval_json() { + local hook_root="$1" + local reason="$2" + + if is_codex_hook_root "$hook_root"; then + python3 - <<'PY' +import json + +print(json.dumps({"continue": True})) +PY + return 0 + fi + + emit_json "approve" "$reason" +} + +resolve_project_dir() { + local hook_root="$1" + local inferred_dir git_root + + if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}" ]; then + printf '%s\n' "$CLAUDE_PROJECT_DIR" + return + fi + + # hook_root is .claude/hooks/ → go up 2 levels to project root + inferred_dir="$(cd "$hook_root/../.." && pwd)" + if git -C "$inferred_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '%s\n' "$inferred_dir" + return + fi + + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ]; then + printf '%s\n' "$git_root" + return + fi + + printf '%s\n' "$inferred_dir" +} + +extract_stop_hook_active() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + print(str(data.get('stop_hook_active', False)).lower()) +except Exception: + print('error') +" <<<"$input" 2>/dev/null || echo "error" +} + +extract_transcript_path() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + value = data.get('transcript_path', '') + print(value if isinstance(value, str) else '') +except Exception: + print('') +" <<<"$input" 2>/dev/null || true +} + +extract_agent_type() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + print(data.get('agent_type', '')) +except Exception: + print('') +" <<<"$input" 2>/dev/null || echo "" +} + +extract_agent_transcript_path() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + value = data.get('agent_transcript_path', '') + print(value if isinstance(value, str) else '') +except Exception: + print('') +" <<<"$input" 2>/dev/null || true +} + +# [2026-03-17][fix] +# 背景: +# - ユーザー依頼意図: PR429レビューで、hook が変更ファイル一覧を誤判定せず、 +# 品質チェックの block/approve 判定を安定して行える状態にしたい。 +# - 守るべき業務ルール: Git 管理下の合法パス(前後空白や改行を含む名前を含む)でも +# 品質ゲートが誤検知・見逃しを起こさないこと。品質ゲートの誤作動は +# 「本来 block すべき変更を素通しする」「関係ない変更で block する」の両面で運用事故になる。 +# - 他案不採用理由: (1) 改行区切りのまま扱う案は改行入りパスで分裂する。 +# (2) strip で前後空白を落とす案は合法パスを別名に変えてしまう。 +# (3) 特殊ケースを無視する案は次回AIが同じバグを再発させるため不採用。 +# 対応: 変更ファイル一覧は JSON 配列で受け渡しし、表示時だけ安全に整形する。 +extract_changed_files_from_input() { + local input="$1" + + python3 -c " +import json +import sys + +PATH_KEYS = {'file_path', 'path', 'new_path', 'old_path', 'target_path'} + +def walk(node, out): + if isinstance(node, dict): + for key, value in node.items(): + if key.lower() in PATH_KEYS and isinstance(value, str) and value != '': + out.add(value) + walk(value, out) + return + if isinstance(node, list): + for item in node: + walk(item, out) + +paths = set() +try: + payload = json.load(sys.stdin) + walk(payload, paths) +except Exception: + pass + +print(json.dumps(sorted(paths), ensure_ascii=False)) +" <<<"$input" 2>/dev/null || echo "[]" +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: /brainstorm のような質問のみセッション (AI が Write/Edit を一切呼ばない) で Stop hook が誤発火する問題 (B1) の主防御。 +# - 守るべき業務ルール: transcript が読み取れた状態で Write 系ツールが 0 件なら、コード変更は本会話由来ではないと判定し git diff fallback を呼ばずに approve する。 +# - 他案不採用理由: +# 1) extract_changed_files_from_transcript の戻り値だけで判定する案 → "[]" が「読めて 0件」と「読めなかった」を区別できず、Bash 経由書き換え時に fallback が呼ばれなくなる。 +# 2) 戻り値に sentinel 文字列を混ぜる案 → 呼び出し側のパース処理が複雑化し、JSON との混在で誤判定リスク。 +# 対応: transcript_path の読み取り可否を別関数で boolean 返却し、呼び出し側で 3 状態 (paths あり / 読めて 0件 / 読めなかった) に分岐する。 +transcript_was_readable() { + local transcript_path="$1" + if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then + echo "true" + else + echo "false" + fi +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: PR87レビューで、transcript が読める状態の Bash 書き込み +# (`cat > file`, `tee`, `sed -i` 等) が Write/Edit 0件扱いで品質ゲートを素通りする問題を直したい。 +# - 守るべき業務ルール: /brainstorm の質問のみセッションでは誤発火させない一方で、git diff fallback は +# Bash 経由書き換えを救う最後の砦として残す必要がある。 +# - 他案不採用理由: +# 1) git diff fallback を完全廃止する案 → Bash 経由書き換え検出が失われるため不採用。 +# 2) transcript 内に Bash があるだけで fallback する案 → `git status` だけの質問セッションで再発火しやすいため不採用。 +# 対応: transcript の Bash command から書き込み系パターンだけを検出し、その時だけ fallback に進める。 +# +# [2026-04-28][fix] +# 背景: +# - ユーザー依頼意図: 読取専用セッション(gh / git 系コマンドのみ)で Stop hook が連続誤発火し、 +# タイポディレクトリ `.claire/` 配下の untracked ファイルを「変更コード」と誤検出する事故が再発した。 +# - 守るべき業務ルール: シェルリダイレクト `2>&1` / `1>&2` はファイル書き込みではない。 +# `&>/dev/null` / `&>>/dev/null` も破棄目的の診断出力であり、WRITE 判定に含めると +# 診断目的の `gh ... 2>&1` 連発で git diff fallback が誤起動し、 +# 別 worktree や typo ディレクトリの差分まで拾ってしまう。 +# - 他案不採用理由: +# 1) WRITE_COMMAND_RE から `>` を完全削除する案 → 真の `cmd > file` 書き込みを見逃すため不採用。 +# 2) DOC_SKIP_PATTERNS に `.claire` を足す案 → 対症療法。次の typo に対応できないため不採用。 +# 3) 実行時に shlex で AST パースする案 → bash heredoc / 複合コマンドで誤動作しやすく過剰実装。 +# 対応: FD複製 (`2>&1`) と `/dev/null` 破棄だけを除外し、`1>file` / `2>file` / `&>file` は +# 真のファイル書き込みとして検出する。 +transcript_has_bash_write_command() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "false" + return 0 + fi + + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "false" +import json +import re +import sys +from typing import Any + +# `>` / `>>` はFD複製 (`2>&1`) と `/dev/null` 破棄だけを除外する。 +# これにより `1>file`, `2>file`, `&>file` は検出し、`2>&1`, `1>&2`, `&>/dev/null` は除外する。 +WRITE_COMMAND_RE = re.compile( + r"((?:^|[\s;|])(?:\d*)?>>?(?!&)(?!\s*/dev/null\b)\s*|(?:^|[\s;|])&>{1,2}(?!>)(?!\s*/dev/null\b)\s*|\btee\b|\bsed\s+-i\b|\bperl\s+-pi\b|\bcp\b|\bmv\b|\brm\b|\btouch\b|\bmkdir\b|\bcat\s+<<)" +) + + +def tool_name(node: Any) -> str: + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + value = node.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def command_text(node: Any) -> str: + if not isinstance(node, dict): + return "" + if isinstance(node.get("command"), str): + return node["command"] + nested = node.get("input") + if isinstance(nested, dict) and isinstance(nested.get("command"), str): + return nested["command"] + tool_input = node.get("tool_input") + if isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str): + return tool_input["command"] + return "" + + +def has_bash_write(node: Any) -> bool: + if isinstance(node, dict): + name = tool_name(node) + if name == "Bash" and WRITE_COMMAND_RE.search(command_text(node)): + return True + return any(has_bash_write(value) for value in node.values()) + if isinstance(node, list): + return any(has_bash_write(item) for item in node) + return False + + +try: + content = open(sys.argv[1], encoding="utf-8", errors="ignore").read() +except Exception: + print("false") + raise SystemExit(0) + +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + if has_bash_write(json.loads(line)): + print("true") + raise SystemExit(0) + except SystemExit: + raise + except Exception: + pass + +try: + result = has_bash_write(json.loads(content)) +except Exception: + result = False + +print("true" if result else "false") +PY +} + +extract_changed_files_from_transcript() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "[]" + return 0 + fi + + # Layer 3: 書き込みツール(Write/Edit/NotebookEdit/MultiEdit)のfile_pathのみ収集。 + # Read/Grep/Globなどの読み取り専用ツールのfile_pathを「変更」と誤認しない。 + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "[]" +import json +import sys +from typing import Any, Set + +WRITE_TOOLS = frozenset({"Write", "Edit", "NotebookEdit", "MultiEdit"}) +PATH_KEYS = frozenset({"file_path", "path", "new_path", "old_path", "target_path"}) + +transcript_path = sys.argv[1] +paths: Set[str] = set() + + +def collect_paths(node: Any) -> None: + """Collect file paths from a node known to belong to a write tool.""" + if isinstance(node, dict): + for key, value in node.items(): + if key.lower() in PATH_KEYS and isinstance(value, str) and value != "": + paths.add(value) + collect_paths(value) + return + if isinstance(node, list): + for item in node: + collect_paths(item) + + +def find_tool_name(node: Any) -> str: + """Extract tool name from a dict node.""" + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + val = node.get(key, "") + if isinstance(val, str) and val: + return val + return "" + + +def process_entry(entry: Any) -> None: + """Walk an entry and only collect paths from write tool invocations.""" + if not isinstance(entry, dict): + return + tool_name = find_tool_name(entry) + if tool_name in WRITE_TOOLS: + collect_paths(entry) + # Recurse into nested structures (content, messages, etc.) + for key in ("content", "messages", "tool_use", "input"): + child = entry.get(key) + if isinstance(child, list): + for item in child: + process_entry(item) + elif isinstance(child, dict): + process_entry(child) + + +try: + with open(transcript_path, encoding="utf-8", errors="ignore") as f: + content = f.read() +except Exception: + print("[]") + sys.exit(0) + +# JSON Lines format +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + process_entry(json.loads(line)) + except Exception: + pass + +# Single JSON object format +try: + process_entry(json.loads(content)) +except Exception: + pass + +print(json.dumps(sorted(paths), ensure_ascii=False)) +PY +} + +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: Bash の作成系コマンド (`cat > f` / `tee f` / `touch f` / `> f`) の +# ターゲットパスを transcript から抽出し、git diff フォールバックで「セッションが作成した +# 未追跡ファイルだけ」を拾えるようにする。 +# - 守るべき業務ルール: 移動・削除系 (mv / cp / rm) は新規コード作成の判定に使わない。 +# `mv tmp dest` のような plumbing を作成扱いすると、他セッション WIP の誤検知 (R1) を再発させる。 +# - 他案不採用理由: +# 1) WRITE_COMMAND_RE の boolean 判定を流用する案は、ターゲットパスが取れず未追跡の絞り込みができない。 +# 2) 正規表現だけでパスを分割する案は、`cat > "src/space file.ts"` のような引用符付きパスを見逃す。 +# 対応: shlex で Bash コマンドの引用符を解釈し、作成系リダイレクト/コマンドのターゲットだけを抽出する。 +extract_bash_created_paths_from_transcript() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "[]" + return 0 + fi + + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "[]" +import json +import os +import re +import shlex +import sys +from typing import Any + +REDIRECT_TOKEN_RE = re.compile(r"^(?:(?:\d*)>{1,2}|&>{1,2})$") +METACHARS = {";", "|", "&", "<", ">", ">>", "&>", "&>>", "&&", "||"} + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `cd scripts && cat > foo.py` のように Bash の cwd が変わった後の +# ファイル作成を transcript から抽出するとき、cwd を無視して相対パスのまま返すため +# `git ls-files --others` の `scripts/foo.py` と一致せず未追跡ファイルを見逃す問題を修正したい。 +# 守るべき業務ルール: 移動・削除系 (mv / cp / rm) は作成扱いしない(R1 誤検知防止)。 +# 変数展開を含む `cd "$VAR"` は追跡不能で、従来どおり相対のまま許容する。 +# cwd 正規化は `detect_changed_files()` 内の created_rel 変換と対称に行う。 +# 他案不採用理由: +# 1) cwd を環境変数で渡す案 → Bash ノード間で状態が引き継がれず `cd && cmd` のケースを処理できない。 +# 2) shlex の AST パース案 → bash heredoc / 複合コマンドで誤動作しやすく過剰実装。 +# 対応: `cwd_from_node()` を追加してノードの cwd フィールドを取得。 +# `harvest()` に cwd 引数を追加し `cd ` を検出したら current_cwd を更新。 +# `add_target()` に cwd 引数を追加して絶対パス正規化を行う。 + +targets = set() + + +def cwd_from_node(node): + """Bash ノードの cwd フィールドを取得する。複数のキー名に対応。""" + if not isinstance(node, dict): + return "" + # 直接フィールド + v = node.get("cwd") + if isinstance(v, str) and v: + return v + # tool_input.cwd + ti = node.get("tool_input") + if isinstance(ti, dict): + v = ti.get("cwd") + if isinstance(v, str) and v: + return v + # input.cwd + inp = node.get("input") + if isinstance(inp, dict): + v = inp.get("cwd") + if isinstance(v, str) and v: + return v + return "" + + +def add_target(tok, cwd=""): + tok = tok.strip() + # フラグ (-a 等)・FD複製 (&1)・破棄先 (/dev/null) は作成ターゲットではない。 + if not tok or tok.startswith(("-", "&")) or tok == "/dev/null" or tok.endswith("/dev/null"): + return + if os.path.isabs(tok): + targets.add(tok) + elif cwd: + targets.add(os.path.normpath(os.path.join(cwd, tok))) + else: + targets.add(tok) + + +def shell_tokens(cmd): + try: + lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + return list(lexer) + except Exception: + return [] + + +def harvest(cmd, cwd=""): + tokens = shell_tokens(cmd) + current_cwd = cwd + for i, tok in enumerate(tokens): + # `cd ` を検出して current_cwd を更新 + if tok == "cd" and i + 1 < len(tokens): + new_dir = tokens[i + 1] + # 変数展開 ($VAR 等) は追跡不能なのでスキップ + if not new_dir.startswith("$") and new_dir not in METACHARS: + if os.path.isabs(new_dir): + current_cwd = new_dir + elif current_cwd: + current_cwd = os.path.normpath(os.path.join(current_cwd, new_dir)) + else: + current_cwd = new_dir + continue + # 作成系リダイレクト `> f` / `1> f`。`2>&1` や `/dev/null` は add_target 側で除外。 + if (tok in {">", ">>", "&>", "&>>"} or REDIRECT_TOKEN_RE.match(tok)) and i + 1 < len(tokens): + add_target(tokens[i + 1], current_cwd) + continue + if tok in {"tee", "touch"}: + for candidate in tokens[i + 1 :]: + if candidate in METACHARS: + break + add_target(candidate, current_cwd) + + +def tool_name(node): + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + v = node.get(key) + if isinstance(v, str) and v: + return v + return "" + + +def command_text(node): + if not isinstance(node, dict): + return "" + if isinstance(node.get("command"), str): + return node["command"] + for key in ("input", "tool_input"): + nested = node.get(key) + if isinstance(nested, dict) and isinstance(nested.get("command"), str): + return nested["command"] + return "" + + +def walk(node: Any) -> None: + if isinstance(node, dict): + if tool_name(node) in {"Bash", "Shell"}: + node_cwd = cwd_from_node(node) + harvest(command_text(node), node_cwd) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for item in node: + walk(item) + + +try: + content = open(sys.argv[1], encoding="utf-8", errors="ignore").read() +except Exception: + print("[]") + raise SystemExit(0) + +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + walk(json.loads(line)) + except Exception: + pass + +try: + walk(json.loads(content)) +except Exception: + pass + +print(json.dumps(sorted(targets), ensure_ascii=False)) +PY +} + +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: jtt-cafe-pj の /insights リフレッシュ作業終了時、Stop hook が +# 別セッションの未追跡 WIP (.claude/skills/dev-guardrails/** 等) を「変更コードファイル」 +# として誤検知し block する事象が実発火した (R1)。クリーンに直したい。 +# - 守るべき業務ルール: git diff フォールバックは transcript 検出 (Write/Edit の file_path) が +# 失敗した時の最終手段。未追跡ファイルは git 履歴がなくセッション帰属を判定できないため、 +# 無条件に拾うと他セッションの WIP・スクラッチ・他ツール生成物を誤検知する。 +# - 他案不採用理由: +# 1) 未追跡検出を完全除去する案 → Bash で新規作成したコードファイル (`cat > scripts/foo.py`) を +# フォールバックで見逃し品質ゲートが弱くなる (Codex レビュー指摘) ため不採用。 +# 2) DOC_SKIP_PATTERNS にディレクトリを足し続ける案 → 「次の untracked に対応できない対症療法」のため不採用。 +# 対応: 追跡変更 (git diff / --cached) は常に対象。未追跡ファイルは +# 「このセッションが Bash 作成系で書いたターゲット」(created_paths) に一致するものだけ対象にする。 +# created_paths が空 (transcript 読めない等) の場合は未追跡を一切拾わない (帰属不能なため安全側)。 +detect_changed_files() { + local project_dir="$1" + local created_paths_json="${2:-[]}" + + python3 - "$project_dir" "$CODE_FILE_PATTERNS" "$created_paths_json" <<'PY' 2>/dev/null || echo "[]" +import json +import os +import re +import subprocess +import sys + +project_dir = sys.argv[1] +code_file_pattern = re.compile(sys.argv[2], re.IGNORECASE) +try: + created = json.loads(sys.argv[3]) + if not isinstance(created, list): + created = [] +except Exception: + created = [] + +# セッションが Bash 作成系で書いたターゲットを project_dir 相対パスに正規化。 +# basename 一致は使わない(別ディレクトリの同名未追跡ファイルを誤検知するため。Codex レビュー指摘)。 +created_rel = set() +for t in created: + if not isinstance(t, str) or not t: + continue + norm = t + if os.path.isabs(t): + try: + norm = os.path.relpath(t, project_dir) + except Exception: + norm = t + if norm.startswith("./"): + norm = norm[2:] + created_rel.add(norm) + +paths = set() + +# 追跡ファイルの変更は常に対象。 +for command in ( + ["git", "-C", project_dir, "diff", "--name-only", "-z", "--diff-filter=ACMR"], + ["git", "-C", project_dir, "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"], +): + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False) + for raw_path in result.stdout.split(b"\0"): + if raw_path: + paths.add(raw_path.decode("utf-8", errors="surrogateescape")) + +# 未追跡は「このセッションが作成したターゲット」に相対パス完全一致するコードファイルだけ対象にする。 +if created_rel: + result = subprocess.run( + ["git", "-C", project_dir, "ls-files", "--others", "--exclude-standard", "-z"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, + ) + for raw_path in result.stdout.split(b"\0"): + if not raw_path: + continue + path = raw_path.decode("utf-8", errors="surrogateescape") + if not code_file_pattern.search(path): + continue + if path in created_rel: + paths.add(path) + +print(json.dumps(sorted(paths), ensure_ascii=False)) +PY +} + +json_file_list_is_empty() { + local files_json="$1" + + python3 -c " +import json +import sys + +try: + print('true' if not json.load(sys.stdin) else 'false') +except Exception: + print('true') +" <<<"$files_json" 2>/dev/null || echo "true" +} + +filter_non_doc_files() { + local files_json="$1" + + python3 -c " +import json +import re +import sys + +pattern = re.compile(sys.argv[1], re.IGNORECASE) + +try: + files = json.loads(sys.argv[2]) +except Exception: + print('[]') + sys.exit(0) + +print(json.dumps([path for path in files if not pattern.search(path)], ensure_ascii=False)) +" "$DOC_SKIP_PATTERNS" "$files_json" 2>/dev/null || echo "[]" +} + +json_file_list_contains_sql() { + local files_json="$1" + + python3 -c " +import json +import re +import sys + +try: + files = json.loads(sys.argv[1]) +except Exception: + print('false') + sys.exit(0) + +print('true' if any(re.search(r'\\.sql$', path, re.IGNORECASE) for path in files) else 'false') +" "$files_json" 2>/dev/null || echo "false" +} + +format_file_list_for_display() { + local files_json="$1" + + python3 -c " +import json +import sys + +try: + files = json.loads(sys.argv[1]) +except Exception: + sys.exit(0) + +for path in files: + print(json.dumps(path, ensure_ascii=False)) +" "$files_json" 2>/dev/null || true +} + +# --- メインエントリーポイント --- +# 引数: +# $1: hook_name - ログ用の識別子 (例: "subagent-quality-check") +# $2: hook_root - hookルートディレクトリ (.claude/hooks/) +# $3: no_file_change_reason - ファイル変更なし時の理由メッセージ +# $4: use_git_diff_fallback - git diffフォールバック使用 (default: true) +run_quality_check_hook() { + local hook_name="$1" + local hook_root="$2" + local no_file_change_reason="$3" + local use_git_diff_fallback="${4:-true}" + local input project_dir checklist_path stop_hook_active input_changed_files transcript_path transcript_changed_files transcript_readable transcript_bash_write bash_created_paths changed_files non_doc_files formatted_non_doc_files block_reason sql_files security_checklist_path is_codex_hook + + is_codex_hook="false" + if is_codex_hook_root "$hook_root"; then + is_codex_hook="true" + fi + + # [2026-04-26][fix] + # 背景: + # - ユーザー依頼意図: Codex Stop hook の stdout/stderr 混在で JSON パース失敗を疑う状態をなくしたい。 + # - 守るべき業務ルール: Codex hook の通常出力は JSON だけに固定し、診断ログは明示的なデバッグ時だけ出す。 + # - 他案不採用理由: 常時 stderr にログを出す案は、Codex 側の厳密な Stop hook JSON 判定で + # invalid JSON 扱いの再発要因になり得るため不採用。 + # 対応: Codex 配布先では CODEX_HOOK_DEBUG=1 の時だけ stderr ログを出す。Claude 側は既存どおりログを出す。 + log() { + if [ "$is_codex_hook" != "true" ] || [ "${CODEX_HOOK_DEBUG:-}" = "1" ]; then + echo "[hook:${hook_name}] $*" >&2 + fi + } + + if ! command -v python3 >/dev/null 2>&1; then + log "python3 command is missing, approving as safe fallback" + if is_codex_hook_root "$hook_root"; then + echo '{"continue":true}' + else + echo '{"decision":"approve","reason":"python3 is required for quality hook. Approved as safe fallback."}' + fi + return 0 + fi + + input="$(cat)" + project_dir="$(resolve_project_dir "$hook_root")" + checklist_path="$hook_root/lib/code-quality-check.md" + + log "hook invoked for project: $project_dir" + + if [ ! -f "$checklist_path" ]; then + log "checklist not found at $checklist_path, approving" + emit_approval_json "$hook_root" "No quality checklist found, skipping." + return 0 + fi + + stop_hook_active="$(extract_stop_hook_active "$input")" + if [ "$stop_hook_active" = "true" ]; then + log "stop_hook_active=true, approving to prevent infinite loop" + emit_approval_json "$hook_root" "Already in quality check loop, approving to prevent infinite loop." + return 0 + fi + if [ "$stop_hook_active" = "error" ]; then + log "WARNING: failed to parse stop_hook_active from input JSON, approving as fallback" + emit_approval_json "$hook_root" "Could not parse hook input JSON, approving as safety fallback." + return 0 + fi + + # Layer 1: agent_type による読み取り専用エージェント即時判定 + # Explore/Plan等はWrite/Editツールを持たない(公式仕様で除外)ため、 + # コード変更は構造的に不可能。ファイル検出を一切行わずapproveする。 + local agent_type + agent_type="$(extract_agent_type "$input")" + case "$agent_type" in + Explore|Plan|feature-dev:code-reviewer|feature-dev:code-architect|feature-dev:code-explorer|claude-code-guide) + log "read-only agent type '$agent_type', approving without quality check" + emit_approval_json "$hook_root" "Read-only agent type ($agent_type), quality check not applicable." + return 0 + ;; + esac + + input_changed_files="$(extract_changed_files_from_input "$input")" + if [ "$(json_file_list_is_empty "$input_changed_files")" = "false" ]; then + changed_files="$input_changed_files" + log "detected changed files from hook input" + else + # Layer 2: agent_transcript_path を優先使用 + # SubagentStopでは agent_transcript_path(サブエージェント固有の履歴)を使い、 + # transcript_path(メインセッション全履歴)へのフォールバックで親の書き込みを誤検知しない。 + transcript_path="$(extract_agent_transcript_path "$input")" + if [ -z "$transcript_path" ]; then + transcript_path="$(extract_transcript_path "$input")" + fi + transcript_changed_files="$(extract_changed_files_from_transcript "$transcript_path")" + transcript_readable="$(transcript_was_readable "$transcript_path")" + transcript_bash_write="$(transcript_has_bash_write_command "$transcript_path")" + if [ "$(json_file_list_is_empty "$transcript_changed_files")" = "false" ]; then + changed_files="$transcript_changed_files" + log "detected changed files from transcript_path" + elif [ "$transcript_readable" = "true" ] && [ "$transcript_bash_write" = "true" ] && [ "$use_git_diff_fallback" = "true" ]; then + # 未追跡はセッションが Bash 作成系で書いたターゲットだけに絞る(他セッション WIP の誤検知 R1 防止) + bash_created_paths="$(extract_bash_created_paths_from_transcript "$transcript_path")" + changed_files="$(detect_changed_files "$project_dir" "$bash_created_paths")" + log "transcript has bash write command, falling back to git diff (untracked limited to session-created targets)" + elif [ "$transcript_readable" = "true" ]; then + # [2026-04-26][fix] + # transcript が読めて Write/Edit/MultiEdit/NotebookEdit が 0 件 → /brainstorm 等の質問のみセッション。 + # git diff fallback を呼ぶとバックグラウンド同期で書き換わった派生物 (.opencode/sync-state.json 等) を + # 「変更ファイル」と誤認するため、ここで approve に進む。 + changed_files="$transcript_changed_files" # = "[]" + log "transcript readable but no write tool invocations, approving (B1 fix)" + elif [ "$use_git_diff_fallback" = "true" ]; then + changed_files="$(detect_changed_files "$project_dir")" + log "transcript unreadable, falling back to git diff" + else + changed_files="" + log "git diff fallback disabled, no input/transcript file changes found" + fi + fi + + if [ "$(json_file_list_is_empty "$changed_files")" = "true" ]; then + log "no file changes detected, approving" + emit_approval_json "$hook_root" "$no_file_change_reason" + return 0 + fi + + non_doc_files="$(filter_non_doc_files "$changed_files")" + if [ "$(json_file_list_is_empty "$non_doc_files")" = "true" ]; then + log "only document files changed, approving" + emit_approval_json "$hook_root" "Document file change - skipping code quality check." + return 0 + fi + + formatted_non_doc_files="$(format_file_list_for_display "$non_doc_files")" + log "code files changed, blocking for quality check: $(echo "$formatted_non_doc_files" | tr '\n' ', ')" + + block_reason="${BLOCK_PREFIX}"$'\n\n'"チェックリスト: ${checklist_path}" + + # SQLファイル変更時はセキュリティレビューチェックリストのパスも追加 + sql_files="$(json_file_list_contains_sql "$non_doc_files")" + if [ "$sql_files" = "true" ]; then + security_checklist_path="$hook_root/lib/security-review-check.md" + if [ -f "$security_checklist_path" ]; then + block_reason="${block_reason}"$'\n'"セキュリティチェックリスト: ${security_checklist_path}" + log "SQL files detected, adding security review checklist path" + fi + fi + + block_reason="${block_reason}"$'\n\n'"変更されたコードファイル:"$'\n'"${formatted_non_doc_files}" + # telemetry(harness-checkup): quality-gate deny を記録(fail-open)。 + agent_hub_telemetry_log hook_deny "$hook_name" deny 2>/dev/null || true + emit_json "block" "$block_reason" + return 0 +} diff --git a/.codex/hooks/lib/storage-url-common.py b/.codex/hooks/lib/storage-url-common.py new file mode 100644 index 000000000..08305bb99 --- /dev/null +++ b/.codex/hooks/lib/storage-url-common.py @@ -0,0 +1,190 @@ +# [2026-05-16][refactor] +# 背景: +# - ユーザー依頼意図: gmail-mcp へ配布された hook-library の Python ファイルも、配布先の CaD ルールに合う形へ揃えたい。 +# - 守るべき業務ルール: Python ファイル冒頭には shebang 直後または冒頭に # 形式の CaD ヘッダーを置く。 +# - 他案不採用理由: docstring 内の履歴だけに残す案は、配布先の CaD 検査で冒頭ヘッダーとして認識されないため不採用。 +# 対応: 既存 docstring 履歴を残したまま、冒頭に配布共通の CaD ヘッダーを追加。 +""" +Storage URL検証の共通ロジック。 +storage-url-check.sh (PostToolUse) と storage-url-pr-gate.sh (PreToolUse) から呼び出される。 + +[2026-03-03][refactor] +背景: jtt-cms Gen 3 のstorage-url-common.pyをAGENT-HUBのhook-libraryにポート。 + Supabase Storage URLの存在検証をPJ横断で共有するためコンポーネント化。 +対応: jtt-cms storage-url-common.py をそのままポート。 + +[2026-03-04][fix] +背景: ユーザー意図は「Python実行環境差でチェックが無効化されないこと」。 + 業務ルールとして、共通ライブラリは最低運用環境でも構文エラーなく動作する必要がある。 + 代替案として Python 3.9+ 専用型ヒントを維持すると、3.8系でゲートが素通りするため不採用。 +対応: 型ヒントを typing.List/Set/Tuple へ置換し、互換性を確保。 + +使い方: + python3 lib/storage-url-common.py [ ...] + mode: "check" (PostToolUse用) または "gate" (PreToolUse用) + +- check モード: 最大5URL検証、未アップロードがあれば stderr + exit 2 +- gate モード: 最大10URL検証(並列)、未アップロードがあれば deny理由を stdout + exit 1 +""" + +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import List, Set, Tuple + +# --- 定数 --- +CURL_TIMEOUT_SECONDS = 2 +MAX_URLS_CHECK_MODE = 5 +MAX_URLS_GATE_MODE = 10 + +STORAGE_URL_PATTERN = re.compile( + r"https://[a-z0-9]+\.supabase\.co/storage/v1/object/public/[^\x22\x27\s,)\]}\x60]+" +) + + +def remove_sql_comments(content: str) -> str: + """SQLコメントを除去する。コメント内のURLを誤検知しないため。""" + content = re.sub(r"--[^\n]*", "", content) + content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) + return content + + +def extract_storage_urls(filepaths: List[str]) -> List[str]: + """ファイル群からStorage URLを抽出し、重複排除・ソートして返す。""" + all_urls: Set[str] = set() + for fp in filepaths: + if not os.path.isfile(fp): + continue + try: + content = open(fp, encoding="utf-8").read() + except Exception: + continue + cleaned = remove_sql_comments(content) + all_urls.update(STORAGE_URL_PATTERN.findall(cleaned)) + return sorted(all_urls) + + +def check_url_head(url: str) -> Tuple[str, str]: + """curl HEAD でURLの存在を検証し、(url, HTTPステータス) を返す。""" + try: + result = subprocess.run( + [ + "curl", "-sI", + "--max-time", str(CURL_TIMEOUT_SECONDS), + "-o", "/dev/null", + "-w", "%{http_code}", + url, + ], + capture_output=True, + text=True, + timeout=CURL_TIMEOUT_SECONDS + 3, + ) + return (url, result.stdout.strip()) + except Exception: + return (url, "error") + + +def build_upload_hints(missing_urls: List[Tuple[str, str]]) -> List[str]: + """未アップロードURLからバケット名・パスを逆算し、アップロードコマンドを生成する。""" + hints: List[str] = [] + for url, _ in missing_urls: + m = re.search(r"https://[^/]+/storage/v1/object/public/([^/]+)/(.+)", url) + if m: + hints.append(f" pnpm upload:storage {m.group(1)} {m.group(2)}") + return hints + + +def run_check_mode(filepaths: List[str]) -> None: + """PostToolUse用: 逐次検証、未アップロードがあればstderr + exit 2。""" + urls = extract_storage_urls(filepaths) + if not urls: + sys.exit(0) + + check_urls = urls[:MAX_URLS_CHECK_MODE] + remaining = max(0, len(urls) - MAX_URLS_CHECK_MODE) + + missing: List[Tuple[str, str]] = [] + for url in check_urls: + url, status = check_url_head(url) + if status != "200": + missing.append((url, status)) + + if not missing: + sys.exit(0) + + msg = "\n[hook:storage-url-check] 未アップロードのStorage画像を検出しました:\n" + for url, status in missing: + msg += f" - {url} -> HTTP {status}\n" + if remaining > 0: + msg += f" (他に{remaining}件のURLが未検証です)\n" + + hints = build_upload_hints(missing) + msg += "\nアップロード方法:\n" + if hints: + msg += "\n".join(hints) + "\n" + else: + msg += " Supabase DashboardまたはMCP経由でStorage画像をアップロードしてください。\n" + msg += "\nアップロード完了後、再度ファイルを保存してください。\n" + + sys.stderr.write(msg) + sys.exit(2) + + +def run_gate_mode(filepaths: List[str]) -> None: + """PreToolUse用: 並列検証、未アップロードがあればdeny理由をstdout + exit 1。""" + urls = extract_storage_urls(filepaths) + if not urls: + sys.exit(0) + + check_urls = urls[:MAX_URLS_GATE_MODE] + remaining = max(0, len(urls) - MAX_URLS_GATE_MODE) + + missing: List[Tuple[str, str]] = [] + with ThreadPoolExecutor(max_workers=MAX_URLS_GATE_MODE) as executor: + futures = {executor.submit(check_url_head, url): url for url in check_urls} + for future in as_completed(futures): + url, status = future.result() + if status != "200": + missing.append((url, status)) + + if not missing: + sys.exit(0) + + parts = [ + "[hook:storage-url-pr-gate] 未アップロードのStorage画像があります。" + "PR作成前にアップロードしてください。\\n\\n未検証URL:" + ] + for url, status in sorted(missing): + parts.append(f" - {url} -> HTTP {status}") + + if remaining > 0: + parts.append(f" (他に{remaining}件のURLが未検証です)") + + hints = build_upload_hints(sorted(missing)) + parts.append("\\nアップロード方法:") + if hints: + parts.extend(hints) + else: + parts.append(" Supabase DashboardまたはMCP経由でStorage画像をアップロードしてください。") + + print("\\n".join(parts)) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} [file2 ...]", file=sys.stderr) + sys.exit(1) + + mode = sys.argv[1] + files = sys.argv[2:] + + if mode == "check": + run_check_mode(files) + elif mode == "gate": + run_gate_mode(files) + else: + print(f"Unknown mode: {mode}", file=sys.stderr) + sys.exit(1) diff --git a/.codex/hooks/scripts/block-destructive-git.sh b/.codex/hooks/scripts/block-destructive-git.sh new file mode 100755 index 000000000..ee13c308f --- /dev/null +++ b/.codex/hooks/scripts/block-destructive-git.sh @@ -0,0 +1,1963 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) destructive git guard. +# AI/自動化が tracked local changes を暗黙に破棄する事故を止める。 +# [2026-06-14][feat] +# 背景: +# - ユーザー依頼意図: AI 横断作業中の `git reset --hard` / `git clean -f` / `git checkout --` による +# tracked local changes の暗黙破棄を止めたい。 +# - 守るべき業務ルール: ローカル変更の破棄は、差分確認後に明示許可した復旧作業だけに限定する。 +# - 他案不採用理由: 破壊的 git をルール文だけで禁止する案は、別セッション WIP の事故を機械的に止められないため不採用。 +# 対応: 安全な dry-run / unstage は許可し、作業ツリーを破棄する git 操作だけを PreToolUse でブロックする。 + +set -uo pipefail + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +. "$(dirname "$0")/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } + +input="$(cat)" + +command="$( + INPUT_JSON="${input}" python3 - <<'PY' 2>/dev/null || true +import json +import os + +try: + data = json.loads(os.environ.get("INPUT_JSON", "{}")) +except json.JSONDecodeError: + data = {} +tool_input = {} +if isinstance(data.get("tool_input"), dict): + tool_input = data["tool_input"] +elif isinstance(data.get("toolInput"), dict): + tool_input = data["toolInput"] +print(tool_input.get("command") or "") +PY +)" + +allow_json() { + printf '{"continue": true}\n' +} + +# [2026-08-03][fix] deny メッセージを「コマンド行の先頭に書けば通る」という誤った案内から、 +# 実際に効く手順(セッションの環境変数として設定)へ正す。 +# 背景: +# - ユーザー依頼意図: 2026-08-03 jtt-cms 作業中、旧メッセージの案内どおり +# `AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 git ...` をコマンド行の先頭に書いて再実行したが、 +# 再びブロックされた。66行目の bypass 判定はこの hook プロセス自身の環境変数だけを見ており、 +# Bash ツールは呼び出しごとに cwd がリセットされるため実際の再実行はほぼ必ず +# `cd && AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 git ...` の形になる。1745行目付近の +# inline bypass はコマンド全体の最初のトークンが裸の代入直後の `git` である場合だけしか +# 救済せず、`cd &&` 等が前に付くと機能しない(実測で再現・恒久的に効く手段ではない)。 +# - 守るべき業務ルール: AI エージェントは自己判断で破壊的 git を通せてはならない。bypass は +# 利用者がセッションの環境変数として明示設定した場合だけに限定する設計を維持する +# (bypass 判定ロジック自体は変更しない・本対応はメッセージ文言のみ)。 +# - 他案不採用理由: 「コマンド行の先頭に書けば常に効くようにする」案は、AI が自分の発行する +# コマンド文字列だけで bypass を成立させられてしまい、破壊的 git を自己判断で通す抜け道になる +# ため不採用。メッセージを正直にし、実際に効く手段(利用者へのセッション環境変数設定の依頼、 +# または hook にかからない代替コマンド)を案内する方針を採る。 +block_json() { + local label="$1" + # telemetry(harness-checkup): deny を記録。fail-open(記録失敗は無視)。 + agent_hub_telemetry_log hook_deny block-destructive-git deny "{\"label\":\"$label\"}" 2>/dev/null || true + HOOK_LABEL="$label" python3 - <<'PY' +import json +import os + +label = os.environ.get("HOOK_LABEL", "") +reason_lines = [ + f"[hook:block-destructive-git] destructive git command blocked: {label}。", + "ローカル変更を暗黙に破棄しないため停止しました。", + ( + "AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 は、このセッションの環境変数として設定されている" + "必要があります。コマンド行の先頭に書くだけでは効きません" + "(cd 等が前に付くと届かないため)。" + ), + ( + "AI はこの環境変数を自分で設定できません。復旧が必要な場合は、利用者に" + "「このセッションで AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 を設定してください」と依頼してください。" + ), + ( + "単一ファイルを HEAD の内容へ戻すだけなら、この hook にかからない " + "`git show HEAD: > ` で足りることが多いです。" + ), +] +reason = "\n".join(reason_lines) +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } +}, ensure_ascii=False)) +PY +} + +if [ -z "${command}" ]; then + allow_json + exit 0 +fi + +if [ "${AGENT_HUB_ALLOW_DESTRUCTIVE_GIT:-0}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass block-destructive-git allow '{"env":"AGENT_HUB_ALLOW_DESTRUCTIVE_GIT"}' 2>/dev/null || true + allow_json + exit 0 +fi + +# [2026-08-02][fix] path-qualified git executable を token 境界で裸の `git` に正規化する。 +# 背景: +# - ユーザー依頼意図: `/usr/bin/git` や空白を含む引用符付き path でも、`reset --hard` / +# `clean` 等を取り逃がさないようにする。 +# - 守るべき業務ルール: 実行 token の basename が `git` の場合だけ、裸の `git` と同じく +# fail-closed で止める。`/tmp/git tools/notgit` のような非 git executable は許可する。 +# - 他案不採用理由: Bash regex で path の slash・quote・空白を列挙する案は token 境界を失い、 +# 新しい path 表記や git を含む別 executable の誤検出を招く。PJ ごとの hook 手修正も不採用。 +# 対応: Python 標準 `shlex` で実行 token を解決し、`os.path.basename(token) == "git"` のときだけ +# その token を `git` に置換してから、後段の Bash 判定へ渡す。 +# [2026-08-02][fix] nice/nohup を安全に解析し、未知・解決不能な前置きを fail-closed にする。 +# 背景: +# - ユーザー依頼意図: 標準ラッパー経由の `nice git ...` / `nohup git ...` でも破壊的 Git を止めたい。 +# - 守るべき業務ルール: 既知の引数だけを消費し、曖昧な option や欠落した command は許可しない。 +# - 他案不採用理由: 任意の `-...` を無条件に読み飛ばす案は、未知 option の後ろの Git を取り逃がすため不採用。 +# 対応: nice の数値 option と nohup の `--` だけを明示的に消費し、未知・解決不能時は marker を出して停止する。 +readonly GIT_BIN='git' +readonly GIT_GLOBAL_OPT='(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--config-env[[:space:]]+[^[:space:]]+|--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--namespace(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--exec-path(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)?|--paginate|--no-pager|--no-replace-objects|--bare|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--help|--version|--html-path|--man-path|--info-path|-p)' +readonly GIT_GLOBAL_OPTS="([[:space:]]+${GIT_GLOBAL_OPT})*" +readonly SUDO_OPT='((-u|-g|-h|-p|-C|-T)[[:space:]]+[^[:space:]]+|-[^[:space:]]+)' +readonly ENV_OPT='((-u|--unset|-C|--chdir)[[:space:]]+[^[:space:]]+|-[^[:space:]]+)' +readonly GIT_PREFIX_TOKEN='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+|!|if|then|else|elif|do|while|until|command([[:space:]]+-p)?|builtin|exec|time([[:space:]]+-p)?|sudo([[:space:]]+'"${SUDO_OPT}"')*)' +readonly ENV_BIN='(/([^[:space:]/]+/)*env|env)' +readonly ENV_PREFIX="${ENV_BIN}"'([[:space:]]+'"${ENV_OPT}"')*([[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+)*' +readonly GIT_SEGMENT_START='^[[:space:]]*(('"${GIT_PREFIX_TOKEN}"'|'"${ENV_PREFIX}"')[[:space:]]+)*'"${GIT_BIN}" + +command_segments="$( + COMMAND_TEXT="$command" python3 - <<'PY' 2>/dev/null || true +import os +import re +import shlex + +cmd = os.environ.get("COMMAND_TEXT", "") + +CONTROL_WORDS = {"!", "if", "then", "else", "elif", "do", "while", "until"} +UNRESOLVED_WRAPPER = -1 + +def executable_basename(token: str, *, decoded: bool = False): + if decoded: + return os.path.basename(token) + try: + lexer = shlex.shlex(token, posix=True) + lexer.whitespace_split = True + words = list(lexer) + except ValueError: + return None + if len(words) != 1: + return None + return os.path.basename(words[0]) + +# [2026-08-02][fix] command wrapperと実行名は静的に確定できる場合だけ許可する。 +# 背景: +# - ユーザー依頼意図: env/time/exec/sudo/eval 等を挟んだ場合や、変数・command substitutionで +# 実行名を組み立てた場合も、破壊的Git操作を同じ基準で止める。 +# - 守るべき業務ルール: wrapper後の実行ファイルを静的に確定できない場合は許可しない。 +# posix lexerでdecode済みのtokenは再度shell parseせず、実ファイル名のquoteをliteralとして扱う。 +# - 他案不採用理由: 各OS・wrapperの全optionを推測して許可すると、引数をcommandとして +# 再解釈するoptionや将来追加されたoptionが新しい迂回経路になる。decode済みtokenの再shlexは +# quoteを含む有効なpathを構文エラーに変え、basename=gitの検出を失うため不採用。 +# 対応: 安全性を確認したoptionだけをwhitelistし、eval・未知option・再分割option・動的実行名は +# unresolved markerへ送る。decode済みtokenのbasenameは文字列から直接取得する。 +def command_executable_index(segment: list[str], *, decoded: bool = False): + sudo_short_options_with_arg = {"-u", "-g", "-h", "-p", "-C", "-T", "-D", "-R", "-r", "-t", "-U"} + sudo_short_options_no_arg = set("ABbEeHiKklnPSsVv") + sudo_long_options_with_arg = { + "--user", "--group", "--host", "--prompt", "--close-from", + "--chdir", "--chroot", "--command-timeout", "--other-user", + "--login-class", "--role", "--type", + } + sudo_long_options_no_arg = { + "--askpass", "--background", "--bell", "--edit", "--help", "--login", + "--list", "--non-interactive", "--preserve-env", "--remove-timestamp", + "--reset-timestamp", "--set-home", "--shell", "--stdin", "--validate", "--version", + } + index = 0 + while index < len(segment): + wrapper_start = index + while index < len(segment): + token = segment[index] + if token in CONTROL_WORDS or re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token): + index += 1 + continue + break + if index >= len(segment): + return None + + executable = executable_basename(segment[index], decoded=decoded) + if executable in {"command", "builtin"}: + index += 1 + while index < len(segment): + if segment[index] == "--": + index += 1 + break + if segment[index] == "-p": + index += 1 + continue + break + elif executable == "eval": + # eval reparses every remaining argument as shell source. + return UNRESOLVED_WRAPPER + elif executable == "exec": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option == "-a": + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("-a") and option != "-a": + index += 1 + continue + if re.fullmatch(r"-[cl]+", option): + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + elif executable == "time": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"-o", "-f", "--output", "--format"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("--output=") or option.startswith("--format="): + index += 1 + continue + if option in {"--append", "--verbose", "--portability", "--quiet"}: + index += 1 + continue + if re.fullmatch(r"-[ahlpv]+", option): + index += 1 + continue + if re.fullmatch(r"-(?:o|f).+", option): + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + # [2026-08-02][fix] timeout wrapper の後段 command を限定解析する。 + # 背景: + # - ユーザー依頼意図: 全PJへ配布する破壊的Git guardで、`timeout 5 git reset --hard` の + # ような標準wrapper経由の実行も直接実行と同じ基準で止める。 + # - 守るべき業務ルール: timeoutの既知optionと必須durationだけを消費し、その直後の + # commandを再帰的に検査する。未知option・値不足・command不足はfail-closedにする。 + # - 他案不採用理由: timeout以下を通常引数として許可する案は破壊操作を見逃し、全optionを + # 無条件に読み飛ばす案は将来の再解釈optionで同じ迂回を再発させるため不採用。 + # 対応: GNU timeoutの副作用を持たない既知optionだけを許可し、durationを1語消費して + # 後段commandへ解析を継続する。hook自身はtimeoutや対象commandを実行しない。 + elif executable == "timeout": + duration_pattern = r"(?:\d+(?:\.\d*)?|\.\d+)(?:s|m|h|d)?" + signal_pattern = r"(?:SIG)?[A-Za-z0-9]+" + + def static_timeout_value(token: str, pattern: str) -> bool: + if token_has_unresolved_executable_expansion(token): + return False + try: + value = token if decoded else decode_shell_command(token) + except (TypeError, ValueError): + return False + return re.fullmatch(pattern, value) is not None + + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"--preserve-status", "--foreground", "--verbose", "-v"}: + index += 1 + continue + if option in {"-k", "--kill-after", "-s", "--signal"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + value_pattern = duration_pattern if option in {"-k", "--kill-after"} else signal_pattern + if not static_timeout_value(segment[index + 1], value_pattern): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("-k") and option != "-k": + if not static_timeout_value(option[2:], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("-s") and option != "-s": + if not static_timeout_value(option[2:], signal_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("--kill-after="): + if not static_timeout_value(option.split("=", 1)[1], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("--signal="): + if not static_timeout_value(option.split("=", 1)[1], signal_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + # timeout requires one duration token followed by a command. + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + if not static_timeout_value(segment[index], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + elif executable == "nice": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option == "-n" or option == "--adjustment": + index += 1 + if index >= len(segment) or not re.fullmatch(r"[+-]?\d+", segment[index]): + return UNRESOLVED_WRAPPER + index += 1 + continue + if re.fullmatch(r"-n[+-]?\d+", option) or re.fullmatch(r"-\+?\d+", option): + index += 1 + continue + if re.fullmatch(r"--adjustment=[+-]?\d+", option): + index += 1 + continue + if option in {"--help", "--version"}: + return None + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + if index >= len(segment): + return UNRESOLVED_WRAPPER + elif executable == "nohup": + index += 1 + if index < len(segment) and segment[index] == "--": + index += 1 + elif index < len(segment) and segment[index].startswith("-"): + return UNRESOLVED_WRAPPER + if index >= len(segment): + return UNRESOLVED_WRAPPER + elif executable == "sudo": + index += 1 + terminated = False + while index < len(segment) and segment[index].startswith("-"): + option = segment[index] + if option == "--": + index += 1 + terminated = True + break + if option.startswith("--"): + if "=" in option: + option_name, _ = option.split("=", 1) + has_attached_value = True + else: + option_name = option + has_attached_value = False + if option_name not in sudo_long_options_with_arg and option_name not in sudo_long_options_no_arg: + return UNRESOLVED_WRAPPER + index += 1 + if ( + not has_attached_value + and option_name in sudo_long_options_with_arg + ): + if index >= len(segment): + return UNRESOLVED_WRAPPER + index += 1 + continue + option_name = option[:2] + if option_name in sudo_short_options_with_arg: + index += 1 + if len(option) == 2: + if index >= len(segment): + return UNRESOLVED_WRAPPER + index += 1 + continue + if all(char in sudo_short_options_no_arg for char in option[1:]): + index += 1 + continue + return UNRESOLVED_WRAPPER + if not terminated: + while index < len(segment) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", segment[index]): + index += 1 + # [2026-08-02][fix] xargs を wrapper として解析し、実行 command へ検査を継続する。 + # 背景: + # - ユーザー依頼意図: `printf 'HEAD' | xargs -n1 git reset --hard` のように xargs 経由で + # 破壊的 Git を起動すると、git が引数位置に見えて検査から漏れていた + # (jtt-cms PR #1542 の codex-review が検出した Critical)。 + # - 守るべき業務ルール: timeout / env と同じく、副作用と再解釈の無い既知 option だけを + # whitelist で消費し、直後の command を通常の検査へ流す。引数が任意個の option + # (GNU の bare -l / -i / -e、--replace 単独等)は静的に境界を確定できないため + # unresolved(fail-closed)に送る。command 無しの xargs は既定 echo のため安全。 + # - 他案不採用理由: xargs を一律 unresolved にする案は、`ls | xargs rm` 等の非 git 用途 + # まで全 deny し誤検知摩擦(#1313 で解消したクラス)を再発させる。全 option の + # 読み飛ばしは将来の再解釈 option で迂回を再発させる(timeout の CaD と同判断)。 + elif executable == "xargs": + xargs_long_with_arg = { + "--arg-file", "--delimiter", "--eof", "--max-args", "--max-chars", + "--max-lines", "--max-procs", "--process-slot-var", + } + xargs_no_arg = { + "-0", "--null", "-p", "--interactive", "-r", "--no-run-if-empty", + "-t", "--verbose", "-x", "--exit", "-o", "--open-tty", + } + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"-n", "-L", "-s", "-P", "-a", "-d", "-E", "-J", "-I"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option in xargs_long_with_arg: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if any(option.startswith(name + "=") for name in xargs_long_with_arg | {"--replace"}): + index += 1 + continue + if re.fullmatch(r"-[nLsPadEJIi].+", option): + # 値が密着した短形(-n1 / -I{} / -i{} / -d\n 等) + index += 1 + continue + if option in xargs_no_arg or re.fullmatch(r"-[0prtxo]+", option): + index += 1 + continue + if option.startswith("-"): + # bare -l / -i / -e / --replace 等の任意引数 option・未知 option + return UNRESOLVED_WRAPPER + break + elif executable == "env": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"-S", "--split-string"} or option.startswith("-S") or option.startswith("--split-string="): + # split-string reparses one token into a complete command. + return UNRESOLVED_WRAPPER + if option in {"-u", "--unset", "-C", "--chdir"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("--unset=") or option.startswith("--chdir="): + index += 1 + continue + if re.fullmatch(r"-(?:u|C).+", option): + index += 1 + continue + if option in {"-", "-i", "--ignore-environment", "-0", "--null", "--debug"}: + index += 1 + continue + if option in {"--help", "--version"}: + return None + if option.startswith("-"): + return UNRESOLVED_WRAPPER + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", option): + index += 1 + continue + break + else: + return index + + if index <= wrapper_start: + return None + return None + +def token_has_unresolved_executable_expansion(token: str) -> bool: + """Return whether an executable word requires shell expansion to resolve.""" + if token.startswith("="): + # zsh expands a leading equals command name to an absolute executable path. + return True + quote = None + index = 0 + while index < len(token): + char = token[index] + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + return True + if char == "$" and index + 1 < len(token): + next_char = token[index + 1] + if next_char in "{([?*!#@$-0123456789_" or next_char.isalpha(): + return True + if char in "<>" and index + 1 < len(token) and token[index + 1] == "(": + return True + if quote is None and char in "*?": + return True + if ( + quote is None + and char in "[{" + and index + 1 < len(token) + and not token[index + 1].isspace() + and token[index + 1] not in ";&|" + ): + return True + if quote is None and char in "@+!" and index + 1 < len(token) and token[index + 1] == "(": + return True + if ( + quote is None + and char == "(" + and index > 0 + and not token[index - 1].isspace() + and token[index - 1] not in ";&|(<" + ): + return True + index += 1 + return False + +def has_unresolved_command_start(text: str) -> bool: + """Inspect raw command-start words without evaluating shell syntax.""" + try: + # Keep parentheses inside words so `$(...)`, extglob, and zsh qualifiers + # remain visible. The regular parser separately handles grouping syntax. + lexer = shlex.shlex(text, posix=False, punctuation_chars=";&|") + # Real shell comments were removed by + # collapse_shell_line_continuations(). Keep `#` inside parameter + # expansions such as `${#name}` visible to the lexer. + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return True + + segment: list[str] = [] + for token in tokens + [";"]: + if token in {"(", ")", "{", "}"} or (token and all(char in ";&|" for char in token)): + if segment: + executable_index = command_executable_index(segment) + if executable_index == UNRESOLVED_WRAPPER: + return True + if ( + executable_index is not None + and executable_index >= 0 + and token_has_unresolved_executable_expansion(segment[executable_index]) + ): + return True + segment = [] + else: + segment.append(token) + return False + +def emit_segment_line(text: str) -> None: + if has_unresolved_command_start(text): + print("__UNRESOLVED_COMMAND_WRAPPER__") + try: + lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + for segment in re.split(r"[;&|(){}]+", text): + segment = segment.strip() + if segment: + print(segment) + return + + segment = [] + + def flush() -> None: + if segment: + # `segment` came from a posix=True lexer, so quoted arguments with + # spaces are already one token. Replace those spaces only for the + # executable parser; nested shell bodies keep their original quotes. + parser_segment = [re.sub(r"\s+", "__ARG_SPACE__", token) for token in segment] + executable_index = command_executable_index(parser_segment, decoded=True) + if executable_index == UNRESOLVED_WRAPPER: + print("__UNRESOLVED_COMMAND_WRAPPER__") + segment.clear() + return + # [2026-08-02][fix] grouping 構文の内側でも動的 executable を fail-closed にする。 + # 背景: + # - ユーザー依頼意図: `{ "$G" reset --hard; }`、subshell、function body のように + # command start が grouping token の後ろにある場合も、破壊的 Git を取り逃がさない。 + # - 守るべき業務ルール: 実行ファイル名を静的に `git` 以外と確定できない command segment は + # grouping の深さに関係なく unresolved marker へ送り、既存の fail-close 契約を保つ。 + # - 他案不採用理由: 外側の raw scanner だけで grouping 全体を一つの command とみなす案は、 + # brace/subshell/function の内側にある実際の executable 境界を失うため不採用。 + # 対応: decoded parser が抽出した各 segment の executable token も検査し、変数または + # command substitution を含む場合は unresolved marker を出す。 + if ( + executable_index is not None + and executable_index >= 0 + and ( + parser_segment[executable_index] == "$" + or token_has_unresolved_executable_expansion(parser_segment[executable_index]) + ) + ): + # The decoded parser also sees command starts inside brace/paren + # groups and function bodies that the outer raw segment begins + # with grouping syntax rather than the eventual executable. + print("__UNRESOLVED_COMMAND_WRAPPER__") + segment.clear() + return + if ( + executable_index is not None + and executable_index >= 0 + and executable_basename(parser_segment[executable_index], decoded=True) == "git" + ): + segment[:] = ["git"] + segment[executable_index + 1:] + # shlex は引用を外すため、空白入り `git -C "/tmp/a b"` をそのまま join すると + # 後段の正規表現が git global option の引数境界を誤る。判定に不要な内部空白だけ + # sentinel に寄せ、実コマンドの語順は保ったまま検査する。 + normalized = [re.sub(r"\s+", "__ARG_SPACE__", token) for token in segment] + print(" ".join(normalized).strip()) + segment.clear() + + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + flush() + else: + segment.append(token) + flush() + +# [2026-08-02][fix] dash / ksh も shell receiver として再帰検査する(issue #1344)。 +# 背景: +# - ユーザー依頼意図: dash / ksh へ here-doc(quoted 'EOF' 区切り)で流し込んだ破壊的 Git が +# receiver 集合の漏れで再帰検査されず素通りしていた(PR #1343 の codex-review が検出)。 +# ※このコメントに here-doc 演算子そのものを書かないこと: 本 Python は bash の $( ) 置換内の +# quoted heredoc に埋まっており、bash の置換パーサはコメント内でも演算子を解釈して壊れる。 +# - 守るべき業務ルール: shell として本文を実行する受け手は全て同じ fail-close 再帰へ送る。 +# - 他案不採用理由: 任意の実行ファイルを receiver 扱いする案は、非 shell の cat/tee まで +# 本文をコマンド検査して誤検知を増やすため不採用(shell 実体の列挙を維持し不足だけ足す)。 +shells = {"sh", "bash", "zsh", "dash", "ksh"} +MAX_SHELL_DEPTH = 4 +UNRESOLVED_COMMAND_MARKER = "__UNRESOLVED_COMMAND_WRAPPER__" + +# [2026-08-02][fix] 引用内改行で論理行を分断しない(issue #1313 誤検知ファミリー)。 +# 背景: +# - ユーザー依頼意図: `git commit -m "<複数行メッセージ>"` / `gh pr create --body "<複数行>"` が +# text.splitlines() の引用非対応分割で引用途中に千切れ、unresolved 判定→deny になっていた +# (1セッション3〜6回の実測摩擦。値は実行されないデータであり真陽性ではない)。 +# - 守るべき業務ルール: shell の行分割は引用外の改行だけがコマンド区切り。引用内・$( ) / +# backtick 内の改行はトークン/置換本文の一部として同じ論理行に留める。未終端の引用・置換は +# 従来どおり None を返し fail-closed(unresolved)へ倒す。$( ) / backtick の本文検査は +# shell_substitution_bodies 側が従来どおり再帰実施するため、検知力は変えない。 +# - 他案不採用理由: -m/--body 等の「データ引数の値」を走査対象から除外する案は、値の中の +# $( ) 置換(shell が実際に実行する)まで免除しかねず、緩和面が広い。引用対応の分割は +# 誤検知3ケースを同時に解消しつつ既存の置換再帰検査を一切変えない最小修正のため採用。 +def split_shell_logical_lines(text: str): + """Split on newlines that are outside quotes / $() / backticks. None if unterminated. + + Context stack model: 'sq' (single quote), 'dq' (double quote), 'sub' + ($() or bare paren inside a substitution), 'bt' (backtick). Newlines break + logical lines only when the stack is empty (= plain command position). + """ + BACKTICK = chr(96) # 字面のバッククォートは外側 bash の置換スキャナを壊すため chr で持つ + lines = [] + current = [] + stack: list[str] = [] + index = 0 + length = len(text) + while index < length: + char = text[index] + state = stack[-1] if stack else None + if state == "sq": + # 単一引用内の backslash+改行は「continuation に見える難読化」の既存保守契約を + # 維持するため unresolved(None)へ倒す(test: single quoted continuation)。 + if char == "\\" and index + 1 < length and text[index + 1] in "\r\n": + return None + current.append(char) + if char == "'": + stack.pop() + index += 1 + continue + if char == "\\": + # escape consumes next char in normal / dq / sub / bt contexts + current.append(char) + if index + 1 < length: + current.append(text[index + 1]) + index += 2 + else: + index += 1 + continue + if state == "dq": + if char == '"': + stack.pop() + elif char == "$" and index + 1 < length and text[index + 1] == "(": + # NOTE: dollar+開き括弧のリテラルを1トークンで書かない。外側 bash の + # 置換スキャナが引用内でも入れ子置換の開始と解釈して構文崩壊するため、 + # 2文字に分けて append する(本ファイル特有の制約)。 + current.append("$") + current.append("(") + stack.append("sub") + index += 2 + continue + elif char == BACKTICK: + stack.append("bt") + current.append(char) + index += 1 + continue + if state == "bt": + if char == BACKTICK: + stack.pop() + current.append(char) + index += 1 + continue + # state is None (top level) or 'sub' — both accept openers + if char == "'": + stack.append("sq") + current.append(char) + index += 1 + continue + if char == '"': + stack.append("dq") + current.append(char) + index += 1 + continue + if char == BACKTICK: + stack.append("bt") + current.append(char) + index += 1 + continue + if char == "$" and index + 1 < length and text[index + 1] == "(": + stack.append("sub") + # NOTE: dollar+開き括弧のリテラルは2文字に分けて append(上の分岐と同じ理由)。 + current.append("$") + current.append("(") + index += 2 + continue + if state == "sub": + if char == "(": + stack.append("sub") + elif char == ")": + stack.pop() + current.append(char) + index += 1 + continue + if char == "\n": + lines.append("".join(current)) + current = [] + index += 1 + continue + current.append(char) + index += 1 + if stack: + return None + lines.append("".join(current)) + return [line for line in lines if line.strip()] or [""] + + +# [2026-08-02][fix] git 無縁と静的に確定できる nested body だけ unresolved deny を免除する +# (issue #1313 案3の安全部分集合・下の呼び出し元 CaD と対)。 +# 背景: +# - ユーザー依頼意図: 変数や特殊パラメータを含むだけの非 git body(例: exit status 表示付きの +# script 実行)が unresolved 扱いで deny される摩擦を解消したい。 +# - 守るべき業務ルール: 既存の fail-closed 契約(変数 executable / glob・brace・class による +# git 難読化 / 引用継続の難読化は deny)を 1 件も後退させない。判定は「安全と証明できた +# 場合のみ許可」の片側条件とし、証明できない形は全て従来どおり deny に落とす。 +# - 他案不採用理由: body へ再帰降下する案は、glob executable(g?t 等)を非 git と誤読する。 +# git 文字列の有無だけで判定する案は、変数 executable(PAYLOAD 経由)を素通りさせる。 +def nested_body_safely_non_git(body: str) -> bool: + """True only when the body is provably inert w.r.t. destructive git. + + 条件(全て満たす時だけ許可・1つでも証明できなければ False = 従来の deny): + 1. body に "git" 文字列が無い(大文字小文字無視・部分一致で安全側) + 2. brace / backtick / 置換開始(dollar+開き括弧)が無い + 3. dollar 展開は単文字特殊パラメータ(? $ ! #)のみ($VAR / ${...} は + eval や interpreter の引数経由で任意コマンド化しうるため一律 deny) + 4. 各 command segment の実行子がプレーンリテラルで、shell でも + 実行 wrapper(eval / exec / env / sudo / xargs 等・引数を実行する類)でもない + """ + lowered = body.lower() + if "git" in lowered: + return False + if "{" in body or "}" in body: + # brace expansion は tokenizer が区切りとして分解し executable 難読化 + # (/usr/bin/g{it} 等)を見えなくするため、含む body は証明不能として deny 側 + return False + if chr(96) in body: + # backtick 置換は静的解決不能 + return False + # [2026-08-02][fix] PR #1354 codex-review Critical 対応: $VAR / ${...} を含む body は + # `eval $PAYLOAD` / `python3 -c $CODE` 等の引数経由で任意コマンド化するため許可しない。 + # 実行時に値が確定済みで不活性なのは単文字特殊パラメータだけ、という許可リストへ縮小する。 + position = body.find("$") + while position != -1: + follower = body[position + 1:position + 2] + if follower not in {"?", "$", "!", "#"}: + return False + position = body.find("$", position + 2) + segments = segment_tokens(body) + if segments is None: + return False + plain_executable = re.compile(r"[A-Za-z0-9_./-]+") + # 引数を新たなコマンドとして実行しうる wrapper。列挙は原理的に完全にならないため、 + # ここに無い未知 wrapper への防御は上の「$VAR 全面 deny」(引数が静的リテラルなら + # wrapper 経由でも body 内に "git" が現れ 1. で deny)と組み合わせて成立させる。 + exec_wrappers = { + "eval", "exec", "command", "builtin", "source", ".", + "env", "sudo", "doas", "su", "xargs", "nohup", "nice", + "time", "timeout", "setsid", "script", "watch", "caffeinate", + } + for segment in segments: + index = command_executable_index(segment) + if index == UNRESOLVED_WRAPPER: + return False + if index is None: + # 実行子なし(純 assignment 等)は破壊操作を持たない + continue + if index < 0 or index >= len(segment): + return False + token = segment[index] + if plain_executable.fullmatch(token) is None: + return False + basename = executable_basename(token) + if basename in shells or basename in exec_wrappers: + # nested-nested shell / 実行 wrapper は本関数で安全証明できないため deny 側 + return False + return True + + +def decode_shell_command(token: str) -> str: + lexer = shlex.shlex(token, posix=True) + lexer.whitespace_split = True + words = list(lexer) + if len(words) != 1: + raise ValueError("invalid shell command argument") + return words[0] + +def segment_tokens(text: str): + try: + # Keep the outer quote around `bash -c`/`sh -c` bodies so the nested + # command can be decoded once without losing its own quoted path tokens. + lexer = shlex.shlex(text, posix=False, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return None + segments: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + if current: + segments.append(current) + current = [] + else: + current.append(token) + if current: + segments.append(current) + return segments + +def shell_start_index(segment: list[str]): + index = command_executable_index(segment) + return index if index is not None and index >= 0 and executable_basename(segment[index]) in shells else None + + +# [2026-08-02][fix] nested shell の動的 body を fail-closed にする。 +# 背景: +# - ユーザー依頼意図: `PAYLOAD="git reset --hard"; bash -c "$PAYLOAD"` のように、 +# shell `-c` の body を変数・command substitution・process substitution で組み立てる +# 経路でも、破壊的 Git の静的検査を迂回させない。 +# - 守るべき業務ルール: hook が安全に確定できない nested body は許可せず、必ず deny する。 +# hook 自身が変数展開や command substitution を実行して body を得ることは禁止する。 +# - 他案不採用理由: body を実行して展開結果を得る案は hook の副作用・コマンドインジェクションを +# 招く。正規表現だけで全ての shell 展開を再現する案は quote/escape 境界を取り違えるため不採用。 +# 対応: shlex で decode 済みの body を小さな quote-aware scanner で確認し、未解決の `$` 展開、 +# backtick、`$()`、process substitution、pathname/brace展開を marker に変換する。 +# 静的 body の再帰検査は従来どおり行う。 +# [2026-08-02][fix] double quote 中の single quote で scanner state を切り替えない。 +# 背景: shell では double quote 内の `'` は literal だが、旧 scanner は single quote 開始と誤認し、 +# 後続の `$PAYLOAD` を「展開されない文字列」として見逃し得た。scanner 単体でも shell semantics と +# 一致させる必要がある。quote 全文を正規表現へ戻す案は既存の escape 境界を失うため不採用。 +# 対応: double quote state は `"` だけで終了し、その中の `'` は通常文字として扱う。 +def has_unresolved_shell_expansion(text: str) -> bool: + """Return whether a nested shell body contains expansion we must not evaluate.""" + quote = None + index = 0 + + def parameter_expansion_at(position: int) -> bool: + if position + 1 >= len(text): + return False + next_char = text[position + 1] + if next_char in "{([?*!#@$-0123456789_": + return True + return next_char.isalpha() + + while index < len(text): + char = text[index] + if quote == "'": + # Single-quoted shell text has no expansion semantics. + if char == "'": + quote = None + index += 1 + continue + + if char == "\\": + # In unquoted/double-quoted text, an escaped next character is literal. + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + return True + if char == "$" and parameter_expansion_at(index): + return True + if char in "<>" and index + 1 < len(text) and text[index + 1] == "(": + return True + if quote is None and char in "*?": + return True + if ( + quote is None + and char in "[{" + and index + 1 < len(text) + and not text[index + 1].isspace() + and text[index + 1] not in ";&|" + ): + return True + if ( + quote is None + and char in "@+!" + and index + 1 < len(text) + and text[index + 1] == "(" + ): + # Bash extglob such as @(git) can synthesize the executable name. + return True + if ( + quote is None + and char == "(" + and index > 0 + and not text[index - 1].isspace() + and text[index - 1] not in ";&|(<" + ): + # zsh glob qualifiers such as /usr/bin/git(.) are attached to a word. + return True + index += 1 + return False + + +# [2026-08-02][fix] 通常 command の引数内にある shell substitution も再帰検査する。 +# 背景: +# - ユーザー依頼意図: `printf '%s' "$(git reset --hard)"` のように、外側の executable が +# `git` でなくても実行される破壊的 Git を取り逃がさない。 +# - 守るべき業務ルール: command / process / backtick substitution の body は、引用位置に関係なく +# 実際に shell が実行する範囲だけを静的に抽出し、既存と同じ fail-close 判定へ渡す。 +# - 他案不採用理由: substitution を含む command を一律 deny すると `$(pwd)` 等の安全な開発操作まで +# 止める。shell 展開を実行して body を得る案は副作用と command injection を招くため不採用。 +# 対応: single quote と escape を尊重する小さな scanner で `$()` / `<()` / `>()` / backtick の +# body を抽出する。対応できない構文・不均衡・深すぎる再帰は unresolved marker へ送る。 +def shell_substitution_bodies(text: str): + """Return executable substitution bodies, or ``None`` when ambiguous.""" + + def backtick_end(start: int): + position = start + 1 + while position < len(text): + if text[position] == "\\": + position += 2 + continue + if text[position] == chr(96): + return position + position += 1 + return None + + def paren_end(open_index: int): + depth = 1 + quote = None + position = open_index + 1 + while position < len(text): + char = text[position] + if quote == "'": + if char == "'": + quote = None + position += 1 + continue + if char == "\\": + position += 2 + continue + if quote == '"': + if char == '"': + quote = None + position += 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "{": + # Parameter expansion patterns may legally contain `)` and + # make a hand-written parenthesis matcher terminate early. + return None + if char == "$" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == chr(96): + nested_end = backtick_end(position) + if nested_end is None: + return None + position = nested_end + 1 + continue + position += 1 + continue + if char in {"'", '"'}: + quote = char + position += 1 + continue + if ( + char == "#" + and ( + position == open_index + 1 + or text[position - 1].isspace() + or text[position - 1] in ";&|({}" + ) + ): + # An unquoted shell comment hides every `)` through the newline. + newline = text.find("\n", position + 1) + if newline < 0: + return None + position = newline + 1 + continue + if char == chr(96): + nested_end = backtick_end(position) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "{": + return None + if char == "<" and position + 1 < len(text) and text[position + 1] == "<": + # Skip a here-doc inside `$()` so a later `)` / command remains visible. + # Example: `$(cat <" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return position + position += 1 + return None + + bodies = [] + quote = None + index = 0 + while index < len(text): + char = text[index] + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + end = backtick_end(index) + if end is None: + return None + body = text[index + 1:end] + # Inside legacy backticks, an escaped backtick opens/closes a nested + # command substitution. Until that grammar is decoded losslessly, + # preserve the documented fail-close boundary instead of treating it + # as a literal escape and dropping the nested executable. + if chr(92) + chr(96) in body: + return None + bodies.append(body) + index = end + 1 + continue + if char == "$" and index + 1 < len(text) and text[index + 1] == "(": + end = paren_end(index + 1) + if end is None: + return None + body = text[index + 2:end] + if body.startswith("("): + # Arithmetic expansion is not itself a command, but may contain one. + nested = shell_substitution_bodies(body) + if nested is None: + return None + bodies.extend(nested) + else: + # A case-pattern `)` is indistinguishable from the substitution + # terminator in this deliberately small scanner. Never infer + # safety from a later `esac` string: it may be pattern data before + # the prematurely matched `)` rather than the closing keyword. + case_start = r"(?:^|[;&|({\n]|\b(?:then|do|else)\b)\s*case\b" + if re.search(case_start, body): + return None + bodies.append(body) + index = end + 1 + continue + if quote is None and char in "<>" and index + 1 < len(text) and text[index + 1] == "(": + end = paren_end(index + 1) + if end is None: + return None + bodies.append(text[index + 2:end]) + index = end + 1 + continue + index += 1 + if quote is not None: + return None + return bodies + + +# [2026-08-02][fix] shell tokenizationより先にline continuationを論理行へ戻す。 +# 背景: +# - ユーザー依頼意図: `g\\\nit reset --hard` のように物理改行で executable を分割しても、 +# 実行時に `git` へ戻る破壊操作を取り逃がさない。 +# - 守るべき業務ルール: shell がtokenize前に行うbackslash-newline除去を静的に再現し、 +# command substitution内外で同じfail-close判定へ渡す。single quote内のliteralは変更しない。 +# - 他案不採用理由: shell自体を実行して展開結果を得る案は、副作用とcommand injectionを招く。 +# 物理行を別々に検査する旧方式は、改行をまたいだ実行tokenを原理的に復元できない。 +# 対応: quote-awareな標準Python処理でLF/CRLF continuationだけを除去し、その後に既存scannerを使う。 +def collapse_shell_line_continuations(text: str) -> str: + """Collapse continuations and remove real comments before ``shlex``.""" + result = [] + quote = None + in_comment = False + index = 0 + while index < len(text): + char = text[index] + if in_comment: + # Backslash-newline is literal comment text here; the physical newline + # still ends the comment before the next command. + if char == "\n": + result.append(char) + in_comment = False + index += 1 + continue + if quote == "'": + result.append(char) + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + if index + 1 < len(text) and text[index + 1] == "\n": + index += 2 + continue + if index + 2 < len(text) and text[index + 1:index + 3] == "\r\n": + index += 3 + continue + result.append(char) + if index + 1 < len(text): + result.append(text[index + 1]) + index += 2 + else: + index += 1 + continue + if ( + quote is None + and char == "#" + and not (len(result) >= 2 and result[-2:] == ["$", "{"]) + and ( + not result + or result[-1].isspace() + or result[-1] in ";&|({}" + ) + ): + in_comment = True + index += 1 + continue + if quote == '"' and char == '"': + quote = None + elif quote is None and char in {"'", '"'}: + quote = char + result.append(char) + index += 1 + return "".join(result) + + + +# [2026-08-02][fix] here-doc 本文は受信コマンドのデータであり、行分割して再検査しない。 +# 背景: +# - ユーザー依頼意図: `git commit -F -` への here-doc や `gh ... --body "$(cat <= len(text) or text[lt_index:lt_index + 2] != "<<": + return None + pos = lt_index + 2 + strip_tabs = False + if pos < len(text) and text[pos] == "-": + strip_tabs = True + pos += 1 + while pos < len(text) and text[pos] in " \t": + pos += 1 + if pos >= len(text) or text[pos] == "\n": + return None + + quoted = False + if text[pos] == "\\": + quoted = True + pos += 1 + if pos >= len(text): + return None + start = pos + while pos < len(text) and (text[pos].isalnum() or text[pos] == "_"): + pos += 1 + delimiter = text[start:pos] + elif text[pos] in {"'", '"'}: + quoted = True + quote = text[pos] + pos += 1 + start = pos + while pos < len(text) and text[pos] != quote: + if text[pos] == "\\" and quote == '"': + pos += 2 + continue + pos += 1 + if pos >= len(text): + return None + delimiter = text[start:pos] + pos += 1 + else: + start = pos + while pos < len(text) and (text[pos].isalnum() or text[pos] in "_-"): + pos += 1 + delimiter = text[start:pos] + if not delimiter: + return None + + newline = text.find("\n", pos) + if newline < 0: + return None + body_pos = newline + 1 + while body_pos <= len(text): + next_nl = text.find("\n", body_pos) + line = text[body_pos:] if next_nl < 0 else text[body_pos:next_nl] + compare = line.lstrip("\t") if strip_tabs else line + if compare == delimiter: + end = len(text) if next_nl < 0 else next_nl + 1 + return end, quoted + if next_nl < 0: + return None + body_pos = next_nl + 1 + return None + + + +def extract_heredocs(text: str): + """Split here-doc bodies from command text. + + Returns ``(without_bodies, shell_bodies, unquoted_bodies)``. + + Here-docs are recognized outside single quotes. Double quotes are ignored as + a quoting barrier so ``"$(cat <= end: + without.append(text[index:end]) + index = end + continue + without.append(text[index : delim_line_end + 1]) + body = text[delim_line_end + 1 : end] + body_lines = body.splitlines(keepends=True) + body_content = "".join(body_lines[:-1]) if body_lines else "" + receiver_line = "".join(without[line_start:]) + text[index:delim_line_end] + try: + lexer = shlex.shlex(receiver_line, posix=True, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + tokens = [] + exec_index = command_executable_index(tokens, decoded=True) if tokens else None + is_shell = ( + exec_index is not None + and exec_index >= 0 + and executable_basename(tokens[exec_index], decoded=True) in shells + ) + if is_shell: + shell_bodies.append(body_content) + elif not quoted and body_content.strip(): + unquoted_bodies.append(body_content) + index = end + if index > 0 and text[index - 1] == "\n": + line_start = len(without) + continue + if char == "\n": + without.append(char) + index += 1 + line_start = len(without) + continue + without.append(char) + index += 1 + return "".join(without), shell_bodies, unquoted_bodies + + + + +def _strip_quoted_heredocs_completely(body: str): + """Remove quoted here-docs entirely from a ``$()`` body. + + Returns ``(remaining, True)`` when every here-doc used a quoted delimiter. + Returns ``(None, False)`` when an unquoted/incomplete here-doc is present + (caller must not collapse — trailing commands or expansions may remain). + """ + sq = chr(39) + remaining = [] + in_single = False + index = 0 + saw_heredoc = False + while index < len(body): + char = body[index] + if in_single: + remaining.append(char) + if char == sq: + in_single = False + index += 1 + continue + if char == "\\": + remaining.append(char) + if index + 1 < len(body): + remaining.append(body[index + 1]) + index += 2 + else: + index += 1 + continue + if char == sq: + in_single = True + remaining.append(char) + index += 1 + continue + if char == "<" and index + 1 < len(body) and body[index + 1] == "<": + end_info = heredoc_skip_end(body, index) + if end_info is None: + return None, False + end, quoted = end_info + if not quoted: + return None, False + saw_heredoc = True + index = end + continue + remaining.append(char) + index += 1 + if not saw_heredoc: + return None, False + return "".join(remaining), True + + +def collapse_data_substitutions(text: str): + """Collapse data-command substitutions that only feed quoted here-doc text. + + Only ``$(cat <<'EOF' ... EOF)`` style payloads collapse. Unquoted here-docs, + trailing ``; cmd``, pipelines, and nested substitutions are left intact so + later scanners still see real executable git. + """ + data_commands = {"cat", "printf", "echo", "head", "tail", "base64", "wc", "true"} + dq = chr(34) + sq = chr(39) + open_sub = "$" + "(" + token = dq + "__HOOK_STATIC_HEREDOC_DATA__" + dq + separators = {";", "&", "|", "||", "&&", "(", ")", "{", "}"} + out = [] + index = 0 + while index < len(text): + dollar = text.find(open_sub, index) + if dollar < 0: + out.append(text[index:]) + break + prefix = text[index:dollar] + in_single = False + p = 0 + while p < len(prefix): + ch = prefix[p] + if in_single: + if ch == sq: + in_single = False + p += 1 + continue + if ch == "\\": + p += 2 + continue + if ch == sq: + in_single = True + p += 1 + if in_single: + out.append(text[index:dollar + len(open_sub)]) + index = dollar + len(open_sub) + continue + depth = 1 + pos = dollar + len(open_sub) + replaced = False + while pos < len(text) and depth: + ch = text[pos] + if ch == "\\": + pos += 2 + continue + if ch == sq: + pos += 1 + while pos < len(text) and text[pos] != sq: + pos += 1 + pos += 1 + continue + if ch == dq: + pos += 1 + while pos < len(text) and text[pos] != dq: + if text[pos] == "\\": + pos += 2 + continue + pos += 1 + pos += 1 + continue + if ch == "<" and pos + 1 < len(text) and text[pos + 1] == "<": + skipped = heredoc_skip_end(text, pos) + if skipped is None: + break + pos = skipped[0] + continue + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + body = text[dollar + len(open_sub):pos] + remaining, ok = _strip_quoted_heredocs_completely(body) + if ok and remaining is not None: + nested = shell_substitution_bodies(remaining) + if nested == []: + try: + lexer = shlex.shlex( + remaining, posix=True, punctuation_chars=";&|(){}" + ) + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + tokens = [] + if tokens and not any(tok in separators for tok in tokens): + exec_index = command_executable_index( + tokens, decoded=True + ) + if ( + exec_index is not None + and exec_index >= 0 + and executable_basename( + tokens[exec_index], decoded=True + ) + in data_commands + ): + start = dollar + endpos = pos + 1 + if ( + start > 0 + and endpos < len(text) + and text[start - 1] == dq + and text[endpos] == dq + ): + start -= 1 + endpos += 1 + out.append(text[index:start]) + out.append(token) + index = endpos + replaced = True + break + pos += 1 + if not replaced: + if pos >= len(text) and depth: + out.append(text[index:]) + break + out.append(text[index:dollar + len(open_sub)]) + index = dollar + len(open_sub) + return "".join(out) + + +def emit_segments(text: str, depth: int = 0) -> None: + """Emit a shell command and recursively inspect every ``*-c`` body. + + Shells can be nested arbitrarily (for example ``bash -c 'sh -c ...'``). + Bound the static expansion so an adversarially deep or malformed payload + becomes an unresolved marker instead of silently bypassing the hook. + """ + if depth > MAX_SHELL_DEPTH: + print(UNRESOLVED_COMMAND_MARKER) + return + + text = collapse_shell_line_continuations(text) + + # Collapse quoted data here-docs inside $() BEFORE stripping, otherwise the + # opener remains and collapse can no longer find the terminator. + text = collapse_data_substitutions(text) + + # Here-doc bodies are data for the receiving command. Do not line-split them + # into fake top-level commands. Shell receivers still re-inspect the body. + stripped, shell_heredocs, unquoted_heredocs = extract_heredocs(text) + if stripped is None: + print(UNRESOLVED_COMMAND_MARKER) + return + text = stripped + + # 引用/置換の内側の改行で論理行を千切らない(split_shell_logical_lines の CaD 参照)。 + # 未終端の引用・置換は None → 従来どおり unresolved で fail-closed。 + lines = split_shell_logical_lines(text) + if lines is None: + print(UNRESOLVED_COMMAND_MARKER) + return + if not lines: + emit_segment_line(text) + else: + for line in lines: + emit_segment_line(line) + + for heredoc_body in shell_heredocs: + if heredoc_body.strip(): + emit_segments(heredoc_body, depth + 1) + + # Unquoted here-doc bodies expand $()/backticks; inspect those only. + for heredoc_body in unquoted_heredocs: + expansion_bodies = shell_substitution_bodies(heredoc_body) + if expansion_bodies is None: + print(UNRESOLVED_COMMAND_MARKER) + return + for body in expansion_bodies: + emit_segments(body, depth + 1) + + substitution_bodies = shell_substitution_bodies(text) + if substitution_bodies is None: + print(UNRESOLVED_COMMAND_MARKER) + return + for body in substitution_bodies: + emit_segments(body, depth + 1) + + segments = segment_tokens(text) + if segments is None: + print(UNRESOLVED_COMMAND_MARKER) + return + if depth > 0 and not text.strip(): + print(UNRESOLVED_COMMAND_MARKER) + return + if depth > 0 and text.strip() and not segments: + print(UNRESOLVED_COMMAND_MARKER) + return + + for segment in segments: + index = shell_start_index(segment) + if index is None: + continue + lookahead = index + 1 + tokens = segment + while lookahead < len(tokens) and tokens[lookahead].startswith("-"): + option_token = tokens[lookahead] + option = option_token.lstrip("-") + if not option_token.startswith("--") and "c" in option: + command_index = lookahead + 1 + if command_index < len(tokens) and tokens[command_index] == "--": + command_index += 1 + if command_index >= len(tokens): + print(UNRESOLVED_COMMAND_MARKER) + break + try: + nested_command = decode_shell_command(tokens[command_index]) + except Exception: + # A malformed nested shell argument must fail closed. + print(UNRESOLVED_COMMAND_MARKER) + break + if has_unresolved_shell_expansion(nested_command): + # Do not evaluate shell variables/substitutions in the hook. The body may + # resolve to destructive Git after the hook returns, so static inspection is + # impossible without executing untrusted input. + # + # [2026-08-02][fix] git 無縁の nested body まで deny しない(issue #1313 案3の + # 安全部分集合)。 + # 背景: + # - ユーザー依頼意図: `bash -c '... echo "exit=$?" ...'` のような、git を + # 一切含まない body が $? / $VAR だけで unresolved 扱いされ deny される + # 摩擦を解消したい(実測: 1セッション3回)。 + # - 守るべき業務ルール: 本 hook の守備範囲は破壊的 Git のみ(冒頭 CaD)。 + # "git" が現れない body は展開後も git になり得る余地を静的に持たない + # (g${X}it 型の難読化は変数側に "git" が現れないが、その場合 body 内に + # substring "git" が無くても executable 難読化は既存の変数 executable + # fail-close が上流で拾う)。substring 判定(大文字小文字無視・単語境界 + # なし)を使い、"digital" 等を含む body も deny 側へ倒す(安全側の過剰)。 + # - 他案不採用理由: unresolved wrapper 全面緩和(案3全体)は影響範囲が + # 読めず不採用。データ引数の値の除外は $( ) 置換の免除リスクがあり不採用。 + # git 文字列の有無だけの判定は変数 executable を素通りさせるため不採用 + # (安全証明は nested_body_safely_non_git に集約)。 + if not nested_body_safely_non_git(nested_command): + print(UNRESOLVED_COMMAND_MARKER) + break + emit_segments(nested_command, depth + 1) + break + if option_token in {"-o", "-O", "--rcfile", "--init-file"} and lookahead + 1 < len(tokens): + lookahead += 2 + continue + lookahead += 1 + +emit_segments(cmd) +PY +)" + +if printf '%s\n' "${command_segments}" | grep -Fqx '__UNRESOLVED_COMMAND_WRAPPER__'; then + block_json "unresolved command wrapper" + exit 0 +fi + +# [2026-08-02][fix] inline override は実行対象の git に直結する assignment だけを許可する。 +# 背景: +# - ユーザー依頼意図: `printf` / `echo` の引数や別 segment に書かれた +# `AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1` を、破壊的 git 復旧の許可と誤認しないようにする。 +# - 守るべき業務ルール: 破壊的 Git 操作は fail-closed で止め、明示的な復旧時だけ +# inherited env または実行対象 `git` の直前 assignment による inline override を許可する。 +# - 他案不採用理由: +# 1) コマンド全体 grep の継続は、文字列・引数・別 segment の偽装を検出できず Critical を再発させる。 +# 2) 生成された PJ 側 hook の手修正は中央正本を迂回して再発する。 +# 3) `git clean` の設定値列挙やルール文だけの禁止は、迂回経路を原理的に閉じない。 +# 4) 新規外部依存や全面的な shell parser 導入は、配布対象を増やし保守境界を曖昧にする。 +# 対応: Python 標準 `shlex` で単一の shell segment を tokenize し、segment 全体が実コマンド先頭の +# assignment token 直後の裸の `git` の場合だけ inline bypass を許可する。separator・改行・引用符・ +# 通常引数・別 segment の文字列は許可せず、tokenize 失敗時は `0` を返して安全側に倒す。 +inline_bypass="$( + COMMAND_TEXT="${command}" python3 - <<'PY' 2>/dev/null || printf '0' +import os +import re +import shlex + +BYPASS = "AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1" +ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$") +PUNCTUATION = ";&|(){}" +text = os.environ.get("COMMAND_TEXT", "") + +def validate_punctuation(tokens): + expected = [] + pairs = {"(": ")", "{": "}"} + for token in tokens: + if not token or not all(char in PUNCTUATION for char in token): + continue + for char in token: + if char in pairs: + expected.append(pairs[char]) + elif char in {")", "}"} and (not expected or expected.pop() != char): + raise ValueError("unbalanced shell punctuation") + if expected: + raise ValueError("unbalanced shell punctuation") + +try: + # posix=True validates quoting/escaping; posix=False retains quote markers + # so a quoted assignment cannot become an override token. + validator = shlex.shlex(text, posix=True, punctuation_chars=PUNCTUATION) + validator.whitespace_split = True + list(validator) + lexer = shlex.shlex(text, posix=False, punctuation_chars=PUNCTUATION) + lexer.whitespace_split = True + tokens = list(lexer) + validate_punctuation(tokens) + if "\n" in text or any(token and all(char in PUNCTUATION for char in token) for token in tokens): + print("0") + raise SystemExit +except Exception: + print("0") + raise SystemExit + +index = 0 +while index < len(tokens) and ASSIGNMENT.fullmatch(tokens[index]): + index += 1 +if index > 0 and index < len(tokens): + if tokens[index] == "git" and tokens[index - 1] == BYPASS: + print("1") + raise SystemExit + +print("0") +PY +)" +if [ "${inline_bypass}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass block-destructive-git allow '{"env":"AGENT_HUB_ALLOW_DESTRUCTIVE_GIT"}' 2>/dev/null || true + allow_json + exit 0 +fi + +reset_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+reset([[:space:]][^;&|()]*)?[[:space:]]--hard([[:space:]]|$)" || true)" +if [ -n "${reset_segments}" ]; then + block_json "git reset --hard" + exit 0 +fi + +clean_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+clean([[:space:]]|$)" || true)" +if [ -n "${clean_segments}" ]; then + while IFS= read -r segment; do + [ -z "${segment}" ] && continue + clean_args="$(printf '%s' "${segment}" | sed -E "s#${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+clean([[:space:]]|$)##")" + if printf '%s' "${clean_args}" | grep -Eq '(^|[[:space:]])(--dry-run|-n|-n[a-zA-Z]*|-[a-zA-Z]*n[a-zA-Z]*)([[:space:]]|$)'; then + continue + fi + # [2026-08-01][fix] `-f` の有無で判定すると設定経由で迂回できる(codex-review Critical)。 + # 背景: + # - ユーザー依頼意図: 破壊的 git 操作ガードが「実際に消せるコマンド」を取り逃がさないようにする。 + # - 守るべき業務ルール: `git clean` は `clean.requireForce=false` を渡すと `-f` 無しで + # 未追跡ファイルを削除できる。`git -c clean.requireForce=false clean -dx` は `.env` 等の + # ローカル秘匿ファイルまで消すため、`-f` を探す実装では素通りする(実測で PASS を確認)。 + # - 他案不採用理由: + # 1) `-c clean.requireForce=false` を追加でパターン検出する案は、`GIT_CONFIG_*` 環境変数や + # `--config-env`、既存の repo/global 設定でも同じ状態を作れるため、列挙が原理的に閉じない。 + # 2) 実際の設定値を読んで判定する案は、hook が対象 repo を確定できない場面(複合コマンド・ + # `--git-dir` 指定)で誤判定するため不採用。 + # 対応: dry-run でない `git clean` は一律 deny する。dry-run は上の continue で通過済み。 + block_json "git clean(--dry-run / -n 以外)" + exit 0 + done <" + exit 0 +fi + +# [2026-08-02][fix] `--` なし checkout の曖昧な位置引数を fail-closed にする。 +# 背景: +# - ユーザー依頼意図: 全PJ共通の破壊的Git guardで、`git checkout README.md` や +# `git checkout .` によるtracked変更の暗黙破棄も確実に止める。 +# - 守るべき業務ルール: checkoutの単一位置引数はbranch名とpathspecを静的に完全判別できないため、 +# 読み取りだけでpathだと断定できない場合も安全側へ倒す。branch移動には`git switch`を使う。 +# - 他案不採用理由: 拡張子・`/`・実在pathだけを列挙する案は、拡張子のないfile、glob、 +# `git -C`先のpathを取り逃がす。hook内でGitのref/path解決を実行する案はrepo/cwd境界を誤る。 +# 対応: 明示的な新規branch作成(`-b` / `--orphan`)、detach、help/versionだけを許可し、 +# `-p` / `--ours` / `--theirs` / `-B` 等を含む残りのcheckoutは一律denyする。 +# 安全optionは最初の位置引数より前にある場合だけ許可し、pathspec後ろのoptionで +# branch作成/detachへ見せかける並び替えは許可しない。安全モード後も引数個数を固定する。 +checkout_ambiguous_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+checkout([[:space:]]|$)" || true)" +if [ -n "${checkout_ambiguous_segments}" ]; then + while IFS= read -r segment; do + [ -z "${segment}" ] && continue + checkout_args="$(printf '%s' "${segment}" | sed -E "s#${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+checkout([[:space:]]|$)##")" + checkout_tokens=() + if [ -n "${checkout_args}" ]; then + read -r -a checkout_tokens <<< "${checkout_args}" + fi + checkout_count="${#checkout_tokens[@]}" + checkout_index=0 + checkout_safe=0 + checkout_invalid=0 + while (( checkout_index < checkout_count )); do + checkout_token="${checkout_tokens[checkout_index]}" + case "${checkout_token}" in + -q|--quiet|-m|--merge) + checkout_index=$((checkout_index + 1)) + ;; + --help|--version) + if (( checkout_index + 1 == checkout_count )); then + checkout_safe=1 + else + checkout_invalid=1 + fi + break + ;; + -b|--orphan) + if (( checkout_index + 2 == checkout_count )); then + checkout_branch="${checkout_tokens[checkout_index + 1]}" + if [ -n "${checkout_branch}" ] && [[ "${checkout_branch}" != -* ]]; then + checkout_safe=1 + else + checkout_invalid=1 + fi + else + checkout_invalid=1 + fi + break + ;; + --detach) + if (( checkout_index + 1 == checkout_count )); then + checkout_safe=1 + elif (( checkout_index + 2 == checkout_count )); then + checkout_ref="${checkout_tokens[checkout_index + 1]}" + if [ -n "${checkout_ref}" ] && [[ "${checkout_ref}" != -* ]]; then + checkout_safe=1 + else + checkout_invalid=1 + fi + else + checkout_invalid=1 + fi + break + ;; + *) + checkout_invalid=1 + break + ;; + esac + done + if (( checkout_safe == 1 && checkout_invalid == 0 )); then + continue + fi + block_json "git checkout(pathspec ambiguity; use git switch for branches)" + exit 0 + done <" + exit 0 + fi + if printf '%s' "${restore_args}" | grep -Eq '(^|[[:space:]])(--staged|-S|-[A-Za-z]*S[A-Za-z]*)([[:space:]]|$)'; then + continue + fi + block_json "git restore " + exit 0 + done <&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) + +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +reason = payload.get("permissionDecisionReason", "") +if "[hook:block-destructive-git]" not in reason: + sys.exit(1) +if "reason" in payload: + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue": true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow_inherited_env() { + local name="$1" + local command="$2" + local out + out="$(AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue": true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_raw() { + local name="$1" + local payload="$2" + local out + out="$(run_hook_raw "$payload" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) + +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if "[hook:block-destructive-git]" not in payload.get("permissionDecisionReason", ""): + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_embedded_python_py39() { + local name="embedded Python blocks parse as Python 3.9" + local out + if out="$(HOOK_SCRIPT="$SCRIPT" python3 - <<'PY' 2>&1 +import ast +import os +import re + +source = open(os.environ["HOOK_SCRIPT"], encoding="utf-8").read() +blocks = re.findall(r"<<'PY'[^\n]*\n(.*?)\nPY(?:\n|$)", source, re.S) +if not blocks: + raise SystemExit("no embedded Python blocks found") +for index, block in enumerate(blocks, 1): + try: + tree = ast.parse(block, feature_version=(3, 9)) + except SyntaxError as exc: + raise SystemExit(f"block {index}: {exc}") + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + returns = node.returns + if isinstance(returns, ast.BinOp) and isinstance(returns.op, ast.BitOr): + raise SystemExit(f"block {index}: Python 3.10 union return annotation") +PY +)"; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_double_quote_single_quote_scanner() { + local name="double quote内single quote後のvariable expansionを検出" + if HOOK_SCRIPT="$SCRIPT" python3 - <<'PY' +import os +import re + +source = open(os.environ["HOOK_SCRIPT"], encoding="utf-8").read() +blocks = re.findall(r"<<'PY'[^\n]*\n(.*?)\nPY(?:\n|$)", source, re.S) +scanner_blocks = [block for block in blocks if "def has_unresolved_shell_expansion" in block] +if len(scanner_blocks) != 1: + raise SystemExit(f"expected one scanner block, got {len(scanner_blocks)}") +namespace = {} +exec(scanner_blocks[0], namespace) +scanner = namespace["has_unresolved_shell_expansion"] +if not scanner('echo "\'"; $PAYLOAD'): + raise SystemExit("variable expansion after a single quote inside double quotes was missed") +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s\n' "$name" + FAIL=$((FAIL + 1)) + fi +} + +expect_embedded_python_py39 +expect_double_quote_single_quote_scanner +expect_block "git reset --hard deny" "git reset --hard" +expect_block "/usr/bin/git reset --hard deny" "/usr/bin/git reset --hard" +expect_block "command /usr/bin/git clean -fd deny" "command /usr/bin/git clean -fd" +expect_block "quoted /usr/bin/git reset --hard deny" "\"/usr/bin/git\" reset --hard" +expect_block "quoted ./bin/git clean -fd deny" "'./bin/git' clean -fd" +expect_block "quoted path with spaces git reset --hard deny" "\"/tmp/git tools/git\" reset --hard" +expect_block "consecutive-slash /usr//bin/git reset --hard deny" "/usr//bin/git reset --hard" +expect_block "consecutive-slash ./bin//git clean -fd deny" "./bin//git clean -fd" +expect_block "git -C reset --hard deny" "git -C /tmp/repo reset --hard origin/main" +expect_block "git -C path with spaces reset --hard deny" "git -C '/tmp/repo with spaces' reset --hard origin/main" +expect_block "git --git-dir/--work-tree path with spaces clean deny" "git --git-dir='/tmp/repo with spaces/.git' --work-tree '/tmp/repo with spaces' clean -fd" +expect_block "command git reset --hard deny" "command git reset --hard" +expect_block "env git clean -fd deny" "env git clean -fd" +expect_block "/usr/bin/env git clean -fd deny" "/usr/bin/env git clean -fd" +expect_block "/usr/bin/env -u FOO git reset --hard deny" "/usr/bin/env -u FOO git reset --hard" +expect_block "env assignment git checkout -f deny" "env FOO=bar git checkout -f main" +expect_block "sudo git reset --hard deny" "sudo git reset --hard" +expect_block "exec git reset --hard deny" "exec git reset --hard" +expect_block "exec alternate argv0 still finds git" "exec -a harmless /usr/bin/git reset --hard" +expect_block "exec unknown option fail closed" "exec --future-option /usr/bin/git reset --hard" +expect_block "sudo git clean -fd deny" "sudo -n git clean -fd" +expect_block "sudo -u root git reset --hard deny" "sudo -u root git reset --hard" +expect_block "sudo --user root path git reset --hard deny" "sudo --user root /usr/bin/git reset --hard" +expect_block "sudo --user=root path git reset --hard deny" "sudo --user=root /usr/bin/git reset --hard" +expect_block "sudo -- terminator path git reset --hard deny" "sudo -- /usr/bin/git reset --hard" +expect_block "sudo short chdir option still finds git" "sudo -D /tmp /usr/bin/git reset --hard" +expect_block "sudo long chdir option still finds git" "sudo --chdir /tmp /usr/bin/git clean -fd" +expect_block "sudo unknown option fail closed" "sudo --future-option /usr/bin/git reset --hard" +expect_block "sudo env path git reset --hard deny" "sudo env /usr/bin/git reset --hard" +expect_block "command env path git clean -fd deny" "command env /usr/bin/git clean -fd" +expect_block "env -u FOO git clean -fd deny" "env -u FOO git clean -fd" +expect_block "env -S reset payload fail closed" "env -S '/usr/bin/git reset --hard'" +expect_block "env --split-string clean payload fail closed" "env --split-string='/usr/bin/git clean -fd'" +expect_block "env unknown option before git fail closed" "env --future-option /usr/bin/git reset --hard" +expect_block "env ignore-environment still finds git" "env -i /usr/bin/git reset --hard" +expect_block "env attached unset still finds git" "env --unset=FOO /usr/bin/git clean -fd" +expect_block "env option terminator still finds git" "env -- /usr/bin/git reset --hard" +expect_block "time macOS long report still finds git" "/usr/bin/time -l /usr/bin/git reset --hard" +expect_block "time output option still finds git" "/usr/bin/time -o /tmp/timing.txt /usr/bin/git clean -fd" +expect_block "time unknown option before git fail closed" "/usr/bin/time --future-option /usr/bin/git reset --hard" +expect_block "eval path git reset fail closed" "eval /usr/bin/git reset --hard" +expect_block "eval quoted git reset fail closed" "eval 'git reset --hard'" +expect_block "nested eval quoted git reset fail closed" "bash -c 'eval \"git reset --hard\"'" +expect_block "nested eval git clean fail closed" "bash -c 'eval git clean -fd'" +expect_block "variable executable path fail closed" 'G=/usr/bin/git; "$G" reset --hard' +expect_block "variable executable basename fail closed" 'GIT=git; $GIT clean -fd' +expect_block "command substitution executable fail closed" '$(printf /usr/bin/git) reset --hard' +expect_block "zsh equals executable reset fail closed" "=git reset --hard" +expect_block "zsh equals executable clean fail closed" "=git clean -fd" +expect_block "brace group variable executable fail closed" '{ "$G" reset --hard; }' +expect_block "brace group command substitution executable fail closed" '{ $(printf git) clean -fd; }' +expect_block "paren group variable executable fail closed" '( "$G" reset --hard )' +expect_block "function body variable executable fail closed" 'danger(){ "$G" reset --hard; }; danger' +expect_block "function body command substitution executable fail closed" 'danger(){ $(printf git) clean -fd; }; danger' +expect_block "quoted argument command substitution reset deny" 'printf '\''%s'\'' "$(git reset --hard)"' +expect_block "quoted argument command substitution clean deny" 'echo "$(git clean -fd)"' +expect_block "argument process substitution restore deny" 'cat <(git restore src/app.ts)' +expect_block "argument backtick checkout deny" 'printf '\''%s'\'' "`git checkout -f main`"' +nested_backtick_argument='echo "`echo \`git reset --hard\``"' +expect_block "nested legacy backtick reset fail closed" "$nested_backtick_argument" +expect_block "nested argument substitution reset deny" 'printf '\''%s'\'' "$(printf '\''%s'\'' "$(git reset --hard)")"' +expect_block "arithmetic nested substitution clean deny" 'printf '\''%s'\'' "$((1 + $(git clean -fd)))"' +expect_block "case pattern esac text cannot hide reset" 'printf '\''%s'\'' "$(case esac in *esac*) git reset --hard ;; esac)"' +comment_substitution="printf '%s' \"\$( # ) +git reset --hard)\"" +expect_block "comment close paren cannot hide reset" "$comment_substitution" +comment_continuation_substitution="printf '%s' \$(echo ok # ) +g\\ +it reset --hard)" +expect_block "comment and line continuation cannot hide reset" "$comment_continuation_substitution" +comment_line_continuation="printf x # foo\\ +git reset --hard" +expect_block "comment line continuation cannot swallow next reset" "$comment_line_continuation" +comment_after_separator="printf x; # foo\\ +git clean -fd" +expect_block "separator comment continuation cannot swallow next clean" "$comment_after_separator" +comment_crlf_continuation=$'printf x # foo\\\r\n\tgit reset --hard' +expect_block "CRLF comment continuation cannot swallow next reset" "$comment_crlf_continuation" +comment_multiple_continuation=$'printf x # foo\\\ng\\\ni\\\nt clean -fd' +expect_block "comment with multiple continuations cannot hide clean" "$comment_multiple_continuation" +parameter_length_continuation="x=value; : \${#x}; g\\ +it reset --hard" +expect_block "parameter length hash is not a comment" "$parameter_length_continuation" +expect_allow "real comment remains inert" 'printf x # git reset --hard' +continued_git="g\\ +it reset --hard" +expect_block "line continuation executable reset deny" "$continued_git" +continued_git_multiple="g\\ +i\\ +t clean -fd" +expect_block "multiple line continuations executable clean deny" "$continued_git_multiple" +continued_git_quoted="printf '%s' \"\$(g\\ +it reset --hard)\"" +expect_block "double quoted continuation executable reset deny" "$continued_git_quoted" +single_quoted_continuation="printf '%s' 'g\\ +it reset --hard'" +expect_block "single quoted continuation remains conservative deny" "$single_quoted_continuation" +expect_block "parameter pattern close paren cannot hide reset" 'printf '\''%s'\'' "$(x=x; : ${x%)}; git reset --hard)"' +heredoc_substitution="printf '%s' \"\$(cat < /tmp/a.txt 2>&1; echo "exit=$?"; tail -3 /tmp/a.txt'\''' +expect_allow "chained add and multiline commit" 'git add -A && git commit -m "one +two"' +expect_block "commit -m command substitution still denied" 'git commit -m "$(git reset --hard)"' +# push --force は本 hook の守備範囲外(ローカル変更破壊系のみ)のため、置換ペイロードは +# 守備範囲内の reset --hard で「データ引数内の置換も deny」を固定する +expect_block "gh body command substitution still denied" 'gh pr create --body "$(git reset --hard)"' +expect_block "bash -c variable body still denied after relaxation" 'bash -c "$BODY"' +# PR #1354 codex-review Critical: 実行 wrapper の引数経由で任意コマンド化する経路を deny 固定 +expect_block "bash -c eval variable payload denied" 'bash -c '\''eval $PAYLOAD'\''' +expect_block "bash -c env variable payload denied" 'bash -c '\''env $PAYLOAD'\''' +expect_block "bash -c interpreter variable code denied" 'bash -c '\''python3 -c $CODE'\''' +dash_heredoc='dash <<'\''EOF'\'' +git reset --hard +EOF' +expect_block "dash heredoc destructive body deny" "$dash_heredoc" +ksh_heredoc='ksh <<'\''EOF'\'' +git clean -fd +EOF' +expect_block "ksh heredoc destructive body deny" "$ksh_heredoc" +expect_block "dash -c destructive body deny" "dash -c 'git reset --hard'" + +# [2026-08-02][test] xargs wrapper 経由の破壊的 Git 検査(jtt-cms PR #1542 codex-review Critical)。 +# 背景: +# - ユーザー依頼意図: xargs 経由の破壊的 Git が引数位置に見えて素通りしていた迂回を deny 固定し、 +# 非 git 用途の xargs(rm 等)や安全 subcommand を巻き込まないことを対で固定する。 +# - 守るべき業務ルール: 任意引数 option(bare -l 等)は静的境界不能として fail-closed。 +# - 他案不採用理由: deny 側のみのテストは、whitelist 縮小で日常 xargs が全滅しても気づけない。 +expect_block "xargs -n1 destructive reset deny" "printf 'HEAD\n' | xargs -n1 git reset --hard" +expect_block "xargs -I replace destructive clean deny" "xargs -I{} git clean -fd" +expect_block "xargs bare optional-arg option fail closed" "xargs -l git reset --hard" +expect_allow "xargs non-git command stays allowed" "ls | xargs -n1 rm -f" +expect_allow "xargs safe git subcommand stays allowed" "printf 'x\n' | xargs git log --oneline" + +TOTAL=$((PASS + FAIL)) +printf '\n=== block-destructive-git.test.sh: %d/%d PASS ===\n' "$PASS" "$TOTAL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.codex/hooks/scripts/block-main-commit.sh b/.codex/hooks/scripts/block-main-commit.sh new file mode 100755 index 000000000..d4c01aac0 --- /dev/null +++ b/.codex/hooks/scripts/block-main-commit.sh @@ -0,0 +1,956 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: +# 依頼意図: AIがmainに直接プッシュする事故の再発防止。 +# ルール記載(branch-rule.md)だけでは防げなかった実績があり(F2直接プッシュ事故)、 +# 技術的強制力を追加する必要があった。 +# 業務ルール: mainマージ = 本番DB即時適用 + 本番デプロイ発火のため、 +# レビューなし変更は業務リスクが高い。 +# 不採用理由: ルール記載のみでは実際の事故を防げなかった実績がある。 +# git hookよりもClaude Code PreToolUseの方が実行パスに近く確実にブロックできる。 +# 対応: jtt-cms block-main-commit.sh をポート。lib/hook-io.sh を使用。 + +# [2026-04-10][fix] +# 背景: +# 依頼意図: `git push origin main` が.mdファイルのみでもブロックされるバグの修正。 +# 守るべき業務ルール: .mdのみの変更はmainで直接コミット・プッシュ可能(branch-rule.md)。 +# 他案不採用理由: Path Aを削除する案はrefspec経由の非docs pushを見逃すため不採用。 +# 軽量変更判定をインライン展開する案はPath Bとの重複(DRY違反)のため不採用。 +# 対応: 軽量変更 push 判定を is_push_lightweight_only() に関数化し、Path A/B両方から呼び出し。 +# 撤回: 2026-07-01 に AI hook 経由の main 直接 commit/push 例外は全廃。上記は履歴のみ。 + +# [2026-04-18][fix] +# 背景: +# 依頼意図: エージェント環境で origin/main が未解決のとき Markdown のみの push まで拒否される。 +# Cursor/CLI の PreToolUse が同じスクリプトを通すため、比較基準 ref の解決を強化したい。 +# 守るべき業務ルール: main 直 push の例外は「Markdown 系ドキュメント + sync-state.json のみ」(CLAUDE.md / branch-rule.md)。 +# 他案不採用理由: 非 .md コードを許可する案は本番自動適用リスクのため不採用。 +# 対応: 比較 ref を origin/main → refs/remotes/origin/main → main@{upstream} の順で解決。 +# 許可拡張子に .mdc / .mdx を含める(Cursor ルール・MDX ドキュメント)。 +# AGENT-HUB: jtt-cms 正本と同一内容を hook-library に同期(docs/prd/prd-active.md 参照)。 +# 撤回: 2026-07-01 に Markdown / sync-state 等の main 直 push 例外は全廃。上記は履歴のみ。 +# +# [2026-04-27][fix] +# 背景: +# 依頼意図: .codex/sync-state.json のような同期状態ファイルだけで main 直コミットが止まるのは運用上のノイズ。 +# 守るべき業務ルール: sync-state.json はツール自動生成の状態ファイルとして Markdown 系ドキュメントと同じ軽量変更扱いにする。 +# 他案不採用理由: .json 全体を許可する案は package.json や設定 JSON までレビューなしで通すため不採用。 +# 対応: main 直コミット/プッシュの例外に sync-state.json だけを追加し、commit/push で共通判定を使う。 +# 撤回: 2026-07-01 に sync-state.json を含む軽量変更例外は全廃。上記は履歴のみ。 + +# [2026-05-05][fix] +# 背景: +# 依頼意図: Issue #123 で、PR #121 内で Revert された Issue #122 対応を安全に再導入したい。 +# 複合コマンド検知ブロックに +# 軽量変更バイパスが未適用のまま main に残っている。.md のみの変更でも `git switch main && git push` で deny される。 +# 守るべき業務ルール: main 直 push の例外は「Markdown 系ドキュメント + sync-state.json のみ」(branch-rule.md)。 +# 3つの検知パス(複合コマンド、push refspec、mainブランチ)は対称に保つ。 +# 他案不採用理由: +# 1) 複合コマンド検知ブロックを削除する案は、refspec 経由の非 docs push を見逃すため不採用(2026-04-10 と同型)。 +# 2) staged diff 判定をインライン展開する案は、mainブランチ検知ブロックとの重複(DRY違反)のため不採用。 +# 3) `scripts/` 配下のローカル hook を併用する案は、比較 ref と許可拡張子が分岐し SSOT が壊れるため不採用。 +# 対応: `is_commit_lightweight_only()` を新設し、mainブランチ検知から呼び出す。 +# 複合コマンド検知では switch 後の target ref (`main`) を比較対象にし、commit を含む場合は安全側で deny。 +# 撤回: 2026-07-01 に軽量変更バイパスは全廃。上記は履歴のみ。 + +# [2026-05-16][feat] +# 背景: +# 依頼意図: ccrec 運用で GitHub Actions / Claude Code クレジットを節約するため、 +# 人手レビュー価値の薄い運用設定ファイル(agents.yaml / typinator-sync.yaml / +# MCP 台帳等)も main 直接 push 可にする。 +# 守るべき業務ルール: ソースコード・hook 本体(*.sh)・CI 定義(.github/workflows)・ +# Web ビルド設定(package.json / tsconfig.json / composer.json)・hook 登録設定は引き続き PR 必須。 +# 許可は事前定義した allowlist のファイル名・パターンに限定する。 +# 他案不採用理由: +# 1) .json / .yaml 拡張子全体を許可: package.json / tsconfig.json / composer.json / +# src/**/*.json までレビューなしで通るため不採用(2026-04-27 と同型の理由)。 +# 2) 拡張子許可 + denylist: denylist 漏れが致命的になるため allowlist で明示する方が安全。 +# 3) AGENT-HUB 限定で CWD 分岐: 配布先 PJ の AI ツール設定もツール再同期で書き換わるため、 +# 全 PJ 一律許可が運用整合的(ユーザー判断 2026-05-16)。 +# 4) .github/workflows/*.yml を許可: CI 挙動を無レビューで変えるリスクのため不採用。 +# 5) *.sh を許可: hook スクリプト挙動を無レビューで変えるリスクのため不採用。 +# 6) 外部設定ファイル化(allowlist を YAML に切り出す): 比較 ref と許可判定の SSOT が +# 分岐するため不採用(2026-05-05 と同型)。 +# 7) hook 登録設定(.claude/settings.json / .codex/hooks.json 等)を許可: block-main-commit +# 自体をレビューなしで弱められるため不採用。 +# 対応: is_allowed_main_direct_path() に case 文 allowlist を追加し、hook 登録を含まない AI ツール設定 / +# AGENT-HUB ルート運用設定 / codex-mcp 台帳を許可する。 +# 撤回: 2026-07-01 に運用設定 allowlist も全廃。上記は履歴のみ。 + +# [2026-05-21][feat] +# 背景: +# 依頼意図: .codex/config.toml と .gemini/hooks/.hook-library-version は Kimi Code MCP 設定 / .cursor/mcp.json +# と同等の sync 完全自動生成ファイル(手動編集 0 行)だが、2026-05-16 拡張時に取りこぼされていた。 +# 対称性を回復して、sync 実行のたびに main 直 push が deny されて GitHub Actions / Claude Code クレジットを +# 消費する状況を解消したい。 +# 守るべき業務ルール: +# - .codex/config.toml は全 PJ で MANAGED CODEX MCP START/END block の完全自動生成のみ。 +# 将来 managed block 外の手動編集領域が追加された場合は branch-rule.md を再評価する。 +# - .gemini/settings.json は hook 登録設定(BeforeTool/AfterTool/BeforeAgent)と MCP を混在で持つため +# allowlist には載せない(hook 登録設定の許可は 2026-05-16 [feat] 不採用理由 7 と同型で禁止)。 +# なお全 PJ で .gemini/settings.json は gitignore のため commit 経路自体が無く、本 hook へ到達しない。 +# 他案不採用理由: +# 1) .gemini/settings.json も同時許可: hook 登録を含む混在ファイルのため、settings.json + bridge スクリプト +# の同時変更で block-main-commit を弱められる経路を作ってしまう(2026-05-16 不採用理由 7 と同型)。 +# 2) .gemini/hooks/{lib,scripts}/*.sh / *.py を許可: hook ロジック本体の無レビュー変更を許す +# (2026-05-16 不採用理由 5 と同型)。 +# 3) .toml 拡張子全体を許可: dotfiles/codex/config.toml.base(features.apps 保護対象)まで通る +# ため不採用(2026-05-16 不採用理由 1 と同型)。 +# 対応: is_allowed_main_direct_path() の case 文に .codex/config.toml と +# .gemini/hooks/.hook-library-version を対称順で追加する。 + +# [2026-06-05][feat] .codex/hooks.json を main 直接 allowlist に追加(ユーザー承認・過去判断の変更) +# 背景: +# - ユーザー依頼意図: Codex hook の user-level 移行(PR #284)で各PJの .codex/hooks.json を +# 縮小版へ再配布する。この派生物コミットを毎回 PR にするのは負荷が高く、伸太郎殿の +# 「AIエージェント設定ファイルだけの変更を毎回PRに出したくない」要望(2026-06-05)に応える。 +# - 守るべき業務ルール: .codex/hooks.json は deploy-hooks.py が hook-registry.yaml から生成する +# sync 自動生成の派生物(手編集禁止、codex-sync.md)。hook 挙動は AGENT-HUB 側 PR で既にレビュー済み。 +# - 他案不採用理由(過去の不採用判断を覆す根拠): +# 2026-05-21 [feat] 不採用理由1 / 2026-05-16 [feat] 不採用理由7 で「hook 登録設定 +# (.claude/settings.json / .codex/hooks.json 等)は block-main-commit 自体を無レビューで +# 弱められるため allowlist 禁止」としていた。今回 .codex/hooks.json のみ覆すのは、 +# (a) deploy-hooks 生成物に限定され手編集しない運用が確立、(b) block-main-commit は +# Claude(.claude/settings.json は allowlist 据え置き=PR必須)でも効くため Codex 側を弱めても +# main 保護の実効性が残る、(c) Codex は補助ツール、の3点でリスク限定的と伸太郎殿が判断したため。 +# .claude/settings.json(hook登録の中核)は引き続き allowlist に入れない(PR必須維持)。 +# 対応: is_allowed_main_direct_path() の case に .codex/hooks.json を追加。.claude/settings.json は据え置き。 + +# [2026-06-05][feat] deploy-hooks 配布物(各PJ .claude/hooks/ ・ .codex/hooks/ の scripts/lib)を allowlist 追加 +# 背景: +# - ユーザー依頼意図: Phase E(PR #283) + Codex 移行(PR #284) + allowlist(PR #285)を全PJへ実配布する際、 +# 各PJの hook 配布物(block-main-commit.sh / block-skill-reverse-edit.sh / lib 等)を毎回 PR にするのは +# 16PJ規模で非現実的。「設定・配布物の機械的更新を毎回PRにしたくない」要望(2026-06-05)に応える。 +# - 守るべき業務ルール: 各PJ .claude/hooks/ ・ .codex/hooks/ 配下の scripts/lib は deploy-hooks.py が +# hook-library(SSOT)から配布する派生物。hook 挙動の変更は hook-library 本体の AGENT-HUB PR でレビュー +# 済み。各PJで人が直接編集する運用はなく、drift は sync-reconcile.py が検出する。 +# - 他案不採用理由(覆した過去判断): +# 2026-05-16 #5 で「*.sh(hook ロジック本体)は allowlist 禁止」としていた。今回 .claude/hooks/scripts/ ・ +# .codex/hooks/scripts/ ・ lib/ 配下の配布物のみ覆すのは、(a) これらは hook-library からの機械配布物で +# SSOT 本体(hook-library/scripts/)は PR 必須のまま、(b) sync-reconcile で drift 検出可能、(c) 各PJ実配布の +# 運用負荷が許容外、の3点。settings.json(block-main-commit の matcher 登録を含む hook 登録の中核)は +# 許可しない(main 保護自体を無レビューで外せてしまうため。2026-05-16 #7 維持)。hook-library/scripts/ +# (SSOT 本体)も別パスのため PR 必須を維持。 +# 対応: is_allowed_main_direct_path() の case に .claude/hooks/{scripts,lib}/ ・ .codex/hooks/{scripts,lib}/ を +# 追加。settings.json と hook-library/scripts/ は据え置き。 + +# [2026-06-23][refactor] 配布差分放置防止のため 2026-06-05 の main 直接 allowlist を撤回 +# 背景: +# - ユーザー依頼意図: AGENT-HUB から hook / skill / rule / agent 派生物を各PJへ配布した後、 +# AI が「これは私の修正したファイルではない」として配布先差分を放置する事故を防ぐ。 +# 配布を実行した担当者が PR 作成・レビュー・マージ・cleanup・clean 確認まで責任を持つ。 +# - 守るべき業務ルール: 機械配布物でも、配布先 PJ の tracked 差分は作った担当者が閉じる。 +# .codex/hooks.json と .claude/.codex hooks scripts/lib は main 直接 push ではなく PR 経由に戻す。 +# - 他案不採用理由: +# 1) ルール文書だけの更新は hook allowlist が残り、main 直 push で closeout を迂回できるため不採用。 +# 2) --push を即削除する案は既存運用互換の破壊が大きいため、まず hook 側で main 直許可を撤回する。 +# 対応: is_allowed_main_direct_path() から .codex/hooks.json と .claude/.codex hooks scripts/lib を削除。 + +# [2026-06-15][fix] worktree/別リポへの refspec 省略 bare push を許可(PR #369 の取りこぼし修正) +# 背景: +# 依頼意図: `cd && git push --force-with-lease`(refspec 省略の bare push)が +# PR #369 後も deny される。ハーネスは Bash cwd を毎回 main 直下に戻すため worktree への push は +# refspec 省略の bare push になることが多く(upstream に任せる常用フロー)、worktree 並行開発が成立しない。 +# 守るべき業務ルール: main 直 push/commit の保護は厳密(fail-closed)に維持する。本番デプロイ=main push のため。 +# 根本原因: has_unsafe_push() が「remote/refspec 欠落の push」を宛先不明として無条件 unsafe にしていた。 +# しかし実効ターゲット(先頭の単一 cd 先)のカレントブランチは判明済み(非 main)で、bare push はその +# カレントブランチを push するだけ。一律 unsafe は過剰だった。 +# 他案不採用理由: +# 1) bare push を実効ブランチ非 main なら無条件許可: push.default=matching(全 matching ブランチ=main 波及) +# や push.default=upstream で upstream が main のとき main を押す経路が残るため不採用。 +# 2) 何もしない案: refspec 省略の worktree push(ユーザーの主要フロー)が不能のままで不便。 +# 対応: dir 解決を effective_target_dir() に関数化し、has_unsafe_push() に eff_dir を渡す。bare/remote-only +# push は eff_dir の push.default + @{upstream} を解決し、matching / upstream→main / 解決不能のみ unsafe、 +# simple(既定)/current 等は非 main カレントブランチのみ push として安全に許可する。明示的 main 宛て / +# --all/--mirror/wildcard/複数 ref は従来どおり deny。汎用設計のため worktree 以外の別リポにも同様に効く。 + +# [2026-07-01][refactor] AI hook 経由の main 直接 commit / push 例外を完全撤回 +# 背景: +# 依頼意図: 文書ルールだけでなく、PreToolUse hook 実体でも Markdown / sync-state.json / +# agents.yaml / typinator-sync.yaml 等の軽量変更 allowlist を閉じ、全ディレクトリ・全 AI で +# main checkout を掴まない運用を強制したい。 +# 守るべき業務ルール: AI の通常作業では main branch の commit / push は軽量変更でも deny。 +# 非 main branch / 専用 worktree の commit / push は従来どおり許可し、PR 作成フローを壊さない。 +# 他案不採用理由: +# 1) allowlist を文書上だけ廃止して hook に残す案は、AI が実際には main 直 commit / push できるため不採用。 +# 2) 環境変数 override を追加する案は、AI が自己判断で例外を使う経路になるため不採用。 +# 3) 初回 repo 作成や人間明示承認を hook が推測して許可する案は、安全側で判定できないため不採用。 +# 対応: is_allowed_main_direct_path は常に deny にし、main branch 検知・main refspec push 検知では +# 軽量差分判定を呼ばず即 deny する。worktree feature branch の早期許可は維持。 + +# [2026-07-18][fix] git標準ラッパーと先頭空白によるmain保護迂回を防止 +# 背景: +# - ユーザー依頼意図: dirty cleanup PRのレビューで `env git commit` / `command git push` / +# 先頭空白付きgitが検出から漏れ、main直操作を許可できることが判明した。 +# - 守るべき業務ルール: 標準ラッパーや整形上の空白でmain保護の強さを変えない。 +# - 他案不採用理由: `env` 後の任意トークンを許す正規表現は `env echo git ...` まで誤検知するため不採用。 +# 対応: command/envの標準形とenv代入だけをコマンド位置で消費し、その後のgitサブコマンドを既存判定へ渡す。 + +set -euo pipefail + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `git -C path push origin main` や `git -c k=v push origin main` のように +# グローバルオプション付きで git を呼び出すと、既存の正規表現 `git[[:space:]]+push` が +# マッチせず main 直 push/commit をスルーしてしまう脆弱性を修正したい。 +# 守るべき業務ルール: main 直 push/commit のブロックは確実でなければならない。 +# false positive(許可ケースを誤拒否)を増やさないこと。 +# 他案不採用理由: +# 1) オプション列を貪欲に `.*` で許可 → セミコロン区切りの複合コマンドで誤マッチしやすい。 +# `[^[:space:]]+` で空白終端を保証する設計の方が安全。 +# 2) `-C` / `-c` だけを許可する案 → `git --no-pager push` が fail-open し、 +# main 保護の目的を満たせないため不採用。 +# 対応: スクリプト先頭に共通定数 GIT_GLOBAL_OPTS を定義し、値あり/値なしの代表的な +# git グローバルオプションを消費してから push/commit/switch/checkout を検知する。 +# git グローバルオプションを 0個以上許容する共通パターン。 +readonly GIT_GLOBAL_OPT='(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--config-env[[:space:]]+[^[:space:]]+|--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--namespace(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--exec-path(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)?|--super-prefix[[:space:]]+[^[:space:]]+|--paginate|--no-pager|--no-replace-objects|--bare|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--help|--version|--html-path|--man-path|--info-path|-p)' +readonly GIT_GLOBAL_OPTS="([[:space:]]+${GIT_GLOBAL_OPT})*" +readonly GIT_ENV_VALUE="([^[:space:];&|()'\"]+|'[^']*'|\"([^\"\\\\]|\\\\.)*\")+" +readonly GIT_ENV_ASSIGN="[A-Za-z_][A-Za-z0-9_]*=${GIT_ENV_VALUE}" +readonly GIT_ENV_PREFIX="(${GIT_ENV_ASSIGN}[[:space:]]+)*" +readonly ENV_OPT_WITH_VALUE='(-u|--unset|-C|--chdir|-P|--path|-S|--split-string)[[:space:]]+[^[:space:];&|()]+' +readonly GIT_COMMAND_WRAPPER="(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+|env([[:space:]]+((${ENV_OPT_WITH_VALUE})|-[^[:space:];&|()]+|${GIT_ENV_ASSIGN}))*[[:space:]]+)?" +readonly GIT_CMD="(^|[;&|()])[[:space:]]*${GIT_ENV_PREFIX}${GIT_COMMAND_WRAPPER}${GIT_ENV_PREFIX}git${GIT_GLOBAL_OPTS}" + +# [2026-07-18][fix] +# 背景: +# - PR1018再レビューで、環境変数代入をenv/command wrapperの前に置くとGIT_CMDがgit writeを見失った。 +# - 守るべき業務ルール: POSIXで有効なprefix順序の違いでmain保護の強さを変えない。 +# - 他案不採用理由: FOO=1だけを文字列denyする案は変数名ごとに再発するため不採用。 +# 対応: 環境変数prefixをwrapperの前後どちらにも許容し、その後のgit commit/pushを同じ判定へ渡す。 + +# [2026-07-18][fix] +# 背景: +# - PR1018最終レビューで、空白を含む引用済み環境変数値がGIT_ENV_PREFIXを分断し、 +# main上の `FOO='a b' git commit` をgit writeなしとして許可できると判明した。 +# - 守るべき業務ルール: shellで有効な引用・escapeを含む代入でもmain保護をfail-openにしない。 +# - 他案不採用理由: quoteを含む行を一律denyすると、説明文やfeature branchの通常操作まで誤拒否する。 +# 対応: 環境変数値をunquoted/single-quoted/double-quotedのshell wordとして認識し、wrapper内外で共通利用する。 + +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: PR1018再レビューで、feature cwdから `env -C
` を使うと +# hook入力のcwd側ブランチだけを見てmain commit/pushを許可し得る経路が見つかった。 +# - 守るべき業務ルール: 実効cwdを確実に解決できないcommit/pushはfail-closedにする。 +# - 他案不採用理由: env chdir先の完全解決は相対path・複数wrapper・複合commandで誤許可を生むため不採用。 +# 対応: env -C/--chdir(=形式を含む)とgit commit/pushが同じ入力にある場合は安全側で拒否する。 +# [2026-08-02][fix] env と -C/-S の間に許すトークンを env 自身のオプション/代入に限定する(issue #1344)。 +# 背景: +# - ユーザー依頼意図: 旧パターンの `env([[:space:]]+[^;&|()]*)?` は貪欲で、 +# `env FOO=bar git -C commit` の **git の -C** まで env の -C(chdir)と誤認し、 +# 正当な feature worktree commit/push を fail-closed で誤 deny していた +# (PR #1343 codex-review 検出・再現ドライバで実測)。 +# - 守るべき業務ルール: env 実行系(-C/--chdir/-S/--split-string)の保守的 deny は維持する。 +# env のオプション解析はコマンド名(最初の非オプション・非代入トークン)で終わるという +# GNU env の実引数規則を静的に再現し、コマンド名以降の -C/-S は誤認対象から外す。 +# - 他案不採用理由: env 形を全て未解決に倒す従来動作の維持は、日常の env prefix commit を +# 恒常的に止め摩擦が大きい。env の後続を完全 tokenize する案は本 hook の軽量 grep 設計に反する。 +# [2026-08-02][fix] 引数を取る env オプション(-u/--unset/シグナル系)は引数ごと消費する +# (PR #1354 codex-review Critical: `env -u FOO -C
git commit` の -C が +# FOO でパターンが止まり chdir 検出から外れるバイパスを塞ぐ)。 +# 引数付きを先に列挙し、その後に汎用オプション(-i 等・引数なし)と assignment を置く。 +# 汎用側で引数を消費しないのは、`env -i git -C ...` の git を env の引数と +# 誤認して #1344 の誤 deny を再導入しないため。 +readonly ENV_OPT_ARG='(-u|--unset|--block-signal|--default-signal|--ignore-signal)[[:space:]]+[^[:space:];&|()]+' +readonly ENV_OWN_TOKENS='(('"${ENV_OPT_ARG}"'|-[^[:space:];&|()]+|[A-Za-z_][A-Za-z0-9_]*=[^[:space:];&|()]*)[[:space:]]+)*' +command_uses_env_chdir() { + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[;&|()])[[:space:]]*(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+)?env[[:space:]]+'"${ENV_OWN_TOKENS}"'(-C([[:space:]]+|[^[:space:];&|()]+)|--chdir(=|[[:space:]]+))' +} + +# env -S/--split-string は1引数内の文字列を再分割してコマンド化するため、通常のwrapper解析では +# 実行されるgitを復元できない。git commit/pushを含む場合だけfail-closedにする。 +command_uses_env_split_git_write() { + echo "$COMMAND" | grep -qE '(^|[;&|()])[[:space:]]*(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+)?env[[:space:]]+'"${ENV_OWN_TOKENS}"'(-S([[:space:]]+|[^[:space:];&|()]+)|--split-string(=|[[:space:]]+))' && + echo "$COMMAND" | grep -qE 'git.*[[:space:]](commit|push)([^A-Za-z0-9_-]|$)' +} + +# [2026-07-11][fix] jtt-apps 本番タグ push 事例(v2.4.37) +# 背景: +# 依頼意図: `git -C push origin v2.4.37` のような単発 -C push が、 +# コマンド中に `2>&1` 等のリダイレクトが含まれるだけで single_git_c_target_dir() の +# `[;&|()]` チェックに誤ヒットし解決不能(deny)になっていた。DEPLOY_CHECKLIST.md の +# 正規タグ push 手順は worktree 経由でしか実行できないため、この誤検知で本番デプロイの +# 唯一の正規経路が塞がれていた。 +# 守るべき業務ルール: main 直 push/commit の fail-closed 判定は維持する。リダイレクトは +# 単一コマンドの出力先を変えるだけで複合コマンドの合図ではないため、それだけで +# 解決不能に倒すのは過剰検知。一方 `&`(バックグラウンド実行)や `|`(パイプ)は真に +# 複合コマンドの合図なので、従来どおり解決不能のまま扱う。 +# 他案不採用理由: +# 1) `[;&|()]` チェック自体を緩める案: `&` 単体や `|` まで見逃すと後続コマンドの +# 存在を検知できなくなり fail-open になるため不採用。 +# 2) has_unsafe_push() のようなトークン単位パーサに全面書き換える案: 影響範囲が +# 広く、今回の誤検知箇所以外の挙動まで変えるリスクがあるため不採用。 +# 対応: quote scanner 自身が引用外のリダイレクトだけを識別し、引用済み本文を変更せずに +# shell 制御演算子を判定する。 + +# [2026-07-12][fix] +# 背景: +# 依頼意図: main checkout を cwd にした Codex から専用 feature worktree へ +# `git -C commit -m 'fix(auth): ...'` を実行すると、引用符内の `()` を +# shell 制御演算子と誤認し、正規の branch + PR フローを deny していた。 +# 守るべき業務ルール: 引用済みメッセージは git の引数データとして許可する一方、非引用の +# `; & | ( )`、引用内でも実行される command substitution、壊れた引用は fail-closed にする。 +# 他案不採用理由: +# 1) `()` の検査を削る案は subshell を見逃して main 操作を早期許可しうるため不採用。 +# 2) conventional commit の括弧だけ正規表現で消す案は、任意の正当な引用済み本文に拡張できず +# セミコロン等で同じ誤検知が再発するため不採用。 +# 対応: 最小の shell quote scanner で、制御演算子が引用の外にある場合だけ真を返す。 +# 引用外の `>file` / `&1` は単一コマンドのリダイレクトとして読み飛ばすが、 +# その後のファイル名や制御演算子は走査を続ける。 +has_unquoted_shell_control() { + local scanner_rc + if COMMAND_TEXT="$1" python3 - <<'PY' +import os +import sys + +text = os.environ.get("COMMAND_TEXT", "") +quote = None +escaped = False +i = 0 +while i < len(text): + ch = text[i] + if escaped: + escaped = False + i += 1 + continue + if ch == "\\" and quote != "'": + escaped = True + i += 1 + continue + if quote == "'": + if ch == "'": + quote = None + i += 1 + continue + if quote == '"': + if ch == '"': + quote = None + elif ch == '`' or (ch == '$' and i + 1 < len(text) and text[i + 1] == '('): + raise SystemExit(0) + i += 1 + continue + if ch in ("'", '"'): + quote = ch + elif ch in "<>": + # Redirection itself does not compose another command. Skip only its + # operator/fd-copy portion; keep scanning the target and anything after it. + direction = ch + while i + 1 < len(text) and text[i + 1] == direction: + i += 1 + if i + 1 < len(text) and text[i + 1] == '&': + i += 1 + while i + 1 < len(text) and (text[i + 1].isdigit() or text[i + 1] == '-'): + i += 1 + elif ch in ";&|()" or ch == '`': + raise SystemExit(0) + i += 1 + +# Unterminated quoting is ambiguous and therefore unsafe. +raise SystemExit(0 if quote is not None or escaped else 1) +PY + then + return 0 + else + scanner_rc=$? + # [2026-07-12][fix] + # 背景: + # - 依頼意図: quote scanner の Python 起動不能や異常終了を「安全」と誤認し、main 保護が + # fail-open になる経路を閉じたい。 + # - 守るべき業務ルール: scanner が明示する rc=1 だけを安全とし、未導入・クラッシュ・ + # 想定外終了はすべて曖昧な入力として拒否する。 + # - 他案不採用理由: テスト用の interpreter override を本番環境変数として公開する案は、 + # exit 1 を返す任意プログラムで保護を迂回できるため不採用。 + # 対応: python3 は固定し、rc=1 以外を unsafe に正規化する。 + # rc=1 is the scanner's only explicit "safe" result. Missing Python, + # interpreter crashes, and every other unexpected status stay fail-closed. + [ "$scanner_rc" -eq 1 ] && return 1 + return 0 + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +# emit_deny(hook-io.sh) を呼び出す前に telemetry へ deny を記録する薄いラッパ。 +# 既存の deny メッセージ・exit 挙動は一切変えない(記録の追加のみ)。 +_emit_deny_with_telemetry() { + agent_hub_telemetry_log hook_deny block-main-commit deny 2>/dev/null || true + emit_deny "$1" +} + +DENY_MSG='[hook:block-main-commit] mainブランチへの直接コミット/プッシュはブロックされました。\n\n対応手順:\n1. git checkout -b feature/xxx でブランチを作成\n2. ブランチ上でコミット\n3. gh pr create でPRを作成\n\n理由: mainマージ = 本番DB自動適用 + 本番デプロイが即座に発動するため、レビューなしの変更は禁止です。' + +read_stdin +COMMAND=$(extract_field command) + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# CWD取得(push refspec検知より前に必要) +CWD=$(extract_field cwd) +if [ -z "$CWD" ]; then + CWD="." +fi + +# [2026-08-02][fix] #1313 / #1256: commit message・PR/Issue本文をgit実行列から除外する。 +# 背景: +# - 依頼意図: `git commit -m '説明; git push origin main'` や +# `gh pr create --body 'git reset --hard'` の本文を、実行されたgit writeとして +# 誤検知しない。ガード自身の修正記録・PR本文が書けない摩擦を解消する。 +# - 守るべき業務ルール: 引用外の `; git ...`、実際の command substitution、shell wrapper は +# 従来どおり安全側で扱う。除外するのは `-m/--message/--body/--body-file` の引数データだけ。 +# - 他案不採用理由: コマンド全体の `git` 文字列を無視する案は、引用外のmain pushを見逃す。 +# 正規表現へ例外を足し続ける案は引用境界を扱えず、同じ誤検知を再発させる。 +# 対応: shellの引用境界を小さく走査し、本文系オプションの次の1 tokenだけを空白化した +# 判定用コピーを作る。実行用の COMMAND は変更せず、quote scanner / -C path 解決は従来どおり +# raw input を参照する。展開を含む本文は空白化せず、保守的に検出・拒否する。 +sanitize_git_data_args() { + COMMAND_TEXT="$1" python3 - <<'PY' 2>/dev/null || printf '%s' "$1" +import os +import shlex + +text = os.environ.get("COMMAND_TEXT", "") +mask = [False] * len(text) +data_options = {"-m", "--message", "--body", "--body-file"} + +def spans(value): + result = [] + index = 0 + length = len(value) + while index < length: + if value[index].isspace(): + index += 1 + continue + if value[index] in ";|&()": + result.append((index, index + 1, value[index])) + index += 1 + continue + start = index + quote = None + escaped = False + while index < length: + char = value[index] + if escaped: + escaped = False + index += 1 + continue + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if quote == '"': + if char == '"': + quote = None + elif char == "\\": + escaped = True + index += 1 + continue + if char in ("'", '"'): + quote = char + index += 1 + continue + if char == "\\": + escaped = True + index += 1 + continue + if char.isspace() or char in ";|&()": + break + index += 1 + result.append((start, index, value[start:index])) + return result + +def decoded(raw): + try: + values = shlex.split(raw, posix=True) + except ValueError: + return raw + return values[0] if len(values) == 1 else raw + +def has_executable_expansion(raw): + quote = None + escaped = False + index = 0 + while index < len(raw): + char = raw[index] + if escaped: + escaped = False + index += 1 + continue + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if quote == '"': + if char == '"': + quote = None + elif char == "\\": + escaped = True + elif char == "$" and index + 1 < len(raw) and raw[index + 1] == "(": + return True + elif char == "`": + return True + index += 1 + continue + if char in ("'", '"'): + quote = char + elif char == "\\": + escaped = True + elif char == "$" and index + 1 < len(raw) and raw[index + 1] == "(": + return True + elif char == "`": + return True + index += 1 + return False + +tokens = spans(text) +expect_data = False +for start, end, raw in tokens: + if raw in ";|&()": + expect_data = False + continue + value = decoded(raw) + if expect_data: + # A command substitution/backtick is executable text, not static data. + # Keep it visible so the existing fail-closed patterns can reject it. + if not has_executable_expansion(raw): + for position in range(start, end): + mask[position] = True + expect_data = False + continue + if value in data_options: + expect_data = True + continue + if any(value.startswith(option + "=") for option in ("--message", "--body", "--body-file")): + for position in range(start, end): + mask[position] = True + continue + # `-mtext` is a valid git short option form. The whole token is message data. + if value.startswith("-m") and len(value) > 2 and not value.startswith("--"): + for position in range(start, end): + mask[position] = True + +print("".join(" " if mask[position] else char for position, char in enumerate(text)), end="") +PY +} + +# All regex-only git write searches below use this copy. Raw COMMAND remains the source for +# quote-aware shell-control and effective path checks. +COMMAND_FOR_GIT_MATCH="$(sanitize_git_data_args "$COMMAND")" + +is_allowed_main_direct_path() { + # 2026-07-01: AI hook 経由の main direct allowlist は廃止。 + # 互換テスト用に関数名は残すが、どの path も許可しない。 + return 1 +} + +# [2026-05-30][fix] issue #210 / cafe48 codex review follow-up +# 背景: +# ユーザー依頼意図: `git -C <別repo> push origin main` のように実効ディレクトリを変える +# グローバルオプション付き push/commit を、hook 実行 cwd ($CWD) の branch/差分で判定すると、 +# 「$CWD が main かつ軽量変更」のとき別 repo の main 直 push を軽量バイパスで許可してしまう +# fail-open が残っていた(#213 で git_command_query=実効 cwd 解決を削除した際の取りこぼし)。 +# 守るべき業務ルール: main 直 push/commit のブロックは確実(fail-closed)であること。 +# 他案不採用理由: +# 1) -C を抽出し実効 cwd を完全復元する案: 複数 -C の相対累積や --git-dir/--work-tree の +# 組合せまで正確に追うのは複雑で、#213 が regex 方式へ寄せた設計に逆行する。 +# 2) 何もしない案: 別 repo の main 直 push を $CWD=main・軽量時に通すため main 保護目的を満たさない。 +# 3) -C/--git-dir/--work-tree のみ検知(PR #229 初版): PR #229 codex レビューで指摘の通り +# `GIT_DIR=` / `GIT_WORK_TREE=` env 経由と `cd /other && git push` の複合コマンドが +# 残存 fail-open になるため不採用(v3.5.7 で同時対応)。 +# 対応: 実効ディレクトリを変える経路(-C / --git-dir / --work-tree / GIT_DIR= / GIT_WORK_TREE= / +# cd && git ...)が push/commit に付く場合は $CWD ベースの軽量バイパスを信頼せず、 +# main 向けは安全側で deny する(fail-closed)。-C なしの通常 cwd 上の Markdown 軽量直 push は +# 従来どおり許可され、false positive を広げない。 +command_targets_other_dir() { + # -C / --git-dir / --work-tree + # ただし `-C .` / `-C ./` は no-op(current dir)のため除外する。 + # path 部分を抽出して `.` または `./` でないことを確認する。 + local c_paths c_path + c_paths=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' || true) + if [ -n "$c_paths" ]; then + while IFS= read -r match; do + [ -z "$match" ] && continue + # 最後のフィールド = path(先頭の空白と -C を除去) + c_path=$(echo "$match" | awk '{print $NF}') + case "$c_path" in + "."|"./") ;; # no-op + *) return 0 ;; + esac + done <<< "$c_paths" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+))'; then + return 0 + fi + # GIT_DIR= / GIT_WORK_TREE= / GIT_NAMESPACE= 環境変数 prefix + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)='; then + return 0 + fi + # cd && git ... / cd ; git ... (複合コマンドで実効 cwd を変える) + # `cd .` / `cd ./` は no-op のため除外する。 + local cd_paths cd_path + cd_paths=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^[:space:];&|]+' || true) + if [ -n "$cd_paths" ]; then + while IFS= read -r match; do + [ -z "$match" ] && continue + cd_path=$(echo "$match" | awk '{print $NF}') + case "$cd_path" in + "."|"./") ;; # no-op + *) + # cd の後に && または ; があり git が続くことを確認 + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "cd[[:space:]]+$(printf '%s' "$cd_path" | sed 's/[[\.*^$/]/\\&/g')[[:space:]]*[;&]"; then + return 0 + fi + ;; + esac + done <<< "$cd_paths" + fi + return 1 +} + +# [2026-06-14][feat] 実効ターゲットディレクトリ(先頭の単一 cd 先)のブランチを解決する。-C は不採用=deny。 +# 背景: +# 依頼意図: Claude Code 等のハーネスは Bash の cwd を毎回プロジェクト直下(main)に戻すため、 +# worktree への操作は `cd && git commit/push` の形になる。$CWD(main) の枝で判定すると +# worktree(feature) への正当なコミット・PR push まで fail-closed で弾かれ、worktree 開発が成立しない。 +# 守るべき業務ルール: 解決対象は「コマンド先頭の単一 cd && ...」だけ(cd は後続コマンドの cwd に +# 効くため commit/push の実効ディレクトリになる)。GIT_DIR/GIT_WORK_TREE env・--git-dir/--work-tree/ +# --namespace・-C・複数 cd・先頭以外の cd が含まれる場合は解決不能(空)を返し、従来どおり fail-closed にする。 +# 他案不採用理由: +# 1) -C を解決に使う案: -C はその git 1 回にしか効かず、`git -C status && git commit` のように +# 後続 commit が main で動く形を誤許可するため不採用(-C は解決根拠にしない=従来 deny のまま)。 +# 2) 複数 cd の相対累積・env トリックまで追う案: 複雑で誤許可リスクが高い。安全に解決できる +# 「先頭単一 cd」だけを許可し、それ以外は安全側(空)に倒す。 +# 実効ターゲットディレクトリ(先頭の単一 cd 先)を解決して絶対パスを stdout に返す。解決不能なら空。 +# [2026-06-15][fix] dir 解決を effective_target_branch から切り出して関数化(bare push の宛先判定で +# has_unsafe_push が同じ dir を再利用するため)。ガード条件は従来と同一(変更なし)。 +effective_target_dir() { + # 実体を差し替える env / オプション / -C が含まれるものは解決不能(fail-closed 用に空を返す)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)=' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir|--work-tree|--namespace)([=[:space:]])' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])-C([[:space:]]|$)' && return 0 + # eval / exec / ` -c` は cd の効果範囲が静的に読めない → 解決不能(fail-closed)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(eval|exec)([[:space:]]|$)' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(sh|bash|zsh|dash|ksh)[[:space:]]+-[A-Za-z]*c([[:space:]]|$)' && return 0 + # コマンド位置(^ / ; & | 直後・サブシェル ( 直後)の cd を数える。複数あれば実効 cwd が曖昧 → 解決不能。 + # サブシェル `( cd /main && git commit )` の隠れた cd も ( を境界に含めることで検出する。 + local cds dir + cds=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[;&|(])[[:space:]]*cd[[:space:]]+[^[:space:];&|()]+' || true) + [ "$(printf '%s\n' "$cds" | grep -c .)" -ne 1 ] && return 0 + # その単一 cd が「先頭」かつ「&& / ; で後続に効く」形であること(背景 & / パイプ | は対象外)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '^[[:space:]]*cd[[:space:]]+[^[:space:];&|()]+[[:space:]]*(&&|;)' || return 0 + # [2026-06-16][fix] COMMAND が複数行(heredoc / 改行入りコミットメッセージ等)のとき、 + # sed が行単位で処理し非マッチ行(2 行目以降のメッセージ本文)を素通しするため dir がゴミ文字列化し、 + # git -C "$dir" が失敗 → 正当な worktree commit/push が誤 deny されていた。cd は先頭行(上の L427 で + # 先頭 + &&/; を保証済)にあるため、1 行目だけから抽出する(複数行は安全に L1 のみを見る)。 + dir=$(printf '%s' "$COMMAND" | sed -nE '1s/^[[:space:]]*cd[[:space:]]+([^[:space:];&|()]+).*/\1/p') + # ~ 展開 / 相対パスは $CWD(JSON の cwd) 基準で正規化(git -C が hook プロセスの cwd で解決するのを防ぐ)。 + case "$dir" in + ""|"."|"./") return 0 ;; + "~") dir="$HOME" ;; + "~/"*) dir="${HOME}/${dir#\~/}" ;; + /*) ;; + *) dir="$CWD/$dir" ;; + esac + printf '%s' "$dir" +} + +effective_target_branch() { + local dir + dir="$(effective_target_dir)" + [ -z "$dir" ] && return 0 + git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true +} + +# [2026-07-09][fix] +# 背景: +# 依頼意図: AGENT-HUB の専用 worktree 上で正当な `git -C commit` が +# block-main-commit に誤ブロックされ、正規の branch + PR フローを閉じられなかった。 +# 守るべき業務ルール: main 直 commit / push は引き続き fail-closed で止める。一方で、実効対象が +# 非 main branch だと確認できる単発 `git -C commit/push` は本番 main に影響しないため許可する。 +# 他案不採用理由: +# 1) `-C` を全面許可する案は、`git -C status && git commit` の後続 commit が main で動く形を +# 誤許可するため不採用。 +# 2) 複合 shell 構文まで静的解析する案は誤許可リスクが高いため不採用。 +# 3) 従来どおり全部 deny する案は、AGENT-HUB の標準 worktree 運用を阻害するため不採用。 +# 対応: shell 制御演算子を含まない単発 git コマンドだけ `-C` の対象 dir を解決し、非 main branch かつ +# unsafe push でない場合だけ早期許可する。env / git-dir / namespace trick は従来どおり fail-closed。 +# [2026-08-02][fix] Wave B / #1258: 引用内の `-C` を git global option と数えない。 +# 背景: +# - ユーザー依頼意図: `git -C commit -m '... -C ...'` のようにメッセージへ `-C` と +# 書いただけで単発 feature commit が deny され、文書・回帰テストが書けない。 +# - 守るべき業務ルール: 引用外の複数 `-C` は従来どおり解決不能。引用済み本文の `-C` はデータ。 +# - 他案不採用理由: メッセージから `-C` 文字を禁止する案は説明文を歪める。複合への -C 対称化はしない。 +# 対応: quote-aware に引用外の `-C ` をちょうど1つだけ抽出し、それを target dir にする。 +single_unquoted_git_c_path() { + COMMAND_TEXT="$1" python3 - <<'PY' 2>/dev/null || true +import os + +text = os.environ.get("COMMAND_TEXT", "") +quote = None +escaped = False +paths = [] +i = 0 +while i < len(text): + ch = text[i] + if escaped: + escaped = False + i += 1 + continue + if ch == "\\" and quote != "'": + escaped = True + i += 1 + continue + if quote == "'": + if ch == "'": + quote = None + i += 1 + continue + if quote == '"': + if ch == '"': + quote = None + i += 1 + continue + if ch in ("'", '"'): + quote = ch + i += 1 + continue + if ch == "-" and i + 1 < len(text) and text[i + 1] == "C": + prev = text[i - 1] if i > 0 else " " + if prev.isspace() or i == 0: + j = i + 2 + while j < len(text) and text[j] in " \t": + j += 1 + if j < len(text) and text[j] not in " \t\n;'\"|&()": + start = j + while j < len(text) and text[j] not in " \t\n;'\"|&()": + j += 1 + paths.append(text[start:j]) + i = j + continue + i += 1 + +if quote is not None or escaped or len(paths) != 1: + raise SystemExit(0) +print(paths[0], end="") +PY +} + +single_git_c_target_dir() { + # 単発 `git -C commit/push` だけを解決する。 + # `git -C status && git commit` のような後続 git へ -C が効かない形は従来どおり解決しない。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)=' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir|--work-tree|--namespace)([=[:space:]])' && return 0 + has_unquoted_shell_control "$COMMAND" && return 0 + # shell controlを除外済みの単発コマンドは、git本体とsubcommandだけを軽量に確認する。 + # ここで巨大な GIT_CMD 正規表現を再利用すると、引用本文を空白化した長い -C pathで + # EREのバックトラックが不安定になり、正当なfeature commit/pushを誤denyするため分離する。 + # [2026-08-02][fix] env / VAR=value prefix 付きの単発 git -C を解決対象に含める(issue #1344)。 + # 背景: + # - ユーザー依頼意図: `env FOO=bar git -C commit` / `FOO=bar git -C commit` + # が本軽量正規表現に一致せず未解決 → fail-closed で正当な feature commit/push まで + # 誤 deny されていた(PR #1343 codex-review が検出・再現ドライバで実測)。 + # - 守るべき業務ルール: GIT_DIR / GIT_WORK_TREE / GIT_NAMESPACE の assignment は本関数 + # 冒頭のガードが先に未解決へ倒す(実効 dir を -C 以外で動かす形は従来どおり保守的)。 + # 値に空白・引用を含む assignment は本パターンに一致せず未解決のまま(安全側)。 + # - 他案不採用理由: GIT_CMD 全体の再利用は上記バックトラック不安定のため不採用(既存判断)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '^[[:space:]]*(env[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*(env[[:space:]]+)?(command[[:space:]]+)?git([[:space:]]+[^[:space:]]+)*[[:space:]]+(commit|push)([[:space:]]|$)' || return 0 + + local dir + dir="$(single_unquoted_git_c_path "$COMMAND")" + case "$dir" in + ""|"."|"./") return 0 ;; + "~") dir="$HOME" ;; + "~/"*) dir="${HOME}/${dir#\~/}" ;; + /*) ;; + *) dir="$CWD/$dir" ;; + esac + printf '%s' "$dir" +} + +# [2026-06-14][feat] / [2026-06-15][fix] 早期許可してはならない push が含まれるか(main 保護の fail-closed 判定)。 +# 引数 $1: 実効ターゲットディレクトリ(effective_target_dir の解決結果)。bare/remote-only push の宛先を +# この dir の push.default + upstream で判定するために使う。空なら bare push は解決不能=unsafe に倒す。 +# 早期許可(worktree feature への exit 0)を通してよいのは: +# 1) 明示的非 main push: git push [安全フラグ]* <非main・非wildcard・非colon の単一ブランチ> +# 2) [2026-06-15][fix] refspec 省略の bare push(git push / git push / git push --force-with-lease)で、 +# 実効 dir のカレントブランチ(=呼び出し側が非 main を保証済み)が push.default 上 main に波及しないもの。 +# `cd && git push --force-with-lease` 形(refspec 省略の常用フロー)を許可するための拡張。 +# それ以外(複数 ref / 値を取るオプション(-o 等) / --all/--mirror / wildcard / main 宛て / +# push.default=matching / upstream が main)は main を押しうるため unsafe=true を返す。 +# トークン単位で解析し、未知オプション(値を取りうる)が残れば unsafe に倒す(保守的)。 +has_unsafe_push() { + local eff_dir="${1:-}" + local segs seg + segs=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE "${GIT_CMD}[[:space:]]+push[^;&|]*" || true) + [ -z "$segs" ] && return 1 # push なし(commit only)→ 安全 + while IFS= read -r seg; do + [ -z "$seg" ] && continue + local args remote="" ref="" extra=0 + args=$(printf '%s' "$seg" | sed -E 's/^.*[[:space:]]push([[:space:]]|$)/ /') + # glob 展開を抑止して push 引数をトークン化(refspec 内の * がファイル展開されないように)。 + set -f + # shellcheck disable=SC2086 + set -- $args + set +f + while [ "$#" -gt 0 ]; do + case "$1" in + # [2026-06-16][fix] リダイレクトトークン(2>&1 / 2> / >file / 1>&2 / &>file 等)を無視する。 + # segment 抽出 [^;&|]* は `2>&1` の `&` で切れ `2>` が残るため、従来はこれを余分な refspec + # と誤認し extra=1 → unsafe → 正当な worktree push が誤 deny されていた。git の refname は + # `<` `>` を含めないため(refname 規則)、これらを含むトークンは refspec ではない=安全に無視できる。 + *'>'*|*'<'*) ;; + # 値を取らない安全フラグのみ消費。 + -u|--set-upstream|-f|--force|--force-with-lease|-q|--quiet|-v|--verbose|-n|--dry-run|--no-verify|--porcelain|--progress|--atomic|--tags|--follow-tags) ;; + -*) return 0 ;; # 未知/値を取るオプション(--all/--mirror/-o 等) → 解析不能 → unsafe + *) + if [ -z "$remote" ]; then remote="$1" + elif [ -z "$ref" ]; then ref="$1" + else extra=1; fi ;; + esac + shift + done + [ "$extra" = 1 ] && return 0 # ref が 2 個以上 → 曖昧 → unsafe + if [ -z "$ref" ]; then + # refspec 省略(git push / git push )→ カレントブランチを push.default に従って push する。 + # 呼び出し側で「実効 dir のカレントブランチ != main」を保証済み。main に波及する設定のみ unsafe。 + [ -z "$eff_dir" ] && return 0 # dir 未解決 → 宛先を検証できない → unsafe(fail-closed) + local pd up + pd=$(git -C "$eff_dir" config --get push.default 2>/dev/null || true) + case "$pd" in + matching) + return 0 ;; # 全 matching ブランチ(main 含む)を push しうる → unsafe + upstream|tracking) + # 設定上の upstream を push。main(またはそれを指す upstream)なら unsafe、解決不能も unsafe。 + up=$(git -C "$eff_dir" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || true) + { [ -z "$up" ] || echo "$up" | grep -qE '(^|/)main$'; } && return 0 ;; + *) + : ;; # simple(既定)/current/nothing/未設定 → カレント(非main)ブランチのみ push → 安全 + esac + continue + fi + echo "$ref" | grep -qE '^[A-Za-z0-9._/-]+$' || return 0 # : や * を含む → unsafe + [ "$ref" = "main" ] && return 0 + echo "$ref" | grep -qE '(^|/)main$' && return 0 # refs/heads/main 等 → unsafe + done <<< "$segs" + return 1 +} + +BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || true) + +# 複合コマンド: checkout/switch main && commit/push を検知 +if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(switch|checkout)([[:space:]]+-[^[:space:]]+)*[[:space:]]+main([[:space:]]|$).*${GIT_CMD}[[:space:]]+(commit|push)([[:space:]]|$)"; then + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+push"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi +fi + +# push コマンドからmain向けrefspecを検知 +PUSH_SEGMENTS=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE "${GIT_CMD}[[:space:]]+push[^;&|]*" || true) +if [ -n "$PUSH_SEGMENTS" ]; then + while IFS= read -r push_segment; do + if echo "$push_segment" | grep -qE '(^|[[:space:]])\+?(refs/heads/)?main([[:space:]]|$)'; then + if [ "$BRANCH" != "main" ]; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + continue + fi + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$push_segment" | grep -qE '(^|[[:space:]])\+?[^[:space:]]*:(refs/heads/)?main([[:space:]]|$)'; then + if [ "$BRANCH" != "main" ]; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + continue + fi + _emit_deny_with_telemetry "$DENY_MSG" + fi + done <<< "$PUSH_SEGMENTS" +fi + +# [2026-07-18][fix] env split-string内のgit writeはGIT_CMDへ展開できないため、先に拒否する。 +if command_uses_env_split_git_write; then + _emit_deny_with_telemetry "$DENY_MSG" +fi + +# git commit / git push を含まない場合は許可 +if ! echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(commit|push)"; then + exit 0 +fi + +# env の chdir は hook JSON の cwd と異なる実効branchへ切り替わる。完全解決せずfail-closed。 +if command_uses_env_chdir; then + _emit_deny_with_telemetry "$DENY_MSG" +fi + +if [ -z "$BRANCH" ]; then + exit 0 +fi + +# mainブランチの場合 — AI hook 経由では軽量変更でも commit / push を許可しない +if [ "$BRANCH" = "main" ]; then + # [2026-06-14][fix] worktree(別ディレクトリ・feature ブランチ)への commit / 非 main push を許可。 + # 背景: + # 依頼意図: ハーネスが Bash cwd を毎回 main 直下に戻すため、worktree 運用は + # `cd && git commit/push` になる。従来は $CWD(main) の枝で fail-closed deny し、 + # worktree(feature) への正当なコミット・PR push まで弾けて worktree 並行開発が成立しなかった。 + # 守るべき業務ルール: 実効ターゲット(先頭の単一 cd 先)のブランチが main 以外で、かつ main への push を + # 含まないなら、本番デプロイ(=main push/merge)に一切影響しないため許可する。 + # 他案不採用理由: + # 1) 何もしない案: worktree 並行開発(ユーザーの主要フロー)が不能のままで不便。 + # 2) commit/push を全面許可する案: main push の fail-open を生むため不可。実効ブランチ判定 + + # has_unsafe_push ガードで main 保護を厳密に保つ(曖昧/全ref/wildcard/main 宛て push は早期許可しない)。 + # 3) 実効 cwd を完全復元する案: 複数 cd・env トリック・サブシェル・-c まで追うのは複雑で誤許可リスク。 + # 先頭の単一 cd のみ解決し(-C は git 1 回しか効かないため不採用=deny)、env トリック/複数 cd/ + # サブシェル/eval/-c シェルは effective_target_branch が空を返す=従来 deny。 + # 注: 本フックは「権限ルールの実体(SSOT)」そのもの。別途の権限ドキュメント同期は不要(ここが正本)。 + if command_targets_other_dir; then + eff_dir="$(effective_target_dir)" + if [ -n "$eff_dir" ]; then + eff_branch="$(git -C "$eff_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ -n "$eff_branch" ] && [ "$eff_branch" != "main" ] && ! has_unsafe_push "$eff_dir"; then + exit 0 # 別 worktree/別リポの feature への commit / 安全な非 main push(refspec 省略含む)→ 許可 + fi + fi + eff_dir="$(single_git_c_target_dir)" + if [ -n "$eff_dir" ]; then + eff_branch="$(git -C "$eff_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ -n "$eff_branch" ] && [ "$eff_branch" != "main" ] && ! has_unsafe_push "$eff_dir"; then + exit 0 # 単発 `git -C commit/push` は -C が対象 git へだけ効くため許可 + fi + fi + fi + + # [2026-05-30][fix] PR #229 codex review NO-GO 追加修正 + # 背景: BRANCH==main かつ $CWD が軽量だけのとき、`git -C /other push`(refspec なし)等で + # 実効 cwd が /other に切り替わるコマンドが CWD の軽量差分で素通りしていた(line 309 残存fail-open)。 + # 対応: command_targets_other_dir なら CWD ベース判定を信頼せず、main 向けは fail-closed。 + # `git -C /other push origin feature` (CWD=main) など希少な workflow を deny する副作用は + # メイン保護のため許容(自然なワークフローは /other へ cd して実行)。 + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(commit|push)"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi +fi + +# main以外は許可 +exit 0 diff --git a/.codex/hooks/scripts/block-main-commit.test.sh b/.codex/hooks/scripts/block-main-commit.test.sh new file mode 100755 index 000000000..f7eb58096 --- /dev/null +++ b/.codex/hooks/scripts/block-main-commit.test.sh @@ -0,0 +1,517 @@ +#!/usr/bin/env bash +set -euo pipefail + +# [2026-04-10][test] +# 背景: +# - 依頼意図: block-main-commit hook の docs-only 例外が再び main 直 push の穴にならないよう、 +# commit / push の軽量変更例外を回帰テストで固定する。 +# - 守るべき業務ルール: main 直コミット/プッシュの例外は Markdown 系ドキュメントと +# sync-state.json など明示 allowlist だけ。コード変更や HEAD:main は拒否する。 +# - 他案不採用理由: 手動確認だけに戻す案は、同じ制御フロー退行を次回レビューまで見逃すため不採用。 +# +# [2026-06-19][test] +# 背景: +# - PR422 / 配布先レビューで、先頭 `cd` を含む複数行コマンドや redirect 付き push の +# 作業ディレクトリ解決が誤 deny される一方、main 明示 push は拒否し続ける必要があると分かった。 +# - 守るべき業務ルール: feature worktree への安全な push は止めず、main 直 push / HEAD:main / +# 解決不能な `git -C` 経由 push は止める。 +# - 他案不採用理由: 実装コメントだけで済ませる案は、sed 抽出の微妙な退行を次の配布まで見逃すため不採用。 + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/block-main-commit.sh" +PASS=0 +FAIL=0 + +json_string() { + python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1" +} + +run_hook() { + local cwd="$1" + local command="$2" + printf '{"tool_name":"Bash","tool_input":{"cwd":%s,"command":%s}}\n' "$(json_string "$cwd")" "$(json_string "$command")" | bash "$SCRIPT" +} + +run_hook_raw() { + local payload="$1" + printf '%s' "$payload" | bash "$SCRIPT" +} + +run_hook_script() { + local cwd="$1" + local command="$2" + local script="$3" + printf '{"tool_name":"Bash","tool_input":{"cwd":%s,"command":%s}}\n' "$(json_string "$cwd")" "$(json_string "$command")" \ + | bash "$script" +} + +expect_allow() { + local name="$1" + local cwd="$2" + local command="$3" + local out + out="$(run_hook "$cwd" "$command" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision'; then + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + else + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + fi +} + +expect_block() { + local name="$1" + local cwd="$2" + local command="$3" + local out + out="$(run_hook "$cwd" "$command" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_raw() { + local name="$1" + local payload="$2" + local out + out="$(run_hook_raw "$payload" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_with_script() { + local name="$1" + local cwd="$2" + local command="$3" + local script="$4" + local out + out="$(run_hook_script "$cwd" "$command" "$script" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +main_repo="$tmp/main" +feature_repo="$tmp/feature" +mkdir -p "$main_repo" "$feature_repo" +git -C "$main_repo" init -q +git -C "$main_repo" checkout -q -b main +git -C "$main_repo" config user.email test@example.com +git -C "$main_repo" config user.name "Test User" +echo init > "$main_repo/README.md" +git -C "$main_repo" add README.md +git -C "$main_repo" commit -q -m init +git -C "$main_repo" update-ref refs/remotes/origin/main HEAD + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only commit は拒否" \ + "$main_repo" \ + "git commit -m docs" +expect_block \ + "main上の先頭空白付きcommit は拒否" \ + "$main_repo" \ + " git commit -m docs" +expect_block \ + "main上のenv経由commit は拒否" \ + "$main_repo" \ + "env git commit -m docs" +expect_block \ + "main上のenv -u経由commit は拒否" \ + "$main_repo" \ + "env -u UNUSED_FLAG git commit -m docs" +expect_block \ + "main上のcommand経由push は拒否" \ + "$main_repo" \ + "command git push origin main" +expect_block \ + "main上の変数代入 + env経由commit は拒否" \ + "$main_repo" \ + "FOO=1 env git commit -m docs" +expect_block \ + "main上のsingle quote空白値 + commit は拒否" \ + "$main_repo" \ + "FOO='a b' git commit -m docs" +expect_block \ + "main上のdouble quote空白値 + env経由commit は拒否" \ + "$main_repo" \ + 'FOO="a b" env git commit -m docs' +expect_block \ + "main上の変数代入 + command経由push は拒否" \ + "$main_repo" \ + "FOO=1 command git push origin main" +git -C "$main_repo" reset -q + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only push は拒否" \ + "$main_repo" \ + "git push origin main" +git -C "$main_repo" reset -q + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only commit && push は拒否" \ + "$main_repo" \ + "git commit -m docs && git push origin main" +git -C "$main_repo" reset -q + +mkdir -p "$main_repo/.cursor/rules" "$main_repo/.codex" +echo rule > "$main_repo/.cursor/rules/project.mdc" +git -C "$main_repo" add .cursor/rules/project.mdc +expect_block \ + "main上の.mdc commit は拒否" \ + "$main_repo" \ + "git commit -m rules" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.cursor" + +echo '{}' > "$main_repo/.codex/sync-state.json" +git -C "$main_repo" add .codex/sync-state.json +expect_block \ + "main上のsync-state.json commit は拒否" \ + "$main_repo" \ + "git commit -m sync" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.codex" + +mkdir -p "$main_repo/.claude/hooks" +echo v > "$main_repo/.claude/hooks/.hook-library-version" +git -C "$main_repo" add .claude/hooks/.hook-library-version +expect_block \ + "main上のhook library version commit は拒否" \ + "$main_repo" \ + "git commit -m hook-version" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.claude" + +mkdir -p "$main_repo/src" +echo "export const value = 1;" > "$main_repo/src/app.ts" +git -C "$main_repo" add src/app.ts +expect_block \ + "main上のコード変更 commit は拒否" \ + "$main_repo" \ + "git commit -m code" +git -C "$main_repo" reset -q +rm -rf "$main_repo/src" + +git -C "$feature_repo" init -q +git -C "$feature_repo" checkout -q -b feature/test +git -C "$feature_repo" config user.email test@example.com +git -C "$feature_repo" config user.name "Test User" +echo init > "$feature_repo/README.md" +git -C "$feature_repo" add README.md +git -C "$feature_repo" commit -q -m init +git -C "$feature_repo" update-ref refs/remotes/origin/main HEAD +git -C "$feature_repo" branch --set-upstream-to=origin/main feature/test >/dev/null 2>&1 || true + +expect_block \ + "feature cwdからenv -C main commitは拒否" \ + "$feature_repo" \ + "env -C $main_repo git commit -m unsafe" + +expect_block \ + "feature cwdからenv --chdir main pushは拒否" \ + "$feature_repo" \ + "env --chdir=$main_repo git push" + +expect_block \ + "feature cwdからenv -S内のmain commitは拒否" \ + "$feature_repo" \ + "env -S 'git -C $main_repo commit -m unsafe'" + +expect_block \ + "feature cwdからenv --split-string内のmain pushは拒否" \ + "$feature_repo" \ + "env --split-string='git -C $main_repo push' ignored" + +# [2026-07-12][test] +# 背景: Codex が main checkout を cwd にしたまま専用 worktree へ単発 `git -C` commit する際、 +# conventional commit の scope 括弧や本文のセミコロンを shell 制御演算子と誤認して deny していた。 +# main 保護は維持しつつ、引用済みコミットメッセージ内の文字は引数データとして扱う必要がある。 +# 他案不採用理由: conventional commit の括弧だけを例外化するテストでは、引用済みのセミコロンや +# リダイレクト文字で同じ誤検知が再発するため、引用境界そのものを正負両方向で固定する。 +expect_allow \ + "-C feature commit の引用済み scope 括弧を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" + +expect_allow \ + "-C feature commit の引用済みセミコロンを許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix: first; second'" + +expect_allow \ + "-C feature commit の引用済み > を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: use > output'" + +# [2026-08-02][test] env / VAR= prefix 付き単発 -C feature commit の許可回帰(issue #1344)。 +# 背景: +# - ユーザー依頼意図: 旧 command_uses_env_chdir の貪欲マッチが git 側の -C を env の +# chdir と誤認し、正当な feature commit を誤 deny していた回帰を固定する。 +# - 守るべき業務ルール: env 自身の -C/--chdir・GIT_DIR 系 assignment の保守的 deny は +# 維持する(許可回帰と deny 回帰を対で置く)。 +# - 他案不採用理由: 許可側だけのテストでは、将来 env 判定を戻した時に chdir バイパスの +# deny が消えても検知できない。 +expect_allow \ + "env prefix の -C feature commit を許可" \ + "$main_repo" \ + "env FOO=bar git -C $feature_repo commit -m docs" +expect_allow \ + "VAR= prefix の -C feature commit を許可" \ + "$main_repo" \ + "FOO=bar git -C $feature_repo commit -m docs" +expect_block \ + "env 自身の -C (chdir) は従来どおり拒否" \ + "$feature_repo" \ + "env -C $main_repo git commit -m docs" +expect_block \ + "env GIT_DIR assignment は従来どおり保守的拒否" \ + "$main_repo" \ + "env GIT_DIR=$main_repo/.git git -C $feature_repo commit -m docs" +# PR #1354 codex-review Critical: 引数付き env オプション越しの chdir バイパスを deny 固定 +expect_block \ + "env -u 引数付きの env -C (chdir) main も拒否" \ + "$feature_repo" \ + "env -u FOO -C $main_repo git commit -m docs" +expect_block \ + "env --unset 引数付きの --chdir main も拒否" \ + "$feature_repo" \ + "env --unset FOO --chdir $main_repo git push origin main" +# 注: `env -i git -C commit` は single_git_c_target_dir が env オプションを +# 解決対象にしないため従来どおり保守的 deny(バイパスではなく安全側・許可回帰は置かない)。 + +expect_allow \ + "-C feature commit の引用済み < を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: use < input'" + +expect_allow \ + "-C feature commit のdouble quote済みメッセージを許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix(auth): allow feature worktree\"" + +# [2026-08-02][test] #1313 / #1256 +# 背景: 引用済みの commit message / PR本文に現れる `git push` や `git reset` を +# 実行コマンドと誤認すると、feature worktreeのcommitやガード修正PRを作れない。 +# 守るべき業務ルール: 本文系オプションの引数はデータとして扱い、引用外の実コマンドは拒否する。 +# 他案不採用理由: message 側の文字列を正規表現の例外へ追加する案は、例外列挙が際限なく増え +# 引用境界の正確な認識という根本対処を先送りするため不採用(PR #1343 codex-review 指摘の補完)。 +expect_allow \ + "-C feature commit message内のmain push文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs; git push origin main'" + +expect_allow \ + "-C feature commit message内のreset文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit --message='docs: git reset --hard は本文'" + +expect_allow \ + "single quote内のliteral command substitution文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: literal \$(git push origin main)'" + +expect_allow \ + "PR本文内のmain push文字列を許可" \ + "$main_repo" \ + "gh pr create --body 'release note; git push origin main'" + +expect_allow \ + "Issue本文のreset文字列を許可" \ + "$main_repo" \ + "gh issue comment 1 --body='docs: git reset --hard は実行しない'" + +expect_block \ + "本文の外にあるmain pushは引き続き拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs' ; git push origin main" + +expect_block \ + "-C feature commit 後の非引用セミコロン複合コマンドは拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m fix; git commit -m unsafe" + +expect_block \ + "-C feature commit の command substitution は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix: \$(git status)\"" + +expect_block \ + "-C feature commit の backtick command substitution は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix: \`git status\`\"" + +scanner_fixture="$tmp/scanner-fixture" +mkdir -p "$scanner_fixture/scripts" "$scanner_fixture/lib" +cp "$SCRIPT" "$scanner_fixture/scripts/block-main-commit.sh" +cp "$(dirname "$SCRIPT")/../lib/hook-io.sh" "$scanner_fixture/lib/hook-io.sh" +sed -i.bak 's/COMMAND_TEXT="$1" python3/COMMAND_TEXT="$1" missing-python3/' "$scanner_fixture/scripts/block-main-commit.sh" +rm -f "$scanner_fixture/scripts/block-main-commit.sh.bak" +expect_block_with_script \ + "quote scanner の起動不能は fail-closed" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" \ + "$scanner_fixture/scripts/block-main-commit.sh" + +crash_scanner="$scanner_fixture/scanner-exit-2" +printf '#!/usr/bin/env bash\nexit 2\n' > "$crash_scanner" +chmod +x "$crash_scanner" +sed -i.bak "s|COMMAND_TEXT=\"\$1\" missing-python3|COMMAND_TEXT=\"\$1\" $crash_scanner|" "$scanner_fixture/scripts/block-main-commit.sh" +rm -f "$scanner_fixture/scripts/block-main-commit.sh.bak" +expect_block_with_script \ + "quote scanner の異常終了(rc=2)は fail-closed" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" \ + "$scanner_fixture/scripts/block-main-commit.sh" + +expect_block \ + "-C feature commit の閉じていない single quote は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'broken" + +expect_block \ + "-C feature commit の閉じていない double quote は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"broken" + +expect_allow \ + "先頭 cd + multiline の feature push を許可" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease +commit body with spaces" + +expect_allow \ + "先頭 cd + redirect 付き feature push を許可" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease 2>&1" + +expect_block \ + "先頭 cd でも main 明示 push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push origin main 2>&1" + +expect_block \ + "HEAD:main は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push origin HEAD:main" + +# [2026-07-18][test] +# 全CLI配布物へ同じ回帰テストを展開する際、Claude/Cursor/Geminiのhook-ioはKimi固有payloadを +# 入力契約に持たない。別CLIのI/O契約まで要求せず、Kimi/Codex/正本でだけKimi payloadを検証する。 +case "$SCRIPT" in + */.claude/*|*/.cursor/*|*/.gemini/*) + printf '[SKIP] Kimi Shell toolInput の HEAD:main は対象外ランタイム\n' + ;; + *) + expect_block_raw \ + "Kimi Shell toolInput の HEAD:main は拒否" \ + "{\"toolName\":\"Shell\",\"toolInput\":{\"cwd\":$(json_string "$main_repo"),\"command\":$(json_string "cd $feature_repo && git push origin HEAD:main")}}" + ;; +esac + +git -C "$feature_repo" config push.default matching +expect_block \ + "push.default=matching の bare push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push" + +git -C "$feature_repo" config push.default upstream +expect_block \ + "upstream が main の bare push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease" + +git -C "$feature_repo" config push.default current +expect_block \ + "複数 cd は解決不能として拒否" \ + "$main_repo" \ + "cd $feature_repo && cd .. && git push" + +# [2026-07-11][test] jtt-apps 本番タグ push 事例(v2.4.37) +# 背景: single_git_c_target_dir()(PR #820)は単発 `git -C commit/push` のうち +# 実効ブランチが非main・かつ安全な push だけを許可する設計に変わっているが、本テストが +# 旧仕様(-C は常に解決不能=拒否)のまま残っていて、この設計変更を検出できずにいた。 +# 合わせて、コマンドに `2>&1` 等のリダイレクトが含まれるだけで誤って解決不能扱いになる +# 問題(DEPLOY_CHECKLIST.md のタグ push 手順が worktree 経由でも実行できなくなる不具合) +# も本ファイル修正で解消したため、そのケースも固定する。 +expect_allow \ + "-C 経由でも非mainブランチへの安全な push は許可" \ + "$main_repo" \ + "git -C $feature_repo push origin feature/test" + +expect_allow \ + "-C 経由 + redirect(2>&1) 付きの安全な push も許可" \ + "$main_repo" \ + "git -C $feature_repo push origin feature/test 2>&1" + +expect_block \ + "-C 経由でも main 宛て push は拒否" \ + "$main_repo" \ + "git -C $feature_repo push origin main" + +expect_block \ + "複数-Cは解決不能として拒否" \ + "$main_repo" \ + "git -C $tmp -C feature commit -m unsafe" + +# [2026-08-02][test] Wave B / #1258 / #1090 H1 +# 背景: +# - ユーザー依頼意図: main cwd から別リポ feature worktree へ commit/push する経路が +# 「無い」ように見える摩擦を、正本の実挙動(既に allow)で固定したい。 +# - 守るべき業務ルール: 先頭単一 `cd && git commit/push` と単発 +# `git -C commit/push` は non-main なら許可。複合への -C 対称化はしない。 +# - 他案不採用理由: helper 再発明や -C の複合対称化は後続 main 書き込みの誤許可を招く。 +# 注: 本 fixture の main_repo と feature_repo は別 git init(クロスリポ相当)。 +expect_allow \ + "クロスリポ相当: 先頭 cd + feature commit を許可" \ + "$main_repo" \ + "cd $feature_repo && git commit --allow-empty -m 'chore: cross-repo feature commit'" + +expect_allow \ + "クロスリポ相当: 単発 -C feature commit を許可" \ + "$main_repo" \ + "git -C $feature_repo commit --allow-empty -m 'chore: cross-repo -C commit'" + +expect_block \ + "-C feature の後続 commit へ対称化しない(複合は拒否)" \ + "$main_repo" \ + "git -C $feature_repo status && git commit --allow-empty -m unsafe" + +expect_block \ + "先頭 cd でも対象が main なら commit 拒否" \ + "$main_repo" \ + "cd $main_repo && git commit --allow-empty -m 'docs: still main'" + +expect_allow \ + "読み取り検索内の git push 文字列は許可" \ + "$main_repo" \ + 'rg -n "git push|post-merge-gate|workflow" hook-library scripts' + +TOTAL=$((PASS + FAIL)) +printf '\n=== block-main-commit.test.sh: %d/%d PASS ===\n' "$PASS" "$TOTAL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.codex/hooks/scripts/block-unauthorized-docs-file.sh b/.codex/hooks/scripts/block-unauthorized-docs-file.sh new file mode 100755 index 000000000..7e270a0ea --- /dev/null +++ b/.codex/hooks/scripts/block-unauthorized-docs-file.sh @@ -0,0 +1,551 @@ +#!/bin/bash +# @description Blocks unauthorized new docs/ SSOT files from file-edit and shell commands. +# @module hook-library/block-unauthorized-docs-file +# @status stable + +# [2026-05-26][feat] +# 背景: +# - ユーザー依頼意図: dev-guardrails 適用 PJ で、AI が docs/prd/ 等の SSOT ディレクトリに +# 推測でファイル名を決めて勝手に新規ファイル(next-action.md 等)を作る事故を止めたい。 +# AI は一度作ったファイルを自分から消さないため、無断生成物が溜まり続ける。ルール文だけでは +# AI が破る(指示の遵守は確率的)ため、機械的にブロックする hook を併設する。 +# - 守るべき業務ルール: docs-structure-rules.md(dev-guardrails)。prd/ は固定3ファイル+archives、 +# その他 SSOT ディレクトリ(architecture/business/api/database/operation/benchmark/testing)と +# docs/ 直下は baseline 許可ファイルのみ。新規 SSOT は伸太郎殿の承認(図解で必要性を説明)後に +# docs/.ssot-allowlist へ登録してから作る。 +# - 他案不採用理由: +# 1) docs/ 配下を全面ブロックする案: design/ release-notes/ 等の作業用ディレクトリへの +# 正当な新規作成(「デザイン案を作って」等)まで止めるため不採用。構造化 SSOT +# ディレクトリと docs/直下に限定する。 +# 2) prompt 型 hook で LLM 判定する案: 非決定的でチャットにプロンプトが漏れる。確定的な +# command hook に統一する(hooks-structure-rule.md)。 +# 3) 既存ファイルもブロックする案: 更新(Edit/上書き)は自由であるべき。ディスク上に存在する +# ファイルは grandfather して素通りさせ、純粋な新規作成のみをブロックする。 +# 対応: PreToolUse(Write|Edit|MultiEdit|Bash) で docs/ 配下の新規ファイルを検査。構造化 SSOT ディレクトリ + +# docs/直下 + 未承認の新規 docs/ サブディレクトリを deny し、図解で承認を取るよう AI に指示する。 +# docs/.ssot-allowlist 自体は AI の抜け道になるため手動更新扱いにし、既存ファイルは素通り。 +# +# [2026-05-27][fix] +# 背景: +# - ユーザー依頼意図: docs/plan/ は「廃止」する。プランの本流は ~/.claude/plans/(Claude)や +# ~/.cursor/plans/ などグローバルへ移っており、各PJの docs/plan/ は古いファイルの堆積(jtt-cms 100件が +# 5/7 から放置等)になっていた。今後 docs/plan/ には新規ファイルを作らせたくない。 +# - 守るべき業務ルール: docs/plan/ は WORK_DIRS(素通り)から外し、完全禁止にする。思考用プランは +# docs/ の外(~/.claude/plans/)に出るため本 hook は発火しない。docs/plan/ への新規作成だけをブロックする。 +# - 他案不採用理由: docs/plan/ を allowlist で個別解禁する案は、廃止方針と矛盾し再堆積を招くため不採用。 +# design/ release-notes/ は現状の用途が明確でないため WORK_DIRS に残し、plan/ のみ完全禁止にする。 +# 対応: WORK_DIRS から plan を除外(design release-notes のみ)。docs/plan/ 新規には「~/.claude/plans/ へ」 +# という専用メッセージで deny する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# 構造化 SSOT ディレクトリ(固定ファイルセットを持つ=新規ファイルを承認制にする) +# design/ release-notes/ archives/ など作業用ディレクトリは含めない(素通りさせる)。 +# plan/ は廃止(プランは ~/.claude/plans/ 等のグローバルへ)。WORK_DIRS から外し完全禁止扱いにする。 +GATED_DIRS="prd architecture business api database operation benchmark testing" +WORK_DIRS="design release-notes" + +# baseline 許可(docs-structure-rules.md と一致。構造定義で既に承認済みの正本ファイル)。 +is_baseline_allowed() { + local rel="$1" # docs/ より後ろの相対パス。例: prd/prd-active.md + case "$rel" in + # docs/ 直下 SSOT + FEATURE_FLAGS.md | PERMISSIONS.md) return 0 ;; + # prd/(固定3ファイル + archives スナップショット) + prd/prd-active.md | prd/prd-upcoming.md | prd/prd-future.md) return 0 ;; + prd/archives/*) return 0 ;; + # architecture/(設計意図 + 条件付き SSOT) + architecture/database-design.md | architecture/api-design.md) return 0 ;; + architecture/infrastructure-design.md) return 0 ;; + architecture/WEBSOCKET_CHANNELS.md) return 0 ;; + # business/ + business/BUSINESS_RULES.md | business/business-design.md | business/ROLE_DEFINITIONS.md) return 0 ;; + # api/ + api/API_SSOT.md) return 0 ;; + # database/ + database/DB_SCHEMA.md | database/DB_SCHEMA_UPDATE_GUIDE.md | database/SCHEMA_RELATIONS.md) return 0 ;; + # operation/ + operation/PROD_OPERATION.md | operation/STAGING_OPERATION.md | operation/LOCAL_OPERATION.md) return 0 ;; + operation/NOTIFICATION.md | operation/DEPLOY_LOG.md) return 0 ;; + operation/DEPLOY_CHECKLIST.md | operation/ENV_VARIABLES.md) return 0 ;; + esac + return 1 +} + +# [2026-06-26][feat] +# 背景: +# - ユーザー依頼意図: dev-guardrails の per-app SSOT 命名統一(-.md・4カテゴリ化) +# に追随し、docs SSOT 承認制 hook の許可パターンを更新する。直前の統一で per-app docs +# は -business-rules.md / -operations.md / --design.md へ +# 命名変更されたが、hook は旧名固定のため新命名ファイルが誤ってブロックされていた。 +# - 守るべき業務ルール: per-app docs は apps//docs/ 配下に限定し、ファイル名プレフィックス +# が app 名と一致することを backreference(\1) で機械的に保証する。旧命名ファイルは移行期中の +# 安全のため grandfather として残す。root の docs/architecture/ は per-app から分離し、 +# root 専用扱いを維持する。 +# - 他案不採用理由: +# 1) root docs 判定ロジックに per-app 判定を混ぜる案: root 専用 architecture/ 等との +# 優先順位・エラーメッセージが複雑化し、root ロジックを変更したくない本件の制約に反するため不採用。 +# 2) 旧命名 BUSINESS_RULES.md / OPERATIONS_SSOT.md を即座に削除する案: 移行期中に旧ファイル +# が存在し得るため、誤って既存ファイルの更新をブロックする恐れがあり不採用。 +# 3) ワイルドカードで apps//docs/* を広く許可する案: app 名不一致の推測ファイルや +# 任意名 SSOT を通してしまい、承認制の意味が薄れるため不採用。 +# 対応: apps//docs/ 配下を新たに検査対象に加え、is_per_app_baseline_allowed() で +# regex backreference 付きの許可パターンを判定する。新命名 + 旧命名 + prd パターンを許可し、 +# それ以外は未承認 SSOT としてブロックする。 +is_per_app_baseline_allowed() { + local rel="$1" + python3 - "$rel" <<'PY' +import re +import sys +rel = sys.argv[1] +patterns = [ + # 新 naming(dev-guardrails per-app SSOT 命名統一: -.md) + r'^apps/([-_a-zA-Z0-9]+)/docs/business/\1-business-rules\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/operation/\1-operations\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/architecture/\1-[-_a-zA-Z0-9]+-design\.md$', + # 既存 prd pattern + r'^apps/([-_a-zA-Z0-9]+)/docs/prd/\1-prd-(active|upcoming|future)\.md$', + # 旧 business/operation(移行期 grandfather) + r'^apps/([-_a-zA-Z0-9]+)/docs/business/BUSINESS_RULES\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/operation/OPERATIONS_SSOT\.md$', +] +for pat in patterns: + if re.match(pat, rel): + sys.exit(0) +sys.exit(1) +PY +} + +# [2026-06-19][fix] +# 背景: +# - jtt-apps レビューで、`auth-design.md` / `PASSWORD_GATES.md` / +# `PERFORMANCE_BASELINE.md` が存在しない PJ でも baseline 扱いとなり、 +# AI が無承認で新規 SSOT を作れる抜け道になると判明した。 +# - 守るべき業務ルール: 既存ファイルの更新は grandfather で許可するが、 +# PJ に存在しない条件付き SSOT の新規作成は docs/.ssot-allowlist 承認後に限る。 +# - 他案不採用理由: 全PJ共通 baseline に残す案は、存在しない SSOT を正本として +# 既成事実化できるため不採用。 + +# docs/.ssot-allowlist の glob パターンに一致するか(伸太郎殿が承認して追記したエントリ)。 +matches_allowlist_file() { + local rel="$1" + local allowlist="$2" + [ -f "$allowlist" ] || return 1 + local line trimmed + while IFS= read -r line || [ -n "$line" ]; do + trimmed="${line%%#*}" # 行コメント除去 + trimmed="$(printf '%s' "$trimmed" | tr -d '[:space:]')" # 空白除去 + [ -z "$trimmed" ] && continue + # case パターンとして glob 展開させるため $trimmed は unquoted + case "$rel" in + $trimmed) return 0 ;; + esac + done <"$allowlist" + return 1 +} + +# 安全な deny 出力(理由に改行・引用符を含められるよう python で JSON エスケープ)。 +emit_deny_safe() { + python3 - "$1" <<'PY' +import json +import sys +reason = sys.argv[1] +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } +})) +PY + exit 0 +} + +read_stdin + +# [2026-07-16][fix] +# 背景: +# - 依頼意図: Codex の apply_patch でも docs SSOT 承認制と docs/.ssot-allowlist 自己承認禁止を効かせる。 +# - 守るべき業務ルール: Codex の公式 hook 契約では Edit|Write matcher が apply_patch にも一致する。 +# matcher だけ配線して script 側で apply_patch を素通りさせてはならない。 +# - 他案不採用理由: changed_files を完了後だけ検査する案は、自己承認済み成果を worker に作らせた後で +# 止めるため不採用。PreToolUse で patch 対象を決定的に検査する。 +# 対応: apply_patch の patch/input から Add/Update/Delete/Move 対象を抽出し、既存の path gate へ渡す。 +# tool_name はトップレベルと bridge 環境変数から取得。matcher 設定がずれても fail-open を避けるため空は通す。 +TOOL_NAME=$(printf '%s' "$HOOK_INPUT" | python3 -c "import json,os,sys; d=json.load(sys.stdin); print(d.get('tool_name') or d.get('toolName') or d.get('name') or os.environ.get('CLAUDE_TOOL_NAME',''))" 2>/dev/null || true) +if [ -n "$TOOL_NAME" ] && [ "$TOOL_NAME" != "apply_patch" ] && [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "MultiEdit" ] && [ "$TOOL_NAME" != "WriteFile" ] && [ "$TOOL_NAME" != "StrReplaceFile" ] && [ "$TOOL_NAME" != "Bash" ] && [ "$TOOL_NAME" != "Shell" ]; then + exit 0 +fi + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" + +# docs/ 配下なら絶対パス + docs/ からの相対パスを返す。配下でなければ空。 +normalize_docs_path() { + FP="$1" ROOT="$PROJECT_DIR" python3 - <<'PY' 2>/dev/null || true +import os +# [2026-06-01][fix] codex PR#65 指摘②: abspath は symlink を解決しないため、 +# docs/ 自体や中間ディレクトリが symlink の場合に承認制を回避できた。realpath で +# symlink と相対(..)を実体パスに正規化してから docs/ 配下判定を行う。比較対象の +# docs も realpath で揃え、新規ファイル(末端未存在)は既存接頭辞だけ解決される。 +fp = os.environ.get("FP", "") +root = os.path.realpath(os.environ.get("ROOT", ".")) +if not fp: + print("") +else: + target = fp if os.path.isabs(fp) else os.path.join(root, fp) + ap = os.path.realpath(target) + docs = os.path.realpath(os.path.join(root, "docs")) + if ap == docs or ap.startswith(docs + os.sep): + print(ap + "\t" + os.path.relpath(ap, docs)) + else: + print("") +PY +} + +is_gated_rel() { + local rel="$1" + local top="$2" + if [ "$rel" = ".ssot-allowlist" ]; then + return 0 + fi + if [ -z "$top" ]; then + return 0 + fi + local d + for d in $GATED_DIRS; do + [ "$top" = "$d" ] && return 0 + done + for d in $WORK_DIRS; do + [ "$top" = "$d" ] && return 1 + done + # 未登録 docs// は docs-structure-rules の「追加ディレクトリ禁止」に合わせて承認制。 + return 0 +} + +check_docs_path() { + local file_path="$1" + local normalized target_path rel top deny_msg + normalized="$(normalize_docs_path "$file_path")" + [ -z "$normalized" ] && return 0 + target_path="${normalized%% *}" + rel="${normalized#* }" + + if [ "$rel" = ".ssot-allowlist" ]; then + deny_msg="[hook:block-unauthorized-docs] docs/.ssot-allowlist の AI 編集をブロックしました。 + +docs/.ssot-allowlist は未承認 SSOT 作成を許可する台帳なので、AI が自分で追記すると承認制の抜け道になります。 +伸太郎殿に図解で必要性を説明し、承認後は伸太郎殿の手動更新として扱ってください。" + emit_deny_safe "$deny_msg" + fi + + # 既存ファイルの更新・上書きは自由(新規作成のみ承認制) + [ -e "$target_path" ] && return 0 + + # 第1階層ディレクトリを判定(docs/直下ファイルは TOP="" 扱い) + case "$rel" in + */*) top="${rel%%/*}" ;; + *) top="" ;; + esac + + is_gated_rel "$rel" "$top" || return 0 + + # docs/plan/ は廃止。プランはグローバル(~/.claude/plans/ 等)へ作る。専用メッセージで deny。 + if [ "$top" = "plan" ]; then + deny_msg="[hook:block-unauthorized-docs] docs/plan/ への新規ファイル作成をブロックしました: docs/${rel} + +docs/plan/ は廃止されました。プランファイルは docs/ ではなくグローバルに作成してください: + - Claude Code のプラン → ~/.claude/plans/(プランモードが自動で書き出す) + - 各PJの docs/plan/ には新規プランを置かない(古いファイルの堆積を防ぐため) + +docs/.ssot-allowlist に plan/... を追加しても docs/plan/ の新規作成は許可されません。" + emit_deny_safe "$deny_msg" + fi + + # baseline / allowlist のいずれかに該当すれば許可 + is_baseline_allowed "$rel" && return 0 + matches_allowlist_file "$rel" "$PROJECT_DIR/docs/.ssot-allowlist" && return 0 + + # 未承認の新規 SSOT → ブロック + deny_msg="[hook:block-unauthorized-docs] docs/ 配下への未承認の新規 SSOT ファイル作成をブロックしました: docs/${rel} + +docs/ 配下の SSOT は承認制です(推測でファイル名を決めて勝手に作らない)。次の手順を踏んでください: + 1. 図解(ASCII)で「なぜこのファイルが必要か」「なぜ既存の構成(prd-active.md 等)では不足か」を伸太郎殿に説明する + 2. 伸太郎殿の承認を得る + 3. 承認後、docs/.ssot-allowlist を伸太郎殿の手動更新として1行追記してから再作成する + +ブロックされないもの: 既存ファイルの更新・編集 / baseline 固定 SSOT(prd-active.md 等)/ design/ release-notes/ 等の作業用ディレクトリ。 +詳細: .claude/skills/dev-guardrails/references/docs-structure-rules.md §7" + + emit_deny_safe "$deny_msg" +} + +# apps//docs/ 配下の正規化。root docs/ とは別の階層なので独立した検査を行う。 +# apps//docs/ 配下でなければ空を返す。 +normalize_per_app_docs_path() { + FP="$1" ROOT="$PROJECT_DIR" python3 - <<'PY' 2>/dev/null || true +import os +fp = os.environ.get("FP", "") +root = os.path.realpath(os.environ.get("ROOT", ".")) +if not fp: + print("") +else: + target = fp if os.path.isabs(fp) else os.path.join(root, fp) + ap = os.path.realpath(target) + apps_dir = os.path.realpath(os.path.join(root, "apps")) + if ap == apps_dir or not ap.startswith(apps_dir + os.sep): + print("") + else: + rel = os.path.relpath(ap, root) + parts = rel.split(os.sep) + # apps//docs/... のみ対象 + if len(parts) >= 4 and parts[2] == "docs": + print(ap + "\t" + rel) + else: + print("") +PY +} + +# apps//docs/ 配下の新規 SSOT 検査。root docs/ ロジックとは独立して動作する。 +check_per_app_docs_path() { + local file_path="$1" + local normalized target_path rel deny_msg + normalized="$(normalize_per_app_docs_path "$file_path")" + [ -z "$normalized" ] && return 0 + target_path="${normalized%% *}" + rel="${normalized#* }" + + # 既存ファイルの更新・上書きは自由(新規作成のみ承認制) + [ -e "$target_path" ] && return 0 + + # baseline のいずれかに該当すれば許可 + is_per_app_baseline_allowed "$rel" && return 0 + + # 未承認の新規 SSOT → ブロック + deny_msg="[hook:block-unauthorized-docs] apps//docs/ 配下への未承認の新規 SSOT ファイル作成をブロックしました: ${rel} + +apps//docs/ 配下の SSOT は承認制です(推測でファイル名を決めて勝手に作らない)。次の手順を踏んでください: + 1. 図解(ASCII)で「なぜこのファイルが必要か」「なぜ既存の構成(-prd-active.md 等)では不足か」を伸太郎殿に説明する + 2. 伸太郎殿の承認を得る + 3. 承認後、docs/.ssot-allowlist を伸太郎殿の手動更新として1行追記してから再作成する + +ブロックされないもの: 既存ファイルの更新・編集 / baseline 固定 SSOT(-business-rules.md 等)。 +詳細: .claude/skills/dev-guardrails/references/docs-structure-rules.md §11" + + emit_deny_safe "$deny_msg" +} + +if [ "$TOOL_NAME" = "apply_patch" ]; then + PATCH_TEXT=$(extract_field patch) + [ -n "$PATCH_TEXT" ] || PATCH_TEXT=$(extract_field input) + [ -n "$PATCH_TEXT" ] || emit_deny_safe "[hook:block-unauthorized-docs] apply_patch の対象パスを検査できないため、安全側でブロックしました。" + PATCH_PATHS=$(printf '%s\n' "$PATCH_TEXT" | python3 -c ' +import re, sys +paths = [] +for line in sys.stdin.read().splitlines(): + match = re.match(r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", line) + if not match: + match = re.match(r"^\*\*\* Move to: (.+)$", line) + if match: + paths.append(match.group(1).strip()) +for path in dict.fromkeys(paths): + print(path) +') + [ -n "$PATCH_PATHS" ] || emit_deny_safe "[hook:block-unauthorized-docs] apply_patch の対象パスを解釈できないため、安全側でブロックしました。" + while IFS= read -r candidate; do + [ -z "$candidate" ] && continue + check_docs_path "$candidate" + check_per_app_docs_path "$candidate" + done <<< "$PATCH_PATHS" + exit 0 +fi + +if [ "$TOOL_NAME" = "Bash" ] || [ "$TOOL_NAME" = "Shell" ]; then + COMMAND=$(extract_field command) + CWD=$(extract_field cwd) + [ -z "$CWD" ] && CWD="$PROJECT_DIR" + [ -z "$COMMAND" ] && exit 0 + printf '%s' "$COMMAND" | grep -Eq '(^|[[:space:];|&])(:>|[0-9]*>{1,2}|&>{1,2}|touch|cat[[:space:]].*([0-9]*>{1,2}|&>{1,2})|cp|mv|install|mkdir|tee|sed[[:space:]].*-i|perl[[:space:]].*-pi)' || exit 0 + CANDIDATES=$( + COMMAND_TEXT="$COMMAND" CWD_TEXT="$CWD" PROJECT_DIR="$PROJECT_DIR" python3 - <<'PY' +import os +import re +import shlex + +cmd = os.environ.get("COMMAND_TEXT", "") +root = os.path.abspath(os.environ.get("PROJECT_DIR", ".")) +current_cwd = os.path.abspath(os.environ.get("CWD_TEXT") or root) +metachars = {";", "|", "&", "<", ">", ">>", "&>", "&>>", "&&", "||"} +paths = [] + + +def resolve_path(token, cwd): + if not token or token in metachars or token.startswith("-") or token.startswith("$"): + return "" + if os.path.isabs(token): + return os.path.normpath(token) + return os.path.normpath(os.path.join(cwd, token)) + + +def add_path(token, cwd=None): + path = resolve_path(token, cwd or current_cwd) + if path: + paths.append(path) + + +def add_copy_like_paths(segment): + if not segment: + return + destination = resolve_path(segment[-1], current_cwd) + if not destination: + return + if os.path.isdir(destination) and len(segment) > 1: + # [2026-06-19][fix] + # 背景: + # - `cp foo.md docs/prd/` のように宛先が既存ディレクトリの場合、 + # `docs/prd/` 自体は既存なので grandfather 判定で許可されていた。 + # - 守るべき業務ルール: 実際に作られる `docs/prd/foo.md` を検査し、 + # 未承認 SSOT の新規作成は同じく止める。 + # - 他案不採用理由: docs ディレクトリ宛てを全面 deny すると、 + # allowlist 済みファイルのコピーまで止まり運用が粗くなるため不採用。 + for source in segment[:-1]: + name = os.path.basename(source.rstrip("/")) + if name and name not in {".", ".."}: + paths.append(os.path.join(destination, name)) + return + paths.append(destination) + + +def copy_like_operands(command, raw_tokens): + option_args = { + "cp": {"-S", "-t", "--suffix", "--target-directory"}, + "mv": {"-S", "-t", "--suffix", "--target-directory"}, + "install": {"-g", "-m", "-o", "-S", "-t", "--group", "--mode", "--owner", "--suffix", "--target-directory"}, + } + target_directory = None + operands = [] + index = 0 + while index < len(raw_tokens): + token = raw_tokens[index] + if token == "--": + operands.extend(raw_tokens[index + 1 :]) + break + if token.startswith("--target-directory="): + target_directory = token.split("=", 1)[1] + index += 1 + continue + if token.startswith("--") and token != "--": + option = token.split("=", 1)[0] + if "=" not in token and option in option_args.get(command, set()): + if option == "--target-directory" and index + 1 < len(raw_tokens): + target_directory = raw_tokens[index + 1] + index += 2 + continue + index += 1 + continue + if token.startswith("-") and token != "-": + short = token[:2] + if token == short and short in option_args.get(command, set()): + if short == "-t" and index + 1 < len(raw_tokens): + target_directory = raw_tokens[index + 1] + index += 2 + continue + if token.startswith("-t") and len(token) > 2: + target_directory = token[2:] + index += 1 + continue + index += 1 + continue + operands.append(token) + index += 1 + if target_directory: + operands.append(target_directory) + return operands + + +try: + lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + tokens = list(lexer) +except Exception: + tokens = [] + +i = 0 +while i < len(tokens): + tok = tokens[i] + if tok == "cd" and i + 1 < len(tokens): + target = tokens[i + 1] + if target not in metachars and not target.startswith("$"): + next_cwd = resolve_path(target, current_cwd) + if next_cwd: + current_cwd = next_cwd + i += 2 + continue + if (tok in {">", ">>", "&>", "&>>"} or re.match(r"^(?:(?:\d*)>{1,2}|&>{1,2})$", tok)) and i + 1 < len(tokens): + add_path(tokens[i + 1]) + i += 2 + continue + if tok in {"touch", "tee", "mkdir"}: + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + add_path(candidate) + if tok in {"cp", "mv", "install"}: + raw_segment = [] + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + raw_segment.append(candidate) + segment = copy_like_operands(tok, raw_segment) + if segment: + add_copy_like_paths(segment) + # [2026-05-27][fix] R2 follow-up: sed/perl の in-place 編集ターゲットも検査対象にする。 + # 背景: 前段 grep は sed -i / perl -pi を作成・編集系として検知するが、ここで対象ファイルを + # paths に追加していなかったため docs/.ssot-allowlist の AI 編集が素通りしていた。 + # 守るべき業務ルール: docs/.ssot-allowlist は既存ファイルでも AI 編集を必ず deny する。 + # 他案不採用理由: fallback を常時 docs/ パス抽出に戻す案は、PR本文や commit message の + # docs/ 言及を再び作成ターゲットと誤認するため不採用。 + if tok in {"sed", "perl"}: + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + if candidate.startswith("-"): + continue + add_path(candidate) + i += 1 + +# [2026-05-27][fix] R2 誤検知: punctuation_chars lexer が 1 トークンも取れなかった +# (引用が壊れた・極端な複合コマンド) 場合に限り、最終手段として docs/ 明示パスを拾う。 +# 正常にトークン化できたコマンド (gh pr create --body "...docs/plan/..." / echo / git commit -m +# 等、説明テキストに docs/ を含むだけ) では作動させない。常時 fallback すると、PR 本文や +# コミットメッセージ中の docs/ 言及を作成ターゲットと誤認して deny してしまう (R2)。 +# 作成系のターゲットは上の operation-aware パス (リダイレクト/touch/tee/mkdir/cp/mv/install) が +# 既に網羅しており、トークン化が成功している限り fallback の追加カバレッジはノイズのみ。 +if not tokens: + try: + fallback_tokens = shlex.split(cmd, posix=True) + except Exception: + fallback_tokens = [] + for token in fallback_tokens: + if token.startswith("./docs/") or token.startswith("docs/"): + paths.append(token[2:] if token.startswith("./") else token) + for match in re.findall(r"(?:^|[\s\"'=<>])(\./docs/[^\s\"'`$;|&<>]+|docs/[^\s\"'`$;|&<>]+)", cmd): + paths.append(match[2:] if match.startswith("./") else match) +for path in dict.fromkeys(paths): + print(path) +PY + ) + while IFS= read -r candidate; do + [ -z "$candidate" ] && continue + check_docs_path "$candidate" + check_per_app_docs_path "$candidate" + done <<< "$CANDIDATES" + exit 0 +fi + +FILE_PATH=$(extract_file_path) +[ -z "$FILE_PATH" ] && exit 0 +check_docs_path "$FILE_PATH" +check_per_app_docs_path "$FILE_PATH" diff --git a/.codex/hooks/scripts/block-unauthorized-docs-file.test.sh b/.codex/hooks/scripts/block-unauthorized-docs-file.test.sh new file mode 100755 index 000000000..4edd38787 --- /dev/null +++ b/.codex/hooks/scripts/block-unauthorized-docs-file.test.sh @@ -0,0 +1,329 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK_PATH="$SCRIPT_DIR/block-unauthorized-docs-file.sh" + +# 一時プロジェクトを作成(docs/ 構造・既存ファイル・allowlist を用意) +TMP_PROJECT="$(mktemp -d)" +trap 'rm -rf "$TMP_PROJECT"' EXIT +mkdir -p "$TMP_PROJECT/docs/prd/archives" \ + "$TMP_PROJECT/docs/architecture" \ + "$TMP_PROJECT/docs/operation" \ + "$TMP_PROJECT/docs/benchmark" \ + "$TMP_PROJECT/docs/plan" \ + "$TMP_PROJECT/docs/database" \ + "$TMP_PROJECT/src" \ + "$TMP_PROJECT/apps/koban-neko/docs/business" \ + "$TMP_PROJECT/apps/hyoka-wanko/docs/operation" \ + "$TMP_PROJECT/apps/chie-fukuro/docs/architecture" \ + "$TMP_PROJECT/apps/foo/docs/business" +# 既存ファイル(grandfather 対象) +: >"$TMP_PROJECT/docs/prd/prd-active.md" +: >"$TMP_PROJECT/docs/database/LEGACY_NOTES.md" # baseline 外だが既存 → 更新は許可される想定 +# allowlist 台帳(承認済みエントリ) +cat >"$TMP_PROJECT/docs/.ssot-allowlist" <<'EOF' +# 伸太郎殿承認済みの追加 SSOT +operation/INCIDENT_LOG.md +architecture/realtime-*.md +EOF + +run_hook() { + local tool_name="$1" + local file_path="$2" # 絶対パス推奨 + local payload + payload=$(python3 - "$tool_name" "$file_path" "$TMP_PROJECT" <<'PY' +import json +import sys +tool_name = sys.argv[1] +file_path = sys.argv[2] +tool_input = {} +if tool_name in {"Bash", "Shell"}: + tool_input["command"] = file_path + tool_input["cwd"] = sys.argv[3] if len(sys.argv) > 3 else "" +elif file_path: + tool_input["file_path"] = file_path +print(json.dumps({"tool_name": tool_name, "tool_input": tool_input}), end="") +PY +) + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +run_hook_raw() { + local payload="$1" + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +run_apply_patch_hook() { + local patch_text="$1" + local payload + payload=$(python3 - "$patch_text" <<'PY' +import json +import sys +print(json.dumps({"tool_name": "apply_patch", "tool_input": {"patch": sys.argv[1]}}), end="") +PY +) + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +assert_denied() { + local output="$1" + local label="$2" + if ! OUT="$output" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if not payload.get("permissionDecisionReason"): + sys.exit(1) +if "reason" in payload: + sys.exit(1) +PY + then + printf '[FAIL] %s : deny を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +assert_allowed() { + local output="$1" + local label="$2" + if [ -n "$output" ]; then + printf '[FAIL] %s : allow(無出力) を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +D="$TMP_PROJECT/docs" + +echo "1/21 prd/ への推測ファイル(next-action.md 新規)-> deny" +assert_denied "$(run_hook Write "$D/prd/next-action.md")" "prd/next-action.md" + +echo "2/21 prd/ baseline 固定ファイル(prd-future.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/prd/prd-future.md")" "prd/prd-future.md" + +echo "3/21 既存ファイル(prd-active.md 上書き)-> allow" +assert_allowed "$(run_hook Write "$D/prd/prd-active.md")" "prd/prd-active.md(existing)" + +echo "4/21 廃止された plan/ への新規 -> deny(docs/plan/ は廃止・~/.claude/plans へ)" +assert_denied "$(run_hook Write "$D/plan/2026-05-26-next-plan.md")" "plan/next-plan.md" + +echo "5/21 prd/archives/ スナップショット新規 -> allow" +assert_allowed "$(run_hook Write "$D/prd/archives/prd-active-2026-05.md")" "prd/archives/snapshot" + +echo "6/21 architecture/ baseline 外の新規(new-thing.md)-> deny" +assert_denied "$(run_hook Write "$D/architecture/new-thing.md")" "architecture/new-thing.md" + +echo "6a/21 database/ 未承認 root SSOT(NEW_RANDOM.md)-> deny" +assert_denied "$(run_hook Write "$D/database/NEW_RANDOM.md")" "database/NEW_RANDOM.md" + +echo "7/21 architecture/ baseline(database-design.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/database-design.md")" "architecture/database-design.md" + +echo "B1-1 architecture/auth-design.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/architecture/auth-design.md")" "architecture/auth-design.md" + +echo "B1-2 operation/PASSWORD_GATES.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/operation/PASSWORD_GATES.md")" "operation/PASSWORD_GATES.md" + +echo "B1-3 benchmark/PERFORMANCE_BASELINE.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/benchmark/PERFORMANCE_BASELINE.md")" "benchmark/PERFORMANCE_BASELINE.md" + +mkdir -p "$D/benchmark" +: >"$D/architecture/auth-design.md" +: >"$D/operation/PASSWORD_GATES.md" +: >"$D/benchmark/PERFORMANCE_BASELINE.md" + +echo "B1-4 architecture/auth-design.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/auth-design.md")" "architecture/auth-design.md(existing)" + +echo "B1-5 operation/PASSWORD_GATES.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Edit "$D/operation/PASSWORD_GATES.md")" "operation/PASSWORD_GATES.md(existing)" + +echo "B1-6 benchmark/PERFORMANCE_BASELINE.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Write "$D/benchmark/PERFORMANCE_BASELINE.md")" "benchmark/PERFORMANCE_BASELINE.md(existing)" + +echo "8/21 docs/ 直下の新規 SSOT(ROADMAP.md)-> deny" +assert_denied "$(run_hook Write "$D/ROADMAP.md")" "docs/ROADMAP.md" + +echo "9/21 docs/ 直下 baseline(FEATURE_FLAGS.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/FEATURE_FLAGS.md")" "docs/FEATURE_FLAGS.md" + +echo "9a/21 operation/PERMISSIONS.md(条件付き権限SSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/operation/PERMISSIONS.md")" "operation/PERMISSIONS.md" + +: >"$D/operation/PERMISSIONS.md" +echo "9b/21 operation/PERMISSIONS.md(条件付き権限SSOT・既存)-> allow" +assert_allowed "$(run_hook Edit "$D/operation/PERMISSIONS.md")" "operation/PERMISSIONS.md(existing)" + +echo "10/21 docs/ 外(src/foo.ts 新規)-> allow" +assert_allowed "$(run_hook Write "$D/../src/foo.ts")" "src/foo.ts" + +echo "11/21 allowlist 完全一致(operation/INCIDENT_LOG.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/operation/INCIDENT_LOG.md")" "operation/INCIDENT_LOG.md" + +echo "12/21 allowlist glob 一致(architecture/realtime-channels.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/realtime-channels.md")" "architecture/realtime-channels.md" + +echo "13/21 allowlist 台帳の新規/更新 -> deny" +assert_denied "$(run_hook Write "$D/.ssot-allowlist")" "docs/.ssot-allowlist" + +echo "13a/21 Kimi MultiEdit で allowlist 編集 -> deny" +assert_denied "$(run_hook MultiEdit "$D/.ssot-allowlist")" "MultiEdit docs/.ssot-allowlist" + +echo "13b/21 Kimi 旧 WriteFile で allowlist 編集 -> deny" +assert_denied "$(run_hook WriteFile "$D/.ssot-allowlist")" "WriteFile docs/.ssot-allowlist" + +echo "13c/21 Kimi 旧 StrReplaceFile で allowlist 編集 -> deny" +assert_denied "$(run_hook StrReplaceFile "$D/.ssot-allowlist")" "StrReplaceFile docs/.ssot-allowlist" + +echo "14/21 未登録 docs/ サブディレクトリへの新規 -> deny" +assert_denied "$(run_hook Write "$D/random/ROADMAP.md")" "docs/random/ROADMAP.md" + +echo "15/21 相対パスの既存ファイル更新(hook cwd がPJ外)-> allow" +(cd /tmp && assert_allowed "$(run_hook Write "docs/prd/prd-active.md")" "relative existing path") + +echo "16/21 Bash touch で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Bash "touch docs/prd/bash-next.md")" "bash touch docs/prd/bash-next.md" + +echo "17/21 Bash echo で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "echo architecture/foo.md >> docs/.ssot-allowlist")" "bash allowlist edit" + +echo "17a/21 Bash sed -i で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "sed -i.bak 's/foo/bar/' docs/.ssot-allowlist")" "bash sed allowlist edit" + +echo "17b/21 Bash perl -pi で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "perl -pi -e 's/foo/bar/' docs/.ssot-allowlist")" "bash perl allowlist edit" + +echo "17c/21 Bash cp で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp tmp-note.md docs/prd/")" "bash cp to docs/prd directory" + +echo "17d/21 Bash mv で既存 docs/architecture/ ディレクトリへ未承認SSOT移動 -> deny" +assert_denied "$(run_hook Bash "mv tmp-note.md docs/architecture/")" "bash mv to docs/architecture directory" + +echo "17e/21 Bash install で既存 docs/operation/ ディレクトリへ未承認SSOT配置 -> deny" +assert_denied "$(run_hook Bash "install tmp-note.md docs/operation/")" "bash install to docs/operation directory" + +mkdir -p "$TMP_PROJECT/tmp" +: >"$TMP_PROJECT/tmp/INCIDENT_LOG.md" +echo "17f/21 Bash install -m 644 で allowlist 済みSSOT配置 -> allow" +assert_allowed "$(run_hook Bash "install -m 644 tmp/INCIDENT_LOG.md docs/operation/")" "bash install mode allowlisted file" + +echo "17g/21 Bash install -m 644 で未承認SSOT配置 -> deny" +install_mode_output="$(run_hook Bash "install -m 644 tmp-note.md docs/operation/")" +assert_denied "$install_mode_output" "bash install mode to docs/operation directory" +if printf '%s' "$install_mode_output" | grep -q 'docs/operation/644'; then + printf '[FAIL] bash install mode option was treated as filename:\n%s\n' "$install_mode_output" >&2 + exit 1 +fi + +echo "17h/21 Bash cp -t で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp -t docs/prd tmp-note.md")" "bash cp -t docs/prd" + +echo "17i/21 Bash cp --target-directory= で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp --target-directory=docs/prd tmp-note.md")" "bash cp --target-directory docs/prd" + +echo "18/21 tool_name=Read(対象外)-> allow" +assert_allowed "$(run_hook Read "$D/prd/next-action.md")" "Read tool" + +echo "19/21 作業用ディレクトリ design/ への新規 -> allow(WORK_DIRS は維持)" +assert_allowed "$(run_hook Write "$D/design/new-mockup.md")" "design/new-mockup.md" + +echo "20/21 Bash cd 後の prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Bash "cd docs/prd && touch cd-next.md")" "bash cd docs/prd touch" + +echo "21/21 Kimi Shell で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Shell "touch docs/prd/shell-next.md")" "shell touch docs/prd/shell-next.md" + +echo "21a/21 Kimi toolInput camelCase で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook_raw '{"toolName":"Shell","toolInput":{"command":"touch docs/prd/kimi-toolinput-next.md","cwd":"'"$TMP_PROJECT"'"}}')" "kimi toolInput shell docs/prd" + +# --- R2 誤検知回帰テスト(2026-05-27): 説明テキスト中の docs/ 言及を作成ターゲットと誤認しない --- +echo "R2-1 gh pr create の --body に docs/plan/ 言及(touch 含む)-> allow" +assert_allowed "$(run_hook Bash 'gh pr create --title x --body "removes docs/plan/ legacy; touch up wording"')" "R2 gh pr create body docs mention" + +echo "R2-2 git commit -m に docs/prd/ 言及(> 含む)-> allow" +assert_allowed "$(run_hook Bash 'git commit -m "drop docs/prd/cleanup-notes.md > archive"')" "R2 git commit msg docs mention" + +echo "R2-3 実リダイレクトでの docs/ 新規作成は引き続き deny(保護が残っていること)" +assert_denied "$(run_hook Bash "printf hi > docs/architecture/brand-new.md")" "R2 real redirect still denied" + +echo "R2-4 数値付きリダイレクトでの docs/ 新規作成 -> deny" +assert_denied "$(run_hook Bash "printf hi 2> docs/architecture/fd-new.md")" "R2 numeric redirect denied" + +echo "R2-5 stdout/stderr リダイレクトでの docs/ 新規作成 -> deny" +assert_denied "$(run_hook Bash "printf hi &> docs/architecture/amp-new.md")" "R2 amp redirect denied" + +# --- per-app baseline 新命名テスト(2026-06-26) --- +echo "PA-1/7 per-app 新命名 business 許可: apps/koban-neko/docs/business/koban-neko-business-rules.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/koban-neko/docs/business/koban-neko-business-rules.md")" "per-app business new naming" + +echo "PA-2/7 per-app 新命名 operation 許可: apps/hyoka-wanko/docs/operation/hyoka-wanko-operations.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/hyoka-wanko/docs/operation/hyoka-wanko-operations.md")" "per-app operation new naming" + +echo "PA-3/7 per-app 新命名 architecture 許可: apps/chie-fukuro/docs/architecture/chie-fukuro-rag-design.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/chie-fukuro/docs/architecture/chie-fukuro-rag-design.md")" "per-app architecture new naming" + +echo "PA-4/7 per-app prd 既存パターン許可: apps/foo/docs/prd/foo-prd-active.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/prd/foo-prd-active.md")" "per-app prd pattern" + +echo "PA-5/7 per-app 旧 business 命名 grandfather 許可: apps/foo/docs/business/BUSINESS_RULES.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/business/BUSINESS_RULES.md")" "per-app old business naming grandfather" + +echo "PA-6/7 per-app 任意名 docs ファイルはブロック維持: apps/foo/docs/business/random-notes.md" +assert_denied "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/business/random-notes.md")" "per-app arbitrary name blocked" + +echo "PA-7/7 per-app app 名不一致はブロック: apps/koban-neko/docs/business/hyoka-wanko-business-rules.md" +assert_denied "$(run_hook Write "$TMP_PROJECT/apps/koban-neko/docs/business/hyoka-wanko-business-rules.md")" "per-app app name mismatch blocked" + +# --- Codex apply_patch hook 配線(2026-07-16) --- +echo "CX-1/5 Codex apply_patch で未承認 docs/prd 新規 -> deny" +assert_denied "$(run_apply_patch_hook $'*** Begin Patch\n*** Add File: docs/prd/codex-next.md\n+new\n*** End Patch')" "Codex apply_patch unauthorized docs" + +echo "CX-2/5 Codex apply_patch で docs/.ssot-allowlist 更新 -> deny" +assert_denied "$(run_apply_patch_hook $'*** Begin Patch\n*** Update File: docs/.ssot-allowlist\n@@\n+prd/codex-next.md\n*** End Patch')" "Codex apply_patch allowlist self-approval" + +echo "CX-3/5 Codex apply_patch で既存 docs/prd 更新 -> allow" +assert_allowed "$(run_apply_patch_hook $'*** Begin Patch\n*** Update File: docs/prd/prd-active.md\n@@\n+updated\n*** End Patch')" "Codex apply_patch existing docs" + +echo "CX-4/5 Codex apply_patch で src 新規 -> allow" +assert_allowed "$(run_apply_patch_hook $'*** Begin Patch\n*** Add File: src/codex.ts\n+export {};\n*** End Patch')" "Codex apply_patch non-docs" + +echo "CX-5/5 Codex apply_patch の対象欠損 -> deny" +assert_denied "$(run_hook_raw '{"tool_name":"apply_patch","tool_input":{}}')" "Codex apply_patch missing target" + +CODEX_HOOKS_JSON="$(cd "$SCRIPT_DIR/../.." && pwd)/hooks.json" +if [ -f "$CODEX_HOOKS_JSON" ]; then + echo "CX-REG Codex hooks.json で cross-runtime matcher 配線済み -> pass" + python3 - "$CODEX_HOOKS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + hooks = json.load(handle).get("hooks", {}).get("PreToolUse", []) +expected_tools = {"Bash", "Edit", "MultiEdit", "Shell", "StrReplaceFile", "Write", "WriteFile"} +registered = any( + expected_tools.issubset(set(entry.get("matcher", "").split("|"))) + and any( + "block-unauthorized-docs-file.sh" in hook.get("command", "") + for hook in entry.get("hooks", []) + ) + for entry in hooks +) +if not registered: + raise SystemExit("Codex hooks.json lacks the cross-runtime docs guard matcher set") +PY +fi + +echo "block-unauthorized-docs-file hook tests passed" diff --git a/.codex/hooks/scripts/freshness-gate.sh b/.codex/hooks/scripts/freshness-gate.sh new file mode 100755 index 000000000..7359fb42a --- /dev/null +++ b/.codex/hooks/scripts/freshness-gate.sh @@ -0,0 +1,266 @@ +#!/bin/bash + +# [2026-03-03][feat] +# 背景: 77スキル中2つだけ日付マーカーあり。サブエージェントはコピー時スナップショット。 +# 手動チェックは現実的に不可能なため、SessionStart hookで毎セッション自動検出が必要。 +# staleness_check.sh(skill-organizer)は手動実行のみだった。skill-audit は 2026-07-13 に +# 正式スキル化(skills/skill-audit/)し、単一スキルの契約遵守を三値判定する。 +# 対応: SessionStart hookで軽量鮮度チェックを実行。 +# (1) hookバージョン差分 (2) スキル鮮度 (3) 依存バージョン乖離を検出。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「鮮度チェックが安全に動作し、監査時に迂回経路を残さないこと」。 +# 業務ルールとして、フック内で外部入力(ファイルパス)をコード文字列に直埋めしてはならない。 +# 代替案としてPythonワンライナーへパスを直接埋め込む実装を維持すると、 +# 特殊文字を含むパスで任意コード実行に繋がるため不採用。 +# 対応: Python呼び出しを引数渡しへ変更し、文字列埋め込みを廃止。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOKS_DIR="$SCRIPT_DIR/.." + +extract_last_verified() { + local skill_md="$1" + python3 - "$skill_md" <<'PY' 2>/dev/null || true +import re +import sys +from pathlib import Path + +skill_path = Path(sys.argv[1]) +try: + text = skill_path.read_text(encoding='utf-8') +except Exception: + print('') + raise SystemExit(0) + +m = re.search(r'last_verified:\s*(\d{4}-\d{2}-\d{2})', text) +print(m.group(1) if m else '') +PY +} + +extract_interval_days() { + local skill_md="$1" + python3 - "$skill_md" <<'PY' 2>/dev/null || echo "60" +import re +import sys +from pathlib import Path + +skill_path = Path(sys.argv[1]) +try: + text = skill_path.read_text(encoding='utf-8') +except Exception: + print('60') + raise SystemExit(0) + +m = re.search(r'interval_days:\s*(\d+)', text) +print(m.group(1) if m else '60') +PY +} + +# --- hookバージョンチェック --- +check_hook_version() { + local version_file="$HOOKS_DIR/.hook-library-version" + local agent_hub_version_file + + # AGENT-HUBのパスを環境変数またはデフォルトから取得 + local agent_hub_path="${AGENT_HUB_PATH:-$HOME/business/AGENT-HUB}" + agent_hub_version_file="$agent_hub_path/hook-library/VERSION" + + if [ ! -f "$version_file" ]; then + echo " - hook-library: バージョン情報なし(未デプロイ or 旧形式)" >&2 + return + fi + + local deployed_version + deployed_version="$(head -1 "$version_file" | sed 's/^v//' | cut -d' ' -f1)" + + if [ -f "$agent_hub_version_file" ]; then + local latest_version + latest_version="$(cat "$agent_hub_version_file" | tr -d '[:space:]')" + + if [ "$deployed_version" != "$latest_version" ]; then + echo " - hook-library: v${latest_version} が利用可能です(現在 v${deployed_version})" >&2 + fi + fi +} + +# --- スキル鮮度チェック --- +check_skill_freshness() { + local skills_dir + + # CLAUDE_PROJECT_DIR が設定されていればそのプロジェクトのスキルをチェック + if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then + skills_dir="$CLAUDE_PROJECT_DIR/.claude/skills" + else + skills_dir="$(pwd)/.claude/skills" + fi + + if [ ! -d "$skills_dir" ]; then + return + fi + + local today_epoch + today_epoch=$(date +%s) + local stale_skills="" + + # 各スキルのSKILL.mdからlast_verifiedを抽出 + for skill_dir in "$skills_dir"/*/; do + [ -d "$skill_dir" ] || continue + local skill_md="$skill_dir/SKILL.md" + [ -f "$skill_md" ] || continue + + local skill_name + skill_name="$(basename "$skill_dir")" + + local last_verified + last_verified="$(extract_last_verified "$skill_md")" + + if [ -z "$last_verified" ]; then + continue # last_verified未設定のスキルはスキップ(Phase 2で順次追加) + fi + + # 経過日数を計算 + local verified_epoch + verified_epoch=$(date -j -f "%Y-%m-%d" "$last_verified" +%s 2>/dev/null || date -d "$last_verified" +%s 2>/dev/null || echo "0") + + if [ "$verified_epoch" = "0" ]; then + continue + fi + + local days_ago=$(( (today_epoch - verified_epoch) / 86400 )) + + # freshness_check.interval_days を取得(デフォルト60日) + local interval + interval="$(extract_interval_days "$skill_md")" + + if [ "$days_ago" -gt "$interval" ]; then + stale_skills="$stale_skills\n - ${skill_name}: ${days_ago}日前(閾値: ${interval}日)" + fi + done + + if [ -n "$stale_skills" ]; then + echo -e " スキル鮮度:$stale_skills" >&2 + fi +} + +# [2026-05-21][feat] / [2026-05-25][refactor] +# 背景: +# - ユーザー依頼意図: 大原則 A「PWAを消して再登録は絶対にしない」と大原則 B「ネイティブアプリ模倣」の +# SSOT 文書が欠落している場合に、SessionStart 時に警告して AI セッションへ必読を促す。 +# 2026-05-25: jtt-apps ローカル限定だった本チェックを hook-library 正本へ upstream +# (/insights deep-check の --diff で「full deploy 時に jtt-apps から消える」ローカル限定実装と判明したため)。 +# - 守るべき業務ルール: SessionStart hook は常に exit 0(ブロックしない、情報提供のみ)。 +# hook-library は複数 PJ で共有されるため、PWA プロジェクト(public/sw.js または public/manifest.json を持つ) +# でのみ発火し、非 PWA PJ(jtt-cms 等)では誤警告させない。 +# - 他案不採用理由: +# 1) Stop hook で AI 最終出力を grep する案は false positive リスクが高すぎる +# (正当な「キャッシュクリア」言及まで誤ブロック)ため不採用。 +# 2) jtt-apps 限定の無条件チェックのまま据え置く案は、full deploy で hook-library 版に巻き戻り +# check_pwa_principles が消えるため不採用(2026-05-25 の deploy --diff で検出)。 +# 3) 無条件で全 PJ に配布する案は、PWA を持たない PJ で毎セッション誤警告を出すため不採用。 +# PWA 検出ゲートで発火対象を PWA PJ に限定する。 +# 対応: PWA 検出(public/sw.js または public/manifest.json)でゲートし、検出時のみ +# PWA_OPERATION_PRINCIPLE.md / PWA_NATIVE_APP_PARITY_RULE.md の存在を確認。メッセージは PJ 非依存化。 +# --- PWA 大原則 SSOT 存在確認(PWA プロジェクトのみ) --- +check_pwa_principles() { + local project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" + + # PWA プロジェクト判定: service worker または manifest を持つ場合のみ発火(非 PWA PJ では誤爆させない) + if [ ! -f "$project_dir/public/sw.js" ] && [ ! -f "$project_dir/public/manifest.json" ]; then + return + fi + + local pwa_op_principle="$project_dir/.claude/rules/general/PWA_OPERATION_PRINCIPLE.md" + local pwa_parity_rule="$project_dir/.claude/rules/general/PWA_NATIVE_APP_PARITY_RULE.md" + + if [ ! -f "$pwa_op_principle" ]; then + echo " ⚠️ PWA_OPERATION_PRINCIPLE.md (.claude/rules/general/) が存在しません。PWA 運用大原則 A (「PWAを消して再登録は絶対にしない」) の SSOT が欠落しています。" >&2 + fi + + if [ ! -f "$pwa_parity_rule" ]; then + echo " ⚠️ PWA_NATIVE_APP_PARITY_RULE.md (.claude/rules/general/) が存在しません。PWA 大原則 B (「ネイティブアプリ模倣」) の SSOT が欠落しています。" >&2 + fi +} + +# [2026-08-02][feat] ローカル main の behind をセッション開始時に警告する(issue #1327)。 +# 背景: +# - ユーザー依頼意図: セッション開始時のシステムプロンプトにはローカルの git log が載るため、 +# AI が「最新」と誤認して古いベースにコミットを積み、push 拒否 → worktree 作り直し → +# 幽霊 hook 誤爆(#1230 と重複)の手戻り連鎖が実測された(2026-08-02 jtt-cafe-pj)。 +# `git fetch` を1回打っていれば全て回避できたため、SessionStart で機械化する。 +# - 守るべき業務ルール: 警告のみで block しない(SessionStart は情報提供・常に exit 0)。 +# オフライン・認証不能・遅延時は fail-open(既存チェックと同じ精神)。 +# macOS 標準に GNU timeout が無いため bg + poll + kill で上限を実装し、 +# GIT_TERMINAL_PROMPT=0 / ssh BatchMode で認証プロンプトの hang を封じる。 +# - 他案不採用理由: PreToolUse(add/commit 時)検知の案 B は、警告が作業途中に割り込み +# ベース選択の時点(worktree 作成)に間に合わない。システムプロンプト側への ahead/behind +# 併記(案 C)は Claude Code 本体の変更で当方から変更不能。 +check_main_behind() { + local repo_root behind fetch_pid waited + repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 + git -C "$repo_root" rev-parse --verify -q refs/heads/main >/dev/null 2>&1 || return 0 + git -C "$repo_root" remote get-url origin >/dev/null 2>&1 || return 0 + ( + export GIT_TERMINAL_PROMPT=0 + export GIT_SSH_COMMAND="ssh -oBatchMode=yes -oConnectTimeout=3" + exec git -C "$repo_root" fetch -q origin "+refs/heads/main:refs/remotes/origin/main" + ) >/dev/null 2>&1 & + fetch_pid=$! + waited=0 + while kill -0 "$fetch_pid" 2>/dev/null; do + if [ "$waited" -ge 50 ]; then + # 5秒(0.1s x 50)で fetch を打ち切り fail-open(オフライン・低速回線) + kill "$fetch_pid" 2>/dev/null || true + wait "$fetch_pid" 2>/dev/null || true + return 0 + fi + sleep 0.1 + waited=$((waited + 1)) + done + wait "$fetch_pid" 2>/dev/null || return 0 + behind="$(git -C "$repo_root" rev-list --count main..origin/main 2>/dev/null)" || return 0 + case "$behind" in ''|*[!0-9]*) return 0 ;; esac + if [ "$behind" -gt 0 ]; then + echo " ⚠ ローカル main が origin/main より ${behind} コミット遅れています(fetch 実行済み)。" >&2 + echo " 冒頭の Recent commits はローカル基準です。古いベースへのコミットを避けるため、" >&2 + echo " worktree / branch は origin/main から作成してください。" >&2 + fi + return 0 +} + +# --- メイン実行 --- +main() { + local warnings="" + + # 一時ファイルで警告を収集 + local tmp_file + tmp_file=$(mktemp) + trap "rm -f '$tmp_file'" EXIT + + check_hook_version 2>"$tmp_file" + warnings="$(cat "$tmp_file")" + + check_skill_freshness 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + check_pwa_principles 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + check_main_behind 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + if [ -n "$warnings" ]; then + echo "" >&2 + echo "🔍 [freshness-gate] 鮮度チェック結果:" >&2 + echo "$warnings" >&2 + echo "" >&2 + echo " 詳細: skills/skill-audit の audit_skill.py または staleness_check.sh で確認してください" >&2 + echo "" >&2 + fi + + # SessionStart hookは常にexit 0(ブロックしない、情報提供のみ) + exit 0 +} + +main diff --git a/.codex/hooks/scripts/handover-preflight.sh b/.codex/hooks/scripts/handover-preflight.sh new file mode 100755 index 000000000..df4a9af71 --- /dev/null +++ b/.codex/hooks/scripts/handover-preflight.sh @@ -0,0 +1,353 @@ +#!/bin/bash +# UserPromptSubmit hook for Handover hints. +# Quiet by default. Prints only when the prompt asks for +# "続き", "引き継ぎ書つくって", "引き継ぎ", "作業終了", "終了整理", "Closeout整理", +# "ふり返り", "振り返り", "ふりかえり", +# "handover", compatibility "takeover", or when HANDOVER_PREFLIGHT_FORCE=1 is set. +# +# [2026-06-30][refactor] +# 背景: +# - ユーザー依頼意図: ユーザー向けの引き継ぎ名を Takeover から Handover へ寄せ、 +# plan / Typinator / hook の入口名を揃えたい。 +# - 守るべき業務ルール: 旧 `takeover` / `continuation` 発話、旧 env、旧 +# `~/.agent-hub/takeovers` の保存済みデータは壊さず、互換入口として残す。 +# - 他案不採用理由: 旧 hook を即削除する案は既存 settings の command を壊す。 +# 新旧を同格にする案は正本名が再び揺れるため不採用。 +# 対応: `handover-preflight` を正本にし、旧 `takeover-preflight` は wrapper から本ファイルを呼ぶ。 + +set -euo pipefail + +RAW_INPUT="$(cat || true)" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +HOOK_INPUT="$RAW_INPUT" command python3 - "$PROJECT_DIR" <<'PY' +import json +import os +import re +import sys +from pathlib import Path + +project_dir = Path(sys.argv[1]).resolve() +raw = os.environ.get("HOOK_INPUT", "") + + +def prompt_from_payload(text: str) -> str: + if not text.strip(): + return "" + try: + payload = json.loads(text) + except Exception: + return text + if not isinstance(payload, dict): + return "" + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str): + return value + nested = payload.get("tool_input") + if isinstance(nested, dict): + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = nested.get(key) + if isinstance(value, str): + return value + return "" + + +def unquote_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def candidate_alias_paths() -> list[Path]: + paths: list[Path] = [] + env_path = os.environ.get("HANDOVER_ALIASES_PATH", "").strip() + if env_path: + paths.append(Path(env_path).expanduser()) + compat_env = os.environ.get("TAKEOVER_ALIASES_PATH", "").strip() + if compat_env: + paths.append(Path(compat_env).expanduser()) + legacy_env = os.environ.get("AGENT_MEMORY_ALIASES_PATH", "").strip() + if legacy_env: + paths.append(Path(legacy_env).expanduser()) + paths.append(project_dir / "agent-memory" / "aliases.yaml") + paths.append(Path("/Users/shintaro/business/AGENT-HUB/agent-memory/aliases.yaml")) + return paths + + +def load_apps(aliases_path: Path) -> list[dict[str, object]]: + apps: list[dict[str, object]] = [] + current_project: str | None = None + current: dict[str, object] | None = None + in_aliases = False + + for raw_line in aliases_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].rstrip() + if not line.strip(): + continue + + project_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if project_match: + current_project = project_match.group(1) + current = None + in_aliases = False + continue + + app_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if app_match and current_project: + current = { + "project": current_project, + "canonical_name": app_match.group(1), + "aliases": [], + } + apps.append(current) + in_aliases = False + continue + + if current is None: + continue + + kv_match = re.match(r"^ ([A-Za-z0-9_]+):\s*(.*)$", line) + if kv_match: + key = kv_match.group(1) + value = unquote_scalar(kv_match.group(2)) + in_aliases = key == "aliases" + if key != "aliases": + current[key] = value + continue + + alias_match = re.match(r"^ -\s*(.+?)\s*$", line) + if in_aliases and alias_match: + aliases = current.setdefault("aliases", []) + if isinstance(aliases, list): + aliases.append(unquote_scalar(alias_match.group(1))) + + return apps + + +def find_aliases_path() -> Path | None: + for path in candidate_alias_paths(): + if path.is_file(): + return path + return None + + +TRIGGER_RE = re.compile( + r"(続き|終了整理|Closeout整理|ふり返り|振り返り|ふりかえり|引継ぎ書つくって|引き継ぎ|作業終了|handover|takeover|continuation|continuation-closeout)", + re.IGNORECASE, +) +NEGATED_CONTINUATION_RE = re.compile( + r"続き\s*(?:ではなくて|ではなく|ではない|でなく|でない|じゃなくて|じゃなく|じゃない|" + r"はなく|はない|は不要|不要|はいらない|いらない|なく|ない)" +) +NEGATED_CLOSEOUT_KEYWORDS = ("終了整理", "Closeout整理", "ふり返り", "振り返り", "ふりかえり", "作業終了") +NEGATED_CLOSEOUT_SUFFIXES = ( + "ではない", + "ではないです", + "ではなく", + "ではなくて", + "でない", + "でないです", + "じゃない", + "じゃないです", + "はない", + "はいらない", + "は不要", + "必要ない", + "不要", + "要らない", + "いらない", + "ない", +) +NEGATED_PUNCTUATION = re.compile(r"[\s、。.!?!?ー−‐\\-]") +MAX_ALIAS_TRIGGER_DISTANCE = 32 + + +def normalize_for_negation(value: str) -> str: + return NEGATED_PUNCTUATION.sub("", value.casefold()) + + +def is_negated_closeout_trigger(prompt: str, trigger: str) -> bool: + folded = normalize_for_negation(prompt) + normalized_trigger = normalize_for_negation(trigger) + index = 0 + while True: + index = folded.find(normalized_trigger, index) + if index < 0: + return False + tail = folded[index + len(normalized_trigger):] + for suffix in NEGATED_CLOSEOUT_SUFFIXES: + if tail.startswith(normalize_for_negation(suffix)): + return True + index += len(normalized_trigger) + + +def positive_trigger_spans(prompt: str) -> list[tuple[int, int]]: + spans: list[tuple[int, int]] = [] + for match in TRIGGER_RE.finditer(prompt): + tail = prompt[match.start() : match.end() + 12] + if match.group(1) == "続き" and NEGATED_CONTINUATION_RE.match(tail): + continue + if match.group(1) in NEGATED_CLOSEOUT_KEYWORDS and is_negated_closeout_trigger(prompt, match.group(1)): + continue + spans.append(match.span()) + return spans + + +def alias_near_trigger(prompt: str, name: str, trigger_spans: list[tuple[int, int]]) -> bool: + if not name: + return False + for name_match in re.finditer(re.escape(name), prompt, flags=re.IGNORECASE): + for trigger_start, trigger_end in trigger_spans: + if name_match.end() <= trigger_start: + distance = trigger_start - name_match.end() + else: + distance = name_match.start() - trigger_end + if 0 <= distance <= MAX_ALIAS_TRIGGER_DISTANCE: + return True + return False + + +def matched_app(prompt: str, apps: list[dict[str, object]]) -> dict[str, object] | None: + folded_prompt = prompt.casefold() + trigger_spans = positive_trigger_spans(prompt) + for app in apps: + names: list[str] = [] + for key in ("canonical_name", "display_name"): + value = app.get(key) + if isinstance(value, str): + names.append(value) + aliases = app.get("aliases") + if isinstance(aliases, list): + names.extend(str(alias) for alias in aliases) + + for name in names: + if trigger_spans and alias_near_trigger(prompt, name, trigger_spans): + return app + if name and name.casefold() in folded_prompt: + return app + return None + + +def project_from_cwd(path: Path) -> str: + if (path / "DISTRIBUTION.yaml").is_file() and (path / "hook-registry.yaml").is_file(): + return "AGENT-HUB" + text = str(path) + checks = [ + ("AGENT-HUB", "/AGENT-HUB"), + ("jtt-system", "/jtt-system"), + ("jtt-apps", "/jtt-apps"), + ("jtt-cms", "/jtt-cms"), + ("jtt-cafe-pj", "/jtt-cafe-pj"), + ("hermes", "/mac-mini-server/hermes"), + ] + for project, marker in checks: + if marker in text: + return project + if (path / "pnpm-workspace.yaml").is_file() and (path / "apps").is_dir(): + return "jtt-system" + return "non-pj" + + +def scope_from_cwd(project: str, path: Path) -> str: + parts = path.parts + if project == "jtt-system" and "apps" in parts: + idx = parts.index("apps") + if idx + 1 < len(parts): + return parts[idx + 1] + if project == "AGENT-HUB": + for marker in ("skills", "hook-library", "snippet-prompts", "agent-memory"): + if marker in parts: + idx = parts.index(marker) + if idx + 1 < len(parts): + return parts[idx + 1] + return marker + return "root" + + +def handover_path(project: str, scope: str) -> str: + return str(Path.home() / ".agent-hub" / "handovers" / project / scope / "current.md") + + +def legacy_path(project: str, scope: str) -> str: + return str(Path.home() / ".agent-hub" / "takeovers" / project / scope / "current.md") + + +PROJECT_CLAUDE_MEMORY_PATHS = { + "AGENT-HUB": "-Users-shintaro-business-AGENT-HUB", + "bank-payment-automator": "-Users-shintaro-business-bank-payment-automator", + "hermes": "-Users-shintaro-mac-mini-server-hermes", + "jtt-apps": "-Users-shintaro-Herd-jtt-apps", + "jtt-cafe-pj": "-Users-shintaro-business-jtt-cafe-pj", + "jtt-cms": "-Users-shintaro-LLM-Dev-jtt-cms", + "jtt-system": "-Users-shintaro-jtt-system", +} + + +def claude_memory_path(project: str, app: dict[str, object] | None) -> str: + if app is not None: + configured = app.get("claude_memory_path") + if isinstance(configured, str) and configured: + return configured + encoded = PROJECT_CLAUDE_MEMORY_PATHS.get(project) + if not encoded: + return "未登録" + return str(Path.home() / ".claude" / "projects" / encoded / "memory" / "MEMORY.md") + + +def print_hint(app: dict[str, object] | None, forced: bool) -> None: + manual_path = "skills/handover-manual/references/handover.md" + reflection_path = "agent-memory/registry/reflection-policy.md" + placement_path = "agent-memory/registry/placement-policy.md" + + if app is not None: + project = str(app.get("project") or project_from_cwd(project_dir)) + scope = str(app.get("canonical_name") or scope_from_cwd(project, project_dir)) + display = app.get("display_name") or scope + else: + project = project_from_cwd(project_dir) + scope = scope_from_cwd(project, project_dir) + display = scope + + print("handover preflight:") + print(f"- scope: {project}/{scope}") + print(f"- handover_path: {handover_path(project, scope)}") + print(f"- legacy_path: {legacy_path(project, scope)}") + print(f"- claude_memory: {claude_memory_path(project, app)}") + print(f"- manual: {manual_path}") + print(f"- reflection-policy: {reflection_path}") + print(f"- placement-policy: {placement_path}") + # [2026-07-18][fix] + # 背景: closeoutでPJ固有の短期状態までGBrain候補に混ざり、人間の判断原則と技術台帳の境界が曖昧だった。 + # 守るべき業務ルール: GBrain候補はユーザーしか判断できない原則へ抽象化し、技術/PJ情報はTech GBrainかSSOTへ置く。 + # 他案不採用理由: 候補を全件GBrainへ送る案は確認負荷と重複を増やすため不採用。 + print("- closeout: 未完了 / 次回やること / Tech G-Brain候補 / GBrain候補 / SSOT昇格候補を分ける") + print("- gbrain: 技術名・PJ固有名・短期状態は候補にせず、人間の判断原則へ抽象化") + print("- handover_update: 未完了がある時だけ current.md を更新") + if forced and app is None: + print("- alias: 未検出。cwdから推定") + elif app is not None: + print(f"- app: {display}") + + +prompt = prompt_from_payload(raw) +forced = ( + os.environ.get("HANDOVER_PREFLIGHT_FORCE", "0") == "1" + or os.environ.get("TAKEOVER_PREFLIGHT_FORCE", "0") == "1" + or os.environ.get("AGENT_MEMORY_PREFLIGHT_FORCE", "0") == "1" +) + +if not forced and not positive_trigger_spans(prompt): + raise SystemExit(0) + +aliases_path = find_aliases_path() +app = None +if aliases_path is not None: + try: + app = matched_app(prompt, load_apps(aliases_path)) + except Exception: + app = None + +print_hint(app, forced) +PY diff --git a/.codex/hooks/scripts/handover-preflight.test.sh b/.codex/hooks/scripts/handover-preflight.test.sh new file mode 100755 index 000000000..0dd3f95e1 --- /dev/null +++ b/.codex/hooks/scripts/handover-preflight.test.sh @@ -0,0 +1,156 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +fi +HOOK="$SCRIPT_DIR/handover-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +extract_field() { + printf "%s\n" "$1" | sed -n "s/^-[[:space:]]*$2: //p" +} + +is_agent_hub_source_repo() { + [ -f "$REPO_ROOT/DISTRIBUTION.yaml" ] && [ -f "$REPO_ROOT/hook-registry.yaml" ] +} + +assert_exact_scope() { + local output="$1" + local expected="$2" + local scope + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + [ "$scope" = "$expected" ] || fail "scope が期待値と一致しない: $output" +} + +assert_scoped_path() { + local output="$1" + local category="$2" + local scope + local expected + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + expected="$HOME/.agent-hub/$category/$scope/current.md" + printf "%s\n" "$output" | grep -Fq "$expected" \ + || fail "$category が scope と一致しない: $output" +} + +assert_claude_memory_path() { + local output="$1" + local marker="$2" + local memory_path + + memory_path="$(extract_field "$output" "claude_memory")" + [ -n "$memory_path" ] || fail "claude_memory が取得できない: $output" + case "$memory_path" in + *"/.claude/projects/"*"/memory/MEMORY.md") + : + ;; + *) + fail "claude_memory の形式が想定外: $memory_path" + ;; + esac + case "$memory_path" in + *"$marker"*) + : + ;; + *) + fail "claude_memory が期待するPJを示していない: $memory_path" + ;; + esac +} + +run_hook() { + local prompt="$1" + local project_dir="${2:-$REPO_ROOT}" + printf '{"user_prompt": "%s"}' "$prompt" | CLAUDE_PROJECT_DIR="$project_dir" bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +negative_output="$(run_hook "評価わんこについて。続きではなく概要を教えて")" +[ -z "$negative_output" ] || fail "否定文は無音であるべき: $negative_output" + +negative_reflection_output="$(run_hook "ふり返りは不要です")" +[ -z "$negative_reflection_output" ] || fail "否定文は無音であるべき: $negative_reflection_output" + +negative_hiragana_reflection_output="$(run_hook "ふりかえりはいらない")" +[ -z "$negative_hiragana_reflection_output" ] || fail "ひらがな否定文は無音であるべき: $negative_hiragana_reflection_output" + +hyoka_output="$(run_hook "評価わんこの続き")" +echo "$hyoka_output" | grep -q "handover preflight:" \ + || fail "handover preflight が出ない: $hyoka_output" +assert_scoped_path "$hyoka_output" "handovers" +assert_scoped_path "$hyoka_output" "takeovers" +echo "$hyoka_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "handover manual が出ない: $hyoka_output" + +admin_output="$(run_hook "引継ぎ書つくって")" +assert_scoped_path "$admin_output" "handovers" + +closeout_output="$(run_hook "作業終了。今回の内容を Handover に整理して")" +echo "$closeout_output" | grep -q "placement-policy" \ + || fail "作業終了で placement-policy が出ない: $closeout_output" +echo "$closeout_output" | grep -q "reflection-policy" \ + || fail "作業終了で reflection-policy が出ない: $closeout_output" +echo "$closeout_output" | grep -q "未完了 / 次回やること / Tech G-Brain候補 / GBrain候補 / SSOT昇格候補" \ + || fail "分類分離の案内が出ない: $closeout_output" +echo "$closeout_output" | grep -q "未完了がある時だけ current.md を更新" \ + || fail "handover更新条件の案内が出ない: $closeout_output" + +jtt_apps_reflection_output="$(run_hook "jtt-appsにふり返りを依頼")" +echo "$jtt_apps_reflection_output" | grep -q "handover preflight:" \ + || fail "jtt-appsのふり返りで preflight が出ない: $jtt_apps_reflection_output" +echo "$jtt_apps_reflection_output" | grep -q "scope: jtt-apps/root" \ + || fail "jtt-appsのscopeが出ない: $jtt_apps_reflection_output" +assert_claude_memory_path "$jtt_apps_reflection_output" "Herd-jtt-apps" + +jtt_apps_hiragana_reflection_output="$(run_hook "jtt-appsのふりかえりをお願い")" +echo "$jtt_apps_hiragana_reflection_output" | grep -q "scope: jtt-apps/root" \ + || fail "jtt-appsのひらがなふりかえりでscopeが出ない: $jtt_apps_hiragana_reflection_output" + +jtt_cms_reflection_output="$(run_hook "ふり返りをお願い" "/Users/shintaro/LLM-Dev/jtt-cms")" +echo "$jtt_cms_reflection_output" | grep -q "handover preflight:" \ + || fail "jtt-cmsのふり返りで preflight が出ない: $jtt_cms_reflection_output" +echo "$jtt_cms_reflection_output" | grep -q "scope: jtt-cms/root" \ + || fail "jtt-cmsのscopeが出ない: $jtt_cms_reflection_output" +assert_scoped_path "$jtt_cms_reflection_output" "handovers" +assert_claude_memory_path "$jtt_cms_reflection_output" "LLM-Dev-jtt-cms" + +jtt_system_reflection_output="$(run_hook "ふり返りをお願い" "/Users/shintaro/jtt-system")" +echo "$jtt_system_reflection_output" | grep -q "scope: jtt-system/root" \ + || fail "jtt-systemのscopeが出ない: $jtt_system_reflection_output" + +if is_agent_hub_source_repo; then + agent_hub_reflection_output="$(run_hook "ふり返りをお願い" "$REPO_ROOT")" + assert_exact_scope "$agent_hub_reflection_output" "AGENT-HUB/root" + assert_scoped_path "$agent_hub_reflection_output" "handovers" +fi + +compat_output="$(run_hook "continuation-closeout")" +echo "$compat_output" | grep -q "handover preflight:" \ + || fail "continuation-closeout 互換 trigger が出ない: $compat_output" + +handover_output="$(run_hook "handover")" +echo "$handover_output" | grep -q "handover preflight:" \ + || fail "handover trigger が出ない: $handover_output" + +force_output="$(printf '{"user_prompt": "ただの相談"}' | HANDOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$force_output" | grep -q "handover preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "alias: 未検出" || fail "FORCE時に alias 推定が出ない: $force_output" + +compat_force_output="$(printf '{"user_prompt": "ただの相談"}' | TAKEOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$compat_force_output" | grep -q "handover preflight:" || fail "旧TAKEOVER_PREFLIGHT_FORCE時の preflight が出ない: $compat_force_output" + +echo "PASS: handover-preflight" diff --git a/.codex/hooks/scripts/post-merge-gate.sh b/.codex/hooks/scripts/post-merge-gate.sh new file mode 100755 index 000000000..c10c54ba7 --- /dev/null +++ b/.codex/hooks/scripts/post-merge-gate.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) post-merge gate. +# `gh pr merge` の直接実行を止め、マージ担当者が ccprmerd 正本を読む wrapper へ誘導する。 +# [2026-06-20][feat] +# 背景: +# - ユーザー依頼意図: マージ担当者がマージ作業の中で必ず `;ccprmerd` 相当の +# Typinator 正本を読み、マージ後確認まで含めて進める運用にしたい。 +# - 守るべき業務ルール: マージ処理は「PRレビュー → マージ時チェックリスト読み込み → +# マージ → 同じチェックリストで反映確認」までを一連の作業として扱う。 +# - 他案不採用理由: SKILL.md に手順だけ書く案は、AI が直接 `gh pr merge` を叩く経路を残し、 +# ccprmerd 読み込み漏れを機械的に防げないため不採用。 +# 対応: PreToolUse(Bash) で直接 `gh pr merge` を deny し、`merge-pr.py` 経由へ誘導する。 +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: dirty cleanup のレビューで、`xargs gh pr merge` が直接マージ禁止を迂回できると判明した。 +# - 守るべき業務ルール: 実行ラッパーを挟んでも `gh pr merge` は merge-pr.py 経由へ統一する。 +# - 他案不採用理由: 単純な文字列検索は説明文を誤検知し、`xargs` 全面禁止は無関係な利用まで止めるため不採用。 +# 対応: xargs のオプションを除いた実行コマンドを既存の gh サブコマンド解析へ渡す。 +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: web2context のレビューで、`nohup gh pr merge` が直接マージ禁止を迂回できると判明した。 +# - 守るべき業務ルール: 実行方法を変える標準ラッパーを挟んでも merge-pr.py 経由を強制する。 +# - 他案不採用理由: `nohup` だけを個別検知する案は `setsid` / `nice` で同じ抜け道を残すため不採用。 +# 対応: 副作用のない実行ラッパー3種と各オプションを prefix parser で正規化する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +read_stdin + +COMMAND="$(extract_field command)" + +if [ -z "$COMMAND" ]; then + printf '{"continue":true}\n' + exit 0 +fi + +if [ "${AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE:-0}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass post-merge-gate allow '{"env":"AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE"}' 2>/dev/null || true + printf '{"continue":true}\n' + exit 0 +fi + +if COMMAND_TEXT="$COMMAND" python3 - <<'PY' +from __future__ import annotations + +import os +import re +import shlex +import sys + +command = os.environ.get("COMMAND_TEXT", "") +RESERVED_PREFIXES = {"if", "while", "until"} + +def normalize_newline_separators(text: str) -> str: + """Turn unquoted newlines into command separators before tokenization.""" + result: list[str] = [] + quote = None + escaped = False + for char in text: + if escaped: + result.append(char) + escaped = False + continue + if char == "\\" and quote != "'": + result.append(char) + escaped = True + continue + if quote is not None: + result.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + result.append(char) + continue + result.append(";" if char in "\r\n" else char) + return "".join(result) + +def split_segments(text: str) -> list[list[str]]: + try: + lexer = shlex.shlex(normalize_newline_separators(text), posix=True, punctuation_chars=";&|(){}") + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return [] + segments: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + if current: + segments.append(current) + current = [] + else: + current.append(token) + if current: + segments.append(current) + return segments + +def split_segments_with_dynamic_commands(text: str) -> list[list[str]]: + """Keep the normal parse and add a view where command expansions are one token.""" + masked = re.sub(r"\$\([^()\r\n]*\)", "$DYNAMIC_COMMAND", text) + masked = re.sub(r"\$\{[^{}\r\n]+\}", "$DYNAMIC_COMMAND", masked) + segments = split_segments(text) + if masked != text: + segments.extend(split_segments(masked)) + return segments + +def iter_backticks(text: str) -> list[str]: + chunks: list[str] = [] + start = None + escaped = False + quote = None + for index, char in enumerate(text): + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if quote == "'": + if char == "'": + quote = None + continue + if start is None and char in {"'", '"'}: + # 二重引用符内の ' は literal(single-quote モードに入れない)。 + # これを怠ると `echo "'`...`'"` で backtick command-sub を見逃す。 + if char == "'" and quote == '"': + continue + quote = None if quote == char else char + continue + if char != "`": + continue + if start is None: + start = index + 1 + else: + chunks.append(text[start:index]) + start = None + return chunks + +def iter_dollar_subshells(text: str) -> list[str]: + chunks: list[str] = [] + index = 0 + quote = None + escaped = False + while index < len(text): + char = text[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\": + escaped = True + index += 1 + continue + if char == "'" and quote != '"': + quote = None if quote == "'" else "'" + index += 1 + continue + if char == '"' and quote != "'": + quote = None if quote == '"' else '"' + index += 1 + continue + if quote == "'" or not text.startswith("$(", index): + index += 1 + continue + start = index + depth = 1 + cursor = start + 2 + inner_quote = None + inner_escaped = False + while cursor < len(text): + char = text[cursor] + if inner_escaped: + inner_escaped = False + elif char == "\\": + inner_escaped = True + elif inner_quote: + if char == inner_quote: + inner_quote = None + elif char in {"'", '"'}: + inner_quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + chunks.append(text[start + 2:cursor]) + break + cursor += 1 + index = cursor + 1 + return chunks + +def strip_prefix(tokens: list[str]) -> list[str]: + index = 0 + while index < len(tokens): + token = os.path.basename(tokens[index]) + if "=" in tokens[index] and tokens[index].split("=", 1)[0].replace("_", "A").isalnum(): + index += 1 + continue + if token in {"command", "builtin", "exec"}: + index += 1 + if index < len(tokens) and tokens[index] == "-p": + index += 1 + continue + if token == "time": + index += 1 + if index < len(tokens) and tokens[index] == "-p": + index += 1 + continue + if token == "sudo": + index += 1 + while index < len(tokens) and tokens[index].startswith("-"): + opt = tokens[index] + index += 1 + if opt in {"-u", "-g", "-h", "-p", "-C", "-T"} and index < len(tokens): + index += 1 + continue + if token == "env": + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt in {"-u", "--unset", "-C", "--chdir"} and index + 1 < len(tokens): + index += 2 + continue + if opt.startswith("-"): + index += 1 + continue + if "=" in opt and opt.split("=", 1)[0].replace("_", "A").isalnum(): + index += 1 + continue + break + continue + if token in {"nohup", "setsid"}: + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt == "--": + index += 1 + break + if not opt.startswith("-"): + break + index += 1 + continue + if token == "nice": + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt == "--": + index += 1 + break + if opt in {"-n", "--adjustment"} and index + 1 < len(tokens): + index += 2 + continue + if opt.startswith("--adjustment=") or (opt.startswith("-") and opt[1:].lstrip("+").isdigit()): + index += 1 + continue + break + continue + break + return tokens[index:] + +def gh_subcommand(tokens: list[str]) -> list[str]: + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "gh": + return [] + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "--": + index += 1 + break + if token in {"-R", "--repo", "--hostname", "--config"}: + index += 2 + continue + if token.startswith("-R") and len(token) > 2: + index += 1 + continue + if token.startswith("--repo=") or token.startswith("--hostname=") or token.startswith("--config="): + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def indirect_subcommand(tokens: list[str]) -> list[str]: + """Fail closed when a dynamic command position could expand to gh.""" + tokens = strip_prefix(tokens) + if not tokens: + return [] + command = tokens[0] + is_simple_var = command.startswith("$") and command[1:].replace("_", "A").isalnum() + is_dynamic_expansion = ( + (command.startswith("${") and command.endswith("}")) + or command.startswith("$(") + or command.startswith("`") + ) + if not (is_simple_var or is_dynamic_expansion): + return [] + # [2026-07-18][fix] + # 背景: + # - PR1018再レビューで `${GH:-gh}` / `${GH?err}` / `$(printf gh)` のような + # command-position expansionが単純変数判定を外れ、直接mergeを実行できると判明した。 + # - 守るべき業務ルール: 実行ファイルを静的に確定できない `pr merge` はfail-closedにする。 + # - 他案不採用理由: shell parameter expansionを評価してghか判定する案は、default/error演算子や + # command substitutionの実行環境を再実装することになり、別形式で再びfail-openするため不採用。 + # 対応: 動的command tokenの後ろからpr subcommand境界を探し、展開形式を限定せず拒否する。 + for index, token in enumerate(tokens[1:], start=1): + if token == "pr": + return tokens[index:] + return [] + +def strip_pr_options(tokens: list[str]) -> list[str]: + index = 0 + while index < len(tokens): + token = tokens[index] + if token in {"-R", "--repo", "--hostname", "--config"}: + index += 2 + continue + if token.startswith("-R") and len(token) > 2: + index += 1 + continue + if token.startswith("--repo=") or token.startswith("--hostname=") or token.startswith("--config="): + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def xargs_command(tokens: list[str]) -> list[str]: + """Return the command executed by xargs, or an empty list.""" + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "xargs": + return [] + options_with_value = { + "-a", "--arg-file", "-d", "--delimiter", "-E", "--eof", "-I", "--replace", "-J", + "-L", "--max-lines", "-n", "--max-args", "-P", "--max-procs", + "-R", "-S", "-s", "--max-chars", + } + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "--": + return tokens[index + 1:] + if token in options_with_value: + index += 2 + continue + if token.startswith("--") and "=" in token: + index += 1 + continue + if token.startswith(("-d", "-E", "-I", "-J", "-L", "-n", "-P", "-R", "-S", "-s")) and len(token) > 2: + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def find_exec_command(tokens: list[str]) -> list[str]: + """Return the command passed to find -exec/-execdir, or an empty list.""" + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "find": + return [] + for index, token in enumerate(tokens): + if token in {"-exec", "-execdir"}: + return tokens[index + 1:] + return [] + +def candidate_commands(segment: list[str]) -> list[list[str]]: + candidates = [segment] + for index, token in enumerate(segment[:-1]): + if token in RESERVED_PREFIXES: + candidates.append(segment[index + 1:]) + return candidates + +def contains_generated_shell_command(text: str, depth: int) -> bool: + """Detect a direct merge emitted by printf/echo inside command substitution.""" + for chunk in iter_dollar_subshells(text) + iter_backticks(text): + for segment in split_segments(chunk): + stripped = strip_prefix(segment) + if not stripped or os.path.basename(stripped[0]) not in {"echo", "printf"}: + continue + for token in stripped[1:]: + if contains_direct_merge(token, depth + 1): + return True + return False + +def contains_direct_merge(text: str, depth: int = 0) -> bool: + if depth > 3: + return False + for chunk in iter_backticks(text): + if contains_direct_merge(chunk, depth + 1): + return True + for chunk in iter_dollar_subshells(text): + if contains_direct_merge(chunk, depth + 1): + return True + for segment in split_segments_with_dynamic_commands(text): + for candidate in candidate_commands(segment): + commands = [candidate] + wrapped = xargs_command(candidate) + if wrapped: + commands.append(wrapped) + find_wrapped = find_exec_command(candidate) + if find_wrapped: + commands.append(find_wrapped) + for nested_tokens in (wrapped, find_wrapped): + if nested_tokens: + nested_text = " ".join(shlex.quote(token) for token in nested_tokens) + if contains_direct_merge(nested_text, depth + 1): + return True + for command_tokens in commands: + sub = gh_subcommand(command_tokens) + if not sub: + sub = indirect_subcommand(command_tokens) + if sub and sub[0] == "pr": + pr_sub = strip_pr_options(sub[1:]) + if pr_sub and pr_sub[0] == "merge": + return True + stripped = strip_prefix(segment) + if stripped and os.path.basename(stripped[0]) == "eval": + for token in stripped[1:]: + if contains_direct_merge(token, depth + 1): + return True + if stripped and os.path.basename(stripped[0]) in {"bash", "sh", "zsh"}: + for i, token in enumerate(stripped[1:], start=1): + if token in {"-c", "-lc"} and i + 1 < len(stripped): + payload = stripped[i + 1] + if contains_generated_shell_command(payload, depth) or contains_direct_merge(payload, depth + 1): + return True + return False + +sys.exit(0 if contains_direct_merge(command) else 1) +PY +then + # telemetry(harness-checkup): deny を記録(fail-open)。 + agent_hub_telemetry_log hook_deny post-merge-gate deny 2>/dev/null || true + # [2026-07-31][docs] Issue #1105: 回避策を deny メッセージに明示する + # 背景: + # - 報告は「PR 本文(--body)に説明目的でコマンド例を書いただけでブロックされる」だったが、実測すると + # ブロックされるのは **二重引用符内に backtick / $() で書いた場合だけ**で、これは bash が実際に + # コマンド置換として実行する形=真陽性だった(単一引用符・素のテキスト・--body-file は通る)。 + # - よって Issue の第一案「判定対象を実行される先頭コマンドに限定する」は採らない。採ると + # `--body "$(...)"` のような本物の実行経路を見逃し、正しい安全検査を弱めるため。 + # - 実際に不足していたのは「なぜ止まったか・どう書けば通るか」の案内なので、Issue の第二案 + # (メッセージへ回避策を明示)だけを実施する。 + emit_deny "[hook:post-merge-gate] 直接の gh pr merge は禁止です。マージ担当者が ccprmerd 正本を読むため、python3 ~/business/AGENT-HUB/skills/post-merge/scripts/merge-pr.py を使ってください。 +説明文・PR 本文にコマンド例を書いただけで止まった場合: 二重引用符の中の backtick や \$() は bash が実際に実行するため検知対象です。単一引用符で囲むか --body-file を使ってください。 +リリース昇格 / forward-merge(head が main 等の長寿命ブランチ)の PR は、既定の --squash だと履歴が乖離します。--method merge --no-delete-branch --no-cleanup を明示してください。 +緊急時のみ AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE=1 を明示できます。" +fi + +printf '{"continue":true}\n' diff --git a/.codex/hooks/scripts/post-merge-gate.test.sh b/.codex/hooks/scripts/post-merge-gate.test.sh new file mode 100755 index 000000000..b45a94b46 --- /dev/null +++ b/.codex/hooks/scripts/post-merge-gate.test.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/post-merge-gate.sh" +PASS=0 +FAIL=0 + +run_hook() { + local command="$1" + printf '{"tool_name":"Bash","tool_input":{"command":%s}}\n' "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$command")" | bash "$SCRIPT" +} + +run_shell_hook() { + local command="$1" + printf '{"tool_name":"Shell","tool_input":{"command":%s}}\n' "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$command")" | bash "$SCRIPT" +} + +expect_block() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if "[hook:post-merge-gate]" not in payload.get("permissionDecisionReason", ""): + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue":true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_shell_block() { + local name="$1" + local command="$2" + local out + out="$(run_shell_hook "$command" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +data = json.loads(os.environ["OUT"]) +payload = data.get("hookSpecificOutput", {}) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block "direct gh pr merge" "gh pr merge 123 --squash --delete-branch" +expect_block "repo option gh pr merge" "gh --repo owner/repo pr merge 123 --squash" +expect_block "short repo option gh pr merge" "gh -Rowner/repo pr merge 123" +expect_block "pr-level repo option gh pr merge" "gh pr --repo owner/repo merge 123" +expect_block "pr-level short repo option gh pr merge" "gh pr -Rowner/repo merge 123" +expect_block "command prefix gh pr merge" "command gh pr merge 123" +expect_block "shell nested gh pr merge" "bash -lc 'gh pr merge 123 --squash'" +expect_block "wrapper mention does not bypass direct merge" "echo merge-pr.py && gh pr merge 123 --squash" +expect_block "if statement gh pr merge" "if gh pr merge 123 --squash; then echo ok; fi" +expect_block "while statement gh pr merge" "while gh pr merge 123; do break; done" +expect_block "eval gh pr merge" "eval \"gh pr merge 123 --squash\"" +expect_block "backtick gh pr merge" "echo \`gh pr merge 123\`" +expect_block "quoted dollar subshell gh pr merge" "echo \"\$(gh pr merge 123)\"" +expect_block "double-quoted single-quote dollar subshell bypass" "echo \"'\$(gh pr merge 123)'\"" +expect_block "double-quoted single-quote backtick bypass" "echo \"'\`gh pr merge 123\`'\"" +expect_block "pipe through xargs gh pr merge" "printf '123\\n' | xargs gh pr merge" +expect_block "xargs with options gh pr merge" "xargs -n1 gh pr merge <<<123" +expect_block "macOS xargs replacement gh pr merge" "xargs -J % gh pr merge % <<<123" +expect_block "macOS xargs size gh pr merge" "xargs -S 255 gh pr merge <<<123" +expect_block "macOS xargs replacements gh pr merge" "xargs -R 1 gh pr merge <<<123" +expect_block "GNU xargs delimiter gh pr merge" "printf '123\\n' | xargs -d '\\n' gh pr merge" +expect_block "newline separated gh pr merge" $'printf ok\ngh pr merge 123' +expect_block "shell generated gh pr merge" "bash -c \"\$(printf 'gh pr merge 123')\"" +expect_block "find exec gh pr merge" "find . -exec gh pr merge 123 {} \\;" +expect_block "xargs shell nested gh pr merge" "printf '123\\n' | xargs sh -c 'gh pr merge \"\$0\"'" +expect_block "find shell nested gh pr merge" "find . -exec sh -c 'gh pr merge 123' \\;" +expect_block "variable command gh pr merge" "GH=gh; \"\$GH\" pr merge 123" +expect_block "default parameter expansion gh pr merge" 'GH=gh; "${GH:-gh}" pr merge 123' +expect_block "error parameter expansion gh pr merge" 'GH=gh; "${GH?err}" pr merge 123' +expect_block "command substitution gh pr merge" '$(printf gh) pr merge 123' +expect_block "nohup gh pr merge" "nohup gh pr merge 123" +expect_block "setsid gh pr merge" "setsid -f gh pr merge 123" +expect_block "nice gh pr merge" "nice -n 5 gh pr merge 123" +expect_shell_block "Shell tool gh pr merge" "gh pr merge 123" + +expect_allow "pr view allowed" "gh pr view 123" +expect_allow "wrapper allowed" "python3 ~/business/AGENT-HUB/skills/post-merge/scripts/merge-pr.py 123 --confirm-read" +expect_allow "text mention allowed" "echo 'gh pr merge 123 should use wrapper'" +expect_allow "single quoted dollar subshell text allowed" "echo '\$(gh pr merge 123)'" +expect_allow "single quoted backtick text allowed" "echo '\`gh pr merge 123\`'" + +# [2026-07-31][test] Issue #1105: deny メッセージが回避策を案内することを固定する。 +# 実測の結果、ブロックされるのは二重引用符内の backtick / $()(bash が実際に実行する形=真陽性)だけで、 +# 単一引用符・素のテキスト・--body-file は上の expect_allow 群のとおり通る。よって判定ロジックは変えず、 +# 「なぜ止まったか・どう書けば通るか」を案内するメッセージだけを追加した。その回帰を固定する。 +expect_deny_message_contains() { + local name="$1" + local command="$2" + local needle="$3" + local out + out="$(run_hook "$command" 2>&1)" + if OUT="$out" NEEDLE="$needle" python3 - <<'PYCHECK' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +reason = data.get("hookSpecificOutput", {}).get("permissionDecisionReason", "") +sys.exit(0 if os.environ["NEEDLE"] in reason else 1) +PYCHECK + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_deny_message_contains "deny message points at --body-file workaround" "gh pr merge 123" "--body-file" +expect_deny_message_contains "deny message explains single quotes" "gh pr merge 123" "単一引用符" +expect_deny_message_contains "deny message still points at the wrapper" "gh pr merge 123" "merge-pr.py" + +printf 'post-merge-gate tests: %s passed, %s failed\n' "$PASS" "$FAIL" +test "$FAIL" -eq 0 diff --git a/.codex/hooks/scripts/pre-implementation-check.sh b/.codex/hooks/scripts/pre-implementation-check.sh new file mode 100755 index 000000000..4dc2732fd --- /dev/null +++ b/.codex/hooks/scripts/pre-implementation-check.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# UserPromptSubmit フック — 軽量リマインダー(重い処理はしない) +# docs/ の構成を検出し、3層読み込み戦略のリマインダーを出力 +# +# 設置先: .claude/hooks/scripts/pre-implementation-check.sh +# トリガー: UserPromptSubmit +# タイムアウト: 5秒 +# +# [2026-03-21][fix] +# 背景: +# - ユーザー依頼意図: PR30レビューで、実装前リマインダーを Claude が次の行動判断に使える状態へ直したい。 +# - 守るべき業務ルール: UserPromptSubmit の非ブロッキング hook は、モデルへ渡したい文言を stdout に出す必要がある。 +# - 他案不採用理由: stderr へ出す方式のままでは、警告文が人間向けログに留まり、実装前コンテキストとして機能しない。 +# 対応: 非ブロッキング成功のまま stdout 出力へ統一し、プロジェクト構成に応じたリマインダーを Claude に渡す。 +# +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: AGENT-HUB の UserPromptSubmit hook が毎回大きなリマインダーを表示し、 +# hook失敗のように見えて作業体験を悪化させているため静かにしたい。 +# - 守るべき業務ルール: CaD確認自体はAGENT-HUB運用で必須。ただし通常プロンプトごとに可視出力して +# 失敗表示と混同させてはいけない。 +# - 他案不採用理由: +# 1) stderrへ戻す案はモデル文脈に渡らず、PR30で不採用済みのため不採用。 +# 2) settingsだけ残して実体を削除する案は hook 実行時の参照切れを再発させるため不採用。 +# 3) CaDリマインダーを完全削除する案は必須運用を失うため不採用。 +# 対応: 通常は無音成功にし、明示的に `AGENT_HUB_SHOW_PRE_IMPL_REMINDER=1` を指定した場合だけ stdout に出す。 + +# プロジェクトルートを検出 +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" + +if [ "${AGENT_HUB_SHOW_PRE_IMPL_REMINDER:-0}" != "1" ]; then + exit 0 +fi + +if [ -d "$PROJECT_DIR/docs/business" ]; then + # docs/business/ が存在する場合: 3層読み込み戦略リマインダー + cat <<'REMINDER' +⚠️ SSOT 3層読み込み戦略を実行せよ: +Layer 1: CLAUDE.md + rules + prd-active Context Summary +Layer 2: business-design.md / BUSINESS_RULES.md の目次→関係セクション特定 +Layer 3: 変更スコープに応じたSSOTの該当セクションだけ全文読み ++ CaD不採用パターンをブロックリスト化 → サブエージェントに引き渡し ++ PM Agent の直接実装禁止 → サブエージェントに委譲 +REMINDER +else + # docs/business/ が存在しない場合: CaD確認リマインダー + cat <<'REMINDER' +⚠️ CaD確認必須: 変更対象の不採用理由をブロックリスト化 → サブエージェントに引き渡し +REMINDER +fi diff --git a/.codex/hooks/scripts/stop-quality-check.sh b/.codex/hooks/scripts/stop-quality-check.sh new file mode 100755 index 000000000..05085b2c2 --- /dev/null +++ b/.codex/hooks/scripts/stop-quality-check.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。薄いラッパーでlib/の共通ロジックを呼び出す。 +# 対応: Stop → lib/quality-check-common.sh の run_quality_check_hook を呼出。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/quality-check-common.sh" + +run_quality_check_hook \ + "stop-quality-check" \ + "$SCRIPT_DIR/.." \ + "No file changes detected - research/planning task, skipping quality check." diff --git a/.codex/hooks/scripts/storage-url-pr-gate.sh b/.codex/hooks/scripts/storage-url-pr-gate.sh new file mode 100755 index 000000000..8e8452887 --- /dev/null +++ b/.codex/hooks/scripts/storage-url-pr-gate.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。PreToolUseでPR作成前にStorage URL全件検証。 +# 対応: jtt-cms storage-url-pr-gate.sh をポート。lib/hook-io.sh + lib/storage-url-common.py を使用。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「PR作成前ゲートが環境差で無効化されず、常に同じ判定になること」。 +# 業務ルールとして、セキュリティ/品質ゲートは fail-open(失敗時素通り)を禁止する。 +# 代替案として `origin/main` 固定 + `|| true` を維持すると、 +# ブランチ構成差やremote未設定時に検査がスキップされるため不採用。 +# 対応: ベースブランチ解決を動的化し、diff取得や検査失敗時は明示denyに変更。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +resolve_base_ref() { + local cwd="$1" + + # 1) origin/HEAD を優先 + local remote_head + remote_head="$(git -C "$cwd" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || true)" + if [ -n "$remote_head" ]; then + echo "${remote_head#refs/remotes/}" + return 0 + fi + + # 2) origin/main または origin/master + if git -C "$cwd" rev-parse --verify origin/main >/dev/null 2>&1; then + echo "origin/main" + return 0 + fi + if git -C "$cwd" rev-parse --verify origin/master >/dev/null 2>&1; then + echo "origin/master" + return 0 + fi + + # 3) 最後のフォールバック: ローカル main/master + if git -C "$cwd" rev-parse --verify main >/dev/null 2>&1; then + echo "main" + return 0 + fi + if git -C "$cwd" rev-parse --verify master >/dev/null 2>&1; then + echo "master" + return 0 + fi + + return 1 +} + +read_stdin +COMMAND=$(extract_field command) + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `gh pr create`(複数空白)や `gh --repo owner/repo pr create` のように +# gh のグローバルオプション付き呼び出しが固定文字列 `gh pr create` に一致せず +# fail-open(ゲートをスルー)する脆弱性を修正したい。 +# 守るべき業務ルール: セキュリティ/品質ゲートは fail-open 禁止(2026-03-04 CaD と同型)。 +# 他案不採用理由: +# 1) `grep -qF "gh pr create"` を維持しつつ空白を `[[:space:]]*` に変えるだけ → 長形式オプション +# (--repo, --base 等) を見逃すため不採用。 +# 2) コマンド全体を解析する案 → shlex が必要で bash のみより複雑。正規表現の方が保守しやすい。 +# 対応: grep -qE で gh のグローバルオプション(短形式 -R / 長形式 --repo 等)と複数空白を許容する正規表現に変更。 +# [2026-05-27][fix] review follow-up: +# --repo / -R のように値を別トークンで取るグローバルオプションも消費する。値なしオプションだけを +# 許容する旧パターンでは `gh --repo owner/repo pr create` が early exit して fail-open するため不採用。 +# [2026-05-28][fix] issue #210 / v3.5.6 regression fix: +# gh が許容する連結形式 `-Rowner/repo`(値を別トークンにせず短縮形へ glue)も消費する。 +# #201/#213 hardening で `-R[^[:space:]]+` 分岐が脱落し、`gh -Rowner/repo pr create` が +# GH_GLOBAL_OPTS にマッチせず early exit → storage URL gate を fail-open する退化が入っていた。 +# `-[A-Za-z]+` 分岐は `-Rowner/repo` の `/` で止まるため連結 repo 値を消費できない。実機検証で +# gh は `-Rowner/repo` を受理するため(git の連結 `-C/path` は逆に弾かれる)、本分岐の復活が必須。 +readonly GH_GLOBAL_OPTS='([[:space:]]+((-R|--repo|--hostname)[[:space:]]+[^[:space:]]+|-R[^[:space:]]+|--repo=[^[:space:]]+|--hostname=[^[:space:]]+|-[A-Za-z]+|--[A-Za-z0-9_-]+))*' +if ! echo "$COMMAND" | grep -qE "gh${GH_GLOBAL_OPTS}[[:space:]]+pr[[:space:]]+create"; then + exit 0 +fi + +CWD=$(extract_field cwd) +if [ -z "$CWD" ]; then + CWD="." +fi + +BASE_REF="" +if ! BASE_REF="$(resolve_base_ref "$CWD")"; then + emit_deny "[hook:storage-url-pr-gate] 比較対象ブランチ(origin/HEAD, main, master)を解決できません。ベースブランチを取得してから再実行してください。" +fi + +set +e +CHANGED_FILES=$(git -C "$CWD" diff --name-only --diff-filter=ACMR "$BASE_REF"...HEAD 2>/dev/null) +DIFF_STATUS=$? +set -e + +if [ "$DIFF_STATUS" -ne 0 ]; then + emit_deny "[hook:storage-url-pr-gate] 変更ファイル差分の取得に失敗しました(base: $BASE_REF)。リポジトリ状態を確認してください。" +fi + +if [ -z "$CHANGED_FILES" ]; then + exit 0 +fi + +MIGRATION_FILES=$(echo "$CHANGED_FILES" | grep -E '^supabase/migrations/.*\.sql$' || true) +if [ -z "$MIGRATION_FILES" ]; then + exit 0 +fi + +FILE_ARGS=() +while IFS= read -r mf; do + FILE_ARGS+=("$CWD/$mf") +done <<< "$MIGRATION_FILES" + +set +e +DENY_REASON=$(python3 "$SCRIPT_DIR/../lib/storage-url-common.py" gate "${FILE_ARGS[@]}" 2>/dev/null) +GATE_STATUS=$? +set -e + +if [ "$GATE_STATUS" -eq 0 ]; then + exit 0 +fi + +if [ "$GATE_STATUS" -eq 1 ] && [ -n "$DENY_REASON" ]; then + emit_deny "$DENY_REASON" +fi + +emit_deny "[hook:storage-url-pr-gate] Storage URL検証処理でエラーが発生しました。ログを確認して再実行してください。" diff --git a/.codex/hooks/scripts/takeover-preflight.sh b/.codex/hooks/scripts/takeover-preflight.sh new file mode 100755 index 000000000..0a3cc6160 --- /dev/null +++ b/.codex/hooks/scripts/takeover-preflight.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Compatibility wrapper. Handover is the canonical preflight name. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec bash "$SCRIPT_DIR/handover-preflight.sh" diff --git a/.codex/hooks/scripts/takeover-preflight.test.sh b/.codex/hooks/scripts/takeover-preflight.test.sh new file mode 100755 index 000000000..b388fa494 --- /dev/null +++ b/.codex/hooks/scripts/takeover-preflight.test.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +fi +HOOK="$SCRIPT_DIR/takeover-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +extract_field() { + printf "%s\n" "$1" | sed -n "s/^-[[:space:]]*$2: //p" +} + +is_agent_hub_source_repo() { + [ -f "$REPO_ROOT/DISTRIBUTION.yaml" ] && [ -f "$REPO_ROOT/hook-registry.yaml" ] +} + +assert_exact_scope() { + local output="$1" + local expected="$2" + local scope + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + [ "$scope" = "$expected" ] || fail "scope が期待値と一致しない: $output" +} + +assert_scoped_path() { + local output="$1" + local category="$2" + local scope + local expected + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + expected="$HOME/.agent-hub/$category/$scope/current.md" + printf "%s\n" "$output" | grep -Fq "$expected" \ + || fail "$category が scope と一致しない: $output" +} + +run_hook() { + local prompt="$1" + printf '{"user_prompt": "%s"}' "$prompt" | CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +negative_output="$(run_hook "評価わんこについて。続きではなく概要を教えて")" +[ -z "$negative_output" ] || fail "否定文は無音であるべき: $negative_output" + +negative_finish_output="$(run_hook "終了整理は不要です")" +[ -z "$negative_finish_output" ] || fail "否定文は無音であるべき: $negative_finish_output" + +negative_closeout_output="$(run_hook "Closeout整理はいらない")" +[ -z "$negative_closeout_output" ] || fail "否定文は無音であるべき: $negative_closeout_output" + +negative_work_output="$(run_hook "作業終了ではないです")" +[ -z "$negative_work_output" ] || fail "否定文は無音であるべき: $negative_work_output" + +representative_prompt="作業終了。今回の内容を終了整理して。GBrain候補は僕の確認待ち、SSOTとTech G-Brainは自動判定で。未完了がある時だけTakeoverも更新して。" +representative_output="$(run_hook "$representative_prompt")" +echo "$representative_output" | grep -q "handover preflight:" \ + || fail "代表入力文で preflight が出ない: $representative_output" +echo "$representative_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "代表入力文で handover manual が出ない: $representative_output" + +closeout_word_output="$(run_hook "終了整理")" +echo "$closeout_word_output" | grep -q "handover preflight:" \ + || fail "終了整理単独で preflight が出ない: $closeout_word_output" + +closeout_compat_output="$(run_hook "Closeout整理")" +echo "$closeout_compat_output" | grep -q "handover preflight:" \ + || fail "Closeout整理で preflight が出ない: $closeout_compat_output" + +hyoka_output="$(run_hook "評価わんこの続き")" +echo "$hyoka_output" | grep -q "handover preflight:" \ + || fail "handover preflight が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q ".agent-hub/handovers/jtt-system/hyoka-wanko/current.md" \ + || fail "評価わんこの handover_path が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q ".agent-hub/takeovers/jtt-system/hyoka-wanko/current.md" \ + || fail "評価わんこの legacy_path が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "handover manual が出ない: $hyoka_output" + +admin_output="$(run_hook "引継ぎ書つくって")" +assert_scoped_path "$admin_output" "handovers" + +compat_output="$(run_hook "continuation-closeout")" +echo "$compat_output" | grep -q "handover preflight:" \ + || fail "continuation-closeout 互換 trigger が出ない: $compat_output" + +force_output="$(printf '{"user_prompt": "ただの相談"}' | TAKEOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$force_output" | grep -q "handover preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "alias: 未検出" || fail "FORCE時に alias 推定が出ない: $force_output" + +compat_force_output="$(printf '{"user_prompt": "ただの相談"}' | AGENT_MEMORY_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$compat_force_output" | grep -q "handover preflight:" || fail "旧AGENT_MEMORY_PREFLIGHT_FORCE 時の preflight が出ない: $compat_force_output" + +if is_agent_hub_source_repo; then + agent_hub_reflection_output="$(run_hook "ふり返りをお願い")" + assert_exact_scope "$agent_hub_reflection_output" "AGENT-HUB/root" + assert_scoped_path "$agent_hub_reflection_output" "handovers" +fi + +echo "PASS: takeover-preflight" diff --git a/.codex/hooks/scripts/telemetry-lib.sh b/.codex/hooks/scripts/telemetry-lib.sh new file mode 100755 index 000000000..163432ccf --- /dev/null +++ b/.codex/hooks/scripts/telemetry-lib.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# telemetry-lib.sh — shared harness telemetry function. +# +# Provides: agent_hub_telemetry_log [meta_json] +# +# 絶対方針: fail-open。 +# - いかなるエラーでも exit 0・ブロックしない・stdout に出力しない。 +# - git / date / python3 / mkdir のいずれかが欠損・失敗しても黙って return 0。 +# - AGENT_HUB_TELEMETRY_DISABLE=1 で完全無効化(何もしない)。 +# - 外部ネットワーク不使用。ローカル JSONL 追記のみ。 +# +# 他 hook からの読み込み(配布先で lib が無くても壊さない no-op fallback): +# . "$(dirname "$0")/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } +# +# 出力先: ${AGENT_HUB_TELEMETRY_DIR:-$HOME/.agent-hub/telemetry}/YYYY-MM-DD.jsonl +# レコード: {"ts","tool","pj","event_type","name","outcome","meta"} + +# 注意: 本ファイルは他 hook から `source` されるため set -e を使わない。 +# 呼び出し元(block-main-commit.sh 等)が set -euo pipefail を設定済みの場合、 +# ここでの未定義変数や失敗コマンドは親の set -e で source 全体を中断しうる。 +# そのため全ての変数参照は ${VAR:-} 形式とし、外部コマンドは || true で包む。 + +agent_hub_telemetry_log() { + # fail-open: 無効化フック + [ "${AGENT_HUB_TELEMETRY_DISABLE:-0}" = "1" ] && return 0 + + local event_type="${1:-}" + local name="${2:-}" + local outcome="${3:-}" + local meta_json="${4:-}" + + # 引数不足でも黙って返す(ブロックしない) + [ -z "$event_type" ] && return 0 + + # 出力ディレクトリ解決(環境変数で上書き可。テスト用) + local base_dir="${AGENT_HUB_TELEMETRY_DIR:-${HOME:-}/.agent-hub/telemetry}" + local date_str + date_str="$(date +%Y-%m-%d 2>/dev/null || echo unknown)" + [ -z "$date_str" ] && date_str="unknown" + local out_file="$base_dir/$date_str.jsonl" + + # ディレクトリ作成(失敗は無視 → 後段の追記も失敗して return 0 に至る) + [ -d "$base_dir" ] || mkdir -p "$base_dir" 2>/dev/null || true + + # pj 解決(優先順: 環境変数 > CLAUDE_PROJECT_DIR > git root basename > PWD basename) + local pj="${AGENT_HUB_TELEMETRY_PJ:-}" + if [ -z "$pj" ]; then + if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then + pj="${CLAUDE_PROJECT_DIR##*/}" + else + local git_root="" + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ]; then + pj="${git_root##*/}" + else + pj="${PWD##*/}" + fi + fi + fi + [ -z "$pj" ] && pj="unknown" + + # ISO8601 UTC タイムスタンプ + local ts + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" + + # [2026-07-07][feat] harness phaseB: telemetry tool名を T_TOOL 由来で上書き可にする + local tool="${T_TOOL:-claude-code}" + + # JSON 1 行を組み立てて追記(python3 で値を escape-safe に)。 + # python3 が無い環境では純 bash で最小エスケープして追記する(fail-open)。 + if command -v python3 >/dev/null 2>&1; then + T_EVENT="$event_type" \ + T_NAME="$name" \ + T_OUTCOME="$outcome" \ + T_PJ="$pj" \ + T_TOOL="$tool" \ + T_TS="$ts" \ + T_META="$meta_json" \ + T_OUT="$out_file" \ + python3 - <<'PY' 2>/dev/null || true +import json +import os + + +def as_str(value: str) -> str: + return value if isinstance(value, str) else "" + + +meta_raw = os.environ.get("T_META", "") +meta_value = {} +if meta_raw: + try: + decoded = json.loads(meta_raw) + if isinstance(decoded, dict): + meta_value = decoded + else: + meta_value = {"value": decoded} + except Exception: + # JSON でなければ文字列として保持(破損させない) + meta_value = {"raw": meta_raw} + +record = { + "ts": as_str(os.environ.get("T_TS", "")), + "tool": as_str(os.environ.get("T_TOOL", "claude-code")), + "pj": as_str(os.environ.get("T_PJ", "")), + "event_type": as_str(os.environ.get("T_EVENT", "")), + "name": as_str(os.environ.get("T_NAME", "")), + "outcome": as_str(os.environ.get("T_OUTCOME", "")), + "meta": meta_value, +} + +out_path = os.environ.get("T_OUT", "") +if not out_path: + raise SystemExit(0) + +try: + with open(out_path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") +except Exception: + pass +PY + else + # python3 無し: JSON パーサ/シリアライザが無いため meta_json を構造化オブジェクトとして + # 安全に組み込めない。 + # [2026-07-04][fix] Codexレビュー対応(PR #670 🟡1): + # 背景: 旧実装は meta_json(呼び出し元が渡す生JSON片、例 {"label":"foo"})に対して + # 文字列用の _telemetry_escape をそのまま適用したうえで "meta":%s (無クォート)へ + # 埋め込んでいた。meta_json 内にダブルクォート/バックスラッシュが含まれると + # エスケープと JSON 構造が二重に競合し、不正な JSONL 行になり得た。 + # 守るべき業務ルール: telemetry は fail-open かつ JSONL を絶対に壊さない。 + # 他案不採用理由: meta_json を素朴な文字列置換で「JSON オブジェクトとして」再構築する案は、 + # ネスト・エスケープの全パターンを網羅できずシェルだけでの安全な JSON 生成は非現実的なため不採用。 + # 対応: python3 無し環境では meta は常に空オブジェクト{}に固定し、元データは + # meta_raw に「文字列値」として安全にエスケープして退避する(構造は壊さず、情報も欠落させない)。 + _telemetry_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/ }" + s="${s//$'\r'/ }" + s="${s//$'\t'/ }" + printf '%s' "$s" + } + if [ -n "$meta_json" ]; then + printf '{"ts":"%s","tool":"%s","pj":"%s","event_type":"%s","name":"%s","outcome":"%s","meta":{},"meta_raw":"%s"}\n' \ + "$(_telemetry_escape "$ts")" \ + "$(_telemetry_escape "$tool")" \ + "$(_telemetry_escape "$pj")" \ + "$(_telemetry_escape "$event_type")" \ + "$(_telemetry_escape "$name")" \ + "$(_telemetry_escape "$outcome")" \ + "$(_telemetry_escape "$meta_json")" \ + >> "$out_file" 2>/dev/null || true + else + printf '{"ts":"%s","tool":"%s","pj":"%s","event_type":"%s","name":"%s","outcome":"%s","meta":{}}\n' \ + "$(_telemetry_escape "$ts")" \ + "$(_telemetry_escape "$tool")" \ + "$(_telemetry_escape "$pj")" \ + "$(_telemetry_escape "$event_type")" \ + "$(_telemetry_escape "$name")" \ + "$(_telemetry_escape "$outcome")" \ + >> "$out_file" 2>/dev/null || true + fi + fi + + return 0 +} diff --git a/.codex/sync-state.json b/.codex/sync-state.json new file mode 100644 index 000000000..c07c1d01b --- /dev/null +++ b/.codex/sync-state.json @@ -0,0 +1,18 @@ +{ + "tool": "codex-mcp-sync", + "generated_at": "2026-08-03T14:47:26.340637+00:00", + "source_commit": "09cea49", + "project": "agentmemory", + "codex_config_mode": "tracked", + "enabled_mcp": [ + "agentmemory-agentmemory", + "ai-worker-mcp", + "codebase-context-engine-agentmemory", + "context7", + "shintaro-gbrain", + "stitch", + "tech-gbrain" + ], + "optional_mcp": [], + "warnings": [] +} diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 000000000..71cd54dfc --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,98 @@ +{ + "version": 1, + "hooks": { + "beforeShellExecution": [ + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; bash \"$PROJECT_DIR/.cursor/hooks/scripts/block-destructive-git.sh\"'", + "timeout": 10 + }, + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/block-main-commit.sh\"'", + "timeout": 10 + }, + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/storage-url-pr-gate.sh\"'", + "timeout": 15 + }, + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/block-unauthorized-docs-file.sh\"'", + "timeout": 10 + }, + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/post-merge-gate.sh\"'", + "timeout": 10 + } + ], + "preToolUse": [ + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/block-skill-reverse-edit.sh\"'", + "timeout": 10, + "matcher": "Write|Edit|MultiEdit" + }, + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/block-unauthorized-docs-file.sh\"'", + "timeout": 10, + "matcher": "Edit|MultiEdit|Write" + } + ], + "sessionStart": [ + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/freshness-gate.sh\"'", + "timeout": 10 + }, + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/telemetry-log.sh\"'", + "timeout": 5 + } + ], + "beforeSubmitPrompt": [ + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/handover-preflight.sh\"'", + "timeout": 5 + }, + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/pre-implementation-check.sh\"'", + "timeout": 5 + } + ], + "subagentStop": [ + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/subagent-quality-check.sh\"'", + "timeout": 30, + "loop_limit": 10 + }, + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/telemetry-log.sh\"'", + "timeout": 5, + "loop_limit": 10 + } + ], + "stop": [ + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/stop-quality-check.sh\"'", + "timeout": 30, + "loop_limit": 10 + }, + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/telemetry-log.sh\"'", + "timeout": 5, + "loop_limit": 10 + } + ] + } +} diff --git a/.cursor/hooks/.hook-library-version b/.cursor/hooks/.hook-library-version new file mode 100644 index 000000000..8d5bf551a --- /dev/null +++ b/.cursor/hooks/.hook-library-version @@ -0,0 +1 @@ +v3.6.37 | profile: agentmemory diff --git a/.cursor/hooks/lib/code-quality-check.md b/.cursor/hooks/lib/code-quality-check.md new file mode 100644 index 000000000..384cad9b1 --- /dev/null +++ b/.cursor/hooks/lib/code-quality-check.md @@ -0,0 +1,205 @@ +# Code Quality Checklist(SubagentStop / Stop hook用) + + + +サブエージェントの作業完了時に、以下の観点で品質チェックを実施する。 +対象: 直前のサブエージェントが**新規作成・変更した**ファイルのみ。 + +--- + + +## コメント品質(Code as ドキュメント) + +### 必須コメント + +| 対象 | ルール | +|------|--------| +| 関数・メソッド | JSDoc / PHPDoc で @param, @returns を記載 | +| 複雑なロジック | 条件分岐3つ以上、正規表現 → 「なぜ」のコメント | +| マジックナンバー | 定数化 or コメントで意味を説明 | +| TODO / FIXME | 理由と期限を記載(// TODO(2025-03): ○○対応後に削除) | + +### 変更コメントの必須フォーマット + +既存コードに意味のある変更を加えた場合、以下のフォーマットでコメントを残すこと。 +**目的:** 次にAIがこの領域を修正する際に同じ過ちを繰り返さないための判断基準を残す。 + +``` +// [YYYY-MM-DD][fix|feat|refactor] +// 背景: ユーザーがその修正を依頼した理由・意図 +// 守るべき業務ルール・ブランド基準 +// 他の実装方法ではダメな理由の判断根拠 +// 対応: 実施した変更内容 +``` + +**背景に含めるべき3要素:** +1. ユーザーがその修正を依頼した理由・意図 +2. その領域で守るべき業務ルール・ブランド基準 +3. なぜ他の実装方法ではダメなのかの判断根拠 + +- [ ] 変更箇所に `[YYYY-MM-DD][fix|feat|refactor]` コメントがあるか +- [ ] 背景に「ユーザー意図」「業務ルール」「不採用理由」が含まれるか +- [ ] 次のAIが同じ判断ミスをしない情報が残っているか + + +--- + + +## 重複機能の禁止(DRY原則) + +| チェック項目 | 基準 | +|-------------|------| +| 既存検索義務 | 新コンポーネント・関数作成前に既存コードベースを検索したか | +| 適用範囲 | ロジック・スタイル定義・色・文言すべてに適用 | +| 類似機能の扱い | 新規作成ではなく既存を拡張・共通化すること | +| パラメータ化 | 同目的のコンポーネントは1つに統合しprops/パラメータで切替 | + +- [ ] 新規関数・コンポーネント作成前にGrep検索で既存を確認したか +- [ ] 同様のロジック・スタイル・文言が既に存在しないか +- [ ] 類似機能がある場合、新規作成ではなく既存を拡張したか + + +--- + + +## ハードコード防止 + +DB由来データ(店舗名、ロール、ステータス等)がコード内にリテラルで直書きされていないかチェックする。 + +### データ管理の優先順位 + +| 優先度 | 方法 | 対象 | +|--------|------|------| +| 1(最優先) | DBから取得 | 変更頻度があるもの: 店舗名、ブランドカラー、設定値、営業時間等 | +| 2 | 定数ファイルに定義 | 環境に依存しない固定値: ステータスEnum、カテゴリ種別等 | +| 3(最終手段) | ハードコード | ①②が不可能な場合のみ。理由をコメントに明記すること | + +**追加ルール:** 同じ値が2箇所以上に出現する場合、必ず①または②で一元管理すること。 + +### チェック項目 + +| 対象 | ルール | +|------|--------| +| ビジネスデータ直書き | 店舗名・ロール名・ステータス等がリテラル文字列で記述されていないか | +| 既存定数の未使用 | プロジェクトにModel定数・Enum・ValueObjectがあるのに文字列比較していないか | +| フロントのマスタデータ | コンポーネント内に選択肢リストがハードコードされていないか(propsまたはAPI経由にする) | +| 固有名詞の条件分岐 | `name.includes('固有名詞')` のような分岐がないか(IDまたはフラグで判定する) | +| TODO_DB / PLACEHOLDER | DB由来データを暫定的に書く場合、`// TODO_DB(YYYY-MM\|ISSUE-123): テーブル名.カラム名` または `// PLACEHOLDER(YYYY-MM\|ISSUE-123): 理由` が付いているか | + +### 許可パターン(チェック対象外) + +- Model / Enum / ValueObject 内の定数定義 +- テストファイル・Seeder・Factory +- config/ 配下の設定ファイル +- 定数ファイル(constants.ts 等) + +- [ ] 定数・設定値がDB or 定数ファイルから取得されているか +- [ ] 同じ値が2箇所以上にハードコードされていないか +- [ ] やむを得ないハードコードに理由コメントがあるか + + +--- + + +## 破壊的変更の事前確認 + +| チェック項目 | 基準 | +|-------------|------| +| 参照洗い出し | 関数・コンポーネント・スタイルの変更/削除前にgrep等で全参照箇所を特定 | +| 整合性修正 | 参照箇所が見つかった場合、全箇所を整合性を保って修正 | +| 報告義務 | 変更した全ファイルと箇所のサマリーをユーザーに報告 | + +- [ ] 変更・削除した関数の全参照箇所をGrepで確認したか +- [ ] 参照箇所を整合性を保って全て修正したか +- [ ] 変更ファイルと箇所のサマリーを報告したか + + +--- + + +## メタ情報コメント(Serena MCP検索対応) + +新規作成ファイルの冒頭に、検索可能なメタ情報コメントがあるか確認する。 +**既存ファイルへの軽微な修正(1-2行の変更)は対象外。** + +### TypeScript / JavaScript / React / React Native / Next.js + +```ts +/** + * @module モジュール名(PascalCase) + * @description 日本語で1行の概要。Serenaのsearch_for_patternで引っかかるキーワードを含める + * @related 関連モジュール名をカンマ区切り + * @stack react-native | react | nextjs ← プロジェクトのスタックを明記 + */ +``` + +### PHP / Laravel + +```php +/** + * @module モジュール名 + * @description 日本語で1行の概要 + * @related 関連クラス・モデル名 + * @stack laravel + */ +``` + +### 対象外(メタ情報コメント不要) +- 設定ファイル(.env, tailwind.config.*, tsconfig.json, composer.json等) +- テストファイル(テスト名が十分なドキュメント) +- 自動生成ファイル(migration以外のartisan generate等) +- package.json, Gemfile, requirements.txt等の依存定義 + +### 命名・配置 + +| チェック項目 | 基準 | +|-------------|------| +| シンボル命名 | 検索しやすい名前か(略語を避ける。ResCtrl → ReservationController) | +| ファイル配置 | プロジェクトの標準ディレクトリに配置されているか | + + +--- + + +## 型チェック(Code as Documentの土台) + +| スタック | ツール | 基準 | +|---------|--------|------| +| TypeScript | `tsc --noEmit` | strict mode必須。any禁止 | +| Laravel | PHPStan | Level 8以上(目標: Level 10) | +| React Native | `tsc --noEmit` | strict mode必須 | + +### チェック項目 + +| 対象 | ルール | +|------|--------| +| 関数の引数・戻り値 | 型アノテーション必須(any / mixed 禁止) | +| API レスポンス | Zod / FormRequest で型を定義 | +| Props | TypeScript interface / PHPDoc @param で明示 | +| 状態管理 | useState / typed Collection で型付け | + +**型が曖昧なコード = ドキュメントとして読めないコード**。AIが推測に頼る原因になるため、型は厳格に。 + + +--- + + +--- + +## 判定基準 + +- 全項目OK → {"decision": "approve", "reason": "品質基準を満たしています"} +- 1つでもNG → {"decision": "block", "reason": "【具体的な指摘と修正指示をここに書く】"} +- stop_hook_activeがtrueの場合 → 無限ループ防止のため必ずapprove + + +- Codex Stop hook の全項目OK / stop_hook_active=true → {"continue": true} diff --git a/.cursor/hooks/lib/hook-io.sh b/.cursor/hooks/lib/hook-io.sh new file mode 100755 index 000000000..101b8a1c4 --- /dev/null +++ b/.cursor/hooks/lib/hook-io.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: jtt-cms Gen 3 のhook-io.shをAGENT-HUBのhook-libraryにポート。 +# PreToolUse/PostToolUse共通のJSON解析・出力関数を一元管理。 +# 3PJで同一ロジックが重複しており、修正時の漏れを防止するためコンポーネント化。 +# 対応: jtt-cms hook-io.sh をそのままポート。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「フック判定が環境差(Node有無)で揺れず、同じ入力なら同じ結果になること」。 +# 業務ルールとして、JSON抽出はエスケープ文字や改行を含む実データでも破綻してはならない。 +# 代替案として sed ベースの簡易抽出を維持すると、文字列中の引用符で誤抽出が起きるため不採用。 +# 対応: Node未導入時は Python JSON パースを使う安全フォールバックへ変更。 + +# --- stdin読み込み --- +# stdinからJSON入力を読み込み、HOOK_INPUT変数に格納する。 +# 各フックのエントリポイントで最初に呼ぶこと。 +read_stdin() { + HOOK_INPUT="$(cat)" +} + +# --- JSON フィールド抽出 (PreToolUse用) --- +# tool_input内の文字列フィールドを抽出する。Node.js優先、sed fallback。 +# 使用例: COMMAND=$(extract_field command) +extract_field() { + local field="$1" + if command -v node >/dev/null 2>&1; then + printf '%s' "$HOOK_INPUT" | node -e ' + const fs = require("fs"); + const field = process.argv[1]; + const raw = fs.readFileSync(0, "utf8"); + let value = ""; + try { + const parsed = JSON.parse(raw); + const source = + parsed && typeof parsed.tool_input === "object" && parsed.tool_input !== null + ? parsed.tool_input + : parsed && typeof parsed.toolInput === "object" && parsed.toolInput !== null + ? parsed.toolInput + : parsed; + if (source && typeof source[field] === "string") { + value = source[field]; + } + } catch {} + process.stdout.write(value); + ' "$field" 2>/dev/null || true + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + PY_FIELD="$field" HOOK_JSON="$HOOK_INPUT" python3 - <<'PY' 2>/dev/null || true +import json +import os + +field = os.environ.get("PY_FIELD", "") +raw = os.environ.get("HOOK_JSON", "") +value = "" +try: + parsed = json.loads(raw) + source = ( + parsed.get("tool_input") + or parsed.get("toolInput") + or parsed + if isinstance(parsed, dict) + else {} + ) + candidate = source.get(field, "") if isinstance(source, dict) else "" + if isinstance(candidate, str): + value = candidate +except Exception: + pass +print(value, end="") +PY + return 0 + fi + + # Node / Python が未導入の場合のみ簡易フォールバック(誤抽出リスクあり) + echo "$HOOK_INPUT" | sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -1 || true +} + +# --- file_path抽出 (PostToolUse用) --- +# tool_inputからfile_path(またはpath)を抽出する。Python3使用。 +# 使用例: filepath=$(extract_file_path) +extract_file_path() { + printf '%s' "$HOOK_INPUT" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + ti = data.get('tool_input') or data.get('toolInput') or {} + print(ti.get('file_path', ti.get('path', ''))) +except Exception: + print('') +" 2>/dev/null || echo "" +} + +# --- deny JSON出力 (PreToolUse用) --- +# hookSpecificOutput形式のdeny JSONを出力し、exit 0で終了する。 +# 使用例: emit_deny "ブロック理由メッセージ" +# [2026-06-19][fix] +# 背景: +# - Claude/Codex/Kimi の hook deny 出力で旧 `reason` キーが混在すると、 +# 新しい権限UIで理由が表示されない環境がある。 +# - 守るべき業務ルール: deny 理由は `permissionDecisionReason` に統一し、 +# JSON 文字列は Python で escape して壊れた hook 出力を防ぐ。 +# - 他案不採用理由: 各 hook で個別に printf する案は schema 差分と escape 漏れが再発するため不採用。 +emit_deny() { + local reason="$1" + HOOK_REASON="$reason" python3 - <<'PY' +import json +import os + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": os.environ.get("HOOK_REASON", ""), + } +}, ensure_ascii=False, separators=(",", ":"))) +PY + exit 0 +} diff --git a/.cursor/hooks/lib/quality-check-common.sh b/.cursor/hooks/lib/quality-check-common.sh new file mode 100755 index 000000000..acb337b4b --- /dev/null +++ b/.cursor/hooks/lib/quality-check-common.sh @@ -0,0 +1,973 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: jtt-cms Gen 3 (403行) をAGENT-HUBのhook-libraryにポート。 +# 3PJで独立進化したhookを統一するため、最先端のGen 3をSSOTとして抽出。 +# 各PJ個別実装だと変更が伝播せず重複が増え続けるため、コンポーネント化して +# deploy-hooks.pyで全PJに配布する設計。 +# 対応: jtt-cms quality-check-common.sh をhook-library/lib/にポート。 +# パス解決をscripts/サブディレクトリ構成に対応させ、 +# チェックリストパスをproject_dir起点に変更。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「transcript解析がPython 3.8環境でも失敗せず動くこと」。 +# 業務ルールとして、品質ゲート共通ライブラリはPJ間で同一挙動を保つ必要がある。 +# 代替案として `set[str]` 型注釈を維持すると、3.8で構文エラーになり判定が抜けるため不採用。 +# 対応: 埋め込みPythonの型注釈を `typing.Set` ベースへ変更。 + +set -euo pipefail + +# telemetry(harness-checkup): quality-gate 系(stop/subagent)の deny を記録。 +# 本 lib は hook-library/lib/ に在り、telemetry-lib.sh は hook-library/scripts/ にある。 +# 配布先でも同じ相対構成(.claude/hooks/lib/ と .claude/hooks/scripts/)のため ../scripts/ で解決できる。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも quality-gate 本体を絶対に壊さない。 +_quality_common_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$_quality_common_dir/../scripts/telemetry-lib.sh" ]; then + . "$_quality_common_dir/../scripts/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: jtt-apps の /brainstorm 質問のみセッションで Stop hook が誤発火する事故 (B1) を、git diff fallback がバックグラウンド同期で書き換わった untracked 派生物 (.opencode/sync-state.json 等) を「変更ファイル」と誤認することで起きる問題として根治したい。 +# - 守るべき業務ルール: 配布先 PJ 側で sync スクリプトが書き換える派生物 (.opencode/, .cursor/, .gemini/, .augment/, .codex/hooks/, .agent/, sync-state.json) は AI のツール呼び出し由来ではないため品質ゲートの対象外にする。 +# - 他案不採用理由: +# 1) .gitignore に追加して回避する案 → 検出ロジックの欠陥は残ったまま、新しい派生物ディレクトリが増えるたびに各 PJ で .gitignore を直す必要があり SSOT 原則違反。 +# 2) git diff fallback を完全廃止する案 → Bash 経由 (sed -i / cat > / tee 等) の書き換えを救う最後の砦が消える。 +# 対応: DOC_SKIP_PATTERNS に sync 派生物パターンを追加し多重防御。主防御は run_quality_check_hook の transcript 判定変更で行う。 +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: business profile の PJ (jtt-cafe-pj / non-pj) は議事録・PRD・戦略などの .md/docs が +# 成果物そのもの。従来は全 PJ 共通で .md/docs を skip していたため、business PJ がローカルで DOC_SKIP を +# 書き換える drift が発生していた (hook-library v3.4.11 配布で露見・Codex 指摘)。SSOT で一元解決したい。 +# - 守るべき業務ルール: 同期派生物 (.opencode/ 等・ツール生成物) は全 profile で skip。文書 (.md/docs 等) は +# code profile では skip、business profile では品質チェック対象にする。配布時に deploy-hooks.py が +# business profile のみ DOC_TYPE_SKIP を外す。配布物のローカル編集 (drift) は禁止のため SSOT 側で分岐させる。 +# - 他案不採用理由: (1) 各 business PJ で DOC_SKIP をローカル編集 → 配布物改変禁止に反し再 drift。 +# (2) runtime で checklist md の文言から profile 推定 → 文言変更で静かに壊れる。 +# 対応: パターンを DOC_TYPE_SKIP (文書) と SYNC_DERIVATIVE_SKIP (同期派生物) に分割。deploy-hooks.py は +# business profile 配布時に下の結合行を `readonly DOC_SKIP_PATTERNS="${SYNC_DERIVATIVE_SKIP}"` へ置換する。 +# [2026-07-09][fix] +# 背景: +# - ユーザー依頼意図: `.brv/` と Kimi 系生成物が同期派生物なのに品質チェック対象へ入り、実作業の本質と +# 無関係な検出ノイズになるのを防ぎたい。 +# - 守るべき業務ルール: AI ツール CLI 派生物は SSOT から再生成・同期されるため、quality check の本文対象ではなく +# SYNC_DERIVATIVE_SKIP に集約する。文書本文の品質チェック分岐は既存の DOC_TYPE_SKIP と分けたまま維持する。 +# - 他案不採用理由: 各 PJ の `.gitignore` へ個別追加する案は配布先ごとの drift を増やすため不採用。 +# DOC_TYPE_SKIP 側へ混ぜる案は business profile の文書チェック分岐を壊すため不採用。 +# 対応: SYNC_DERIVATIVE_SKIP に `.brv/`、`.kimi-code/`、`.kimi/` を追加する。 +# [2026-07-30][fix] +# 背景: +# - ユーザー依頼意図: jtt-apps の実装セッション(シフト確定 v2.5.2)で、Stop hook が +# `.claude/hooks/.hook-library-version` を「変更されたコードファイル」として毎回検知し、 +# 品質チェック済みでも完了報告のたびに block を繰り返した。セッション由来でない配布物で止めたくない。 +# - 守るべき業務ルール: `.claude/hooks/**` は deploy-hooks.py が hook-library 正本から生成する配布物であり、 +# 配布先での直接編集は禁止(settings-protection-coexistence)。よって配布先 PJ で品質チェックの +# 対象にする意味がなく、正本側(AGENT-HUB `hook-library/`)でチェックすべき対象である。 +# 既に `^\.codex/hooks/` は除外済みで、Claude 側だけが抜けていた非対称性が原因。 +# - 他案不採用理由: +# 1) git diff fallback で追跡変更を拾うのを止める案 → Bash 経由(sed -i / cat >)の実コード変更を +# 見逃し品質ゲートが弱くなるため不採用(2026-05-26 の判断を維持)。 +# 2) `.hook-library-version` だけをファイル名で除外する案 → 同じ配布物である `lib/*.sh` や +# `scripts/*.sh` のドリフトで再発するため対症療法。ディレクトリ単位で `.codex/hooks/` と揃える。 +# 3) 配布先 PJ の drift をその都度コミットして消す案 → 配布のたびに日付スタンプで再発するため恒久解にならない。 +# 対応: SYNC_DERIVATIVE_SKIP に `^\.claude/hooks/|/\.claude/hooks/` を追加し、`.codex/hooks/` と対称にする。 +readonly DOC_TYPE_SKIP='\.md$|\.prd$|\.txt$|^docs/|/docs/|\.template$|CLAUDE\.md|README|CHANGELOG' +readonly SYNC_DERIVATIVE_SKIP='^\.opencode/|/\.opencode/|^\.cursor/|/\.cursor/|^\.gemini/|/\.gemini/|^\.augment/|/\.augment/|^\.claude/hooks/|/\.claude/hooks/|^\.codex/hooks/|/\.codex/hooks/|^\.agent/|/\.agent/|^\.brv/|/\.brv/|^\.kimi-code/|/\.kimi-code/|^\.kimi/|/\.kimi/|sync-state\.json$' +# DEPLOY-MARKER(business): deploy-hooks.py は business profile でこの行を SYNC_DERIVATIVE_SKIP のみへ置換する。 +readonly DOC_SKIP_PATTERNS="${DOC_TYPE_SKIP}|${SYNC_DERIVATIVE_SKIP}" +# [2026-03-17][refactor] +# 背景: +# - ユーザー依頼意図: hookのblock reasonにチェックリスト全文(395行)が毎回チャットに出力され、 +# 視認性が悪くコンテキストウィンドウを圧迫するため、最小限の出力に変更したい。 +# - 守るべき業務ルール: dev-guardrails SKILL.md Section 9「発火フロー」に記載の +# 「ファイルパス参照指示を block reason に記載 → AIが Read ツールで code-quality-check.md を +# 読み込み品質チェック実施」方式をランタイムで実現すること。 +# - 他案不採用理由: (1) チェックリスト全文のインライン注入は視認性を壊す(現状の問題そのもの)。 +# (2) 要約版を別ファイルで管理する案はDRY違反で同期漏れを再発させるため不採用。 +# (3) block reasonを完全に空にする案はAIが何をすべきか分からなくなるため不採用。 +# 対応: block reasonにはファイルパス+変更ファイル一覧のみ出力し、 +# AIにReadツールでチェックリストを読ませる方式に変更。 +readonly BLOCK_PREFIX='作業完了前に品質チェックを実施してください。指定されたチェックリストファイルを Read ツールで読み込み、各項目を確認してください。問題があれば修正してから再度完了を報告してください。' +readonly CODE_FILE_PATTERNS='\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|sql|php|py|sh|yaml|yml|toml|ini|mdx?)$' + +emit_json() { + local decision="$1" + local reason="$2" + + PY_DECISION="$decision" PY_REASON="$reason" python3 - <<'PY' +import json +import os + +print( + json.dumps( + {"decision": os.environ["PY_DECISION"], "reason": os.environ["PY_REASON"]}, + ensure_ascii=False, + ) +) +PY +} + +is_codex_hook_root() { + local hook_root="$1" + local normalized_hook_root + + normalized_hook_root="$(cd "$hook_root" 2>/dev/null && pwd || printf '%s\n' "$hook_root")" + + case "$normalized_hook_root" in + */.codex/hooks|*/.codex/hooks/) return 0 ;; + *) return 1 ;; + esac +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: Codex Stop hook が2回目停止時に +# "hook returned invalid stop hook JSON output" で失敗する問題を、配布元の正本で直したい。 +# - 守るべき業務ルール: hook-library は Claude Code / Codex CLI の共通正本なので、 +# Codex だけに必要な出力差分は配布先 hook_root で分岐し、Claude 側の既存応答を維持する。 +# - 他案不採用理由: 共通ライブラリ全体を `decision: approve` のままにする案は Codex Stop で再発する。 +# 逆に全環境を `continue: true` に変える案は Claude Code 側の既存運用に不要な互換リスクを持ち込むため不採用。 +# 対応: `.codex/hooks` 配下で動く approve 相当分岐だけ `{"continue": true}` を返す。 +emit_approval_json() { + local hook_root="$1" + local reason="$2" + + if is_codex_hook_root "$hook_root"; then + python3 - <<'PY' +import json + +print(json.dumps({"continue": True})) +PY + return 0 + fi + + emit_json "approve" "$reason" +} + +resolve_project_dir() { + local hook_root="$1" + local inferred_dir git_root + + if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}" ]; then + printf '%s\n' "$CLAUDE_PROJECT_DIR" + return + fi + + # hook_root is .claude/hooks/ → go up 2 levels to project root + inferred_dir="$(cd "$hook_root/../.." && pwd)" + if git -C "$inferred_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '%s\n' "$inferred_dir" + return + fi + + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ]; then + printf '%s\n' "$git_root" + return + fi + + printf '%s\n' "$inferred_dir" +} + +extract_stop_hook_active() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + print(str(data.get('stop_hook_active', False)).lower()) +except Exception: + print('error') +" <<<"$input" 2>/dev/null || echo "error" +} + +extract_transcript_path() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + value = data.get('transcript_path', '') + print(value if isinstance(value, str) else '') +except Exception: + print('') +" <<<"$input" 2>/dev/null || true +} + +extract_agent_type() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + print(data.get('agent_type', '')) +except Exception: + print('') +" <<<"$input" 2>/dev/null || echo "" +} + +extract_agent_transcript_path() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + value = data.get('agent_transcript_path', '') + print(value if isinstance(value, str) else '') +except Exception: + print('') +" <<<"$input" 2>/dev/null || true +} + +# [2026-03-17][fix] +# 背景: +# - ユーザー依頼意図: PR429レビューで、hook が変更ファイル一覧を誤判定せず、 +# 品質チェックの block/approve 判定を安定して行える状態にしたい。 +# - 守るべき業務ルール: Git 管理下の合法パス(前後空白や改行を含む名前を含む)でも +# 品質ゲートが誤検知・見逃しを起こさないこと。品質ゲートの誤作動は +# 「本来 block すべき変更を素通しする」「関係ない変更で block する」の両面で運用事故になる。 +# - 他案不採用理由: (1) 改行区切りのまま扱う案は改行入りパスで分裂する。 +# (2) strip で前後空白を落とす案は合法パスを別名に変えてしまう。 +# (3) 特殊ケースを無視する案は次回AIが同じバグを再発させるため不採用。 +# 対応: 変更ファイル一覧は JSON 配列で受け渡しし、表示時だけ安全に整形する。 +extract_changed_files_from_input() { + local input="$1" + + python3 -c " +import json +import sys + +PATH_KEYS = {'file_path', 'path', 'new_path', 'old_path', 'target_path'} + +def walk(node, out): + if isinstance(node, dict): + for key, value in node.items(): + if key.lower() in PATH_KEYS and isinstance(value, str) and value != '': + out.add(value) + walk(value, out) + return + if isinstance(node, list): + for item in node: + walk(item, out) + +paths = set() +try: + payload = json.load(sys.stdin) + walk(payload, paths) +except Exception: + pass + +print(json.dumps(sorted(paths), ensure_ascii=False)) +" <<<"$input" 2>/dev/null || echo "[]" +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: /brainstorm のような質問のみセッション (AI が Write/Edit を一切呼ばない) で Stop hook が誤発火する問題 (B1) の主防御。 +# - 守るべき業務ルール: transcript が読み取れた状態で Write 系ツールが 0 件なら、コード変更は本会話由来ではないと判定し git diff fallback を呼ばずに approve する。 +# - 他案不採用理由: +# 1) extract_changed_files_from_transcript の戻り値だけで判定する案 → "[]" が「読めて 0件」と「読めなかった」を区別できず、Bash 経由書き換え時に fallback が呼ばれなくなる。 +# 2) 戻り値に sentinel 文字列を混ぜる案 → 呼び出し側のパース処理が複雑化し、JSON との混在で誤判定リスク。 +# 対応: transcript_path の読み取り可否を別関数で boolean 返却し、呼び出し側で 3 状態 (paths あり / 読めて 0件 / 読めなかった) に分岐する。 +transcript_was_readable() { + local transcript_path="$1" + if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then + echo "true" + else + echo "false" + fi +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: PR87レビューで、transcript が読める状態の Bash 書き込み +# (`cat > file`, `tee`, `sed -i` 等) が Write/Edit 0件扱いで品質ゲートを素通りする問題を直したい。 +# - 守るべき業務ルール: /brainstorm の質問のみセッションでは誤発火させない一方で、git diff fallback は +# Bash 経由書き換えを救う最後の砦として残す必要がある。 +# - 他案不採用理由: +# 1) git diff fallback を完全廃止する案 → Bash 経由書き換え検出が失われるため不採用。 +# 2) transcript 内に Bash があるだけで fallback する案 → `git status` だけの質問セッションで再発火しやすいため不採用。 +# 対応: transcript の Bash command から書き込み系パターンだけを検出し、その時だけ fallback に進める。 +# +# [2026-04-28][fix] +# 背景: +# - ユーザー依頼意図: 読取専用セッション(gh / git 系コマンドのみ)で Stop hook が連続誤発火し、 +# タイポディレクトリ `.claire/` 配下の untracked ファイルを「変更コード」と誤検出する事故が再発した。 +# - 守るべき業務ルール: シェルリダイレクト `2>&1` / `1>&2` はファイル書き込みではない。 +# `&>/dev/null` / `&>>/dev/null` も破棄目的の診断出力であり、WRITE 判定に含めると +# 診断目的の `gh ... 2>&1` 連発で git diff fallback が誤起動し、 +# 別 worktree や typo ディレクトリの差分まで拾ってしまう。 +# - 他案不採用理由: +# 1) WRITE_COMMAND_RE から `>` を完全削除する案 → 真の `cmd > file` 書き込みを見逃すため不採用。 +# 2) DOC_SKIP_PATTERNS に `.claire` を足す案 → 対症療法。次の typo に対応できないため不採用。 +# 3) 実行時に shlex で AST パースする案 → bash heredoc / 複合コマンドで誤動作しやすく過剰実装。 +# 対応: FD複製 (`2>&1`) と `/dev/null` 破棄だけを除外し、`1>file` / `2>file` / `&>file` は +# 真のファイル書き込みとして検出する。 +transcript_has_bash_write_command() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "false" + return 0 + fi + + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "false" +import json +import re +import sys +from typing import Any + +# `>` / `>>` はFD複製 (`2>&1`) と `/dev/null` 破棄だけを除外する。 +# これにより `1>file`, `2>file`, `&>file` は検出し、`2>&1`, `1>&2`, `&>/dev/null` は除外する。 +WRITE_COMMAND_RE = re.compile( + r"((?:^|[\s;|])(?:\d*)?>>?(?!&)(?!\s*/dev/null\b)\s*|(?:^|[\s;|])&>{1,2}(?!>)(?!\s*/dev/null\b)\s*|\btee\b|\bsed\s+-i\b|\bperl\s+-pi\b|\bcp\b|\bmv\b|\brm\b|\btouch\b|\bmkdir\b|\bcat\s+<<)" +) + + +def tool_name(node: Any) -> str: + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + value = node.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def command_text(node: Any) -> str: + if not isinstance(node, dict): + return "" + if isinstance(node.get("command"), str): + return node["command"] + nested = node.get("input") + if isinstance(nested, dict) and isinstance(nested.get("command"), str): + return nested["command"] + tool_input = node.get("tool_input") + if isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str): + return tool_input["command"] + return "" + + +def has_bash_write(node: Any) -> bool: + if isinstance(node, dict): + name = tool_name(node) + if name == "Bash" and WRITE_COMMAND_RE.search(command_text(node)): + return True + return any(has_bash_write(value) for value in node.values()) + if isinstance(node, list): + return any(has_bash_write(item) for item in node) + return False + + +try: + content = open(sys.argv[1], encoding="utf-8", errors="ignore").read() +except Exception: + print("false") + raise SystemExit(0) + +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + if has_bash_write(json.loads(line)): + print("true") + raise SystemExit(0) + except SystemExit: + raise + except Exception: + pass + +try: + result = has_bash_write(json.loads(content)) +except Exception: + result = False + +print("true" if result else "false") +PY +} + +extract_changed_files_from_transcript() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "[]" + return 0 + fi + + # Layer 3: 書き込みツール(Write/Edit/NotebookEdit/MultiEdit)のfile_pathのみ収集。 + # Read/Grep/Globなどの読み取り専用ツールのfile_pathを「変更」と誤認しない。 + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "[]" +import json +import sys +from typing import Any, Set + +WRITE_TOOLS = frozenset({"Write", "Edit", "NotebookEdit", "MultiEdit"}) +PATH_KEYS = frozenset({"file_path", "path", "new_path", "old_path", "target_path"}) + +transcript_path = sys.argv[1] +paths: Set[str] = set() + + +def collect_paths(node: Any) -> None: + """Collect file paths from a node known to belong to a write tool.""" + if isinstance(node, dict): + for key, value in node.items(): + if key.lower() in PATH_KEYS and isinstance(value, str) and value != "": + paths.add(value) + collect_paths(value) + return + if isinstance(node, list): + for item in node: + collect_paths(item) + + +def find_tool_name(node: Any) -> str: + """Extract tool name from a dict node.""" + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + val = node.get(key, "") + if isinstance(val, str) and val: + return val + return "" + + +def process_entry(entry: Any) -> None: + """Walk an entry and only collect paths from write tool invocations.""" + if not isinstance(entry, dict): + return + tool_name = find_tool_name(entry) + if tool_name in WRITE_TOOLS: + collect_paths(entry) + # Recurse into nested structures (content, messages, etc.) + for key in ("content", "messages", "tool_use", "input"): + child = entry.get(key) + if isinstance(child, list): + for item in child: + process_entry(item) + elif isinstance(child, dict): + process_entry(child) + + +try: + with open(transcript_path, encoding="utf-8", errors="ignore") as f: + content = f.read() +except Exception: + print("[]") + sys.exit(0) + +# JSON Lines format +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + process_entry(json.loads(line)) + except Exception: + pass + +# Single JSON object format +try: + process_entry(json.loads(content)) +except Exception: + pass + +print(json.dumps(sorted(paths), ensure_ascii=False)) +PY +} + +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: Bash の作成系コマンド (`cat > f` / `tee f` / `touch f` / `> f`) の +# ターゲットパスを transcript から抽出し、git diff フォールバックで「セッションが作成した +# 未追跡ファイルだけ」を拾えるようにする。 +# - 守るべき業務ルール: 移動・削除系 (mv / cp / rm) は新規コード作成の判定に使わない。 +# `mv tmp dest` のような plumbing を作成扱いすると、他セッション WIP の誤検知 (R1) を再発させる。 +# - 他案不採用理由: +# 1) WRITE_COMMAND_RE の boolean 判定を流用する案は、ターゲットパスが取れず未追跡の絞り込みができない。 +# 2) 正規表現だけでパスを分割する案は、`cat > "src/space file.ts"` のような引用符付きパスを見逃す。 +# 対応: shlex で Bash コマンドの引用符を解釈し、作成系リダイレクト/コマンドのターゲットだけを抽出する。 +extract_bash_created_paths_from_transcript() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "[]" + return 0 + fi + + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "[]" +import json +import os +import re +import shlex +import sys +from typing import Any + +REDIRECT_TOKEN_RE = re.compile(r"^(?:(?:\d*)>{1,2}|&>{1,2})$") +METACHARS = {";", "|", "&", "<", ">", ">>", "&>", "&>>", "&&", "||"} + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `cd scripts && cat > foo.py` のように Bash の cwd が変わった後の +# ファイル作成を transcript から抽出するとき、cwd を無視して相対パスのまま返すため +# `git ls-files --others` の `scripts/foo.py` と一致せず未追跡ファイルを見逃す問題を修正したい。 +# 守るべき業務ルール: 移動・削除系 (mv / cp / rm) は作成扱いしない(R1 誤検知防止)。 +# 変数展開を含む `cd "$VAR"` は追跡不能で、従来どおり相対のまま許容する。 +# cwd 正規化は `detect_changed_files()` 内の created_rel 変換と対称に行う。 +# 他案不採用理由: +# 1) cwd を環境変数で渡す案 → Bash ノード間で状態が引き継がれず `cd && cmd` のケースを処理できない。 +# 2) shlex の AST パース案 → bash heredoc / 複合コマンドで誤動作しやすく過剰実装。 +# 対応: `cwd_from_node()` を追加してノードの cwd フィールドを取得。 +# `harvest()` に cwd 引数を追加し `cd ` を検出したら current_cwd を更新。 +# `add_target()` に cwd 引数を追加して絶対パス正規化を行う。 + +targets = set() + + +def cwd_from_node(node): + """Bash ノードの cwd フィールドを取得する。複数のキー名に対応。""" + if not isinstance(node, dict): + return "" + # 直接フィールド + v = node.get("cwd") + if isinstance(v, str) and v: + return v + # tool_input.cwd + ti = node.get("tool_input") + if isinstance(ti, dict): + v = ti.get("cwd") + if isinstance(v, str) and v: + return v + # input.cwd + inp = node.get("input") + if isinstance(inp, dict): + v = inp.get("cwd") + if isinstance(v, str) and v: + return v + return "" + + +def add_target(tok, cwd=""): + tok = tok.strip() + # フラグ (-a 等)・FD複製 (&1)・破棄先 (/dev/null) は作成ターゲットではない。 + if not tok or tok.startswith(("-", "&")) or tok == "/dev/null" or tok.endswith("/dev/null"): + return + if os.path.isabs(tok): + targets.add(tok) + elif cwd: + targets.add(os.path.normpath(os.path.join(cwd, tok))) + else: + targets.add(tok) + + +def shell_tokens(cmd): + try: + lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + return list(lexer) + except Exception: + return [] + + +def harvest(cmd, cwd=""): + tokens = shell_tokens(cmd) + current_cwd = cwd + for i, tok in enumerate(tokens): + # `cd ` を検出して current_cwd を更新 + if tok == "cd" and i + 1 < len(tokens): + new_dir = tokens[i + 1] + # 変数展開 ($VAR 等) は追跡不能なのでスキップ + if not new_dir.startswith("$") and new_dir not in METACHARS: + if os.path.isabs(new_dir): + current_cwd = new_dir + elif current_cwd: + current_cwd = os.path.normpath(os.path.join(current_cwd, new_dir)) + else: + current_cwd = new_dir + continue + # 作成系リダイレクト `> f` / `1> f`。`2>&1` や `/dev/null` は add_target 側で除外。 + if (tok in {">", ">>", "&>", "&>>"} or REDIRECT_TOKEN_RE.match(tok)) and i + 1 < len(tokens): + add_target(tokens[i + 1], current_cwd) + continue + if tok in {"tee", "touch"}: + for candidate in tokens[i + 1 :]: + if candidate in METACHARS: + break + add_target(candidate, current_cwd) + + +def tool_name(node): + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + v = node.get(key) + if isinstance(v, str) and v: + return v + return "" + + +def command_text(node): + if not isinstance(node, dict): + return "" + if isinstance(node.get("command"), str): + return node["command"] + for key in ("input", "tool_input"): + nested = node.get(key) + if isinstance(nested, dict) and isinstance(nested.get("command"), str): + return nested["command"] + return "" + + +def walk(node: Any) -> None: + if isinstance(node, dict): + if tool_name(node) in {"Bash", "Shell"}: + node_cwd = cwd_from_node(node) + harvest(command_text(node), node_cwd) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for item in node: + walk(item) + + +try: + content = open(sys.argv[1], encoding="utf-8", errors="ignore").read() +except Exception: + print("[]") + raise SystemExit(0) + +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + walk(json.loads(line)) + except Exception: + pass + +try: + walk(json.loads(content)) +except Exception: + pass + +print(json.dumps(sorted(targets), ensure_ascii=False)) +PY +} + +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: jtt-cafe-pj の /insights リフレッシュ作業終了時、Stop hook が +# 別セッションの未追跡 WIP (.claude/skills/dev-guardrails/** 等) を「変更コードファイル」 +# として誤検知し block する事象が実発火した (R1)。クリーンに直したい。 +# - 守るべき業務ルール: git diff フォールバックは transcript 検出 (Write/Edit の file_path) が +# 失敗した時の最終手段。未追跡ファイルは git 履歴がなくセッション帰属を判定できないため、 +# 無条件に拾うと他セッションの WIP・スクラッチ・他ツール生成物を誤検知する。 +# - 他案不採用理由: +# 1) 未追跡検出を完全除去する案 → Bash で新規作成したコードファイル (`cat > scripts/foo.py`) を +# フォールバックで見逃し品質ゲートが弱くなる (Codex レビュー指摘) ため不採用。 +# 2) DOC_SKIP_PATTERNS にディレクトリを足し続ける案 → 「次の untracked に対応できない対症療法」のため不採用。 +# 対応: 追跡変更 (git diff / --cached) は常に対象。未追跡ファイルは +# 「このセッションが Bash 作成系で書いたターゲット」(created_paths) に一致するものだけ対象にする。 +# created_paths が空 (transcript 読めない等) の場合は未追跡を一切拾わない (帰属不能なため安全側)。 +detect_changed_files() { + local project_dir="$1" + local created_paths_json="${2:-[]}" + + python3 - "$project_dir" "$CODE_FILE_PATTERNS" "$created_paths_json" <<'PY' 2>/dev/null || echo "[]" +import json +import os +import re +import subprocess +import sys + +project_dir = sys.argv[1] +code_file_pattern = re.compile(sys.argv[2], re.IGNORECASE) +try: + created = json.loads(sys.argv[3]) + if not isinstance(created, list): + created = [] +except Exception: + created = [] + +# セッションが Bash 作成系で書いたターゲットを project_dir 相対パスに正規化。 +# basename 一致は使わない(別ディレクトリの同名未追跡ファイルを誤検知するため。Codex レビュー指摘)。 +created_rel = set() +for t in created: + if not isinstance(t, str) or not t: + continue + norm = t + if os.path.isabs(t): + try: + norm = os.path.relpath(t, project_dir) + except Exception: + norm = t + if norm.startswith("./"): + norm = norm[2:] + created_rel.add(norm) + +paths = set() + +# 追跡ファイルの変更は常に対象。 +for command in ( + ["git", "-C", project_dir, "diff", "--name-only", "-z", "--diff-filter=ACMR"], + ["git", "-C", project_dir, "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"], +): + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False) + for raw_path in result.stdout.split(b"\0"): + if raw_path: + paths.add(raw_path.decode("utf-8", errors="surrogateescape")) + +# 未追跡は「このセッションが作成したターゲット」に相対パス完全一致するコードファイルだけ対象にする。 +if created_rel: + result = subprocess.run( + ["git", "-C", project_dir, "ls-files", "--others", "--exclude-standard", "-z"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, + ) + for raw_path in result.stdout.split(b"\0"): + if not raw_path: + continue + path = raw_path.decode("utf-8", errors="surrogateescape") + if not code_file_pattern.search(path): + continue + if path in created_rel: + paths.add(path) + +print(json.dumps(sorted(paths), ensure_ascii=False)) +PY +} + +json_file_list_is_empty() { + local files_json="$1" + + python3 -c " +import json +import sys + +try: + print('true' if not json.load(sys.stdin) else 'false') +except Exception: + print('true') +" <<<"$files_json" 2>/dev/null || echo "true" +} + +filter_non_doc_files() { + local files_json="$1" + + python3 -c " +import json +import re +import sys + +pattern = re.compile(sys.argv[1], re.IGNORECASE) + +try: + files = json.loads(sys.argv[2]) +except Exception: + print('[]') + sys.exit(0) + +print(json.dumps([path for path in files if not pattern.search(path)], ensure_ascii=False)) +" "$DOC_SKIP_PATTERNS" "$files_json" 2>/dev/null || echo "[]" +} + +json_file_list_contains_sql() { + local files_json="$1" + + python3 -c " +import json +import re +import sys + +try: + files = json.loads(sys.argv[1]) +except Exception: + print('false') + sys.exit(0) + +print('true' if any(re.search(r'\\.sql$', path, re.IGNORECASE) for path in files) else 'false') +" "$files_json" 2>/dev/null || echo "false" +} + +format_file_list_for_display() { + local files_json="$1" + + python3 -c " +import json +import sys + +try: + files = json.loads(sys.argv[1]) +except Exception: + sys.exit(0) + +for path in files: + print(json.dumps(path, ensure_ascii=False)) +" "$files_json" 2>/dev/null || true +} + +# --- メインエントリーポイント --- +# 引数: +# $1: hook_name - ログ用の識別子 (例: "subagent-quality-check") +# $2: hook_root - hookルートディレクトリ (.claude/hooks/) +# $3: no_file_change_reason - ファイル変更なし時の理由メッセージ +# $4: use_git_diff_fallback - git diffフォールバック使用 (default: true) +run_quality_check_hook() { + local hook_name="$1" + local hook_root="$2" + local no_file_change_reason="$3" + local use_git_diff_fallback="${4:-true}" + local input project_dir checklist_path stop_hook_active input_changed_files transcript_path transcript_changed_files transcript_readable transcript_bash_write bash_created_paths changed_files non_doc_files formatted_non_doc_files block_reason sql_files security_checklist_path is_codex_hook + + is_codex_hook="false" + if is_codex_hook_root "$hook_root"; then + is_codex_hook="true" + fi + + # [2026-04-26][fix] + # 背景: + # - ユーザー依頼意図: Codex Stop hook の stdout/stderr 混在で JSON パース失敗を疑う状態をなくしたい。 + # - 守るべき業務ルール: Codex hook の通常出力は JSON だけに固定し、診断ログは明示的なデバッグ時だけ出す。 + # - 他案不採用理由: 常時 stderr にログを出す案は、Codex 側の厳密な Stop hook JSON 判定で + # invalid JSON 扱いの再発要因になり得るため不採用。 + # 対応: Codex 配布先では CODEX_HOOK_DEBUG=1 の時だけ stderr ログを出す。Claude 側は既存どおりログを出す。 + log() { + if [ "$is_codex_hook" != "true" ] || [ "${CODEX_HOOK_DEBUG:-}" = "1" ]; then + echo "[hook:${hook_name}] $*" >&2 + fi + } + + if ! command -v python3 >/dev/null 2>&1; then + log "python3 command is missing, approving as safe fallback" + if is_codex_hook_root "$hook_root"; then + echo '{"continue":true}' + else + echo '{"decision":"approve","reason":"python3 is required for quality hook. Approved as safe fallback."}' + fi + return 0 + fi + + input="$(cat)" + project_dir="$(resolve_project_dir "$hook_root")" + checklist_path="$hook_root/lib/code-quality-check.md" + + log "hook invoked for project: $project_dir" + + if [ ! -f "$checklist_path" ]; then + log "checklist not found at $checklist_path, approving" + emit_approval_json "$hook_root" "No quality checklist found, skipping." + return 0 + fi + + stop_hook_active="$(extract_stop_hook_active "$input")" + if [ "$stop_hook_active" = "true" ]; then + log "stop_hook_active=true, approving to prevent infinite loop" + emit_approval_json "$hook_root" "Already in quality check loop, approving to prevent infinite loop." + return 0 + fi + if [ "$stop_hook_active" = "error" ]; then + log "WARNING: failed to parse stop_hook_active from input JSON, approving as fallback" + emit_approval_json "$hook_root" "Could not parse hook input JSON, approving as safety fallback." + return 0 + fi + + # Layer 1: agent_type による読み取り専用エージェント即時判定 + # Explore/Plan等はWrite/Editツールを持たない(公式仕様で除外)ため、 + # コード変更は構造的に不可能。ファイル検出を一切行わずapproveする。 + local agent_type + agent_type="$(extract_agent_type "$input")" + case "$agent_type" in + Explore|Plan|feature-dev:code-reviewer|feature-dev:code-architect|feature-dev:code-explorer|claude-code-guide) + log "read-only agent type '$agent_type', approving without quality check" + emit_approval_json "$hook_root" "Read-only agent type ($agent_type), quality check not applicable." + return 0 + ;; + esac + + input_changed_files="$(extract_changed_files_from_input "$input")" + if [ "$(json_file_list_is_empty "$input_changed_files")" = "false" ]; then + changed_files="$input_changed_files" + log "detected changed files from hook input" + else + # Layer 2: agent_transcript_path を優先使用 + # SubagentStopでは agent_transcript_path(サブエージェント固有の履歴)を使い、 + # transcript_path(メインセッション全履歴)へのフォールバックで親の書き込みを誤検知しない。 + transcript_path="$(extract_agent_transcript_path "$input")" + if [ -z "$transcript_path" ]; then + transcript_path="$(extract_transcript_path "$input")" + fi + transcript_changed_files="$(extract_changed_files_from_transcript "$transcript_path")" + transcript_readable="$(transcript_was_readable "$transcript_path")" + transcript_bash_write="$(transcript_has_bash_write_command "$transcript_path")" + if [ "$(json_file_list_is_empty "$transcript_changed_files")" = "false" ]; then + changed_files="$transcript_changed_files" + log "detected changed files from transcript_path" + elif [ "$transcript_readable" = "true" ] && [ "$transcript_bash_write" = "true" ] && [ "$use_git_diff_fallback" = "true" ]; then + # 未追跡はセッションが Bash 作成系で書いたターゲットだけに絞る(他セッション WIP の誤検知 R1 防止) + bash_created_paths="$(extract_bash_created_paths_from_transcript "$transcript_path")" + changed_files="$(detect_changed_files "$project_dir" "$bash_created_paths")" + log "transcript has bash write command, falling back to git diff (untracked limited to session-created targets)" + elif [ "$transcript_readable" = "true" ]; then + # [2026-04-26][fix] + # transcript が読めて Write/Edit/MultiEdit/NotebookEdit が 0 件 → /brainstorm 等の質問のみセッション。 + # git diff fallback を呼ぶとバックグラウンド同期で書き換わった派生物 (.opencode/sync-state.json 等) を + # 「変更ファイル」と誤認するため、ここで approve に進む。 + changed_files="$transcript_changed_files" # = "[]" + log "transcript readable but no write tool invocations, approving (B1 fix)" + elif [ "$use_git_diff_fallback" = "true" ]; then + changed_files="$(detect_changed_files "$project_dir")" + log "transcript unreadable, falling back to git diff" + else + changed_files="" + log "git diff fallback disabled, no input/transcript file changes found" + fi + fi + + if [ "$(json_file_list_is_empty "$changed_files")" = "true" ]; then + log "no file changes detected, approving" + emit_approval_json "$hook_root" "$no_file_change_reason" + return 0 + fi + + non_doc_files="$(filter_non_doc_files "$changed_files")" + if [ "$(json_file_list_is_empty "$non_doc_files")" = "true" ]; then + log "only document files changed, approving" + emit_approval_json "$hook_root" "Document file change - skipping code quality check." + return 0 + fi + + formatted_non_doc_files="$(format_file_list_for_display "$non_doc_files")" + log "code files changed, blocking for quality check: $(echo "$formatted_non_doc_files" | tr '\n' ', ')" + + block_reason="${BLOCK_PREFIX}"$'\n\n'"チェックリスト: ${checklist_path}" + + # SQLファイル変更時はセキュリティレビューチェックリストのパスも追加 + sql_files="$(json_file_list_contains_sql "$non_doc_files")" + if [ "$sql_files" = "true" ]; then + security_checklist_path="$hook_root/lib/security-review-check.md" + if [ -f "$security_checklist_path" ]; then + block_reason="${block_reason}"$'\n'"セキュリティチェックリスト: ${security_checklist_path}" + log "SQL files detected, adding security review checklist path" + fi + fi + + block_reason="${block_reason}"$'\n\n'"変更されたコードファイル:"$'\n'"${formatted_non_doc_files}" + # telemetry(harness-checkup): quality-gate deny を記録(fail-open)。 + agent_hub_telemetry_log hook_deny "$hook_name" deny 2>/dev/null || true + emit_json "block" "$block_reason" + return 0 +} diff --git a/.cursor/hooks/lib/storage-url-common.py b/.cursor/hooks/lib/storage-url-common.py new file mode 100644 index 000000000..08305bb99 --- /dev/null +++ b/.cursor/hooks/lib/storage-url-common.py @@ -0,0 +1,190 @@ +# [2026-05-16][refactor] +# 背景: +# - ユーザー依頼意図: gmail-mcp へ配布された hook-library の Python ファイルも、配布先の CaD ルールに合う形へ揃えたい。 +# - 守るべき業務ルール: Python ファイル冒頭には shebang 直後または冒頭に # 形式の CaD ヘッダーを置く。 +# - 他案不採用理由: docstring 内の履歴だけに残す案は、配布先の CaD 検査で冒頭ヘッダーとして認識されないため不採用。 +# 対応: 既存 docstring 履歴を残したまま、冒頭に配布共通の CaD ヘッダーを追加。 +""" +Storage URL検証の共通ロジック。 +storage-url-check.sh (PostToolUse) と storage-url-pr-gate.sh (PreToolUse) から呼び出される。 + +[2026-03-03][refactor] +背景: jtt-cms Gen 3 のstorage-url-common.pyをAGENT-HUBのhook-libraryにポート。 + Supabase Storage URLの存在検証をPJ横断で共有するためコンポーネント化。 +対応: jtt-cms storage-url-common.py をそのままポート。 + +[2026-03-04][fix] +背景: ユーザー意図は「Python実行環境差でチェックが無効化されないこと」。 + 業務ルールとして、共通ライブラリは最低運用環境でも構文エラーなく動作する必要がある。 + 代替案として Python 3.9+ 専用型ヒントを維持すると、3.8系でゲートが素通りするため不採用。 +対応: 型ヒントを typing.List/Set/Tuple へ置換し、互換性を確保。 + +使い方: + python3 lib/storage-url-common.py [ ...] + mode: "check" (PostToolUse用) または "gate" (PreToolUse用) + +- check モード: 最大5URL検証、未アップロードがあれば stderr + exit 2 +- gate モード: 最大10URL検証(並列)、未アップロードがあれば deny理由を stdout + exit 1 +""" + +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import List, Set, Tuple + +# --- 定数 --- +CURL_TIMEOUT_SECONDS = 2 +MAX_URLS_CHECK_MODE = 5 +MAX_URLS_GATE_MODE = 10 + +STORAGE_URL_PATTERN = re.compile( + r"https://[a-z0-9]+\.supabase\.co/storage/v1/object/public/[^\x22\x27\s,)\]}\x60]+" +) + + +def remove_sql_comments(content: str) -> str: + """SQLコメントを除去する。コメント内のURLを誤検知しないため。""" + content = re.sub(r"--[^\n]*", "", content) + content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) + return content + + +def extract_storage_urls(filepaths: List[str]) -> List[str]: + """ファイル群からStorage URLを抽出し、重複排除・ソートして返す。""" + all_urls: Set[str] = set() + for fp in filepaths: + if not os.path.isfile(fp): + continue + try: + content = open(fp, encoding="utf-8").read() + except Exception: + continue + cleaned = remove_sql_comments(content) + all_urls.update(STORAGE_URL_PATTERN.findall(cleaned)) + return sorted(all_urls) + + +def check_url_head(url: str) -> Tuple[str, str]: + """curl HEAD でURLの存在を検証し、(url, HTTPステータス) を返す。""" + try: + result = subprocess.run( + [ + "curl", "-sI", + "--max-time", str(CURL_TIMEOUT_SECONDS), + "-o", "/dev/null", + "-w", "%{http_code}", + url, + ], + capture_output=True, + text=True, + timeout=CURL_TIMEOUT_SECONDS + 3, + ) + return (url, result.stdout.strip()) + except Exception: + return (url, "error") + + +def build_upload_hints(missing_urls: List[Tuple[str, str]]) -> List[str]: + """未アップロードURLからバケット名・パスを逆算し、アップロードコマンドを生成する。""" + hints: List[str] = [] + for url, _ in missing_urls: + m = re.search(r"https://[^/]+/storage/v1/object/public/([^/]+)/(.+)", url) + if m: + hints.append(f" pnpm upload:storage {m.group(1)} {m.group(2)}") + return hints + + +def run_check_mode(filepaths: List[str]) -> None: + """PostToolUse用: 逐次検証、未アップロードがあればstderr + exit 2。""" + urls = extract_storage_urls(filepaths) + if not urls: + sys.exit(0) + + check_urls = urls[:MAX_URLS_CHECK_MODE] + remaining = max(0, len(urls) - MAX_URLS_CHECK_MODE) + + missing: List[Tuple[str, str]] = [] + for url in check_urls: + url, status = check_url_head(url) + if status != "200": + missing.append((url, status)) + + if not missing: + sys.exit(0) + + msg = "\n[hook:storage-url-check] 未アップロードのStorage画像を検出しました:\n" + for url, status in missing: + msg += f" - {url} -> HTTP {status}\n" + if remaining > 0: + msg += f" (他に{remaining}件のURLが未検証です)\n" + + hints = build_upload_hints(missing) + msg += "\nアップロード方法:\n" + if hints: + msg += "\n".join(hints) + "\n" + else: + msg += " Supabase DashboardまたはMCP経由でStorage画像をアップロードしてください。\n" + msg += "\nアップロード完了後、再度ファイルを保存してください。\n" + + sys.stderr.write(msg) + sys.exit(2) + + +def run_gate_mode(filepaths: List[str]) -> None: + """PreToolUse用: 並列検証、未アップロードがあればdeny理由をstdout + exit 1。""" + urls = extract_storage_urls(filepaths) + if not urls: + sys.exit(0) + + check_urls = urls[:MAX_URLS_GATE_MODE] + remaining = max(0, len(urls) - MAX_URLS_GATE_MODE) + + missing: List[Tuple[str, str]] = [] + with ThreadPoolExecutor(max_workers=MAX_URLS_GATE_MODE) as executor: + futures = {executor.submit(check_url_head, url): url for url in check_urls} + for future in as_completed(futures): + url, status = future.result() + if status != "200": + missing.append((url, status)) + + if not missing: + sys.exit(0) + + parts = [ + "[hook:storage-url-pr-gate] 未アップロードのStorage画像があります。" + "PR作成前にアップロードしてください。\\n\\n未検証URL:" + ] + for url, status in sorted(missing): + parts.append(f" - {url} -> HTTP {status}") + + if remaining > 0: + parts.append(f" (他に{remaining}件のURLが未検証です)") + + hints = build_upload_hints(sorted(missing)) + parts.append("\\nアップロード方法:") + if hints: + parts.extend(hints) + else: + parts.append(" Supabase DashboardまたはMCP経由でStorage画像をアップロードしてください。") + + print("\\n".join(parts)) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} [file2 ...]", file=sys.stderr) + sys.exit(1) + + mode = sys.argv[1] + files = sys.argv[2:] + + if mode == "check": + run_check_mode(files) + elif mode == "gate": + run_gate_mode(files) + else: + print(f"Unknown mode: {mode}", file=sys.stderr) + sys.exit(1) diff --git a/.cursor/hooks/scripts/block-destructive-git.sh b/.cursor/hooks/scripts/block-destructive-git.sh new file mode 100755 index 000000000..ee13c308f --- /dev/null +++ b/.cursor/hooks/scripts/block-destructive-git.sh @@ -0,0 +1,1963 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) destructive git guard. +# AI/自動化が tracked local changes を暗黙に破棄する事故を止める。 +# [2026-06-14][feat] +# 背景: +# - ユーザー依頼意図: AI 横断作業中の `git reset --hard` / `git clean -f` / `git checkout --` による +# tracked local changes の暗黙破棄を止めたい。 +# - 守るべき業務ルール: ローカル変更の破棄は、差分確認後に明示許可した復旧作業だけに限定する。 +# - 他案不採用理由: 破壊的 git をルール文だけで禁止する案は、別セッション WIP の事故を機械的に止められないため不採用。 +# 対応: 安全な dry-run / unstage は許可し、作業ツリーを破棄する git 操作だけを PreToolUse でブロックする。 + +set -uo pipefail + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +. "$(dirname "$0")/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } + +input="$(cat)" + +command="$( + INPUT_JSON="${input}" python3 - <<'PY' 2>/dev/null || true +import json +import os + +try: + data = json.loads(os.environ.get("INPUT_JSON", "{}")) +except json.JSONDecodeError: + data = {} +tool_input = {} +if isinstance(data.get("tool_input"), dict): + tool_input = data["tool_input"] +elif isinstance(data.get("toolInput"), dict): + tool_input = data["toolInput"] +print(tool_input.get("command") or "") +PY +)" + +allow_json() { + printf '{"continue": true}\n' +} + +# [2026-08-03][fix] deny メッセージを「コマンド行の先頭に書けば通る」という誤った案内から、 +# 実際に効く手順(セッションの環境変数として設定)へ正す。 +# 背景: +# - ユーザー依頼意図: 2026-08-03 jtt-cms 作業中、旧メッセージの案内どおり +# `AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 git ...` をコマンド行の先頭に書いて再実行したが、 +# 再びブロックされた。66行目の bypass 判定はこの hook プロセス自身の環境変数だけを見ており、 +# Bash ツールは呼び出しごとに cwd がリセットされるため実際の再実行はほぼ必ず +# `cd && AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 git ...` の形になる。1745行目付近の +# inline bypass はコマンド全体の最初のトークンが裸の代入直後の `git` である場合だけしか +# 救済せず、`cd &&` 等が前に付くと機能しない(実測で再現・恒久的に効く手段ではない)。 +# - 守るべき業務ルール: AI エージェントは自己判断で破壊的 git を通せてはならない。bypass は +# 利用者がセッションの環境変数として明示設定した場合だけに限定する設計を維持する +# (bypass 判定ロジック自体は変更しない・本対応はメッセージ文言のみ)。 +# - 他案不採用理由: 「コマンド行の先頭に書けば常に効くようにする」案は、AI が自分の発行する +# コマンド文字列だけで bypass を成立させられてしまい、破壊的 git を自己判断で通す抜け道になる +# ため不採用。メッセージを正直にし、実際に効く手段(利用者へのセッション環境変数設定の依頼、 +# または hook にかからない代替コマンド)を案内する方針を採る。 +block_json() { + local label="$1" + # telemetry(harness-checkup): deny を記録。fail-open(記録失敗は無視)。 + agent_hub_telemetry_log hook_deny block-destructive-git deny "{\"label\":\"$label\"}" 2>/dev/null || true + HOOK_LABEL="$label" python3 - <<'PY' +import json +import os + +label = os.environ.get("HOOK_LABEL", "") +reason_lines = [ + f"[hook:block-destructive-git] destructive git command blocked: {label}。", + "ローカル変更を暗黙に破棄しないため停止しました。", + ( + "AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 は、このセッションの環境変数として設定されている" + "必要があります。コマンド行の先頭に書くだけでは効きません" + "(cd 等が前に付くと届かないため)。" + ), + ( + "AI はこの環境変数を自分で設定できません。復旧が必要な場合は、利用者に" + "「このセッションで AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 を設定してください」と依頼してください。" + ), + ( + "単一ファイルを HEAD の内容へ戻すだけなら、この hook にかからない " + "`git show HEAD: > ` で足りることが多いです。" + ), +] +reason = "\n".join(reason_lines) +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } +}, ensure_ascii=False)) +PY +} + +if [ -z "${command}" ]; then + allow_json + exit 0 +fi + +if [ "${AGENT_HUB_ALLOW_DESTRUCTIVE_GIT:-0}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass block-destructive-git allow '{"env":"AGENT_HUB_ALLOW_DESTRUCTIVE_GIT"}' 2>/dev/null || true + allow_json + exit 0 +fi + +# [2026-08-02][fix] path-qualified git executable を token 境界で裸の `git` に正規化する。 +# 背景: +# - ユーザー依頼意図: `/usr/bin/git` や空白を含む引用符付き path でも、`reset --hard` / +# `clean` 等を取り逃がさないようにする。 +# - 守るべき業務ルール: 実行 token の basename が `git` の場合だけ、裸の `git` と同じく +# fail-closed で止める。`/tmp/git tools/notgit` のような非 git executable は許可する。 +# - 他案不採用理由: Bash regex で path の slash・quote・空白を列挙する案は token 境界を失い、 +# 新しい path 表記や git を含む別 executable の誤検出を招く。PJ ごとの hook 手修正も不採用。 +# 対応: Python 標準 `shlex` で実行 token を解決し、`os.path.basename(token) == "git"` のときだけ +# その token を `git` に置換してから、後段の Bash 判定へ渡す。 +# [2026-08-02][fix] nice/nohup を安全に解析し、未知・解決不能な前置きを fail-closed にする。 +# 背景: +# - ユーザー依頼意図: 標準ラッパー経由の `nice git ...` / `nohup git ...` でも破壊的 Git を止めたい。 +# - 守るべき業務ルール: 既知の引数だけを消費し、曖昧な option や欠落した command は許可しない。 +# - 他案不採用理由: 任意の `-...` を無条件に読み飛ばす案は、未知 option の後ろの Git を取り逃がすため不採用。 +# 対応: nice の数値 option と nohup の `--` だけを明示的に消費し、未知・解決不能時は marker を出して停止する。 +readonly GIT_BIN='git' +readonly GIT_GLOBAL_OPT='(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--config-env[[:space:]]+[^[:space:]]+|--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--namespace(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--exec-path(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)?|--paginate|--no-pager|--no-replace-objects|--bare|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--help|--version|--html-path|--man-path|--info-path|-p)' +readonly GIT_GLOBAL_OPTS="([[:space:]]+${GIT_GLOBAL_OPT})*" +readonly SUDO_OPT='((-u|-g|-h|-p|-C|-T)[[:space:]]+[^[:space:]]+|-[^[:space:]]+)' +readonly ENV_OPT='((-u|--unset|-C|--chdir)[[:space:]]+[^[:space:]]+|-[^[:space:]]+)' +readonly GIT_PREFIX_TOKEN='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+|!|if|then|else|elif|do|while|until|command([[:space:]]+-p)?|builtin|exec|time([[:space:]]+-p)?|sudo([[:space:]]+'"${SUDO_OPT}"')*)' +readonly ENV_BIN='(/([^[:space:]/]+/)*env|env)' +readonly ENV_PREFIX="${ENV_BIN}"'([[:space:]]+'"${ENV_OPT}"')*([[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+)*' +readonly GIT_SEGMENT_START='^[[:space:]]*(('"${GIT_PREFIX_TOKEN}"'|'"${ENV_PREFIX}"')[[:space:]]+)*'"${GIT_BIN}" + +command_segments="$( + COMMAND_TEXT="$command" python3 - <<'PY' 2>/dev/null || true +import os +import re +import shlex + +cmd = os.environ.get("COMMAND_TEXT", "") + +CONTROL_WORDS = {"!", "if", "then", "else", "elif", "do", "while", "until"} +UNRESOLVED_WRAPPER = -1 + +def executable_basename(token: str, *, decoded: bool = False): + if decoded: + return os.path.basename(token) + try: + lexer = shlex.shlex(token, posix=True) + lexer.whitespace_split = True + words = list(lexer) + except ValueError: + return None + if len(words) != 1: + return None + return os.path.basename(words[0]) + +# [2026-08-02][fix] command wrapperと実行名は静的に確定できる場合だけ許可する。 +# 背景: +# - ユーザー依頼意図: env/time/exec/sudo/eval 等を挟んだ場合や、変数・command substitutionで +# 実行名を組み立てた場合も、破壊的Git操作を同じ基準で止める。 +# - 守るべき業務ルール: wrapper後の実行ファイルを静的に確定できない場合は許可しない。 +# posix lexerでdecode済みのtokenは再度shell parseせず、実ファイル名のquoteをliteralとして扱う。 +# - 他案不採用理由: 各OS・wrapperの全optionを推測して許可すると、引数をcommandとして +# 再解釈するoptionや将来追加されたoptionが新しい迂回経路になる。decode済みtokenの再shlexは +# quoteを含む有効なpathを構文エラーに変え、basename=gitの検出を失うため不採用。 +# 対応: 安全性を確認したoptionだけをwhitelistし、eval・未知option・再分割option・動的実行名は +# unresolved markerへ送る。decode済みtokenのbasenameは文字列から直接取得する。 +def command_executable_index(segment: list[str], *, decoded: bool = False): + sudo_short_options_with_arg = {"-u", "-g", "-h", "-p", "-C", "-T", "-D", "-R", "-r", "-t", "-U"} + sudo_short_options_no_arg = set("ABbEeHiKklnPSsVv") + sudo_long_options_with_arg = { + "--user", "--group", "--host", "--prompt", "--close-from", + "--chdir", "--chroot", "--command-timeout", "--other-user", + "--login-class", "--role", "--type", + } + sudo_long_options_no_arg = { + "--askpass", "--background", "--bell", "--edit", "--help", "--login", + "--list", "--non-interactive", "--preserve-env", "--remove-timestamp", + "--reset-timestamp", "--set-home", "--shell", "--stdin", "--validate", "--version", + } + index = 0 + while index < len(segment): + wrapper_start = index + while index < len(segment): + token = segment[index] + if token in CONTROL_WORDS or re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token): + index += 1 + continue + break + if index >= len(segment): + return None + + executable = executable_basename(segment[index], decoded=decoded) + if executable in {"command", "builtin"}: + index += 1 + while index < len(segment): + if segment[index] == "--": + index += 1 + break + if segment[index] == "-p": + index += 1 + continue + break + elif executable == "eval": + # eval reparses every remaining argument as shell source. + return UNRESOLVED_WRAPPER + elif executable == "exec": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option == "-a": + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("-a") and option != "-a": + index += 1 + continue + if re.fullmatch(r"-[cl]+", option): + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + elif executable == "time": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"-o", "-f", "--output", "--format"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("--output=") or option.startswith("--format="): + index += 1 + continue + if option in {"--append", "--verbose", "--portability", "--quiet"}: + index += 1 + continue + if re.fullmatch(r"-[ahlpv]+", option): + index += 1 + continue + if re.fullmatch(r"-(?:o|f).+", option): + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + # [2026-08-02][fix] timeout wrapper の後段 command を限定解析する。 + # 背景: + # - ユーザー依頼意図: 全PJへ配布する破壊的Git guardで、`timeout 5 git reset --hard` の + # ような標準wrapper経由の実行も直接実行と同じ基準で止める。 + # - 守るべき業務ルール: timeoutの既知optionと必須durationだけを消費し、その直後の + # commandを再帰的に検査する。未知option・値不足・command不足はfail-closedにする。 + # - 他案不採用理由: timeout以下を通常引数として許可する案は破壊操作を見逃し、全optionを + # 無条件に読み飛ばす案は将来の再解釈optionで同じ迂回を再発させるため不採用。 + # 対応: GNU timeoutの副作用を持たない既知optionだけを許可し、durationを1語消費して + # 後段commandへ解析を継続する。hook自身はtimeoutや対象commandを実行しない。 + elif executable == "timeout": + duration_pattern = r"(?:\d+(?:\.\d*)?|\.\d+)(?:s|m|h|d)?" + signal_pattern = r"(?:SIG)?[A-Za-z0-9]+" + + def static_timeout_value(token: str, pattern: str) -> bool: + if token_has_unresolved_executable_expansion(token): + return False + try: + value = token if decoded else decode_shell_command(token) + except (TypeError, ValueError): + return False + return re.fullmatch(pattern, value) is not None + + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"--preserve-status", "--foreground", "--verbose", "-v"}: + index += 1 + continue + if option in {"-k", "--kill-after", "-s", "--signal"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + value_pattern = duration_pattern if option in {"-k", "--kill-after"} else signal_pattern + if not static_timeout_value(segment[index + 1], value_pattern): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("-k") and option != "-k": + if not static_timeout_value(option[2:], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("-s") and option != "-s": + if not static_timeout_value(option[2:], signal_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("--kill-after="): + if not static_timeout_value(option.split("=", 1)[1], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("--signal="): + if not static_timeout_value(option.split("=", 1)[1], signal_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + # timeout requires one duration token followed by a command. + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + if not static_timeout_value(segment[index], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + elif executable == "nice": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option == "-n" or option == "--adjustment": + index += 1 + if index >= len(segment) or not re.fullmatch(r"[+-]?\d+", segment[index]): + return UNRESOLVED_WRAPPER + index += 1 + continue + if re.fullmatch(r"-n[+-]?\d+", option) or re.fullmatch(r"-\+?\d+", option): + index += 1 + continue + if re.fullmatch(r"--adjustment=[+-]?\d+", option): + index += 1 + continue + if option in {"--help", "--version"}: + return None + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + if index >= len(segment): + return UNRESOLVED_WRAPPER + elif executable == "nohup": + index += 1 + if index < len(segment) and segment[index] == "--": + index += 1 + elif index < len(segment) and segment[index].startswith("-"): + return UNRESOLVED_WRAPPER + if index >= len(segment): + return UNRESOLVED_WRAPPER + elif executable == "sudo": + index += 1 + terminated = False + while index < len(segment) and segment[index].startswith("-"): + option = segment[index] + if option == "--": + index += 1 + terminated = True + break + if option.startswith("--"): + if "=" in option: + option_name, _ = option.split("=", 1) + has_attached_value = True + else: + option_name = option + has_attached_value = False + if option_name not in sudo_long_options_with_arg and option_name not in sudo_long_options_no_arg: + return UNRESOLVED_WRAPPER + index += 1 + if ( + not has_attached_value + and option_name in sudo_long_options_with_arg + ): + if index >= len(segment): + return UNRESOLVED_WRAPPER + index += 1 + continue + option_name = option[:2] + if option_name in sudo_short_options_with_arg: + index += 1 + if len(option) == 2: + if index >= len(segment): + return UNRESOLVED_WRAPPER + index += 1 + continue + if all(char in sudo_short_options_no_arg for char in option[1:]): + index += 1 + continue + return UNRESOLVED_WRAPPER + if not terminated: + while index < len(segment) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", segment[index]): + index += 1 + # [2026-08-02][fix] xargs を wrapper として解析し、実行 command へ検査を継続する。 + # 背景: + # - ユーザー依頼意図: `printf 'HEAD' | xargs -n1 git reset --hard` のように xargs 経由で + # 破壊的 Git を起動すると、git が引数位置に見えて検査から漏れていた + # (jtt-cms PR #1542 の codex-review が検出した Critical)。 + # - 守るべき業務ルール: timeout / env と同じく、副作用と再解釈の無い既知 option だけを + # whitelist で消費し、直後の command を通常の検査へ流す。引数が任意個の option + # (GNU の bare -l / -i / -e、--replace 単独等)は静的に境界を確定できないため + # unresolved(fail-closed)に送る。command 無しの xargs は既定 echo のため安全。 + # - 他案不採用理由: xargs を一律 unresolved にする案は、`ls | xargs rm` 等の非 git 用途 + # まで全 deny し誤検知摩擦(#1313 で解消したクラス)を再発させる。全 option の + # 読み飛ばしは将来の再解釈 option で迂回を再発させる(timeout の CaD と同判断)。 + elif executable == "xargs": + xargs_long_with_arg = { + "--arg-file", "--delimiter", "--eof", "--max-args", "--max-chars", + "--max-lines", "--max-procs", "--process-slot-var", + } + xargs_no_arg = { + "-0", "--null", "-p", "--interactive", "-r", "--no-run-if-empty", + "-t", "--verbose", "-x", "--exit", "-o", "--open-tty", + } + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"-n", "-L", "-s", "-P", "-a", "-d", "-E", "-J", "-I"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option in xargs_long_with_arg: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if any(option.startswith(name + "=") for name in xargs_long_with_arg | {"--replace"}): + index += 1 + continue + if re.fullmatch(r"-[nLsPadEJIi].+", option): + # 値が密着した短形(-n1 / -I{} / -i{} / -d\n 等) + index += 1 + continue + if option in xargs_no_arg or re.fullmatch(r"-[0prtxo]+", option): + index += 1 + continue + if option.startswith("-"): + # bare -l / -i / -e / --replace 等の任意引数 option・未知 option + return UNRESOLVED_WRAPPER + break + elif executable == "env": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"-S", "--split-string"} or option.startswith("-S") or option.startswith("--split-string="): + # split-string reparses one token into a complete command. + return UNRESOLVED_WRAPPER + if option in {"-u", "--unset", "-C", "--chdir"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("--unset=") or option.startswith("--chdir="): + index += 1 + continue + if re.fullmatch(r"-(?:u|C).+", option): + index += 1 + continue + if option in {"-", "-i", "--ignore-environment", "-0", "--null", "--debug"}: + index += 1 + continue + if option in {"--help", "--version"}: + return None + if option.startswith("-"): + return UNRESOLVED_WRAPPER + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", option): + index += 1 + continue + break + else: + return index + + if index <= wrapper_start: + return None + return None + +def token_has_unresolved_executable_expansion(token: str) -> bool: + """Return whether an executable word requires shell expansion to resolve.""" + if token.startswith("="): + # zsh expands a leading equals command name to an absolute executable path. + return True + quote = None + index = 0 + while index < len(token): + char = token[index] + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + return True + if char == "$" and index + 1 < len(token): + next_char = token[index + 1] + if next_char in "{([?*!#@$-0123456789_" or next_char.isalpha(): + return True + if char in "<>" and index + 1 < len(token) and token[index + 1] == "(": + return True + if quote is None and char in "*?": + return True + if ( + quote is None + and char in "[{" + and index + 1 < len(token) + and not token[index + 1].isspace() + and token[index + 1] not in ";&|" + ): + return True + if quote is None and char in "@+!" and index + 1 < len(token) and token[index + 1] == "(": + return True + if ( + quote is None + and char == "(" + and index > 0 + and not token[index - 1].isspace() + and token[index - 1] not in ";&|(<" + ): + return True + index += 1 + return False + +def has_unresolved_command_start(text: str) -> bool: + """Inspect raw command-start words without evaluating shell syntax.""" + try: + # Keep parentheses inside words so `$(...)`, extglob, and zsh qualifiers + # remain visible. The regular parser separately handles grouping syntax. + lexer = shlex.shlex(text, posix=False, punctuation_chars=";&|") + # Real shell comments were removed by + # collapse_shell_line_continuations(). Keep `#` inside parameter + # expansions such as `${#name}` visible to the lexer. + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return True + + segment: list[str] = [] + for token in tokens + [";"]: + if token in {"(", ")", "{", "}"} or (token and all(char in ";&|" for char in token)): + if segment: + executable_index = command_executable_index(segment) + if executable_index == UNRESOLVED_WRAPPER: + return True + if ( + executable_index is not None + and executable_index >= 0 + and token_has_unresolved_executable_expansion(segment[executable_index]) + ): + return True + segment = [] + else: + segment.append(token) + return False + +def emit_segment_line(text: str) -> None: + if has_unresolved_command_start(text): + print("__UNRESOLVED_COMMAND_WRAPPER__") + try: + lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + for segment in re.split(r"[;&|(){}]+", text): + segment = segment.strip() + if segment: + print(segment) + return + + segment = [] + + def flush() -> None: + if segment: + # `segment` came from a posix=True lexer, so quoted arguments with + # spaces are already one token. Replace those spaces only for the + # executable parser; nested shell bodies keep their original quotes. + parser_segment = [re.sub(r"\s+", "__ARG_SPACE__", token) for token in segment] + executable_index = command_executable_index(parser_segment, decoded=True) + if executable_index == UNRESOLVED_WRAPPER: + print("__UNRESOLVED_COMMAND_WRAPPER__") + segment.clear() + return + # [2026-08-02][fix] grouping 構文の内側でも動的 executable を fail-closed にする。 + # 背景: + # - ユーザー依頼意図: `{ "$G" reset --hard; }`、subshell、function body のように + # command start が grouping token の後ろにある場合も、破壊的 Git を取り逃がさない。 + # - 守るべき業務ルール: 実行ファイル名を静的に `git` 以外と確定できない command segment は + # grouping の深さに関係なく unresolved marker へ送り、既存の fail-close 契約を保つ。 + # - 他案不採用理由: 外側の raw scanner だけで grouping 全体を一つの command とみなす案は、 + # brace/subshell/function の内側にある実際の executable 境界を失うため不採用。 + # 対応: decoded parser が抽出した各 segment の executable token も検査し、変数または + # command substitution を含む場合は unresolved marker を出す。 + if ( + executable_index is not None + and executable_index >= 0 + and ( + parser_segment[executable_index] == "$" + or token_has_unresolved_executable_expansion(parser_segment[executable_index]) + ) + ): + # The decoded parser also sees command starts inside brace/paren + # groups and function bodies that the outer raw segment begins + # with grouping syntax rather than the eventual executable. + print("__UNRESOLVED_COMMAND_WRAPPER__") + segment.clear() + return + if ( + executable_index is not None + and executable_index >= 0 + and executable_basename(parser_segment[executable_index], decoded=True) == "git" + ): + segment[:] = ["git"] + segment[executable_index + 1:] + # shlex は引用を外すため、空白入り `git -C "/tmp/a b"` をそのまま join すると + # 後段の正規表現が git global option の引数境界を誤る。判定に不要な内部空白だけ + # sentinel に寄せ、実コマンドの語順は保ったまま検査する。 + normalized = [re.sub(r"\s+", "__ARG_SPACE__", token) for token in segment] + print(" ".join(normalized).strip()) + segment.clear() + + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + flush() + else: + segment.append(token) + flush() + +# [2026-08-02][fix] dash / ksh も shell receiver として再帰検査する(issue #1344)。 +# 背景: +# - ユーザー依頼意図: dash / ksh へ here-doc(quoted 'EOF' 区切り)で流し込んだ破壊的 Git が +# receiver 集合の漏れで再帰検査されず素通りしていた(PR #1343 の codex-review が検出)。 +# ※このコメントに here-doc 演算子そのものを書かないこと: 本 Python は bash の $( ) 置換内の +# quoted heredoc に埋まっており、bash の置換パーサはコメント内でも演算子を解釈して壊れる。 +# - 守るべき業務ルール: shell として本文を実行する受け手は全て同じ fail-close 再帰へ送る。 +# - 他案不採用理由: 任意の実行ファイルを receiver 扱いする案は、非 shell の cat/tee まで +# 本文をコマンド検査して誤検知を増やすため不採用(shell 実体の列挙を維持し不足だけ足す)。 +shells = {"sh", "bash", "zsh", "dash", "ksh"} +MAX_SHELL_DEPTH = 4 +UNRESOLVED_COMMAND_MARKER = "__UNRESOLVED_COMMAND_WRAPPER__" + +# [2026-08-02][fix] 引用内改行で論理行を分断しない(issue #1313 誤検知ファミリー)。 +# 背景: +# - ユーザー依頼意図: `git commit -m "<複数行メッセージ>"` / `gh pr create --body "<複数行>"` が +# text.splitlines() の引用非対応分割で引用途中に千切れ、unresolved 判定→deny になっていた +# (1セッション3〜6回の実測摩擦。値は実行されないデータであり真陽性ではない)。 +# - 守るべき業務ルール: shell の行分割は引用外の改行だけがコマンド区切り。引用内・$( ) / +# backtick 内の改行はトークン/置換本文の一部として同じ論理行に留める。未終端の引用・置換は +# 従来どおり None を返し fail-closed(unresolved)へ倒す。$( ) / backtick の本文検査は +# shell_substitution_bodies 側が従来どおり再帰実施するため、検知力は変えない。 +# - 他案不採用理由: -m/--body 等の「データ引数の値」を走査対象から除外する案は、値の中の +# $( ) 置換(shell が実際に実行する)まで免除しかねず、緩和面が広い。引用対応の分割は +# 誤検知3ケースを同時に解消しつつ既存の置換再帰検査を一切変えない最小修正のため採用。 +def split_shell_logical_lines(text: str): + """Split on newlines that are outside quotes / $() / backticks. None if unterminated. + + Context stack model: 'sq' (single quote), 'dq' (double quote), 'sub' + ($() or bare paren inside a substitution), 'bt' (backtick). Newlines break + logical lines only when the stack is empty (= plain command position). + """ + BACKTICK = chr(96) # 字面のバッククォートは外側 bash の置換スキャナを壊すため chr で持つ + lines = [] + current = [] + stack: list[str] = [] + index = 0 + length = len(text) + while index < length: + char = text[index] + state = stack[-1] if stack else None + if state == "sq": + # 単一引用内の backslash+改行は「continuation に見える難読化」の既存保守契約を + # 維持するため unresolved(None)へ倒す(test: single quoted continuation)。 + if char == "\\" and index + 1 < length and text[index + 1] in "\r\n": + return None + current.append(char) + if char == "'": + stack.pop() + index += 1 + continue + if char == "\\": + # escape consumes next char in normal / dq / sub / bt contexts + current.append(char) + if index + 1 < length: + current.append(text[index + 1]) + index += 2 + else: + index += 1 + continue + if state == "dq": + if char == '"': + stack.pop() + elif char == "$" and index + 1 < length and text[index + 1] == "(": + # NOTE: dollar+開き括弧のリテラルを1トークンで書かない。外側 bash の + # 置換スキャナが引用内でも入れ子置換の開始と解釈して構文崩壊するため、 + # 2文字に分けて append する(本ファイル特有の制約)。 + current.append("$") + current.append("(") + stack.append("sub") + index += 2 + continue + elif char == BACKTICK: + stack.append("bt") + current.append(char) + index += 1 + continue + if state == "bt": + if char == BACKTICK: + stack.pop() + current.append(char) + index += 1 + continue + # state is None (top level) or 'sub' — both accept openers + if char == "'": + stack.append("sq") + current.append(char) + index += 1 + continue + if char == '"': + stack.append("dq") + current.append(char) + index += 1 + continue + if char == BACKTICK: + stack.append("bt") + current.append(char) + index += 1 + continue + if char == "$" and index + 1 < length and text[index + 1] == "(": + stack.append("sub") + # NOTE: dollar+開き括弧のリテラルは2文字に分けて append(上の分岐と同じ理由)。 + current.append("$") + current.append("(") + index += 2 + continue + if state == "sub": + if char == "(": + stack.append("sub") + elif char == ")": + stack.pop() + current.append(char) + index += 1 + continue + if char == "\n": + lines.append("".join(current)) + current = [] + index += 1 + continue + current.append(char) + index += 1 + if stack: + return None + lines.append("".join(current)) + return [line for line in lines if line.strip()] or [""] + + +# [2026-08-02][fix] git 無縁と静的に確定できる nested body だけ unresolved deny を免除する +# (issue #1313 案3の安全部分集合・下の呼び出し元 CaD と対)。 +# 背景: +# - ユーザー依頼意図: 変数や特殊パラメータを含むだけの非 git body(例: exit status 表示付きの +# script 実行)が unresolved 扱いで deny される摩擦を解消したい。 +# - 守るべき業務ルール: 既存の fail-closed 契約(変数 executable / glob・brace・class による +# git 難読化 / 引用継続の難読化は deny)を 1 件も後退させない。判定は「安全と証明できた +# 場合のみ許可」の片側条件とし、証明できない形は全て従来どおり deny に落とす。 +# - 他案不採用理由: body へ再帰降下する案は、glob executable(g?t 等)を非 git と誤読する。 +# git 文字列の有無だけで判定する案は、変数 executable(PAYLOAD 経由)を素通りさせる。 +def nested_body_safely_non_git(body: str) -> bool: + """True only when the body is provably inert w.r.t. destructive git. + + 条件(全て満たす時だけ許可・1つでも証明できなければ False = 従来の deny): + 1. body に "git" 文字列が無い(大文字小文字無視・部分一致で安全側) + 2. brace / backtick / 置換開始(dollar+開き括弧)が無い + 3. dollar 展開は単文字特殊パラメータ(? $ ! #)のみ($VAR / ${...} は + eval や interpreter の引数経由で任意コマンド化しうるため一律 deny) + 4. 各 command segment の実行子がプレーンリテラルで、shell でも + 実行 wrapper(eval / exec / env / sudo / xargs 等・引数を実行する類)でもない + """ + lowered = body.lower() + if "git" in lowered: + return False + if "{" in body or "}" in body: + # brace expansion は tokenizer が区切りとして分解し executable 難読化 + # (/usr/bin/g{it} 等)を見えなくするため、含む body は証明不能として deny 側 + return False + if chr(96) in body: + # backtick 置換は静的解決不能 + return False + # [2026-08-02][fix] PR #1354 codex-review Critical 対応: $VAR / ${...} を含む body は + # `eval $PAYLOAD` / `python3 -c $CODE` 等の引数経由で任意コマンド化するため許可しない。 + # 実行時に値が確定済みで不活性なのは単文字特殊パラメータだけ、という許可リストへ縮小する。 + position = body.find("$") + while position != -1: + follower = body[position + 1:position + 2] + if follower not in {"?", "$", "!", "#"}: + return False + position = body.find("$", position + 2) + segments = segment_tokens(body) + if segments is None: + return False + plain_executable = re.compile(r"[A-Za-z0-9_./-]+") + # 引数を新たなコマンドとして実行しうる wrapper。列挙は原理的に完全にならないため、 + # ここに無い未知 wrapper への防御は上の「$VAR 全面 deny」(引数が静的リテラルなら + # wrapper 経由でも body 内に "git" が現れ 1. で deny)と組み合わせて成立させる。 + exec_wrappers = { + "eval", "exec", "command", "builtin", "source", ".", + "env", "sudo", "doas", "su", "xargs", "nohup", "nice", + "time", "timeout", "setsid", "script", "watch", "caffeinate", + } + for segment in segments: + index = command_executable_index(segment) + if index == UNRESOLVED_WRAPPER: + return False + if index is None: + # 実行子なし(純 assignment 等)は破壊操作を持たない + continue + if index < 0 or index >= len(segment): + return False + token = segment[index] + if plain_executable.fullmatch(token) is None: + return False + basename = executable_basename(token) + if basename in shells or basename in exec_wrappers: + # nested-nested shell / 実行 wrapper は本関数で安全証明できないため deny 側 + return False + return True + + +def decode_shell_command(token: str) -> str: + lexer = shlex.shlex(token, posix=True) + lexer.whitespace_split = True + words = list(lexer) + if len(words) != 1: + raise ValueError("invalid shell command argument") + return words[0] + +def segment_tokens(text: str): + try: + # Keep the outer quote around `bash -c`/`sh -c` bodies so the nested + # command can be decoded once without losing its own quoted path tokens. + lexer = shlex.shlex(text, posix=False, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return None + segments: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + if current: + segments.append(current) + current = [] + else: + current.append(token) + if current: + segments.append(current) + return segments + +def shell_start_index(segment: list[str]): + index = command_executable_index(segment) + return index if index is not None and index >= 0 and executable_basename(segment[index]) in shells else None + + +# [2026-08-02][fix] nested shell の動的 body を fail-closed にする。 +# 背景: +# - ユーザー依頼意図: `PAYLOAD="git reset --hard"; bash -c "$PAYLOAD"` のように、 +# shell `-c` の body を変数・command substitution・process substitution で組み立てる +# 経路でも、破壊的 Git の静的検査を迂回させない。 +# - 守るべき業務ルール: hook が安全に確定できない nested body は許可せず、必ず deny する。 +# hook 自身が変数展開や command substitution を実行して body を得ることは禁止する。 +# - 他案不採用理由: body を実行して展開結果を得る案は hook の副作用・コマンドインジェクションを +# 招く。正規表現だけで全ての shell 展開を再現する案は quote/escape 境界を取り違えるため不採用。 +# 対応: shlex で decode 済みの body を小さな quote-aware scanner で確認し、未解決の `$` 展開、 +# backtick、`$()`、process substitution、pathname/brace展開を marker に変換する。 +# 静的 body の再帰検査は従来どおり行う。 +# [2026-08-02][fix] double quote 中の single quote で scanner state を切り替えない。 +# 背景: shell では double quote 内の `'` は literal だが、旧 scanner は single quote 開始と誤認し、 +# 後続の `$PAYLOAD` を「展開されない文字列」として見逃し得た。scanner 単体でも shell semantics と +# 一致させる必要がある。quote 全文を正規表現へ戻す案は既存の escape 境界を失うため不採用。 +# 対応: double quote state は `"` だけで終了し、その中の `'` は通常文字として扱う。 +def has_unresolved_shell_expansion(text: str) -> bool: + """Return whether a nested shell body contains expansion we must not evaluate.""" + quote = None + index = 0 + + def parameter_expansion_at(position: int) -> bool: + if position + 1 >= len(text): + return False + next_char = text[position + 1] + if next_char in "{([?*!#@$-0123456789_": + return True + return next_char.isalpha() + + while index < len(text): + char = text[index] + if quote == "'": + # Single-quoted shell text has no expansion semantics. + if char == "'": + quote = None + index += 1 + continue + + if char == "\\": + # In unquoted/double-quoted text, an escaped next character is literal. + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + return True + if char == "$" and parameter_expansion_at(index): + return True + if char in "<>" and index + 1 < len(text) and text[index + 1] == "(": + return True + if quote is None and char in "*?": + return True + if ( + quote is None + and char in "[{" + and index + 1 < len(text) + and not text[index + 1].isspace() + and text[index + 1] not in ";&|" + ): + return True + if ( + quote is None + and char in "@+!" + and index + 1 < len(text) + and text[index + 1] == "(" + ): + # Bash extglob such as @(git) can synthesize the executable name. + return True + if ( + quote is None + and char == "(" + and index > 0 + and not text[index - 1].isspace() + and text[index - 1] not in ";&|(<" + ): + # zsh glob qualifiers such as /usr/bin/git(.) are attached to a word. + return True + index += 1 + return False + + +# [2026-08-02][fix] 通常 command の引数内にある shell substitution も再帰検査する。 +# 背景: +# - ユーザー依頼意図: `printf '%s' "$(git reset --hard)"` のように、外側の executable が +# `git` でなくても実行される破壊的 Git を取り逃がさない。 +# - 守るべき業務ルール: command / process / backtick substitution の body は、引用位置に関係なく +# 実際に shell が実行する範囲だけを静的に抽出し、既存と同じ fail-close 判定へ渡す。 +# - 他案不採用理由: substitution を含む command を一律 deny すると `$(pwd)` 等の安全な開発操作まで +# 止める。shell 展開を実行して body を得る案は副作用と command injection を招くため不採用。 +# 対応: single quote と escape を尊重する小さな scanner で `$()` / `<()` / `>()` / backtick の +# body を抽出する。対応できない構文・不均衡・深すぎる再帰は unresolved marker へ送る。 +def shell_substitution_bodies(text: str): + """Return executable substitution bodies, or ``None`` when ambiguous.""" + + def backtick_end(start: int): + position = start + 1 + while position < len(text): + if text[position] == "\\": + position += 2 + continue + if text[position] == chr(96): + return position + position += 1 + return None + + def paren_end(open_index: int): + depth = 1 + quote = None + position = open_index + 1 + while position < len(text): + char = text[position] + if quote == "'": + if char == "'": + quote = None + position += 1 + continue + if char == "\\": + position += 2 + continue + if quote == '"': + if char == '"': + quote = None + position += 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "{": + # Parameter expansion patterns may legally contain `)` and + # make a hand-written parenthesis matcher terminate early. + return None + if char == "$" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == chr(96): + nested_end = backtick_end(position) + if nested_end is None: + return None + position = nested_end + 1 + continue + position += 1 + continue + if char in {"'", '"'}: + quote = char + position += 1 + continue + if ( + char == "#" + and ( + position == open_index + 1 + or text[position - 1].isspace() + or text[position - 1] in ";&|({}" + ) + ): + # An unquoted shell comment hides every `)` through the newline. + newline = text.find("\n", position + 1) + if newline < 0: + return None + position = newline + 1 + continue + if char == chr(96): + nested_end = backtick_end(position) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "{": + return None + if char == "<" and position + 1 < len(text) and text[position + 1] == "<": + # Skip a here-doc inside `$()` so a later `)` / command remains visible. + # Example: `$(cat <" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return position + position += 1 + return None + + bodies = [] + quote = None + index = 0 + while index < len(text): + char = text[index] + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + end = backtick_end(index) + if end is None: + return None + body = text[index + 1:end] + # Inside legacy backticks, an escaped backtick opens/closes a nested + # command substitution. Until that grammar is decoded losslessly, + # preserve the documented fail-close boundary instead of treating it + # as a literal escape and dropping the nested executable. + if chr(92) + chr(96) in body: + return None + bodies.append(body) + index = end + 1 + continue + if char == "$" and index + 1 < len(text) and text[index + 1] == "(": + end = paren_end(index + 1) + if end is None: + return None + body = text[index + 2:end] + if body.startswith("("): + # Arithmetic expansion is not itself a command, but may contain one. + nested = shell_substitution_bodies(body) + if nested is None: + return None + bodies.extend(nested) + else: + # A case-pattern `)` is indistinguishable from the substitution + # terminator in this deliberately small scanner. Never infer + # safety from a later `esac` string: it may be pattern data before + # the prematurely matched `)` rather than the closing keyword. + case_start = r"(?:^|[;&|({\n]|\b(?:then|do|else)\b)\s*case\b" + if re.search(case_start, body): + return None + bodies.append(body) + index = end + 1 + continue + if quote is None and char in "<>" and index + 1 < len(text) and text[index + 1] == "(": + end = paren_end(index + 1) + if end is None: + return None + bodies.append(text[index + 2:end]) + index = end + 1 + continue + index += 1 + if quote is not None: + return None + return bodies + + +# [2026-08-02][fix] shell tokenizationより先にline continuationを論理行へ戻す。 +# 背景: +# - ユーザー依頼意図: `g\\\nit reset --hard` のように物理改行で executable を分割しても、 +# 実行時に `git` へ戻る破壊操作を取り逃がさない。 +# - 守るべき業務ルール: shell がtokenize前に行うbackslash-newline除去を静的に再現し、 +# command substitution内外で同じfail-close判定へ渡す。single quote内のliteralは変更しない。 +# - 他案不採用理由: shell自体を実行して展開結果を得る案は、副作用とcommand injectionを招く。 +# 物理行を別々に検査する旧方式は、改行をまたいだ実行tokenを原理的に復元できない。 +# 対応: quote-awareな標準Python処理でLF/CRLF continuationだけを除去し、その後に既存scannerを使う。 +def collapse_shell_line_continuations(text: str) -> str: + """Collapse continuations and remove real comments before ``shlex``.""" + result = [] + quote = None + in_comment = False + index = 0 + while index < len(text): + char = text[index] + if in_comment: + # Backslash-newline is literal comment text here; the physical newline + # still ends the comment before the next command. + if char == "\n": + result.append(char) + in_comment = False + index += 1 + continue + if quote == "'": + result.append(char) + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + if index + 1 < len(text) and text[index + 1] == "\n": + index += 2 + continue + if index + 2 < len(text) and text[index + 1:index + 3] == "\r\n": + index += 3 + continue + result.append(char) + if index + 1 < len(text): + result.append(text[index + 1]) + index += 2 + else: + index += 1 + continue + if ( + quote is None + and char == "#" + and not (len(result) >= 2 and result[-2:] == ["$", "{"]) + and ( + not result + or result[-1].isspace() + or result[-1] in ";&|({}" + ) + ): + in_comment = True + index += 1 + continue + if quote == '"' and char == '"': + quote = None + elif quote is None and char in {"'", '"'}: + quote = char + result.append(char) + index += 1 + return "".join(result) + + + +# [2026-08-02][fix] here-doc 本文は受信コマンドのデータであり、行分割して再検査しない。 +# 背景: +# - ユーザー依頼意図: `git commit -F -` への here-doc や `gh ... --body "$(cat <= len(text) or text[lt_index:lt_index + 2] != "<<": + return None + pos = lt_index + 2 + strip_tabs = False + if pos < len(text) and text[pos] == "-": + strip_tabs = True + pos += 1 + while pos < len(text) and text[pos] in " \t": + pos += 1 + if pos >= len(text) or text[pos] == "\n": + return None + + quoted = False + if text[pos] == "\\": + quoted = True + pos += 1 + if pos >= len(text): + return None + start = pos + while pos < len(text) and (text[pos].isalnum() or text[pos] == "_"): + pos += 1 + delimiter = text[start:pos] + elif text[pos] in {"'", '"'}: + quoted = True + quote = text[pos] + pos += 1 + start = pos + while pos < len(text) and text[pos] != quote: + if text[pos] == "\\" and quote == '"': + pos += 2 + continue + pos += 1 + if pos >= len(text): + return None + delimiter = text[start:pos] + pos += 1 + else: + start = pos + while pos < len(text) and (text[pos].isalnum() or text[pos] in "_-"): + pos += 1 + delimiter = text[start:pos] + if not delimiter: + return None + + newline = text.find("\n", pos) + if newline < 0: + return None + body_pos = newline + 1 + while body_pos <= len(text): + next_nl = text.find("\n", body_pos) + line = text[body_pos:] if next_nl < 0 else text[body_pos:next_nl] + compare = line.lstrip("\t") if strip_tabs else line + if compare == delimiter: + end = len(text) if next_nl < 0 else next_nl + 1 + return end, quoted + if next_nl < 0: + return None + body_pos = next_nl + 1 + return None + + + +def extract_heredocs(text: str): + """Split here-doc bodies from command text. + + Returns ``(without_bodies, shell_bodies, unquoted_bodies)``. + + Here-docs are recognized outside single quotes. Double quotes are ignored as + a quoting barrier so ``"$(cat <= end: + without.append(text[index:end]) + index = end + continue + without.append(text[index : delim_line_end + 1]) + body = text[delim_line_end + 1 : end] + body_lines = body.splitlines(keepends=True) + body_content = "".join(body_lines[:-1]) if body_lines else "" + receiver_line = "".join(without[line_start:]) + text[index:delim_line_end] + try: + lexer = shlex.shlex(receiver_line, posix=True, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + tokens = [] + exec_index = command_executable_index(tokens, decoded=True) if tokens else None + is_shell = ( + exec_index is not None + and exec_index >= 0 + and executable_basename(tokens[exec_index], decoded=True) in shells + ) + if is_shell: + shell_bodies.append(body_content) + elif not quoted and body_content.strip(): + unquoted_bodies.append(body_content) + index = end + if index > 0 and text[index - 1] == "\n": + line_start = len(without) + continue + if char == "\n": + without.append(char) + index += 1 + line_start = len(without) + continue + without.append(char) + index += 1 + return "".join(without), shell_bodies, unquoted_bodies + + + + +def _strip_quoted_heredocs_completely(body: str): + """Remove quoted here-docs entirely from a ``$()`` body. + + Returns ``(remaining, True)`` when every here-doc used a quoted delimiter. + Returns ``(None, False)`` when an unquoted/incomplete here-doc is present + (caller must not collapse — trailing commands or expansions may remain). + """ + sq = chr(39) + remaining = [] + in_single = False + index = 0 + saw_heredoc = False + while index < len(body): + char = body[index] + if in_single: + remaining.append(char) + if char == sq: + in_single = False + index += 1 + continue + if char == "\\": + remaining.append(char) + if index + 1 < len(body): + remaining.append(body[index + 1]) + index += 2 + else: + index += 1 + continue + if char == sq: + in_single = True + remaining.append(char) + index += 1 + continue + if char == "<" and index + 1 < len(body) and body[index + 1] == "<": + end_info = heredoc_skip_end(body, index) + if end_info is None: + return None, False + end, quoted = end_info + if not quoted: + return None, False + saw_heredoc = True + index = end + continue + remaining.append(char) + index += 1 + if not saw_heredoc: + return None, False + return "".join(remaining), True + + +def collapse_data_substitutions(text: str): + """Collapse data-command substitutions that only feed quoted here-doc text. + + Only ``$(cat <<'EOF' ... EOF)`` style payloads collapse. Unquoted here-docs, + trailing ``; cmd``, pipelines, and nested substitutions are left intact so + later scanners still see real executable git. + """ + data_commands = {"cat", "printf", "echo", "head", "tail", "base64", "wc", "true"} + dq = chr(34) + sq = chr(39) + open_sub = "$" + "(" + token = dq + "__HOOK_STATIC_HEREDOC_DATA__" + dq + separators = {";", "&", "|", "||", "&&", "(", ")", "{", "}"} + out = [] + index = 0 + while index < len(text): + dollar = text.find(open_sub, index) + if dollar < 0: + out.append(text[index:]) + break + prefix = text[index:dollar] + in_single = False + p = 0 + while p < len(prefix): + ch = prefix[p] + if in_single: + if ch == sq: + in_single = False + p += 1 + continue + if ch == "\\": + p += 2 + continue + if ch == sq: + in_single = True + p += 1 + if in_single: + out.append(text[index:dollar + len(open_sub)]) + index = dollar + len(open_sub) + continue + depth = 1 + pos = dollar + len(open_sub) + replaced = False + while pos < len(text) and depth: + ch = text[pos] + if ch == "\\": + pos += 2 + continue + if ch == sq: + pos += 1 + while pos < len(text) and text[pos] != sq: + pos += 1 + pos += 1 + continue + if ch == dq: + pos += 1 + while pos < len(text) and text[pos] != dq: + if text[pos] == "\\": + pos += 2 + continue + pos += 1 + pos += 1 + continue + if ch == "<" and pos + 1 < len(text) and text[pos + 1] == "<": + skipped = heredoc_skip_end(text, pos) + if skipped is None: + break + pos = skipped[0] + continue + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + body = text[dollar + len(open_sub):pos] + remaining, ok = _strip_quoted_heredocs_completely(body) + if ok and remaining is not None: + nested = shell_substitution_bodies(remaining) + if nested == []: + try: + lexer = shlex.shlex( + remaining, posix=True, punctuation_chars=";&|(){}" + ) + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + tokens = [] + if tokens and not any(tok in separators for tok in tokens): + exec_index = command_executable_index( + tokens, decoded=True + ) + if ( + exec_index is not None + and exec_index >= 0 + and executable_basename( + tokens[exec_index], decoded=True + ) + in data_commands + ): + start = dollar + endpos = pos + 1 + if ( + start > 0 + and endpos < len(text) + and text[start - 1] == dq + and text[endpos] == dq + ): + start -= 1 + endpos += 1 + out.append(text[index:start]) + out.append(token) + index = endpos + replaced = True + break + pos += 1 + if not replaced: + if pos >= len(text) and depth: + out.append(text[index:]) + break + out.append(text[index:dollar + len(open_sub)]) + index = dollar + len(open_sub) + return "".join(out) + + +def emit_segments(text: str, depth: int = 0) -> None: + """Emit a shell command and recursively inspect every ``*-c`` body. + + Shells can be nested arbitrarily (for example ``bash -c 'sh -c ...'``). + Bound the static expansion so an adversarially deep or malformed payload + becomes an unresolved marker instead of silently bypassing the hook. + """ + if depth > MAX_SHELL_DEPTH: + print(UNRESOLVED_COMMAND_MARKER) + return + + text = collapse_shell_line_continuations(text) + + # Collapse quoted data here-docs inside $() BEFORE stripping, otherwise the + # opener remains and collapse can no longer find the terminator. + text = collapse_data_substitutions(text) + + # Here-doc bodies are data for the receiving command. Do not line-split them + # into fake top-level commands. Shell receivers still re-inspect the body. + stripped, shell_heredocs, unquoted_heredocs = extract_heredocs(text) + if stripped is None: + print(UNRESOLVED_COMMAND_MARKER) + return + text = stripped + + # 引用/置換の内側の改行で論理行を千切らない(split_shell_logical_lines の CaD 参照)。 + # 未終端の引用・置換は None → 従来どおり unresolved で fail-closed。 + lines = split_shell_logical_lines(text) + if lines is None: + print(UNRESOLVED_COMMAND_MARKER) + return + if not lines: + emit_segment_line(text) + else: + for line in lines: + emit_segment_line(line) + + for heredoc_body in shell_heredocs: + if heredoc_body.strip(): + emit_segments(heredoc_body, depth + 1) + + # Unquoted here-doc bodies expand $()/backticks; inspect those only. + for heredoc_body in unquoted_heredocs: + expansion_bodies = shell_substitution_bodies(heredoc_body) + if expansion_bodies is None: + print(UNRESOLVED_COMMAND_MARKER) + return + for body in expansion_bodies: + emit_segments(body, depth + 1) + + substitution_bodies = shell_substitution_bodies(text) + if substitution_bodies is None: + print(UNRESOLVED_COMMAND_MARKER) + return + for body in substitution_bodies: + emit_segments(body, depth + 1) + + segments = segment_tokens(text) + if segments is None: + print(UNRESOLVED_COMMAND_MARKER) + return + if depth > 0 and not text.strip(): + print(UNRESOLVED_COMMAND_MARKER) + return + if depth > 0 and text.strip() and not segments: + print(UNRESOLVED_COMMAND_MARKER) + return + + for segment in segments: + index = shell_start_index(segment) + if index is None: + continue + lookahead = index + 1 + tokens = segment + while lookahead < len(tokens) and tokens[lookahead].startswith("-"): + option_token = tokens[lookahead] + option = option_token.lstrip("-") + if not option_token.startswith("--") and "c" in option: + command_index = lookahead + 1 + if command_index < len(tokens) and tokens[command_index] == "--": + command_index += 1 + if command_index >= len(tokens): + print(UNRESOLVED_COMMAND_MARKER) + break + try: + nested_command = decode_shell_command(tokens[command_index]) + except Exception: + # A malformed nested shell argument must fail closed. + print(UNRESOLVED_COMMAND_MARKER) + break + if has_unresolved_shell_expansion(nested_command): + # Do not evaluate shell variables/substitutions in the hook. The body may + # resolve to destructive Git after the hook returns, so static inspection is + # impossible without executing untrusted input. + # + # [2026-08-02][fix] git 無縁の nested body まで deny しない(issue #1313 案3の + # 安全部分集合)。 + # 背景: + # - ユーザー依頼意図: `bash -c '... echo "exit=$?" ...'` のような、git を + # 一切含まない body が $? / $VAR だけで unresolved 扱いされ deny される + # 摩擦を解消したい(実測: 1セッション3回)。 + # - 守るべき業務ルール: 本 hook の守備範囲は破壊的 Git のみ(冒頭 CaD)。 + # "git" が現れない body は展開後も git になり得る余地を静的に持たない + # (g${X}it 型の難読化は変数側に "git" が現れないが、その場合 body 内に + # substring "git" が無くても executable 難読化は既存の変数 executable + # fail-close が上流で拾う)。substring 判定(大文字小文字無視・単語境界 + # なし)を使い、"digital" 等を含む body も deny 側へ倒す(安全側の過剰)。 + # - 他案不採用理由: unresolved wrapper 全面緩和(案3全体)は影響範囲が + # 読めず不採用。データ引数の値の除外は $( ) 置換の免除リスクがあり不採用。 + # git 文字列の有無だけの判定は変数 executable を素通りさせるため不採用 + # (安全証明は nested_body_safely_non_git に集約)。 + if not nested_body_safely_non_git(nested_command): + print(UNRESOLVED_COMMAND_MARKER) + break + emit_segments(nested_command, depth + 1) + break + if option_token in {"-o", "-O", "--rcfile", "--init-file"} and lookahead + 1 < len(tokens): + lookahead += 2 + continue + lookahead += 1 + +emit_segments(cmd) +PY +)" + +if printf '%s\n' "${command_segments}" | grep -Fqx '__UNRESOLVED_COMMAND_WRAPPER__'; then + block_json "unresolved command wrapper" + exit 0 +fi + +# [2026-08-02][fix] inline override は実行対象の git に直結する assignment だけを許可する。 +# 背景: +# - ユーザー依頼意図: `printf` / `echo` の引数や別 segment に書かれた +# `AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1` を、破壊的 git 復旧の許可と誤認しないようにする。 +# - 守るべき業務ルール: 破壊的 Git 操作は fail-closed で止め、明示的な復旧時だけ +# inherited env または実行対象 `git` の直前 assignment による inline override を許可する。 +# - 他案不採用理由: +# 1) コマンド全体 grep の継続は、文字列・引数・別 segment の偽装を検出できず Critical を再発させる。 +# 2) 生成された PJ 側 hook の手修正は中央正本を迂回して再発する。 +# 3) `git clean` の設定値列挙やルール文だけの禁止は、迂回経路を原理的に閉じない。 +# 4) 新規外部依存や全面的な shell parser 導入は、配布対象を増やし保守境界を曖昧にする。 +# 対応: Python 標準 `shlex` で単一の shell segment を tokenize し、segment 全体が実コマンド先頭の +# assignment token 直後の裸の `git` の場合だけ inline bypass を許可する。separator・改行・引用符・ +# 通常引数・別 segment の文字列は許可せず、tokenize 失敗時は `0` を返して安全側に倒す。 +inline_bypass="$( + COMMAND_TEXT="${command}" python3 - <<'PY' 2>/dev/null || printf '0' +import os +import re +import shlex + +BYPASS = "AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1" +ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$") +PUNCTUATION = ";&|(){}" +text = os.environ.get("COMMAND_TEXT", "") + +def validate_punctuation(tokens): + expected = [] + pairs = {"(": ")", "{": "}"} + for token in tokens: + if not token or not all(char in PUNCTUATION for char in token): + continue + for char in token: + if char in pairs: + expected.append(pairs[char]) + elif char in {")", "}"} and (not expected or expected.pop() != char): + raise ValueError("unbalanced shell punctuation") + if expected: + raise ValueError("unbalanced shell punctuation") + +try: + # posix=True validates quoting/escaping; posix=False retains quote markers + # so a quoted assignment cannot become an override token. + validator = shlex.shlex(text, posix=True, punctuation_chars=PUNCTUATION) + validator.whitespace_split = True + list(validator) + lexer = shlex.shlex(text, posix=False, punctuation_chars=PUNCTUATION) + lexer.whitespace_split = True + tokens = list(lexer) + validate_punctuation(tokens) + if "\n" in text or any(token and all(char in PUNCTUATION for char in token) for token in tokens): + print("0") + raise SystemExit +except Exception: + print("0") + raise SystemExit + +index = 0 +while index < len(tokens) and ASSIGNMENT.fullmatch(tokens[index]): + index += 1 +if index > 0 and index < len(tokens): + if tokens[index] == "git" and tokens[index - 1] == BYPASS: + print("1") + raise SystemExit + +print("0") +PY +)" +if [ "${inline_bypass}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass block-destructive-git allow '{"env":"AGENT_HUB_ALLOW_DESTRUCTIVE_GIT"}' 2>/dev/null || true + allow_json + exit 0 +fi + +reset_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+reset([[:space:]][^;&|()]*)?[[:space:]]--hard([[:space:]]|$)" || true)" +if [ -n "${reset_segments}" ]; then + block_json "git reset --hard" + exit 0 +fi + +clean_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+clean([[:space:]]|$)" || true)" +if [ -n "${clean_segments}" ]; then + while IFS= read -r segment; do + [ -z "${segment}" ] && continue + clean_args="$(printf '%s' "${segment}" | sed -E "s#${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+clean([[:space:]]|$)##")" + if printf '%s' "${clean_args}" | grep -Eq '(^|[[:space:]])(--dry-run|-n|-n[a-zA-Z]*|-[a-zA-Z]*n[a-zA-Z]*)([[:space:]]|$)'; then + continue + fi + # [2026-08-01][fix] `-f` の有無で判定すると設定経由で迂回できる(codex-review Critical)。 + # 背景: + # - ユーザー依頼意図: 破壊的 git 操作ガードが「実際に消せるコマンド」を取り逃がさないようにする。 + # - 守るべき業務ルール: `git clean` は `clean.requireForce=false` を渡すと `-f` 無しで + # 未追跡ファイルを削除できる。`git -c clean.requireForce=false clean -dx` は `.env` 等の + # ローカル秘匿ファイルまで消すため、`-f` を探す実装では素通りする(実測で PASS を確認)。 + # - 他案不採用理由: + # 1) `-c clean.requireForce=false` を追加でパターン検出する案は、`GIT_CONFIG_*` 環境変数や + # `--config-env`、既存の repo/global 設定でも同じ状態を作れるため、列挙が原理的に閉じない。 + # 2) 実際の設定値を読んで判定する案は、hook が対象 repo を確定できない場面(複合コマンド・ + # `--git-dir` 指定)で誤判定するため不採用。 + # 対応: dry-run でない `git clean` は一律 deny する。dry-run は上の continue で通過済み。 + block_json "git clean(--dry-run / -n 以外)" + exit 0 + done <" + exit 0 +fi + +# [2026-08-02][fix] `--` なし checkout の曖昧な位置引数を fail-closed にする。 +# 背景: +# - ユーザー依頼意図: 全PJ共通の破壊的Git guardで、`git checkout README.md` や +# `git checkout .` によるtracked変更の暗黙破棄も確実に止める。 +# - 守るべき業務ルール: checkoutの単一位置引数はbranch名とpathspecを静的に完全判別できないため、 +# 読み取りだけでpathだと断定できない場合も安全側へ倒す。branch移動には`git switch`を使う。 +# - 他案不採用理由: 拡張子・`/`・実在pathだけを列挙する案は、拡張子のないfile、glob、 +# `git -C`先のpathを取り逃がす。hook内でGitのref/path解決を実行する案はrepo/cwd境界を誤る。 +# 対応: 明示的な新規branch作成(`-b` / `--orphan`)、detach、help/versionだけを許可し、 +# `-p` / `--ours` / `--theirs` / `-B` 等を含む残りのcheckoutは一律denyする。 +# 安全optionは最初の位置引数より前にある場合だけ許可し、pathspec後ろのoptionで +# branch作成/detachへ見せかける並び替えは許可しない。安全モード後も引数個数を固定する。 +checkout_ambiguous_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+checkout([[:space:]]|$)" || true)" +if [ -n "${checkout_ambiguous_segments}" ]; then + while IFS= read -r segment; do + [ -z "${segment}" ] && continue + checkout_args="$(printf '%s' "${segment}" | sed -E "s#${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+checkout([[:space:]]|$)##")" + checkout_tokens=() + if [ -n "${checkout_args}" ]; then + read -r -a checkout_tokens <<< "${checkout_args}" + fi + checkout_count="${#checkout_tokens[@]}" + checkout_index=0 + checkout_safe=0 + checkout_invalid=0 + while (( checkout_index < checkout_count )); do + checkout_token="${checkout_tokens[checkout_index]}" + case "${checkout_token}" in + -q|--quiet|-m|--merge) + checkout_index=$((checkout_index + 1)) + ;; + --help|--version) + if (( checkout_index + 1 == checkout_count )); then + checkout_safe=1 + else + checkout_invalid=1 + fi + break + ;; + -b|--orphan) + if (( checkout_index + 2 == checkout_count )); then + checkout_branch="${checkout_tokens[checkout_index + 1]}" + if [ -n "${checkout_branch}" ] && [[ "${checkout_branch}" != -* ]]; then + checkout_safe=1 + else + checkout_invalid=1 + fi + else + checkout_invalid=1 + fi + break + ;; + --detach) + if (( checkout_index + 1 == checkout_count )); then + checkout_safe=1 + elif (( checkout_index + 2 == checkout_count )); then + checkout_ref="${checkout_tokens[checkout_index + 1]}" + if [ -n "${checkout_ref}" ] && [[ "${checkout_ref}" != -* ]]; then + checkout_safe=1 + else + checkout_invalid=1 + fi + else + checkout_invalid=1 + fi + break + ;; + *) + checkout_invalid=1 + break + ;; + esac + done + if (( checkout_safe == 1 && checkout_invalid == 0 )); then + continue + fi + block_json "git checkout(pathspec ambiguity; use git switch for branches)" + exit 0 + done <" + exit 0 + fi + if printf '%s' "${restore_args}" | grep -Eq '(^|[[:space:]])(--staged|-S|-[A-Za-z]*S[A-Za-z]*)([[:space:]]|$)'; then + continue + fi + block_json "git restore " + exit 0 + done <&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) + +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +reason = payload.get("permissionDecisionReason", "") +if "[hook:block-destructive-git]" not in reason: + sys.exit(1) +if "reason" in payload: + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue": true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow_inherited_env() { + local name="$1" + local command="$2" + local out + out="$(AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue": true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_raw() { + local name="$1" + local payload="$2" + local out + out="$(run_hook_raw "$payload" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) + +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if "[hook:block-destructive-git]" not in payload.get("permissionDecisionReason", ""): + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_embedded_python_py39() { + local name="embedded Python blocks parse as Python 3.9" + local out + if out="$(HOOK_SCRIPT="$SCRIPT" python3 - <<'PY' 2>&1 +import ast +import os +import re + +source = open(os.environ["HOOK_SCRIPT"], encoding="utf-8").read() +blocks = re.findall(r"<<'PY'[^\n]*\n(.*?)\nPY(?:\n|$)", source, re.S) +if not blocks: + raise SystemExit("no embedded Python blocks found") +for index, block in enumerate(blocks, 1): + try: + tree = ast.parse(block, feature_version=(3, 9)) + except SyntaxError as exc: + raise SystemExit(f"block {index}: {exc}") + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + returns = node.returns + if isinstance(returns, ast.BinOp) and isinstance(returns.op, ast.BitOr): + raise SystemExit(f"block {index}: Python 3.10 union return annotation") +PY +)"; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_double_quote_single_quote_scanner() { + local name="double quote内single quote後のvariable expansionを検出" + if HOOK_SCRIPT="$SCRIPT" python3 - <<'PY' +import os +import re + +source = open(os.environ["HOOK_SCRIPT"], encoding="utf-8").read() +blocks = re.findall(r"<<'PY'[^\n]*\n(.*?)\nPY(?:\n|$)", source, re.S) +scanner_blocks = [block for block in blocks if "def has_unresolved_shell_expansion" in block] +if len(scanner_blocks) != 1: + raise SystemExit(f"expected one scanner block, got {len(scanner_blocks)}") +namespace = {} +exec(scanner_blocks[0], namespace) +scanner = namespace["has_unresolved_shell_expansion"] +if not scanner('echo "\'"; $PAYLOAD'): + raise SystemExit("variable expansion after a single quote inside double quotes was missed") +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s\n' "$name" + FAIL=$((FAIL + 1)) + fi +} + +expect_embedded_python_py39 +expect_double_quote_single_quote_scanner +expect_block "git reset --hard deny" "git reset --hard" +expect_block "/usr/bin/git reset --hard deny" "/usr/bin/git reset --hard" +expect_block "command /usr/bin/git clean -fd deny" "command /usr/bin/git clean -fd" +expect_block "quoted /usr/bin/git reset --hard deny" "\"/usr/bin/git\" reset --hard" +expect_block "quoted ./bin/git clean -fd deny" "'./bin/git' clean -fd" +expect_block "quoted path with spaces git reset --hard deny" "\"/tmp/git tools/git\" reset --hard" +expect_block "consecutive-slash /usr//bin/git reset --hard deny" "/usr//bin/git reset --hard" +expect_block "consecutive-slash ./bin//git clean -fd deny" "./bin//git clean -fd" +expect_block "git -C reset --hard deny" "git -C /tmp/repo reset --hard origin/main" +expect_block "git -C path with spaces reset --hard deny" "git -C '/tmp/repo with spaces' reset --hard origin/main" +expect_block "git --git-dir/--work-tree path with spaces clean deny" "git --git-dir='/tmp/repo with spaces/.git' --work-tree '/tmp/repo with spaces' clean -fd" +expect_block "command git reset --hard deny" "command git reset --hard" +expect_block "env git clean -fd deny" "env git clean -fd" +expect_block "/usr/bin/env git clean -fd deny" "/usr/bin/env git clean -fd" +expect_block "/usr/bin/env -u FOO git reset --hard deny" "/usr/bin/env -u FOO git reset --hard" +expect_block "env assignment git checkout -f deny" "env FOO=bar git checkout -f main" +expect_block "sudo git reset --hard deny" "sudo git reset --hard" +expect_block "exec git reset --hard deny" "exec git reset --hard" +expect_block "exec alternate argv0 still finds git" "exec -a harmless /usr/bin/git reset --hard" +expect_block "exec unknown option fail closed" "exec --future-option /usr/bin/git reset --hard" +expect_block "sudo git clean -fd deny" "sudo -n git clean -fd" +expect_block "sudo -u root git reset --hard deny" "sudo -u root git reset --hard" +expect_block "sudo --user root path git reset --hard deny" "sudo --user root /usr/bin/git reset --hard" +expect_block "sudo --user=root path git reset --hard deny" "sudo --user=root /usr/bin/git reset --hard" +expect_block "sudo -- terminator path git reset --hard deny" "sudo -- /usr/bin/git reset --hard" +expect_block "sudo short chdir option still finds git" "sudo -D /tmp /usr/bin/git reset --hard" +expect_block "sudo long chdir option still finds git" "sudo --chdir /tmp /usr/bin/git clean -fd" +expect_block "sudo unknown option fail closed" "sudo --future-option /usr/bin/git reset --hard" +expect_block "sudo env path git reset --hard deny" "sudo env /usr/bin/git reset --hard" +expect_block "command env path git clean -fd deny" "command env /usr/bin/git clean -fd" +expect_block "env -u FOO git clean -fd deny" "env -u FOO git clean -fd" +expect_block "env -S reset payload fail closed" "env -S '/usr/bin/git reset --hard'" +expect_block "env --split-string clean payload fail closed" "env --split-string='/usr/bin/git clean -fd'" +expect_block "env unknown option before git fail closed" "env --future-option /usr/bin/git reset --hard" +expect_block "env ignore-environment still finds git" "env -i /usr/bin/git reset --hard" +expect_block "env attached unset still finds git" "env --unset=FOO /usr/bin/git clean -fd" +expect_block "env option terminator still finds git" "env -- /usr/bin/git reset --hard" +expect_block "time macOS long report still finds git" "/usr/bin/time -l /usr/bin/git reset --hard" +expect_block "time output option still finds git" "/usr/bin/time -o /tmp/timing.txt /usr/bin/git clean -fd" +expect_block "time unknown option before git fail closed" "/usr/bin/time --future-option /usr/bin/git reset --hard" +expect_block "eval path git reset fail closed" "eval /usr/bin/git reset --hard" +expect_block "eval quoted git reset fail closed" "eval 'git reset --hard'" +expect_block "nested eval quoted git reset fail closed" "bash -c 'eval \"git reset --hard\"'" +expect_block "nested eval git clean fail closed" "bash -c 'eval git clean -fd'" +expect_block "variable executable path fail closed" 'G=/usr/bin/git; "$G" reset --hard' +expect_block "variable executable basename fail closed" 'GIT=git; $GIT clean -fd' +expect_block "command substitution executable fail closed" '$(printf /usr/bin/git) reset --hard' +expect_block "zsh equals executable reset fail closed" "=git reset --hard" +expect_block "zsh equals executable clean fail closed" "=git clean -fd" +expect_block "brace group variable executable fail closed" '{ "$G" reset --hard; }' +expect_block "brace group command substitution executable fail closed" '{ $(printf git) clean -fd; }' +expect_block "paren group variable executable fail closed" '( "$G" reset --hard )' +expect_block "function body variable executable fail closed" 'danger(){ "$G" reset --hard; }; danger' +expect_block "function body command substitution executable fail closed" 'danger(){ $(printf git) clean -fd; }; danger' +expect_block "quoted argument command substitution reset deny" 'printf '\''%s'\'' "$(git reset --hard)"' +expect_block "quoted argument command substitution clean deny" 'echo "$(git clean -fd)"' +expect_block "argument process substitution restore deny" 'cat <(git restore src/app.ts)' +expect_block "argument backtick checkout deny" 'printf '\''%s'\'' "`git checkout -f main`"' +nested_backtick_argument='echo "`echo \`git reset --hard\``"' +expect_block "nested legacy backtick reset fail closed" "$nested_backtick_argument" +expect_block "nested argument substitution reset deny" 'printf '\''%s'\'' "$(printf '\''%s'\'' "$(git reset --hard)")"' +expect_block "arithmetic nested substitution clean deny" 'printf '\''%s'\'' "$((1 + $(git clean -fd)))"' +expect_block "case pattern esac text cannot hide reset" 'printf '\''%s'\'' "$(case esac in *esac*) git reset --hard ;; esac)"' +comment_substitution="printf '%s' \"\$( # ) +git reset --hard)\"" +expect_block "comment close paren cannot hide reset" "$comment_substitution" +comment_continuation_substitution="printf '%s' \$(echo ok # ) +g\\ +it reset --hard)" +expect_block "comment and line continuation cannot hide reset" "$comment_continuation_substitution" +comment_line_continuation="printf x # foo\\ +git reset --hard" +expect_block "comment line continuation cannot swallow next reset" "$comment_line_continuation" +comment_after_separator="printf x; # foo\\ +git clean -fd" +expect_block "separator comment continuation cannot swallow next clean" "$comment_after_separator" +comment_crlf_continuation=$'printf x # foo\\\r\n\tgit reset --hard' +expect_block "CRLF comment continuation cannot swallow next reset" "$comment_crlf_continuation" +comment_multiple_continuation=$'printf x # foo\\\ng\\\ni\\\nt clean -fd' +expect_block "comment with multiple continuations cannot hide clean" "$comment_multiple_continuation" +parameter_length_continuation="x=value; : \${#x}; g\\ +it reset --hard" +expect_block "parameter length hash is not a comment" "$parameter_length_continuation" +expect_allow "real comment remains inert" 'printf x # git reset --hard' +continued_git="g\\ +it reset --hard" +expect_block "line continuation executable reset deny" "$continued_git" +continued_git_multiple="g\\ +i\\ +t clean -fd" +expect_block "multiple line continuations executable clean deny" "$continued_git_multiple" +continued_git_quoted="printf '%s' \"\$(g\\ +it reset --hard)\"" +expect_block "double quoted continuation executable reset deny" "$continued_git_quoted" +single_quoted_continuation="printf '%s' 'g\\ +it reset --hard'" +expect_block "single quoted continuation remains conservative deny" "$single_quoted_continuation" +expect_block "parameter pattern close paren cannot hide reset" 'printf '\''%s'\'' "$(x=x; : ${x%)}; git reset --hard)"' +heredoc_substitution="printf '%s' \"\$(cat < /tmp/a.txt 2>&1; echo "exit=$?"; tail -3 /tmp/a.txt'\''' +expect_allow "chained add and multiline commit" 'git add -A && git commit -m "one +two"' +expect_block "commit -m command substitution still denied" 'git commit -m "$(git reset --hard)"' +# push --force は本 hook の守備範囲外(ローカル変更破壊系のみ)のため、置換ペイロードは +# 守備範囲内の reset --hard で「データ引数内の置換も deny」を固定する +expect_block "gh body command substitution still denied" 'gh pr create --body "$(git reset --hard)"' +expect_block "bash -c variable body still denied after relaxation" 'bash -c "$BODY"' +# PR #1354 codex-review Critical: 実行 wrapper の引数経由で任意コマンド化する経路を deny 固定 +expect_block "bash -c eval variable payload denied" 'bash -c '\''eval $PAYLOAD'\''' +expect_block "bash -c env variable payload denied" 'bash -c '\''env $PAYLOAD'\''' +expect_block "bash -c interpreter variable code denied" 'bash -c '\''python3 -c $CODE'\''' +dash_heredoc='dash <<'\''EOF'\'' +git reset --hard +EOF' +expect_block "dash heredoc destructive body deny" "$dash_heredoc" +ksh_heredoc='ksh <<'\''EOF'\'' +git clean -fd +EOF' +expect_block "ksh heredoc destructive body deny" "$ksh_heredoc" +expect_block "dash -c destructive body deny" "dash -c 'git reset --hard'" + +# [2026-08-02][test] xargs wrapper 経由の破壊的 Git 検査(jtt-cms PR #1542 codex-review Critical)。 +# 背景: +# - ユーザー依頼意図: xargs 経由の破壊的 Git が引数位置に見えて素通りしていた迂回を deny 固定し、 +# 非 git 用途の xargs(rm 等)や安全 subcommand を巻き込まないことを対で固定する。 +# - 守るべき業務ルール: 任意引数 option(bare -l 等)は静的境界不能として fail-closed。 +# - 他案不採用理由: deny 側のみのテストは、whitelist 縮小で日常 xargs が全滅しても気づけない。 +expect_block "xargs -n1 destructive reset deny" "printf 'HEAD\n' | xargs -n1 git reset --hard" +expect_block "xargs -I replace destructive clean deny" "xargs -I{} git clean -fd" +expect_block "xargs bare optional-arg option fail closed" "xargs -l git reset --hard" +expect_allow "xargs non-git command stays allowed" "ls | xargs -n1 rm -f" +expect_allow "xargs safe git subcommand stays allowed" "printf 'x\n' | xargs git log --oneline" + +TOTAL=$((PASS + FAIL)) +printf '\n=== block-destructive-git.test.sh: %d/%d PASS ===\n' "$PASS" "$TOTAL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.cursor/hooks/scripts/block-main-commit.sh b/.cursor/hooks/scripts/block-main-commit.sh new file mode 100755 index 000000000..d4c01aac0 --- /dev/null +++ b/.cursor/hooks/scripts/block-main-commit.sh @@ -0,0 +1,956 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: +# 依頼意図: AIがmainに直接プッシュする事故の再発防止。 +# ルール記載(branch-rule.md)だけでは防げなかった実績があり(F2直接プッシュ事故)、 +# 技術的強制力を追加する必要があった。 +# 業務ルール: mainマージ = 本番DB即時適用 + 本番デプロイ発火のため、 +# レビューなし変更は業務リスクが高い。 +# 不採用理由: ルール記載のみでは実際の事故を防げなかった実績がある。 +# git hookよりもClaude Code PreToolUseの方が実行パスに近く確実にブロックできる。 +# 対応: jtt-cms block-main-commit.sh をポート。lib/hook-io.sh を使用。 + +# [2026-04-10][fix] +# 背景: +# 依頼意図: `git push origin main` が.mdファイルのみでもブロックされるバグの修正。 +# 守るべき業務ルール: .mdのみの変更はmainで直接コミット・プッシュ可能(branch-rule.md)。 +# 他案不採用理由: Path Aを削除する案はrefspec経由の非docs pushを見逃すため不採用。 +# 軽量変更判定をインライン展開する案はPath Bとの重複(DRY違反)のため不採用。 +# 対応: 軽量変更 push 判定を is_push_lightweight_only() に関数化し、Path A/B両方から呼び出し。 +# 撤回: 2026-07-01 に AI hook 経由の main 直接 commit/push 例外は全廃。上記は履歴のみ。 + +# [2026-04-18][fix] +# 背景: +# 依頼意図: エージェント環境で origin/main が未解決のとき Markdown のみの push まで拒否される。 +# Cursor/CLI の PreToolUse が同じスクリプトを通すため、比較基準 ref の解決を強化したい。 +# 守るべき業務ルール: main 直 push の例外は「Markdown 系ドキュメント + sync-state.json のみ」(CLAUDE.md / branch-rule.md)。 +# 他案不採用理由: 非 .md コードを許可する案は本番自動適用リスクのため不採用。 +# 対応: 比較 ref を origin/main → refs/remotes/origin/main → main@{upstream} の順で解決。 +# 許可拡張子に .mdc / .mdx を含める(Cursor ルール・MDX ドキュメント)。 +# AGENT-HUB: jtt-cms 正本と同一内容を hook-library に同期(docs/prd/prd-active.md 参照)。 +# 撤回: 2026-07-01 に Markdown / sync-state 等の main 直 push 例外は全廃。上記は履歴のみ。 +# +# [2026-04-27][fix] +# 背景: +# 依頼意図: .codex/sync-state.json のような同期状態ファイルだけで main 直コミットが止まるのは運用上のノイズ。 +# 守るべき業務ルール: sync-state.json はツール自動生成の状態ファイルとして Markdown 系ドキュメントと同じ軽量変更扱いにする。 +# 他案不採用理由: .json 全体を許可する案は package.json や設定 JSON までレビューなしで通すため不採用。 +# 対応: main 直コミット/プッシュの例外に sync-state.json だけを追加し、commit/push で共通判定を使う。 +# 撤回: 2026-07-01 に sync-state.json を含む軽量変更例外は全廃。上記は履歴のみ。 + +# [2026-05-05][fix] +# 背景: +# 依頼意図: Issue #123 で、PR #121 内で Revert された Issue #122 対応を安全に再導入したい。 +# 複合コマンド検知ブロックに +# 軽量変更バイパスが未適用のまま main に残っている。.md のみの変更でも `git switch main && git push` で deny される。 +# 守るべき業務ルール: main 直 push の例外は「Markdown 系ドキュメント + sync-state.json のみ」(branch-rule.md)。 +# 3つの検知パス(複合コマンド、push refspec、mainブランチ)は対称に保つ。 +# 他案不採用理由: +# 1) 複合コマンド検知ブロックを削除する案は、refspec 経由の非 docs push を見逃すため不採用(2026-04-10 と同型)。 +# 2) staged diff 判定をインライン展開する案は、mainブランチ検知ブロックとの重複(DRY違反)のため不採用。 +# 3) `scripts/` 配下のローカル hook を併用する案は、比較 ref と許可拡張子が分岐し SSOT が壊れるため不採用。 +# 対応: `is_commit_lightweight_only()` を新設し、mainブランチ検知から呼び出す。 +# 複合コマンド検知では switch 後の target ref (`main`) を比較対象にし、commit を含む場合は安全側で deny。 +# 撤回: 2026-07-01 に軽量変更バイパスは全廃。上記は履歴のみ。 + +# [2026-05-16][feat] +# 背景: +# 依頼意図: ccrec 運用で GitHub Actions / Claude Code クレジットを節約するため、 +# 人手レビュー価値の薄い運用設定ファイル(agents.yaml / typinator-sync.yaml / +# MCP 台帳等)も main 直接 push 可にする。 +# 守るべき業務ルール: ソースコード・hook 本体(*.sh)・CI 定義(.github/workflows)・ +# Web ビルド設定(package.json / tsconfig.json / composer.json)・hook 登録設定は引き続き PR 必須。 +# 許可は事前定義した allowlist のファイル名・パターンに限定する。 +# 他案不採用理由: +# 1) .json / .yaml 拡張子全体を許可: package.json / tsconfig.json / composer.json / +# src/**/*.json までレビューなしで通るため不採用(2026-04-27 と同型の理由)。 +# 2) 拡張子許可 + denylist: denylist 漏れが致命的になるため allowlist で明示する方が安全。 +# 3) AGENT-HUB 限定で CWD 分岐: 配布先 PJ の AI ツール設定もツール再同期で書き換わるため、 +# 全 PJ 一律許可が運用整合的(ユーザー判断 2026-05-16)。 +# 4) .github/workflows/*.yml を許可: CI 挙動を無レビューで変えるリスクのため不採用。 +# 5) *.sh を許可: hook スクリプト挙動を無レビューで変えるリスクのため不採用。 +# 6) 外部設定ファイル化(allowlist を YAML に切り出す): 比較 ref と許可判定の SSOT が +# 分岐するため不採用(2026-05-05 と同型)。 +# 7) hook 登録設定(.claude/settings.json / .codex/hooks.json 等)を許可: block-main-commit +# 自体をレビューなしで弱められるため不採用。 +# 対応: is_allowed_main_direct_path() に case 文 allowlist を追加し、hook 登録を含まない AI ツール設定 / +# AGENT-HUB ルート運用設定 / codex-mcp 台帳を許可する。 +# 撤回: 2026-07-01 に運用設定 allowlist も全廃。上記は履歴のみ。 + +# [2026-05-21][feat] +# 背景: +# 依頼意図: .codex/config.toml と .gemini/hooks/.hook-library-version は Kimi Code MCP 設定 / .cursor/mcp.json +# と同等の sync 完全自動生成ファイル(手動編集 0 行)だが、2026-05-16 拡張時に取りこぼされていた。 +# 対称性を回復して、sync 実行のたびに main 直 push が deny されて GitHub Actions / Claude Code クレジットを +# 消費する状況を解消したい。 +# 守るべき業務ルール: +# - .codex/config.toml は全 PJ で MANAGED CODEX MCP START/END block の完全自動生成のみ。 +# 将来 managed block 外の手動編集領域が追加された場合は branch-rule.md を再評価する。 +# - .gemini/settings.json は hook 登録設定(BeforeTool/AfterTool/BeforeAgent)と MCP を混在で持つため +# allowlist には載せない(hook 登録設定の許可は 2026-05-16 [feat] 不採用理由 7 と同型で禁止)。 +# なお全 PJ で .gemini/settings.json は gitignore のため commit 経路自体が無く、本 hook へ到達しない。 +# 他案不採用理由: +# 1) .gemini/settings.json も同時許可: hook 登録を含む混在ファイルのため、settings.json + bridge スクリプト +# の同時変更で block-main-commit を弱められる経路を作ってしまう(2026-05-16 不採用理由 7 と同型)。 +# 2) .gemini/hooks/{lib,scripts}/*.sh / *.py を許可: hook ロジック本体の無レビュー変更を許す +# (2026-05-16 不採用理由 5 と同型)。 +# 3) .toml 拡張子全体を許可: dotfiles/codex/config.toml.base(features.apps 保護対象)まで通る +# ため不採用(2026-05-16 不採用理由 1 と同型)。 +# 対応: is_allowed_main_direct_path() の case 文に .codex/config.toml と +# .gemini/hooks/.hook-library-version を対称順で追加する。 + +# [2026-06-05][feat] .codex/hooks.json を main 直接 allowlist に追加(ユーザー承認・過去判断の変更) +# 背景: +# - ユーザー依頼意図: Codex hook の user-level 移行(PR #284)で各PJの .codex/hooks.json を +# 縮小版へ再配布する。この派生物コミットを毎回 PR にするのは負荷が高く、伸太郎殿の +# 「AIエージェント設定ファイルだけの変更を毎回PRに出したくない」要望(2026-06-05)に応える。 +# - 守るべき業務ルール: .codex/hooks.json は deploy-hooks.py が hook-registry.yaml から生成する +# sync 自動生成の派生物(手編集禁止、codex-sync.md)。hook 挙動は AGENT-HUB 側 PR で既にレビュー済み。 +# - 他案不採用理由(過去の不採用判断を覆す根拠): +# 2026-05-21 [feat] 不採用理由1 / 2026-05-16 [feat] 不採用理由7 で「hook 登録設定 +# (.claude/settings.json / .codex/hooks.json 等)は block-main-commit 自体を無レビューで +# 弱められるため allowlist 禁止」としていた。今回 .codex/hooks.json のみ覆すのは、 +# (a) deploy-hooks 生成物に限定され手編集しない運用が確立、(b) block-main-commit は +# Claude(.claude/settings.json は allowlist 据え置き=PR必須)でも効くため Codex 側を弱めても +# main 保護の実効性が残る、(c) Codex は補助ツール、の3点でリスク限定的と伸太郎殿が判断したため。 +# .claude/settings.json(hook登録の中核)は引き続き allowlist に入れない(PR必須維持)。 +# 対応: is_allowed_main_direct_path() の case に .codex/hooks.json を追加。.claude/settings.json は据え置き。 + +# [2026-06-05][feat] deploy-hooks 配布物(各PJ .claude/hooks/ ・ .codex/hooks/ の scripts/lib)を allowlist 追加 +# 背景: +# - ユーザー依頼意図: Phase E(PR #283) + Codex 移行(PR #284) + allowlist(PR #285)を全PJへ実配布する際、 +# 各PJの hook 配布物(block-main-commit.sh / block-skill-reverse-edit.sh / lib 等)を毎回 PR にするのは +# 16PJ規模で非現実的。「設定・配布物の機械的更新を毎回PRにしたくない」要望(2026-06-05)に応える。 +# - 守るべき業務ルール: 各PJ .claude/hooks/ ・ .codex/hooks/ 配下の scripts/lib は deploy-hooks.py が +# hook-library(SSOT)から配布する派生物。hook 挙動の変更は hook-library 本体の AGENT-HUB PR でレビュー +# 済み。各PJで人が直接編集する運用はなく、drift は sync-reconcile.py が検出する。 +# - 他案不採用理由(覆した過去判断): +# 2026-05-16 #5 で「*.sh(hook ロジック本体)は allowlist 禁止」としていた。今回 .claude/hooks/scripts/ ・ +# .codex/hooks/scripts/ ・ lib/ 配下の配布物のみ覆すのは、(a) これらは hook-library からの機械配布物で +# SSOT 本体(hook-library/scripts/)は PR 必須のまま、(b) sync-reconcile で drift 検出可能、(c) 各PJ実配布の +# 運用負荷が許容外、の3点。settings.json(block-main-commit の matcher 登録を含む hook 登録の中核)は +# 許可しない(main 保護自体を無レビューで外せてしまうため。2026-05-16 #7 維持)。hook-library/scripts/ +# (SSOT 本体)も別パスのため PR 必須を維持。 +# 対応: is_allowed_main_direct_path() の case に .claude/hooks/{scripts,lib}/ ・ .codex/hooks/{scripts,lib}/ を +# 追加。settings.json と hook-library/scripts/ は据え置き。 + +# [2026-06-23][refactor] 配布差分放置防止のため 2026-06-05 の main 直接 allowlist を撤回 +# 背景: +# - ユーザー依頼意図: AGENT-HUB から hook / skill / rule / agent 派生物を各PJへ配布した後、 +# AI が「これは私の修正したファイルではない」として配布先差分を放置する事故を防ぐ。 +# 配布を実行した担当者が PR 作成・レビュー・マージ・cleanup・clean 確認まで責任を持つ。 +# - 守るべき業務ルール: 機械配布物でも、配布先 PJ の tracked 差分は作った担当者が閉じる。 +# .codex/hooks.json と .claude/.codex hooks scripts/lib は main 直接 push ではなく PR 経由に戻す。 +# - 他案不採用理由: +# 1) ルール文書だけの更新は hook allowlist が残り、main 直 push で closeout を迂回できるため不採用。 +# 2) --push を即削除する案は既存運用互換の破壊が大きいため、まず hook 側で main 直許可を撤回する。 +# 対応: is_allowed_main_direct_path() から .codex/hooks.json と .claude/.codex hooks scripts/lib を削除。 + +# [2026-06-15][fix] worktree/別リポへの refspec 省略 bare push を許可(PR #369 の取りこぼし修正) +# 背景: +# 依頼意図: `cd && git push --force-with-lease`(refspec 省略の bare push)が +# PR #369 後も deny される。ハーネスは Bash cwd を毎回 main 直下に戻すため worktree への push は +# refspec 省略の bare push になることが多く(upstream に任せる常用フロー)、worktree 並行開発が成立しない。 +# 守るべき業務ルール: main 直 push/commit の保護は厳密(fail-closed)に維持する。本番デプロイ=main push のため。 +# 根本原因: has_unsafe_push() が「remote/refspec 欠落の push」を宛先不明として無条件 unsafe にしていた。 +# しかし実効ターゲット(先頭の単一 cd 先)のカレントブランチは判明済み(非 main)で、bare push はその +# カレントブランチを push するだけ。一律 unsafe は過剰だった。 +# 他案不採用理由: +# 1) bare push を実効ブランチ非 main なら無条件許可: push.default=matching(全 matching ブランチ=main 波及) +# や push.default=upstream で upstream が main のとき main を押す経路が残るため不採用。 +# 2) 何もしない案: refspec 省略の worktree push(ユーザーの主要フロー)が不能のままで不便。 +# 対応: dir 解決を effective_target_dir() に関数化し、has_unsafe_push() に eff_dir を渡す。bare/remote-only +# push は eff_dir の push.default + @{upstream} を解決し、matching / upstream→main / 解決不能のみ unsafe、 +# simple(既定)/current 等は非 main カレントブランチのみ push として安全に許可する。明示的 main 宛て / +# --all/--mirror/wildcard/複数 ref は従来どおり deny。汎用設計のため worktree 以外の別リポにも同様に効く。 + +# [2026-07-01][refactor] AI hook 経由の main 直接 commit / push 例外を完全撤回 +# 背景: +# 依頼意図: 文書ルールだけでなく、PreToolUse hook 実体でも Markdown / sync-state.json / +# agents.yaml / typinator-sync.yaml 等の軽量変更 allowlist を閉じ、全ディレクトリ・全 AI で +# main checkout を掴まない運用を強制したい。 +# 守るべき業務ルール: AI の通常作業では main branch の commit / push は軽量変更でも deny。 +# 非 main branch / 専用 worktree の commit / push は従来どおり許可し、PR 作成フローを壊さない。 +# 他案不採用理由: +# 1) allowlist を文書上だけ廃止して hook に残す案は、AI が実際には main 直 commit / push できるため不採用。 +# 2) 環境変数 override を追加する案は、AI が自己判断で例外を使う経路になるため不採用。 +# 3) 初回 repo 作成や人間明示承認を hook が推測して許可する案は、安全側で判定できないため不採用。 +# 対応: is_allowed_main_direct_path は常に deny にし、main branch 検知・main refspec push 検知では +# 軽量差分判定を呼ばず即 deny する。worktree feature branch の早期許可は維持。 + +# [2026-07-18][fix] git標準ラッパーと先頭空白によるmain保護迂回を防止 +# 背景: +# - ユーザー依頼意図: dirty cleanup PRのレビューで `env git commit` / `command git push` / +# 先頭空白付きgitが検出から漏れ、main直操作を許可できることが判明した。 +# - 守るべき業務ルール: 標準ラッパーや整形上の空白でmain保護の強さを変えない。 +# - 他案不採用理由: `env` 後の任意トークンを許す正規表現は `env echo git ...` まで誤検知するため不採用。 +# 対応: command/envの標準形とenv代入だけをコマンド位置で消費し、その後のgitサブコマンドを既存判定へ渡す。 + +set -euo pipefail + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `git -C path push origin main` や `git -c k=v push origin main` のように +# グローバルオプション付きで git を呼び出すと、既存の正規表現 `git[[:space:]]+push` が +# マッチせず main 直 push/commit をスルーしてしまう脆弱性を修正したい。 +# 守るべき業務ルール: main 直 push/commit のブロックは確実でなければならない。 +# false positive(許可ケースを誤拒否)を増やさないこと。 +# 他案不採用理由: +# 1) オプション列を貪欲に `.*` で許可 → セミコロン区切りの複合コマンドで誤マッチしやすい。 +# `[^[:space:]]+` で空白終端を保証する設計の方が安全。 +# 2) `-C` / `-c` だけを許可する案 → `git --no-pager push` が fail-open し、 +# main 保護の目的を満たせないため不採用。 +# 対応: スクリプト先頭に共通定数 GIT_GLOBAL_OPTS を定義し、値あり/値なしの代表的な +# git グローバルオプションを消費してから push/commit/switch/checkout を検知する。 +# git グローバルオプションを 0個以上許容する共通パターン。 +readonly GIT_GLOBAL_OPT='(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--config-env[[:space:]]+[^[:space:]]+|--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--namespace(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--exec-path(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)?|--super-prefix[[:space:]]+[^[:space:]]+|--paginate|--no-pager|--no-replace-objects|--bare|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--help|--version|--html-path|--man-path|--info-path|-p)' +readonly GIT_GLOBAL_OPTS="([[:space:]]+${GIT_GLOBAL_OPT})*" +readonly GIT_ENV_VALUE="([^[:space:];&|()'\"]+|'[^']*'|\"([^\"\\\\]|\\\\.)*\")+" +readonly GIT_ENV_ASSIGN="[A-Za-z_][A-Za-z0-9_]*=${GIT_ENV_VALUE}" +readonly GIT_ENV_PREFIX="(${GIT_ENV_ASSIGN}[[:space:]]+)*" +readonly ENV_OPT_WITH_VALUE='(-u|--unset|-C|--chdir|-P|--path|-S|--split-string)[[:space:]]+[^[:space:];&|()]+' +readonly GIT_COMMAND_WRAPPER="(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+|env([[:space:]]+((${ENV_OPT_WITH_VALUE})|-[^[:space:];&|()]+|${GIT_ENV_ASSIGN}))*[[:space:]]+)?" +readonly GIT_CMD="(^|[;&|()])[[:space:]]*${GIT_ENV_PREFIX}${GIT_COMMAND_WRAPPER}${GIT_ENV_PREFIX}git${GIT_GLOBAL_OPTS}" + +# [2026-07-18][fix] +# 背景: +# - PR1018再レビューで、環境変数代入をenv/command wrapperの前に置くとGIT_CMDがgit writeを見失った。 +# - 守るべき業務ルール: POSIXで有効なprefix順序の違いでmain保護の強さを変えない。 +# - 他案不採用理由: FOO=1だけを文字列denyする案は変数名ごとに再発するため不採用。 +# 対応: 環境変数prefixをwrapperの前後どちらにも許容し、その後のgit commit/pushを同じ判定へ渡す。 + +# [2026-07-18][fix] +# 背景: +# - PR1018最終レビューで、空白を含む引用済み環境変数値がGIT_ENV_PREFIXを分断し、 +# main上の `FOO='a b' git commit` をgit writeなしとして許可できると判明した。 +# - 守るべき業務ルール: shellで有効な引用・escapeを含む代入でもmain保護をfail-openにしない。 +# - 他案不採用理由: quoteを含む行を一律denyすると、説明文やfeature branchの通常操作まで誤拒否する。 +# 対応: 環境変数値をunquoted/single-quoted/double-quotedのshell wordとして認識し、wrapper内外で共通利用する。 + +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: PR1018再レビューで、feature cwdから `env -C
` を使うと +# hook入力のcwd側ブランチだけを見てmain commit/pushを許可し得る経路が見つかった。 +# - 守るべき業務ルール: 実効cwdを確実に解決できないcommit/pushはfail-closedにする。 +# - 他案不採用理由: env chdir先の完全解決は相対path・複数wrapper・複合commandで誤許可を生むため不採用。 +# 対応: env -C/--chdir(=形式を含む)とgit commit/pushが同じ入力にある場合は安全側で拒否する。 +# [2026-08-02][fix] env と -C/-S の間に許すトークンを env 自身のオプション/代入に限定する(issue #1344)。 +# 背景: +# - ユーザー依頼意図: 旧パターンの `env([[:space:]]+[^;&|()]*)?` は貪欲で、 +# `env FOO=bar git -C commit` の **git の -C** まで env の -C(chdir)と誤認し、 +# 正当な feature worktree commit/push を fail-closed で誤 deny していた +# (PR #1343 codex-review 検出・再現ドライバで実測)。 +# - 守るべき業務ルール: env 実行系(-C/--chdir/-S/--split-string)の保守的 deny は維持する。 +# env のオプション解析はコマンド名(最初の非オプション・非代入トークン)で終わるという +# GNU env の実引数規則を静的に再現し、コマンド名以降の -C/-S は誤認対象から外す。 +# - 他案不採用理由: env 形を全て未解決に倒す従来動作の維持は、日常の env prefix commit を +# 恒常的に止め摩擦が大きい。env の後続を完全 tokenize する案は本 hook の軽量 grep 設計に反する。 +# [2026-08-02][fix] 引数を取る env オプション(-u/--unset/シグナル系)は引数ごと消費する +# (PR #1354 codex-review Critical: `env -u FOO -C
git commit` の -C が +# FOO でパターンが止まり chdir 検出から外れるバイパスを塞ぐ)。 +# 引数付きを先に列挙し、その後に汎用オプション(-i 等・引数なし)と assignment を置く。 +# 汎用側で引数を消費しないのは、`env -i git -C ...` の git を env の引数と +# 誤認して #1344 の誤 deny を再導入しないため。 +readonly ENV_OPT_ARG='(-u|--unset|--block-signal|--default-signal|--ignore-signal)[[:space:]]+[^[:space:];&|()]+' +readonly ENV_OWN_TOKENS='(('"${ENV_OPT_ARG}"'|-[^[:space:];&|()]+|[A-Za-z_][A-Za-z0-9_]*=[^[:space:];&|()]*)[[:space:]]+)*' +command_uses_env_chdir() { + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[;&|()])[[:space:]]*(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+)?env[[:space:]]+'"${ENV_OWN_TOKENS}"'(-C([[:space:]]+|[^[:space:];&|()]+)|--chdir(=|[[:space:]]+))' +} + +# env -S/--split-string は1引数内の文字列を再分割してコマンド化するため、通常のwrapper解析では +# 実行されるgitを復元できない。git commit/pushを含む場合だけfail-closedにする。 +command_uses_env_split_git_write() { + echo "$COMMAND" | grep -qE '(^|[;&|()])[[:space:]]*(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+)?env[[:space:]]+'"${ENV_OWN_TOKENS}"'(-S([[:space:]]+|[^[:space:];&|()]+)|--split-string(=|[[:space:]]+))' && + echo "$COMMAND" | grep -qE 'git.*[[:space:]](commit|push)([^A-Za-z0-9_-]|$)' +} + +# [2026-07-11][fix] jtt-apps 本番タグ push 事例(v2.4.37) +# 背景: +# 依頼意図: `git -C push origin v2.4.37` のような単発 -C push が、 +# コマンド中に `2>&1` 等のリダイレクトが含まれるだけで single_git_c_target_dir() の +# `[;&|()]` チェックに誤ヒットし解決不能(deny)になっていた。DEPLOY_CHECKLIST.md の +# 正規タグ push 手順は worktree 経由でしか実行できないため、この誤検知で本番デプロイの +# 唯一の正規経路が塞がれていた。 +# 守るべき業務ルール: main 直 push/commit の fail-closed 判定は維持する。リダイレクトは +# 単一コマンドの出力先を変えるだけで複合コマンドの合図ではないため、それだけで +# 解決不能に倒すのは過剰検知。一方 `&`(バックグラウンド実行)や `|`(パイプ)は真に +# 複合コマンドの合図なので、従来どおり解決不能のまま扱う。 +# 他案不採用理由: +# 1) `[;&|()]` チェック自体を緩める案: `&` 単体や `|` まで見逃すと後続コマンドの +# 存在を検知できなくなり fail-open になるため不採用。 +# 2) has_unsafe_push() のようなトークン単位パーサに全面書き換える案: 影響範囲が +# 広く、今回の誤検知箇所以外の挙動まで変えるリスクがあるため不採用。 +# 対応: quote scanner 自身が引用外のリダイレクトだけを識別し、引用済み本文を変更せずに +# shell 制御演算子を判定する。 + +# [2026-07-12][fix] +# 背景: +# 依頼意図: main checkout を cwd にした Codex から専用 feature worktree へ +# `git -C commit -m 'fix(auth): ...'` を実行すると、引用符内の `()` を +# shell 制御演算子と誤認し、正規の branch + PR フローを deny していた。 +# 守るべき業務ルール: 引用済みメッセージは git の引数データとして許可する一方、非引用の +# `; & | ( )`、引用内でも実行される command substitution、壊れた引用は fail-closed にする。 +# 他案不採用理由: +# 1) `()` の検査を削る案は subshell を見逃して main 操作を早期許可しうるため不採用。 +# 2) conventional commit の括弧だけ正規表現で消す案は、任意の正当な引用済み本文に拡張できず +# セミコロン等で同じ誤検知が再発するため不採用。 +# 対応: 最小の shell quote scanner で、制御演算子が引用の外にある場合だけ真を返す。 +# 引用外の `>file` / `&1` は単一コマンドのリダイレクトとして読み飛ばすが、 +# その後のファイル名や制御演算子は走査を続ける。 +has_unquoted_shell_control() { + local scanner_rc + if COMMAND_TEXT="$1" python3 - <<'PY' +import os +import sys + +text = os.environ.get("COMMAND_TEXT", "") +quote = None +escaped = False +i = 0 +while i < len(text): + ch = text[i] + if escaped: + escaped = False + i += 1 + continue + if ch == "\\" and quote != "'": + escaped = True + i += 1 + continue + if quote == "'": + if ch == "'": + quote = None + i += 1 + continue + if quote == '"': + if ch == '"': + quote = None + elif ch == '`' or (ch == '$' and i + 1 < len(text) and text[i + 1] == '('): + raise SystemExit(0) + i += 1 + continue + if ch in ("'", '"'): + quote = ch + elif ch in "<>": + # Redirection itself does not compose another command. Skip only its + # operator/fd-copy portion; keep scanning the target and anything after it. + direction = ch + while i + 1 < len(text) and text[i + 1] == direction: + i += 1 + if i + 1 < len(text) and text[i + 1] == '&': + i += 1 + while i + 1 < len(text) and (text[i + 1].isdigit() or text[i + 1] == '-'): + i += 1 + elif ch in ";&|()" or ch == '`': + raise SystemExit(0) + i += 1 + +# Unterminated quoting is ambiguous and therefore unsafe. +raise SystemExit(0 if quote is not None or escaped else 1) +PY + then + return 0 + else + scanner_rc=$? + # [2026-07-12][fix] + # 背景: + # - 依頼意図: quote scanner の Python 起動不能や異常終了を「安全」と誤認し、main 保護が + # fail-open になる経路を閉じたい。 + # - 守るべき業務ルール: scanner が明示する rc=1 だけを安全とし、未導入・クラッシュ・ + # 想定外終了はすべて曖昧な入力として拒否する。 + # - 他案不採用理由: テスト用の interpreter override を本番環境変数として公開する案は、 + # exit 1 を返す任意プログラムで保護を迂回できるため不採用。 + # 対応: python3 は固定し、rc=1 以外を unsafe に正規化する。 + # rc=1 is the scanner's only explicit "safe" result. Missing Python, + # interpreter crashes, and every other unexpected status stay fail-closed. + [ "$scanner_rc" -eq 1 ] && return 1 + return 0 + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +# emit_deny(hook-io.sh) を呼び出す前に telemetry へ deny を記録する薄いラッパ。 +# 既存の deny メッセージ・exit 挙動は一切変えない(記録の追加のみ)。 +_emit_deny_with_telemetry() { + agent_hub_telemetry_log hook_deny block-main-commit deny 2>/dev/null || true + emit_deny "$1" +} + +DENY_MSG='[hook:block-main-commit] mainブランチへの直接コミット/プッシュはブロックされました。\n\n対応手順:\n1. git checkout -b feature/xxx でブランチを作成\n2. ブランチ上でコミット\n3. gh pr create でPRを作成\n\n理由: mainマージ = 本番DB自動適用 + 本番デプロイが即座に発動するため、レビューなしの変更は禁止です。' + +read_stdin +COMMAND=$(extract_field command) + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# CWD取得(push refspec検知より前に必要) +CWD=$(extract_field cwd) +if [ -z "$CWD" ]; then + CWD="." +fi + +# [2026-08-02][fix] #1313 / #1256: commit message・PR/Issue本文をgit実行列から除外する。 +# 背景: +# - 依頼意図: `git commit -m '説明; git push origin main'` や +# `gh pr create --body 'git reset --hard'` の本文を、実行されたgit writeとして +# 誤検知しない。ガード自身の修正記録・PR本文が書けない摩擦を解消する。 +# - 守るべき業務ルール: 引用外の `; git ...`、実際の command substitution、shell wrapper は +# 従来どおり安全側で扱う。除外するのは `-m/--message/--body/--body-file` の引数データだけ。 +# - 他案不採用理由: コマンド全体の `git` 文字列を無視する案は、引用外のmain pushを見逃す。 +# 正規表現へ例外を足し続ける案は引用境界を扱えず、同じ誤検知を再発させる。 +# 対応: shellの引用境界を小さく走査し、本文系オプションの次の1 tokenだけを空白化した +# 判定用コピーを作る。実行用の COMMAND は変更せず、quote scanner / -C path 解決は従来どおり +# raw input を参照する。展開を含む本文は空白化せず、保守的に検出・拒否する。 +sanitize_git_data_args() { + COMMAND_TEXT="$1" python3 - <<'PY' 2>/dev/null || printf '%s' "$1" +import os +import shlex + +text = os.environ.get("COMMAND_TEXT", "") +mask = [False] * len(text) +data_options = {"-m", "--message", "--body", "--body-file"} + +def spans(value): + result = [] + index = 0 + length = len(value) + while index < length: + if value[index].isspace(): + index += 1 + continue + if value[index] in ";|&()": + result.append((index, index + 1, value[index])) + index += 1 + continue + start = index + quote = None + escaped = False + while index < length: + char = value[index] + if escaped: + escaped = False + index += 1 + continue + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if quote == '"': + if char == '"': + quote = None + elif char == "\\": + escaped = True + index += 1 + continue + if char in ("'", '"'): + quote = char + index += 1 + continue + if char == "\\": + escaped = True + index += 1 + continue + if char.isspace() or char in ";|&()": + break + index += 1 + result.append((start, index, value[start:index])) + return result + +def decoded(raw): + try: + values = shlex.split(raw, posix=True) + except ValueError: + return raw + return values[0] if len(values) == 1 else raw + +def has_executable_expansion(raw): + quote = None + escaped = False + index = 0 + while index < len(raw): + char = raw[index] + if escaped: + escaped = False + index += 1 + continue + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if quote == '"': + if char == '"': + quote = None + elif char == "\\": + escaped = True + elif char == "$" and index + 1 < len(raw) and raw[index + 1] == "(": + return True + elif char == "`": + return True + index += 1 + continue + if char in ("'", '"'): + quote = char + elif char == "\\": + escaped = True + elif char == "$" and index + 1 < len(raw) and raw[index + 1] == "(": + return True + elif char == "`": + return True + index += 1 + return False + +tokens = spans(text) +expect_data = False +for start, end, raw in tokens: + if raw in ";|&()": + expect_data = False + continue + value = decoded(raw) + if expect_data: + # A command substitution/backtick is executable text, not static data. + # Keep it visible so the existing fail-closed patterns can reject it. + if not has_executable_expansion(raw): + for position in range(start, end): + mask[position] = True + expect_data = False + continue + if value in data_options: + expect_data = True + continue + if any(value.startswith(option + "=") for option in ("--message", "--body", "--body-file")): + for position in range(start, end): + mask[position] = True + continue + # `-mtext` is a valid git short option form. The whole token is message data. + if value.startswith("-m") and len(value) > 2 and not value.startswith("--"): + for position in range(start, end): + mask[position] = True + +print("".join(" " if mask[position] else char for position, char in enumerate(text)), end="") +PY +} + +# All regex-only git write searches below use this copy. Raw COMMAND remains the source for +# quote-aware shell-control and effective path checks. +COMMAND_FOR_GIT_MATCH="$(sanitize_git_data_args "$COMMAND")" + +is_allowed_main_direct_path() { + # 2026-07-01: AI hook 経由の main direct allowlist は廃止。 + # 互換テスト用に関数名は残すが、どの path も許可しない。 + return 1 +} + +# [2026-05-30][fix] issue #210 / cafe48 codex review follow-up +# 背景: +# ユーザー依頼意図: `git -C <別repo> push origin main` のように実効ディレクトリを変える +# グローバルオプション付き push/commit を、hook 実行 cwd ($CWD) の branch/差分で判定すると、 +# 「$CWD が main かつ軽量変更」のとき別 repo の main 直 push を軽量バイパスで許可してしまう +# fail-open が残っていた(#213 で git_command_query=実効 cwd 解決を削除した際の取りこぼし)。 +# 守るべき業務ルール: main 直 push/commit のブロックは確実(fail-closed)であること。 +# 他案不採用理由: +# 1) -C を抽出し実効 cwd を完全復元する案: 複数 -C の相対累積や --git-dir/--work-tree の +# 組合せまで正確に追うのは複雑で、#213 が regex 方式へ寄せた設計に逆行する。 +# 2) 何もしない案: 別 repo の main 直 push を $CWD=main・軽量時に通すため main 保護目的を満たさない。 +# 3) -C/--git-dir/--work-tree のみ検知(PR #229 初版): PR #229 codex レビューで指摘の通り +# `GIT_DIR=` / `GIT_WORK_TREE=` env 経由と `cd /other && git push` の複合コマンドが +# 残存 fail-open になるため不採用(v3.5.7 で同時対応)。 +# 対応: 実効ディレクトリを変える経路(-C / --git-dir / --work-tree / GIT_DIR= / GIT_WORK_TREE= / +# cd && git ...)が push/commit に付く場合は $CWD ベースの軽量バイパスを信頼せず、 +# main 向けは安全側で deny する(fail-closed)。-C なしの通常 cwd 上の Markdown 軽量直 push は +# 従来どおり許可され、false positive を広げない。 +command_targets_other_dir() { + # -C / --git-dir / --work-tree + # ただし `-C .` / `-C ./` は no-op(current dir)のため除外する。 + # path 部分を抽出して `.` または `./` でないことを確認する。 + local c_paths c_path + c_paths=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' || true) + if [ -n "$c_paths" ]; then + while IFS= read -r match; do + [ -z "$match" ] && continue + # 最後のフィールド = path(先頭の空白と -C を除去) + c_path=$(echo "$match" | awk '{print $NF}') + case "$c_path" in + "."|"./") ;; # no-op + *) return 0 ;; + esac + done <<< "$c_paths" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+))'; then + return 0 + fi + # GIT_DIR= / GIT_WORK_TREE= / GIT_NAMESPACE= 環境変数 prefix + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)='; then + return 0 + fi + # cd && git ... / cd ; git ... (複合コマンドで実効 cwd を変える) + # `cd .` / `cd ./` は no-op のため除外する。 + local cd_paths cd_path + cd_paths=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^[:space:];&|]+' || true) + if [ -n "$cd_paths" ]; then + while IFS= read -r match; do + [ -z "$match" ] && continue + cd_path=$(echo "$match" | awk '{print $NF}') + case "$cd_path" in + "."|"./") ;; # no-op + *) + # cd の後に && または ; があり git が続くことを確認 + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "cd[[:space:]]+$(printf '%s' "$cd_path" | sed 's/[[\.*^$/]/\\&/g')[[:space:]]*[;&]"; then + return 0 + fi + ;; + esac + done <<< "$cd_paths" + fi + return 1 +} + +# [2026-06-14][feat] 実効ターゲットディレクトリ(先頭の単一 cd 先)のブランチを解決する。-C は不採用=deny。 +# 背景: +# 依頼意図: Claude Code 等のハーネスは Bash の cwd を毎回プロジェクト直下(main)に戻すため、 +# worktree への操作は `cd && git commit/push` の形になる。$CWD(main) の枝で判定すると +# worktree(feature) への正当なコミット・PR push まで fail-closed で弾かれ、worktree 開発が成立しない。 +# 守るべき業務ルール: 解決対象は「コマンド先頭の単一 cd && ...」だけ(cd は後続コマンドの cwd に +# 効くため commit/push の実効ディレクトリになる)。GIT_DIR/GIT_WORK_TREE env・--git-dir/--work-tree/ +# --namespace・-C・複数 cd・先頭以外の cd が含まれる場合は解決不能(空)を返し、従来どおり fail-closed にする。 +# 他案不採用理由: +# 1) -C を解決に使う案: -C はその git 1 回にしか効かず、`git -C status && git commit` のように +# 後続 commit が main で動く形を誤許可するため不採用(-C は解決根拠にしない=従来 deny のまま)。 +# 2) 複数 cd の相対累積・env トリックまで追う案: 複雑で誤許可リスクが高い。安全に解決できる +# 「先頭単一 cd」だけを許可し、それ以外は安全側(空)に倒す。 +# 実効ターゲットディレクトリ(先頭の単一 cd 先)を解決して絶対パスを stdout に返す。解決不能なら空。 +# [2026-06-15][fix] dir 解決を effective_target_branch から切り出して関数化(bare push の宛先判定で +# has_unsafe_push が同じ dir を再利用するため)。ガード条件は従来と同一(変更なし)。 +effective_target_dir() { + # 実体を差し替える env / オプション / -C が含まれるものは解決不能(fail-closed 用に空を返す)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)=' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir|--work-tree|--namespace)([=[:space:]])' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])-C([[:space:]]|$)' && return 0 + # eval / exec / ` -c` は cd の効果範囲が静的に読めない → 解決不能(fail-closed)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(eval|exec)([[:space:]]|$)' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(sh|bash|zsh|dash|ksh)[[:space:]]+-[A-Za-z]*c([[:space:]]|$)' && return 0 + # コマンド位置(^ / ; & | 直後・サブシェル ( 直後)の cd を数える。複数あれば実効 cwd が曖昧 → 解決不能。 + # サブシェル `( cd /main && git commit )` の隠れた cd も ( を境界に含めることで検出する。 + local cds dir + cds=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[;&|(])[[:space:]]*cd[[:space:]]+[^[:space:];&|()]+' || true) + [ "$(printf '%s\n' "$cds" | grep -c .)" -ne 1 ] && return 0 + # その単一 cd が「先頭」かつ「&& / ; で後続に効く」形であること(背景 & / パイプ | は対象外)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '^[[:space:]]*cd[[:space:]]+[^[:space:];&|()]+[[:space:]]*(&&|;)' || return 0 + # [2026-06-16][fix] COMMAND が複数行(heredoc / 改行入りコミットメッセージ等)のとき、 + # sed が行単位で処理し非マッチ行(2 行目以降のメッセージ本文)を素通しするため dir がゴミ文字列化し、 + # git -C "$dir" が失敗 → 正当な worktree commit/push が誤 deny されていた。cd は先頭行(上の L427 で + # 先頭 + &&/; を保証済)にあるため、1 行目だけから抽出する(複数行は安全に L1 のみを見る)。 + dir=$(printf '%s' "$COMMAND" | sed -nE '1s/^[[:space:]]*cd[[:space:]]+([^[:space:];&|()]+).*/\1/p') + # ~ 展開 / 相対パスは $CWD(JSON の cwd) 基準で正規化(git -C が hook プロセスの cwd で解決するのを防ぐ)。 + case "$dir" in + ""|"."|"./") return 0 ;; + "~") dir="$HOME" ;; + "~/"*) dir="${HOME}/${dir#\~/}" ;; + /*) ;; + *) dir="$CWD/$dir" ;; + esac + printf '%s' "$dir" +} + +effective_target_branch() { + local dir + dir="$(effective_target_dir)" + [ -z "$dir" ] && return 0 + git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true +} + +# [2026-07-09][fix] +# 背景: +# 依頼意図: AGENT-HUB の専用 worktree 上で正当な `git -C commit` が +# block-main-commit に誤ブロックされ、正規の branch + PR フローを閉じられなかった。 +# 守るべき業務ルール: main 直 commit / push は引き続き fail-closed で止める。一方で、実効対象が +# 非 main branch だと確認できる単発 `git -C commit/push` は本番 main に影響しないため許可する。 +# 他案不採用理由: +# 1) `-C` を全面許可する案は、`git -C status && git commit` の後続 commit が main で動く形を +# 誤許可するため不採用。 +# 2) 複合 shell 構文まで静的解析する案は誤許可リスクが高いため不採用。 +# 3) 従来どおり全部 deny する案は、AGENT-HUB の標準 worktree 運用を阻害するため不採用。 +# 対応: shell 制御演算子を含まない単発 git コマンドだけ `-C` の対象 dir を解決し、非 main branch かつ +# unsafe push でない場合だけ早期許可する。env / git-dir / namespace trick は従来どおり fail-closed。 +# [2026-08-02][fix] Wave B / #1258: 引用内の `-C` を git global option と数えない。 +# 背景: +# - ユーザー依頼意図: `git -C commit -m '... -C ...'` のようにメッセージへ `-C` と +# 書いただけで単発 feature commit が deny され、文書・回帰テストが書けない。 +# - 守るべき業務ルール: 引用外の複数 `-C` は従来どおり解決不能。引用済み本文の `-C` はデータ。 +# - 他案不採用理由: メッセージから `-C` 文字を禁止する案は説明文を歪める。複合への -C 対称化はしない。 +# 対応: quote-aware に引用外の `-C ` をちょうど1つだけ抽出し、それを target dir にする。 +single_unquoted_git_c_path() { + COMMAND_TEXT="$1" python3 - <<'PY' 2>/dev/null || true +import os + +text = os.environ.get("COMMAND_TEXT", "") +quote = None +escaped = False +paths = [] +i = 0 +while i < len(text): + ch = text[i] + if escaped: + escaped = False + i += 1 + continue + if ch == "\\" and quote != "'": + escaped = True + i += 1 + continue + if quote == "'": + if ch == "'": + quote = None + i += 1 + continue + if quote == '"': + if ch == '"': + quote = None + i += 1 + continue + if ch in ("'", '"'): + quote = ch + i += 1 + continue + if ch == "-" and i + 1 < len(text) and text[i + 1] == "C": + prev = text[i - 1] if i > 0 else " " + if prev.isspace() or i == 0: + j = i + 2 + while j < len(text) and text[j] in " \t": + j += 1 + if j < len(text) and text[j] not in " \t\n;'\"|&()": + start = j + while j < len(text) and text[j] not in " \t\n;'\"|&()": + j += 1 + paths.append(text[start:j]) + i = j + continue + i += 1 + +if quote is not None or escaped or len(paths) != 1: + raise SystemExit(0) +print(paths[0], end="") +PY +} + +single_git_c_target_dir() { + # 単発 `git -C commit/push` だけを解決する。 + # `git -C status && git commit` のような後続 git へ -C が効かない形は従来どおり解決しない。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)=' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir|--work-tree|--namespace)([=[:space:]])' && return 0 + has_unquoted_shell_control "$COMMAND" && return 0 + # shell controlを除外済みの単発コマンドは、git本体とsubcommandだけを軽量に確認する。 + # ここで巨大な GIT_CMD 正規表現を再利用すると、引用本文を空白化した長い -C pathで + # EREのバックトラックが不安定になり、正当なfeature commit/pushを誤denyするため分離する。 + # [2026-08-02][fix] env / VAR=value prefix 付きの単発 git -C を解決対象に含める(issue #1344)。 + # 背景: + # - ユーザー依頼意図: `env FOO=bar git -C commit` / `FOO=bar git -C commit` + # が本軽量正規表現に一致せず未解決 → fail-closed で正当な feature commit/push まで + # 誤 deny されていた(PR #1343 codex-review が検出・再現ドライバで実測)。 + # - 守るべき業務ルール: GIT_DIR / GIT_WORK_TREE / GIT_NAMESPACE の assignment は本関数 + # 冒頭のガードが先に未解決へ倒す(実効 dir を -C 以外で動かす形は従来どおり保守的)。 + # 値に空白・引用を含む assignment は本パターンに一致せず未解決のまま(安全側)。 + # - 他案不採用理由: GIT_CMD 全体の再利用は上記バックトラック不安定のため不採用(既存判断)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '^[[:space:]]*(env[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*(env[[:space:]]+)?(command[[:space:]]+)?git([[:space:]]+[^[:space:]]+)*[[:space:]]+(commit|push)([[:space:]]|$)' || return 0 + + local dir + dir="$(single_unquoted_git_c_path "$COMMAND")" + case "$dir" in + ""|"."|"./") return 0 ;; + "~") dir="$HOME" ;; + "~/"*) dir="${HOME}/${dir#\~/}" ;; + /*) ;; + *) dir="$CWD/$dir" ;; + esac + printf '%s' "$dir" +} + +# [2026-06-14][feat] / [2026-06-15][fix] 早期許可してはならない push が含まれるか(main 保護の fail-closed 判定)。 +# 引数 $1: 実効ターゲットディレクトリ(effective_target_dir の解決結果)。bare/remote-only push の宛先を +# この dir の push.default + upstream で判定するために使う。空なら bare push は解決不能=unsafe に倒す。 +# 早期許可(worktree feature への exit 0)を通してよいのは: +# 1) 明示的非 main push: git push [安全フラグ]* <非main・非wildcard・非colon の単一ブランチ> +# 2) [2026-06-15][fix] refspec 省略の bare push(git push / git push / git push --force-with-lease)で、 +# 実効 dir のカレントブランチ(=呼び出し側が非 main を保証済み)が push.default 上 main に波及しないもの。 +# `cd && git push --force-with-lease` 形(refspec 省略の常用フロー)を許可するための拡張。 +# それ以外(複数 ref / 値を取るオプション(-o 等) / --all/--mirror / wildcard / main 宛て / +# push.default=matching / upstream が main)は main を押しうるため unsafe=true を返す。 +# トークン単位で解析し、未知オプション(値を取りうる)が残れば unsafe に倒す(保守的)。 +has_unsafe_push() { + local eff_dir="${1:-}" + local segs seg + segs=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE "${GIT_CMD}[[:space:]]+push[^;&|]*" || true) + [ -z "$segs" ] && return 1 # push なし(commit only)→ 安全 + while IFS= read -r seg; do + [ -z "$seg" ] && continue + local args remote="" ref="" extra=0 + args=$(printf '%s' "$seg" | sed -E 's/^.*[[:space:]]push([[:space:]]|$)/ /') + # glob 展開を抑止して push 引数をトークン化(refspec 内の * がファイル展開されないように)。 + set -f + # shellcheck disable=SC2086 + set -- $args + set +f + while [ "$#" -gt 0 ]; do + case "$1" in + # [2026-06-16][fix] リダイレクトトークン(2>&1 / 2> / >file / 1>&2 / &>file 等)を無視する。 + # segment 抽出 [^;&|]* は `2>&1` の `&` で切れ `2>` が残るため、従来はこれを余分な refspec + # と誤認し extra=1 → unsafe → 正当な worktree push が誤 deny されていた。git の refname は + # `<` `>` を含めないため(refname 規則)、これらを含むトークンは refspec ではない=安全に無視できる。 + *'>'*|*'<'*) ;; + # 値を取らない安全フラグのみ消費。 + -u|--set-upstream|-f|--force|--force-with-lease|-q|--quiet|-v|--verbose|-n|--dry-run|--no-verify|--porcelain|--progress|--atomic|--tags|--follow-tags) ;; + -*) return 0 ;; # 未知/値を取るオプション(--all/--mirror/-o 等) → 解析不能 → unsafe + *) + if [ -z "$remote" ]; then remote="$1" + elif [ -z "$ref" ]; then ref="$1" + else extra=1; fi ;; + esac + shift + done + [ "$extra" = 1 ] && return 0 # ref が 2 個以上 → 曖昧 → unsafe + if [ -z "$ref" ]; then + # refspec 省略(git push / git push )→ カレントブランチを push.default に従って push する。 + # 呼び出し側で「実効 dir のカレントブランチ != main」を保証済み。main に波及する設定のみ unsafe。 + [ -z "$eff_dir" ] && return 0 # dir 未解決 → 宛先を検証できない → unsafe(fail-closed) + local pd up + pd=$(git -C "$eff_dir" config --get push.default 2>/dev/null || true) + case "$pd" in + matching) + return 0 ;; # 全 matching ブランチ(main 含む)を push しうる → unsafe + upstream|tracking) + # 設定上の upstream を push。main(またはそれを指す upstream)なら unsafe、解決不能も unsafe。 + up=$(git -C "$eff_dir" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || true) + { [ -z "$up" ] || echo "$up" | grep -qE '(^|/)main$'; } && return 0 ;; + *) + : ;; # simple(既定)/current/nothing/未設定 → カレント(非main)ブランチのみ push → 安全 + esac + continue + fi + echo "$ref" | grep -qE '^[A-Za-z0-9._/-]+$' || return 0 # : や * を含む → unsafe + [ "$ref" = "main" ] && return 0 + echo "$ref" | grep -qE '(^|/)main$' && return 0 # refs/heads/main 等 → unsafe + done <<< "$segs" + return 1 +} + +BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || true) + +# 複合コマンド: checkout/switch main && commit/push を検知 +if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(switch|checkout)([[:space:]]+-[^[:space:]]+)*[[:space:]]+main([[:space:]]|$).*${GIT_CMD}[[:space:]]+(commit|push)([[:space:]]|$)"; then + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+push"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi +fi + +# push コマンドからmain向けrefspecを検知 +PUSH_SEGMENTS=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE "${GIT_CMD}[[:space:]]+push[^;&|]*" || true) +if [ -n "$PUSH_SEGMENTS" ]; then + while IFS= read -r push_segment; do + if echo "$push_segment" | grep -qE '(^|[[:space:]])\+?(refs/heads/)?main([[:space:]]|$)'; then + if [ "$BRANCH" != "main" ]; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + continue + fi + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$push_segment" | grep -qE '(^|[[:space:]])\+?[^[:space:]]*:(refs/heads/)?main([[:space:]]|$)'; then + if [ "$BRANCH" != "main" ]; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + continue + fi + _emit_deny_with_telemetry "$DENY_MSG" + fi + done <<< "$PUSH_SEGMENTS" +fi + +# [2026-07-18][fix] env split-string内のgit writeはGIT_CMDへ展開できないため、先に拒否する。 +if command_uses_env_split_git_write; then + _emit_deny_with_telemetry "$DENY_MSG" +fi + +# git commit / git push を含まない場合は許可 +if ! echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(commit|push)"; then + exit 0 +fi + +# env の chdir は hook JSON の cwd と異なる実効branchへ切り替わる。完全解決せずfail-closed。 +if command_uses_env_chdir; then + _emit_deny_with_telemetry "$DENY_MSG" +fi + +if [ -z "$BRANCH" ]; then + exit 0 +fi + +# mainブランチの場合 — AI hook 経由では軽量変更でも commit / push を許可しない +if [ "$BRANCH" = "main" ]; then + # [2026-06-14][fix] worktree(別ディレクトリ・feature ブランチ)への commit / 非 main push を許可。 + # 背景: + # 依頼意図: ハーネスが Bash cwd を毎回 main 直下に戻すため、worktree 運用は + # `cd && git commit/push` になる。従来は $CWD(main) の枝で fail-closed deny し、 + # worktree(feature) への正当なコミット・PR push まで弾けて worktree 並行開発が成立しなかった。 + # 守るべき業務ルール: 実効ターゲット(先頭の単一 cd 先)のブランチが main 以外で、かつ main への push を + # 含まないなら、本番デプロイ(=main push/merge)に一切影響しないため許可する。 + # 他案不採用理由: + # 1) 何もしない案: worktree 並行開発(ユーザーの主要フロー)が不能のままで不便。 + # 2) commit/push を全面許可する案: main push の fail-open を生むため不可。実効ブランチ判定 + + # has_unsafe_push ガードで main 保護を厳密に保つ(曖昧/全ref/wildcard/main 宛て push は早期許可しない)。 + # 3) 実効 cwd を完全復元する案: 複数 cd・env トリック・サブシェル・-c まで追うのは複雑で誤許可リスク。 + # 先頭の単一 cd のみ解決し(-C は git 1 回しか効かないため不採用=deny)、env トリック/複数 cd/ + # サブシェル/eval/-c シェルは effective_target_branch が空を返す=従来 deny。 + # 注: 本フックは「権限ルールの実体(SSOT)」そのもの。別途の権限ドキュメント同期は不要(ここが正本)。 + if command_targets_other_dir; then + eff_dir="$(effective_target_dir)" + if [ -n "$eff_dir" ]; then + eff_branch="$(git -C "$eff_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ -n "$eff_branch" ] && [ "$eff_branch" != "main" ] && ! has_unsafe_push "$eff_dir"; then + exit 0 # 別 worktree/別リポの feature への commit / 安全な非 main push(refspec 省略含む)→ 許可 + fi + fi + eff_dir="$(single_git_c_target_dir)" + if [ -n "$eff_dir" ]; then + eff_branch="$(git -C "$eff_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ -n "$eff_branch" ] && [ "$eff_branch" != "main" ] && ! has_unsafe_push "$eff_dir"; then + exit 0 # 単発 `git -C commit/push` は -C が対象 git へだけ効くため許可 + fi + fi + fi + + # [2026-05-30][fix] PR #229 codex review NO-GO 追加修正 + # 背景: BRANCH==main かつ $CWD が軽量だけのとき、`git -C /other push`(refspec なし)等で + # 実効 cwd が /other に切り替わるコマンドが CWD の軽量差分で素通りしていた(line 309 残存fail-open)。 + # 対応: command_targets_other_dir なら CWD ベース判定を信頼せず、main 向けは fail-closed。 + # `git -C /other push origin feature` (CWD=main) など希少な workflow を deny する副作用は + # メイン保護のため許容(自然なワークフローは /other へ cd して実行)。 + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(commit|push)"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi +fi + +# main以外は許可 +exit 0 diff --git a/.cursor/hooks/scripts/block-main-commit.test.sh b/.cursor/hooks/scripts/block-main-commit.test.sh new file mode 100755 index 000000000..f7eb58096 --- /dev/null +++ b/.cursor/hooks/scripts/block-main-commit.test.sh @@ -0,0 +1,517 @@ +#!/usr/bin/env bash +set -euo pipefail + +# [2026-04-10][test] +# 背景: +# - 依頼意図: block-main-commit hook の docs-only 例外が再び main 直 push の穴にならないよう、 +# commit / push の軽量変更例外を回帰テストで固定する。 +# - 守るべき業務ルール: main 直コミット/プッシュの例外は Markdown 系ドキュメントと +# sync-state.json など明示 allowlist だけ。コード変更や HEAD:main は拒否する。 +# - 他案不採用理由: 手動確認だけに戻す案は、同じ制御フロー退行を次回レビューまで見逃すため不採用。 +# +# [2026-06-19][test] +# 背景: +# - PR422 / 配布先レビューで、先頭 `cd` を含む複数行コマンドや redirect 付き push の +# 作業ディレクトリ解決が誤 deny される一方、main 明示 push は拒否し続ける必要があると分かった。 +# - 守るべき業務ルール: feature worktree への安全な push は止めず、main 直 push / HEAD:main / +# 解決不能な `git -C` 経由 push は止める。 +# - 他案不採用理由: 実装コメントだけで済ませる案は、sed 抽出の微妙な退行を次の配布まで見逃すため不採用。 + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/block-main-commit.sh" +PASS=0 +FAIL=0 + +json_string() { + python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1" +} + +run_hook() { + local cwd="$1" + local command="$2" + printf '{"tool_name":"Bash","tool_input":{"cwd":%s,"command":%s}}\n' "$(json_string "$cwd")" "$(json_string "$command")" | bash "$SCRIPT" +} + +run_hook_raw() { + local payload="$1" + printf '%s' "$payload" | bash "$SCRIPT" +} + +run_hook_script() { + local cwd="$1" + local command="$2" + local script="$3" + printf '{"tool_name":"Bash","tool_input":{"cwd":%s,"command":%s}}\n' "$(json_string "$cwd")" "$(json_string "$command")" \ + | bash "$script" +} + +expect_allow() { + local name="$1" + local cwd="$2" + local command="$3" + local out + out="$(run_hook "$cwd" "$command" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision'; then + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + else + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + fi +} + +expect_block() { + local name="$1" + local cwd="$2" + local command="$3" + local out + out="$(run_hook "$cwd" "$command" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_raw() { + local name="$1" + local payload="$2" + local out + out="$(run_hook_raw "$payload" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_with_script() { + local name="$1" + local cwd="$2" + local command="$3" + local script="$4" + local out + out="$(run_hook_script "$cwd" "$command" "$script" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +main_repo="$tmp/main" +feature_repo="$tmp/feature" +mkdir -p "$main_repo" "$feature_repo" +git -C "$main_repo" init -q +git -C "$main_repo" checkout -q -b main +git -C "$main_repo" config user.email test@example.com +git -C "$main_repo" config user.name "Test User" +echo init > "$main_repo/README.md" +git -C "$main_repo" add README.md +git -C "$main_repo" commit -q -m init +git -C "$main_repo" update-ref refs/remotes/origin/main HEAD + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only commit は拒否" \ + "$main_repo" \ + "git commit -m docs" +expect_block \ + "main上の先頭空白付きcommit は拒否" \ + "$main_repo" \ + " git commit -m docs" +expect_block \ + "main上のenv経由commit は拒否" \ + "$main_repo" \ + "env git commit -m docs" +expect_block \ + "main上のenv -u経由commit は拒否" \ + "$main_repo" \ + "env -u UNUSED_FLAG git commit -m docs" +expect_block \ + "main上のcommand経由push は拒否" \ + "$main_repo" \ + "command git push origin main" +expect_block \ + "main上の変数代入 + env経由commit は拒否" \ + "$main_repo" \ + "FOO=1 env git commit -m docs" +expect_block \ + "main上のsingle quote空白値 + commit は拒否" \ + "$main_repo" \ + "FOO='a b' git commit -m docs" +expect_block \ + "main上のdouble quote空白値 + env経由commit は拒否" \ + "$main_repo" \ + 'FOO="a b" env git commit -m docs' +expect_block \ + "main上の変数代入 + command経由push は拒否" \ + "$main_repo" \ + "FOO=1 command git push origin main" +git -C "$main_repo" reset -q + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only push は拒否" \ + "$main_repo" \ + "git push origin main" +git -C "$main_repo" reset -q + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only commit && push は拒否" \ + "$main_repo" \ + "git commit -m docs && git push origin main" +git -C "$main_repo" reset -q + +mkdir -p "$main_repo/.cursor/rules" "$main_repo/.codex" +echo rule > "$main_repo/.cursor/rules/project.mdc" +git -C "$main_repo" add .cursor/rules/project.mdc +expect_block \ + "main上の.mdc commit は拒否" \ + "$main_repo" \ + "git commit -m rules" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.cursor" + +echo '{}' > "$main_repo/.codex/sync-state.json" +git -C "$main_repo" add .codex/sync-state.json +expect_block \ + "main上のsync-state.json commit は拒否" \ + "$main_repo" \ + "git commit -m sync" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.codex" + +mkdir -p "$main_repo/.claude/hooks" +echo v > "$main_repo/.claude/hooks/.hook-library-version" +git -C "$main_repo" add .claude/hooks/.hook-library-version +expect_block \ + "main上のhook library version commit は拒否" \ + "$main_repo" \ + "git commit -m hook-version" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.claude" + +mkdir -p "$main_repo/src" +echo "export const value = 1;" > "$main_repo/src/app.ts" +git -C "$main_repo" add src/app.ts +expect_block \ + "main上のコード変更 commit は拒否" \ + "$main_repo" \ + "git commit -m code" +git -C "$main_repo" reset -q +rm -rf "$main_repo/src" + +git -C "$feature_repo" init -q +git -C "$feature_repo" checkout -q -b feature/test +git -C "$feature_repo" config user.email test@example.com +git -C "$feature_repo" config user.name "Test User" +echo init > "$feature_repo/README.md" +git -C "$feature_repo" add README.md +git -C "$feature_repo" commit -q -m init +git -C "$feature_repo" update-ref refs/remotes/origin/main HEAD +git -C "$feature_repo" branch --set-upstream-to=origin/main feature/test >/dev/null 2>&1 || true + +expect_block \ + "feature cwdからenv -C main commitは拒否" \ + "$feature_repo" \ + "env -C $main_repo git commit -m unsafe" + +expect_block \ + "feature cwdからenv --chdir main pushは拒否" \ + "$feature_repo" \ + "env --chdir=$main_repo git push" + +expect_block \ + "feature cwdからenv -S内のmain commitは拒否" \ + "$feature_repo" \ + "env -S 'git -C $main_repo commit -m unsafe'" + +expect_block \ + "feature cwdからenv --split-string内のmain pushは拒否" \ + "$feature_repo" \ + "env --split-string='git -C $main_repo push' ignored" + +# [2026-07-12][test] +# 背景: Codex が main checkout を cwd にしたまま専用 worktree へ単発 `git -C` commit する際、 +# conventional commit の scope 括弧や本文のセミコロンを shell 制御演算子と誤認して deny していた。 +# main 保護は維持しつつ、引用済みコミットメッセージ内の文字は引数データとして扱う必要がある。 +# 他案不採用理由: conventional commit の括弧だけを例外化するテストでは、引用済みのセミコロンや +# リダイレクト文字で同じ誤検知が再発するため、引用境界そのものを正負両方向で固定する。 +expect_allow \ + "-C feature commit の引用済み scope 括弧を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" + +expect_allow \ + "-C feature commit の引用済みセミコロンを許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix: first; second'" + +expect_allow \ + "-C feature commit の引用済み > を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: use > output'" + +# [2026-08-02][test] env / VAR= prefix 付き単発 -C feature commit の許可回帰(issue #1344)。 +# 背景: +# - ユーザー依頼意図: 旧 command_uses_env_chdir の貪欲マッチが git 側の -C を env の +# chdir と誤認し、正当な feature commit を誤 deny していた回帰を固定する。 +# - 守るべき業務ルール: env 自身の -C/--chdir・GIT_DIR 系 assignment の保守的 deny は +# 維持する(許可回帰と deny 回帰を対で置く)。 +# - 他案不採用理由: 許可側だけのテストでは、将来 env 判定を戻した時に chdir バイパスの +# deny が消えても検知できない。 +expect_allow \ + "env prefix の -C feature commit を許可" \ + "$main_repo" \ + "env FOO=bar git -C $feature_repo commit -m docs" +expect_allow \ + "VAR= prefix の -C feature commit を許可" \ + "$main_repo" \ + "FOO=bar git -C $feature_repo commit -m docs" +expect_block \ + "env 自身の -C (chdir) は従来どおり拒否" \ + "$feature_repo" \ + "env -C $main_repo git commit -m docs" +expect_block \ + "env GIT_DIR assignment は従来どおり保守的拒否" \ + "$main_repo" \ + "env GIT_DIR=$main_repo/.git git -C $feature_repo commit -m docs" +# PR #1354 codex-review Critical: 引数付き env オプション越しの chdir バイパスを deny 固定 +expect_block \ + "env -u 引数付きの env -C (chdir) main も拒否" \ + "$feature_repo" \ + "env -u FOO -C $main_repo git commit -m docs" +expect_block \ + "env --unset 引数付きの --chdir main も拒否" \ + "$feature_repo" \ + "env --unset FOO --chdir $main_repo git push origin main" +# 注: `env -i git -C commit` は single_git_c_target_dir が env オプションを +# 解決対象にしないため従来どおり保守的 deny(バイパスではなく安全側・許可回帰は置かない)。 + +expect_allow \ + "-C feature commit の引用済み < を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: use < input'" + +expect_allow \ + "-C feature commit のdouble quote済みメッセージを許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix(auth): allow feature worktree\"" + +# [2026-08-02][test] #1313 / #1256 +# 背景: 引用済みの commit message / PR本文に現れる `git push` や `git reset` を +# 実行コマンドと誤認すると、feature worktreeのcommitやガード修正PRを作れない。 +# 守るべき業務ルール: 本文系オプションの引数はデータとして扱い、引用外の実コマンドは拒否する。 +# 他案不採用理由: message 側の文字列を正規表現の例外へ追加する案は、例外列挙が際限なく増え +# 引用境界の正確な認識という根本対処を先送りするため不採用(PR #1343 codex-review 指摘の補完)。 +expect_allow \ + "-C feature commit message内のmain push文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs; git push origin main'" + +expect_allow \ + "-C feature commit message内のreset文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit --message='docs: git reset --hard は本文'" + +expect_allow \ + "single quote内のliteral command substitution文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: literal \$(git push origin main)'" + +expect_allow \ + "PR本文内のmain push文字列を許可" \ + "$main_repo" \ + "gh pr create --body 'release note; git push origin main'" + +expect_allow \ + "Issue本文のreset文字列を許可" \ + "$main_repo" \ + "gh issue comment 1 --body='docs: git reset --hard は実行しない'" + +expect_block \ + "本文の外にあるmain pushは引き続き拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs' ; git push origin main" + +expect_block \ + "-C feature commit 後の非引用セミコロン複合コマンドは拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m fix; git commit -m unsafe" + +expect_block \ + "-C feature commit の command substitution は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix: \$(git status)\"" + +expect_block \ + "-C feature commit の backtick command substitution は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix: \`git status\`\"" + +scanner_fixture="$tmp/scanner-fixture" +mkdir -p "$scanner_fixture/scripts" "$scanner_fixture/lib" +cp "$SCRIPT" "$scanner_fixture/scripts/block-main-commit.sh" +cp "$(dirname "$SCRIPT")/../lib/hook-io.sh" "$scanner_fixture/lib/hook-io.sh" +sed -i.bak 's/COMMAND_TEXT="$1" python3/COMMAND_TEXT="$1" missing-python3/' "$scanner_fixture/scripts/block-main-commit.sh" +rm -f "$scanner_fixture/scripts/block-main-commit.sh.bak" +expect_block_with_script \ + "quote scanner の起動不能は fail-closed" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" \ + "$scanner_fixture/scripts/block-main-commit.sh" + +crash_scanner="$scanner_fixture/scanner-exit-2" +printf '#!/usr/bin/env bash\nexit 2\n' > "$crash_scanner" +chmod +x "$crash_scanner" +sed -i.bak "s|COMMAND_TEXT=\"\$1\" missing-python3|COMMAND_TEXT=\"\$1\" $crash_scanner|" "$scanner_fixture/scripts/block-main-commit.sh" +rm -f "$scanner_fixture/scripts/block-main-commit.sh.bak" +expect_block_with_script \ + "quote scanner の異常終了(rc=2)は fail-closed" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" \ + "$scanner_fixture/scripts/block-main-commit.sh" + +expect_block \ + "-C feature commit の閉じていない single quote は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'broken" + +expect_block \ + "-C feature commit の閉じていない double quote は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"broken" + +expect_allow \ + "先頭 cd + multiline の feature push を許可" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease +commit body with spaces" + +expect_allow \ + "先頭 cd + redirect 付き feature push を許可" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease 2>&1" + +expect_block \ + "先頭 cd でも main 明示 push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push origin main 2>&1" + +expect_block \ + "HEAD:main は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push origin HEAD:main" + +# [2026-07-18][test] +# 全CLI配布物へ同じ回帰テストを展開する際、Claude/Cursor/Geminiのhook-ioはKimi固有payloadを +# 入力契約に持たない。別CLIのI/O契約まで要求せず、Kimi/Codex/正本でだけKimi payloadを検証する。 +case "$SCRIPT" in + */.claude/*|*/.cursor/*|*/.gemini/*) + printf '[SKIP] Kimi Shell toolInput の HEAD:main は対象外ランタイム\n' + ;; + *) + expect_block_raw \ + "Kimi Shell toolInput の HEAD:main は拒否" \ + "{\"toolName\":\"Shell\",\"toolInput\":{\"cwd\":$(json_string "$main_repo"),\"command\":$(json_string "cd $feature_repo && git push origin HEAD:main")}}" + ;; +esac + +git -C "$feature_repo" config push.default matching +expect_block \ + "push.default=matching の bare push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push" + +git -C "$feature_repo" config push.default upstream +expect_block \ + "upstream が main の bare push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease" + +git -C "$feature_repo" config push.default current +expect_block \ + "複数 cd は解決不能として拒否" \ + "$main_repo" \ + "cd $feature_repo && cd .. && git push" + +# [2026-07-11][test] jtt-apps 本番タグ push 事例(v2.4.37) +# 背景: single_git_c_target_dir()(PR #820)は単発 `git -C commit/push` のうち +# 実効ブランチが非main・かつ安全な push だけを許可する設計に変わっているが、本テストが +# 旧仕様(-C は常に解決不能=拒否)のまま残っていて、この設計変更を検出できずにいた。 +# 合わせて、コマンドに `2>&1` 等のリダイレクトが含まれるだけで誤って解決不能扱いになる +# 問題(DEPLOY_CHECKLIST.md のタグ push 手順が worktree 経由でも実行できなくなる不具合) +# も本ファイル修正で解消したため、そのケースも固定する。 +expect_allow \ + "-C 経由でも非mainブランチへの安全な push は許可" \ + "$main_repo" \ + "git -C $feature_repo push origin feature/test" + +expect_allow \ + "-C 経由 + redirect(2>&1) 付きの安全な push も許可" \ + "$main_repo" \ + "git -C $feature_repo push origin feature/test 2>&1" + +expect_block \ + "-C 経由でも main 宛て push は拒否" \ + "$main_repo" \ + "git -C $feature_repo push origin main" + +expect_block \ + "複数-Cは解決不能として拒否" \ + "$main_repo" \ + "git -C $tmp -C feature commit -m unsafe" + +# [2026-08-02][test] Wave B / #1258 / #1090 H1 +# 背景: +# - ユーザー依頼意図: main cwd から別リポ feature worktree へ commit/push する経路が +# 「無い」ように見える摩擦を、正本の実挙動(既に allow)で固定したい。 +# - 守るべき業務ルール: 先頭単一 `cd && git commit/push` と単発 +# `git -C commit/push` は non-main なら許可。複合への -C 対称化はしない。 +# - 他案不採用理由: helper 再発明や -C の複合対称化は後続 main 書き込みの誤許可を招く。 +# 注: 本 fixture の main_repo と feature_repo は別 git init(クロスリポ相当)。 +expect_allow \ + "クロスリポ相当: 先頭 cd + feature commit を許可" \ + "$main_repo" \ + "cd $feature_repo && git commit --allow-empty -m 'chore: cross-repo feature commit'" + +expect_allow \ + "クロスリポ相当: 単発 -C feature commit を許可" \ + "$main_repo" \ + "git -C $feature_repo commit --allow-empty -m 'chore: cross-repo -C commit'" + +expect_block \ + "-C feature の後続 commit へ対称化しない(複合は拒否)" \ + "$main_repo" \ + "git -C $feature_repo status && git commit --allow-empty -m unsafe" + +expect_block \ + "先頭 cd でも対象が main なら commit 拒否" \ + "$main_repo" \ + "cd $main_repo && git commit --allow-empty -m 'docs: still main'" + +expect_allow \ + "読み取り検索内の git push 文字列は許可" \ + "$main_repo" \ + 'rg -n "git push|post-merge-gate|workflow" hook-library scripts' + +TOTAL=$((PASS + FAIL)) +printf '\n=== block-main-commit.test.sh: %d/%d PASS ===\n' "$PASS" "$TOTAL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.cursor/hooks/scripts/block-skill-reverse-edit.sh b/.cursor/hooks/scripts/block-skill-reverse-edit.sh new file mode 100755 index 000000000..e1c4845e4 --- /dev/null +++ b/.cursor/hooks/scripts/block-skill-reverse-edit.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +# [2026-06-05][feat] Phase E: スキル参照一元化の逆流(SSOT汚染)ブロック +# 背景: +# - ユーザー依頼意図: スキルは AGENT-HUB を唯一の正本(SSOT)とし、各PJは +# .claude/skills/ の相対symlinkで参照する「参照一元化」へ移行済み +# (skill-reference-unification / AGENT-HUB PR #281・#282)。この構成では、PJ で +# 作業中に symlink経由でスキルファイル(.claude/skills//SKILL.md 等)を +# Write/Edit すると、symlink先の実体(AGENT-HUB/skills//...)がレビューなしで +# 直接書き換わり、参照中の全PJへ波及する(逆流)。ルール文だけでは AI が破る +# (遵守は確率的)ため、機械的にブロックして HUB のPR運用へ誘導したい。 +# - 守るべき業務ルール: スキル実体の編集は AGENT-HUB でブランチを切り +# PR→レビュー→マージ→各PJへ反映、の一方向に統一する。PJ側からの逆流編集は禁止。 +# - 他案不採用理由: +# 1) 警告のみ(非ブロック)案: AI は警告を無視して編集を続けるため SSOT 汚染を +# 防げず不採用。完全ブロックにする(ユーザー判断 2026-06-05「完全ブロック」)。 +# 2) パス文字列(.claude/skills/)だけで判定する案: PJ_LOCAL_EXCEPTION の実体コピー +# スキルや AGENT-HUB worktree 内の直接編集まで誤ブロックするため不採用。 +# realpath(symlink解決)で「実体が /skills/ か」「論理パスが hub の内か外か」 +# を見て、逆流(hub外の論理パス→hub内の実体)だけを deny する。 +# 3) hub パスをハードコードする案: worktree や別クローンで破綻するため、realpath を +# 遡って DISTRIBUTION.yaml を持つ skills 親を動的に hub root とみなす。 +# 4) CODEX_SCRIPT_MAP へ追加する案: PreToolUse(Write|Edit) は Codex のツール名体系と +# 異なり非対応(block-unauthorized-docs-file と同型)のため Claude 専用にする。 +# 対応: PreToolUse(Write|Edit|MultiEdit) で編集先 file_path を realpath 解決。実体が +# /skills//... (skills の親に DISTRIBUTION.yaml) かつ 論理パスが hub root の +# 外(=PJ の .claude/skills/ symlink経由)のときだけ deny。AGENT-HUB(worktree含む)内の +# 直接編集・PJの実体コピースキル・PJソースコードは素通り(fail-open: 逆流見逃しは +# 本番破壊ではないため、判定異常時は許可してAIの作業を止めない)。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny を記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +DENY_MSG='[hook:block-skill-reverse-edit] このスキルの正本(SSOT)は AGENT-HUB です。PJ の .claude/skills/(symlink)経由で実体を直接編集すると、レビューなしで参照中の全PJへ波及します(逆流)。\n\n対応手順:\n1. cd ~/business/AGENT-HUB\n2. git checkout -b feat/-update でブランチ作成\n3. skills// を編集\n4. gh pr create -> レビュー -> マージ(各PJへ自動反映)\n\n理由: スキルは1実体をHUBに一元管理(参照一元化)。PJ側からの編集はSSOT汚染になるためHUBのPR運用に統一します。' + +# emit_deny は hook-io.sh にもあるが reason を heredoc へ直接展開し JSON エスケープしない。 +# 将来 DENY_MSG に二重引用符等を含めても壊れないよう json.dumps でエスケープして deny を出す +# (block-unauthorized-docs-file.sh の emit_deny_safe と同型)。argv でなく env 経由で渡し安全化。 +emit_deny_safe() { + # telemetry(harness-checkup): deny を記録(記録失敗は無視・fail-open)。 + agent_hub_telemetry_log hook_deny block-skill-reverse-edit deny 2>/dev/null || true + HOOK_REASON="$1" python3 -c ' +import json, os +print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": os.environ.get("HOOK_REASON", "")}})) +' || true + exit 0 +} + +read_stdin +FILE_PATH=$(extract_file_path) + +# file_path を持たないツール入力は対象外 +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +# bash 前段フィルタ: スキル実体は必ず /skills/ 配下にある。パスに skills/ を含まない +# 大多数の編集は確実に対象外なので、python3 を起動せず即許可して発火コストを避ける。 +case "$FILE_PATH" in + */skills/*) : ;; # skills/ を含む → 詳細判定へ進む + *) exit 0 ;; # 含まない → 対象外(allow) +esac + +# 逆流判定は realpath 解決を伴うため python3 で行う(bash の realpath は未存在末端で +# 揺れるため)。verdict は "deny"(逆流) / "allow"(対象外 or HUB内直接編集)。 +verdict=$(HOOK_FILE_PATH="$FILE_PATH" python3 - <<'PY' 2>/dev/null || true +import os + +fp = os.environ.get("HOOK_FILE_PATH", "") +if not fp: + print("allow") + raise SystemExit(0) + +# 実体パス(symlink解決後)。os.path.realpath は末端が未存在でも経路上の symlink を +# 解決する(Write 新規作成に対応)。macOS の /var -> /private/var 等の上位 symlink も +# 正規化されるため、比較する hub もすべて realpath で揃える(prefix ずれ回避)。 +real = os.path.realpath(fp) + + +def find_hub_skill_root(real_path): + """real_path が /skills//... の形なら、DISTRIBUTION.yaml を持つ + skills 親(hub root, realpath)を返す。スキル実体でなければ None。""" + parts = real_path.split(os.sep) + for i, seg in enumerate(parts): + if seg == "skills" and i > 0: + hub_root = os.sep.join(parts[:i]) + if hub_root and os.path.isfile(os.path.join(hub_root, "DISTRIBUTION.yaml")): + return os.path.realpath(hub_root) + return None + + +def find_enclosing_hub(path): + """path(論理)を文字列的に上へ辿り、DISTRIBUTION.yaml を持つ最も近い祖先(realpath)を + 返す。symlink は辿らない(file_path が物理的にどの hub の中に在るかを見る)。 + 前提: bootstrap-skills.py は per-skill symlink(.claude/skills/)のみ生成し + .claude/skills/ ディレクトリ自体は実ディレクトリ。仮に .claude/skills/ 全体を hub への + symlink にする非標準構成では os.path.isfile が辿って誤許可しうるが、実環境では + bootstrap が生成しないため発生しない(fail-open 受容)。""" + cur = os.path.abspath(path) + while True: + if os.path.isfile(os.path.join(cur, "DISTRIBUTION.yaml")): + return os.path.realpath(cur) + parent = os.path.dirname(cur) + if parent == cur: + return None + cur = parent + + +real_hub = find_hub_skill_root(real) +if real_hub is None: + # スキル実体への書き込みではない(PJソース/実体コピースキル/通常ファイル) -> 対象外 + print("allow") + raise SystemExit(0) + +enclosing_hub = find_enclosing_hub(fp) +if enclosing_hub is not None and enclosing_hub == real_hub: + # file_path が物理的に属する hub と実体の hub が同一 -> HUB(worktree含む)内の直接編集 + print("allow") +else: + # file_path は hub の外(PJ)に在り、実体だけ hub 内 -> .claude/skills/ symlink 逆流 + print("deny") +PY +) + +if [ "$verdict" = "deny" ]; then + emit_deny_safe "$DENY_MSG" +fi + +exit 0 diff --git a/.cursor/hooks/scripts/block-skill-reverse-edit.test.sh b/.cursor/hooks/scripts/block-skill-reverse-edit.test.sh new file mode 100755 index 000000000..4cef02e2d --- /dev/null +++ b/.cursor/hooks/scripts/block-skill-reverse-edit.test.sh @@ -0,0 +1,137 @@ +#!/bin/bash + +# block-skill-reverse-edit.sh の回帰テスト。 +# 模擬 HUB(DISTRIBUTION.yaml + skills/ 実体)と模擬 PJ(.claude/skills/ が +# HUB 実体への相対symlink)を作り、逆流 deny / 各種許可ケースを検証する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK_PATH="$SCRIPT_DIR/block-skill-reverse-edit.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# --- 模擬 HUB(AGENT-HUB クローン相当) --- +HUB="$TMP/hub" +mkdir -p "$HUB/skills/demo-skill/references" "$HUB/skills/skills-manager" +: >"$HUB/DISTRIBUTION.yaml" +echo "demo" >"$HUB/skills/demo-skill/SKILL.md" +echo "mgr" >"$HUB/skills/skills-manager/SKILL.md" +# HUB 自身の .claude/skills/(skills 実体への相対symlink。bootstrap-skills.py 相当) +mkdir -p "$HUB/.claude/skills" +ln -s ../../skills/demo-skill "$HUB/.claude/skills/demo-skill" + +# --- 模擬 PJ(別ルートのプロジェクト) --- +PJ="$TMP/pj" +mkdir -p "$PJ/.claude/skills" "$PJ/src" "$PJ/.agents/skills/ext-skill" +# PJ の .claude/skills/ -> HUB の実体への相対symlink(参照一元化) +ln -s ../../../hub/skills/demo-skill "$PJ/.claude/skills/demo-skill" +# スキル名に 'skills' を含むケース(skills-manager) +ln -s ../../../hub/skills/skills-manager "$PJ/.claude/skills/skills-manager" +# PJ_LOCAL_EXCEPTION: 実体コピーのローカルスキル(symlink でない) +mkdir -p "$PJ/.claude/skills/local-skill" +echo "local" >"$PJ/.claude/skills/local-skill/SKILL.md" +# .agents/skills/ 外部管理スキル(DISTRIBUTION.yaml を持たない領域)への symlink +echo "ext" >"$PJ/.agents/skills/ext-skill/SKILL.md" +ln -s ../../.agents/skills/ext-skill "$PJ/.claude/skills/ext-skill" + +# --- PJ 自体が DISTRIBUTION.yaml を持つ(別 hub クローン)。HUB の skill を symlink --- +PJCLONE="$TMP/pj-clone" +mkdir -p "$PJCLONE/.claude/skills" +: >"$PJCLONE/DISTRIBUTION.yaml" +ln -s ../../../hub/skills/demo-skill "$PJCLONE/.claude/skills/demo-skill" + +# --- 別 hub(hub2, DISTRIBUTION.yaml あり)の skill を指す PJ2 --- +HUB2="$TMP/hub2" +mkdir -p "$HUB2/skills/demo-skill" +: >"$HUB2/DISTRIBUTION.yaml" +echo "demo2" >"$HUB2/skills/demo-skill/SKILL.md" +PJ2="$TMP/pj2" +mkdir -p "$PJ2/.claude/skills" +ln -s ../../../hub2/skills/demo-skill "$PJ2/.claude/skills/demo-skill" + +run_hook() { + # $1=file_path / $2=tool_name(既定 Write) / $3=tool_input のキー(既定 file_path) + local file_path="$1" + local tool_name="${2:-Write}" + local key="${3:-file_path}" + printf '{"tool_name":"%s","tool_input":{"%s":"%s"}}' "$tool_name" "$key" "$file_path" \ + | bash "$HOOK_PATH" +} + +run_hook_no_path() { + printf '{"tool_name":"Read","tool_input":{}}' | bash "$HOOK_PATH" +} + +assert_denied() { + local output="$1" label="$2" + # emit_deny_safe は json.dumps(セパレータにスペース)で出力するため空白0/1を許容。 + if ! printf '%s' "$output" | grep -qE '"permissionDecision": ?"deny"'; then + printf '[FAIL] %s : deny を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi + if ! printf '%s' "$output" | grep -qE '"permissionDecisionReason": ?'; then + printf '[FAIL] %s : permissionDecisionReason を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi + if printf '%s' "$output" | grep -qE '"reason": ?'; then + printf '[FAIL] %s : 旧 reason キーが残っている:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +assert_allowed() { + local output="$1" label="$2" + if [ -n "$output" ]; then + printf '[FAIL] %s : allow(無出力) を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +echo "1/15 PJ symlink 経由で SKILL.md 編集 -> deny(逆流)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md")" "pj symlink SKILL.md" + +echo "2/15 PJ symlink 経由で新規ファイル作成(未存在) -> deny(逆流)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/references/new.md")" "pj symlink new file" + +echo "3/15 HUB 内で skills/ を直接編集 -> allow(PR運用の本拠地)" +assert_allowed "$(run_hook "$HUB/skills/demo-skill/SKILL.md")" "hub direct skills edit" + +echo "4/15 HUB 内で .claude/skills/(自身のsymlink)経由 -> allow(worktree/HUB内)" +assert_allowed "$(run_hook "$HUB/.claude/skills/demo-skill/SKILL.md")" "hub .claude/skills symlink" + +echo "5/15 PJ の実体コピースキル(PJ_LOCAL_EXCEPTION) -> allow" +assert_allowed "$(run_hook "$PJ/.claude/skills/local-skill/SKILL.md")" "pj local real skill" + +echo "6/15 PJ のソースコード(.claude/skills 外) -> allow" +assert_allowed "$(run_hook "$PJ/src/foo.ts")" "pj source file" + +echo "7/15 file_path を持たないツール入力 -> allow(対象外)" +assert_allowed "$(run_hook_no_path)" "no file_path" + +echo "8/15 PJ symlink 経由で MultiEdit(file_path キー)の SKILL.md -> deny" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md" MultiEdit)" "pj symlink MultiEdit file_path" + +echo "9/15 PJ symlink 経由で Edit 単体 -> deny(matcher Edit カバレッジ)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md" Edit)" "pj symlink Edit" + +echo "10/15 PJ symlink 経由で MultiEdit(path キー) -> deny(実ペイロード形式)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md" MultiEdit path)" "pj symlink MultiEdit path-key" + +echo "11/15 PJ symlink 経由で多段ネスト(references/api/v2/schema.md) -> deny" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/references/api/v2/schema.md")" "pj symlink deep nest" + +echo "12/15 PJ 自体が DISTRIBUTION.yaml を持つ(別hubクローン)が HUB の skill を symlink -> deny" +assert_denied "$(run_hook "$PJCLONE/.claude/skills/demo-skill/SKILL.md")" "pj-clone with own DISTRIBUTION.yaml" + +echo "13/15 別 hub(hub2)の skill を指す PJ2 symlink -> deny(multi-hub)" +assert_denied "$(run_hook "$PJ2/.claude/skills/demo-skill/SKILL.md")" "pj2 -> hub2 symlink" + +echo "14/15 .agents/skills/ 外部管理スキル(DISTRIBUTION.yaml なし) -> allow" +assert_allowed "$(run_hook "$PJ/.claude/skills/ext-skill/SKILL.md")" "pj .agents external skill" + +echo "15/15 スキル名に 'skills' を含む(skills-manager) symlink 経由 -> deny" +assert_denied "$(run_hook "$PJ/.claude/skills/skills-manager/SKILL.md")" "pj symlink skills-manager" + +echo "block-skill-reverse-edit hook tests passed (15 cases)" diff --git a/.cursor/hooks/scripts/block-unauthorized-docs-file.sh b/.cursor/hooks/scripts/block-unauthorized-docs-file.sh new file mode 100755 index 000000000..7e270a0ea --- /dev/null +++ b/.cursor/hooks/scripts/block-unauthorized-docs-file.sh @@ -0,0 +1,551 @@ +#!/bin/bash +# @description Blocks unauthorized new docs/ SSOT files from file-edit and shell commands. +# @module hook-library/block-unauthorized-docs-file +# @status stable + +# [2026-05-26][feat] +# 背景: +# - ユーザー依頼意図: dev-guardrails 適用 PJ で、AI が docs/prd/ 等の SSOT ディレクトリに +# 推測でファイル名を決めて勝手に新規ファイル(next-action.md 等)を作る事故を止めたい。 +# AI は一度作ったファイルを自分から消さないため、無断生成物が溜まり続ける。ルール文だけでは +# AI が破る(指示の遵守は確率的)ため、機械的にブロックする hook を併設する。 +# - 守るべき業務ルール: docs-structure-rules.md(dev-guardrails)。prd/ は固定3ファイル+archives、 +# その他 SSOT ディレクトリ(architecture/business/api/database/operation/benchmark/testing)と +# docs/ 直下は baseline 許可ファイルのみ。新規 SSOT は伸太郎殿の承認(図解で必要性を説明)後に +# docs/.ssot-allowlist へ登録してから作る。 +# - 他案不採用理由: +# 1) docs/ 配下を全面ブロックする案: design/ release-notes/ 等の作業用ディレクトリへの +# 正当な新規作成(「デザイン案を作って」等)まで止めるため不採用。構造化 SSOT +# ディレクトリと docs/直下に限定する。 +# 2) prompt 型 hook で LLM 判定する案: 非決定的でチャットにプロンプトが漏れる。確定的な +# command hook に統一する(hooks-structure-rule.md)。 +# 3) 既存ファイルもブロックする案: 更新(Edit/上書き)は自由であるべき。ディスク上に存在する +# ファイルは grandfather して素通りさせ、純粋な新規作成のみをブロックする。 +# 対応: PreToolUse(Write|Edit|MultiEdit|Bash) で docs/ 配下の新規ファイルを検査。構造化 SSOT ディレクトリ + +# docs/直下 + 未承認の新規 docs/ サブディレクトリを deny し、図解で承認を取るよう AI に指示する。 +# docs/.ssot-allowlist 自体は AI の抜け道になるため手動更新扱いにし、既存ファイルは素通り。 +# +# [2026-05-27][fix] +# 背景: +# - ユーザー依頼意図: docs/plan/ は「廃止」する。プランの本流は ~/.claude/plans/(Claude)や +# ~/.cursor/plans/ などグローバルへ移っており、各PJの docs/plan/ は古いファイルの堆積(jtt-cms 100件が +# 5/7 から放置等)になっていた。今後 docs/plan/ には新規ファイルを作らせたくない。 +# - 守るべき業務ルール: docs/plan/ は WORK_DIRS(素通り)から外し、完全禁止にする。思考用プランは +# docs/ の外(~/.claude/plans/)に出るため本 hook は発火しない。docs/plan/ への新規作成だけをブロックする。 +# - 他案不採用理由: docs/plan/ を allowlist で個別解禁する案は、廃止方針と矛盾し再堆積を招くため不採用。 +# design/ release-notes/ は現状の用途が明確でないため WORK_DIRS に残し、plan/ のみ完全禁止にする。 +# 対応: WORK_DIRS から plan を除外(design release-notes のみ)。docs/plan/ 新規には「~/.claude/plans/ へ」 +# という専用メッセージで deny する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# 構造化 SSOT ディレクトリ(固定ファイルセットを持つ=新規ファイルを承認制にする) +# design/ release-notes/ archives/ など作業用ディレクトリは含めない(素通りさせる)。 +# plan/ は廃止(プランは ~/.claude/plans/ 等のグローバルへ)。WORK_DIRS から外し完全禁止扱いにする。 +GATED_DIRS="prd architecture business api database operation benchmark testing" +WORK_DIRS="design release-notes" + +# baseline 許可(docs-structure-rules.md と一致。構造定義で既に承認済みの正本ファイル)。 +is_baseline_allowed() { + local rel="$1" # docs/ より後ろの相対パス。例: prd/prd-active.md + case "$rel" in + # docs/ 直下 SSOT + FEATURE_FLAGS.md | PERMISSIONS.md) return 0 ;; + # prd/(固定3ファイル + archives スナップショット) + prd/prd-active.md | prd/prd-upcoming.md | prd/prd-future.md) return 0 ;; + prd/archives/*) return 0 ;; + # architecture/(設計意図 + 条件付き SSOT) + architecture/database-design.md | architecture/api-design.md) return 0 ;; + architecture/infrastructure-design.md) return 0 ;; + architecture/WEBSOCKET_CHANNELS.md) return 0 ;; + # business/ + business/BUSINESS_RULES.md | business/business-design.md | business/ROLE_DEFINITIONS.md) return 0 ;; + # api/ + api/API_SSOT.md) return 0 ;; + # database/ + database/DB_SCHEMA.md | database/DB_SCHEMA_UPDATE_GUIDE.md | database/SCHEMA_RELATIONS.md) return 0 ;; + # operation/ + operation/PROD_OPERATION.md | operation/STAGING_OPERATION.md | operation/LOCAL_OPERATION.md) return 0 ;; + operation/NOTIFICATION.md | operation/DEPLOY_LOG.md) return 0 ;; + operation/DEPLOY_CHECKLIST.md | operation/ENV_VARIABLES.md) return 0 ;; + esac + return 1 +} + +# [2026-06-26][feat] +# 背景: +# - ユーザー依頼意図: dev-guardrails の per-app SSOT 命名統一(-.md・4カテゴリ化) +# に追随し、docs SSOT 承認制 hook の許可パターンを更新する。直前の統一で per-app docs +# は -business-rules.md / -operations.md / --design.md へ +# 命名変更されたが、hook は旧名固定のため新命名ファイルが誤ってブロックされていた。 +# - 守るべき業務ルール: per-app docs は apps//docs/ 配下に限定し、ファイル名プレフィックス +# が app 名と一致することを backreference(\1) で機械的に保証する。旧命名ファイルは移行期中の +# 安全のため grandfather として残す。root の docs/architecture/ は per-app から分離し、 +# root 専用扱いを維持する。 +# - 他案不採用理由: +# 1) root docs 判定ロジックに per-app 判定を混ぜる案: root 専用 architecture/ 等との +# 優先順位・エラーメッセージが複雑化し、root ロジックを変更したくない本件の制約に反するため不採用。 +# 2) 旧命名 BUSINESS_RULES.md / OPERATIONS_SSOT.md を即座に削除する案: 移行期中に旧ファイル +# が存在し得るため、誤って既存ファイルの更新をブロックする恐れがあり不採用。 +# 3) ワイルドカードで apps//docs/* を広く許可する案: app 名不一致の推測ファイルや +# 任意名 SSOT を通してしまい、承認制の意味が薄れるため不採用。 +# 対応: apps//docs/ 配下を新たに検査対象に加え、is_per_app_baseline_allowed() で +# regex backreference 付きの許可パターンを判定する。新命名 + 旧命名 + prd パターンを許可し、 +# それ以外は未承認 SSOT としてブロックする。 +is_per_app_baseline_allowed() { + local rel="$1" + python3 - "$rel" <<'PY' +import re +import sys +rel = sys.argv[1] +patterns = [ + # 新 naming(dev-guardrails per-app SSOT 命名統一: -.md) + r'^apps/([-_a-zA-Z0-9]+)/docs/business/\1-business-rules\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/operation/\1-operations\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/architecture/\1-[-_a-zA-Z0-9]+-design\.md$', + # 既存 prd pattern + r'^apps/([-_a-zA-Z0-9]+)/docs/prd/\1-prd-(active|upcoming|future)\.md$', + # 旧 business/operation(移行期 grandfather) + r'^apps/([-_a-zA-Z0-9]+)/docs/business/BUSINESS_RULES\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/operation/OPERATIONS_SSOT\.md$', +] +for pat in patterns: + if re.match(pat, rel): + sys.exit(0) +sys.exit(1) +PY +} + +# [2026-06-19][fix] +# 背景: +# - jtt-apps レビューで、`auth-design.md` / `PASSWORD_GATES.md` / +# `PERFORMANCE_BASELINE.md` が存在しない PJ でも baseline 扱いとなり、 +# AI が無承認で新規 SSOT を作れる抜け道になると判明した。 +# - 守るべき業務ルール: 既存ファイルの更新は grandfather で許可するが、 +# PJ に存在しない条件付き SSOT の新規作成は docs/.ssot-allowlist 承認後に限る。 +# - 他案不採用理由: 全PJ共通 baseline に残す案は、存在しない SSOT を正本として +# 既成事実化できるため不採用。 + +# docs/.ssot-allowlist の glob パターンに一致するか(伸太郎殿が承認して追記したエントリ)。 +matches_allowlist_file() { + local rel="$1" + local allowlist="$2" + [ -f "$allowlist" ] || return 1 + local line trimmed + while IFS= read -r line || [ -n "$line" ]; do + trimmed="${line%%#*}" # 行コメント除去 + trimmed="$(printf '%s' "$trimmed" | tr -d '[:space:]')" # 空白除去 + [ -z "$trimmed" ] && continue + # case パターンとして glob 展開させるため $trimmed は unquoted + case "$rel" in + $trimmed) return 0 ;; + esac + done <"$allowlist" + return 1 +} + +# 安全な deny 出力(理由に改行・引用符を含められるよう python で JSON エスケープ)。 +emit_deny_safe() { + python3 - "$1" <<'PY' +import json +import sys +reason = sys.argv[1] +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } +})) +PY + exit 0 +} + +read_stdin + +# [2026-07-16][fix] +# 背景: +# - 依頼意図: Codex の apply_patch でも docs SSOT 承認制と docs/.ssot-allowlist 自己承認禁止を効かせる。 +# - 守るべき業務ルール: Codex の公式 hook 契約では Edit|Write matcher が apply_patch にも一致する。 +# matcher だけ配線して script 側で apply_patch を素通りさせてはならない。 +# - 他案不採用理由: changed_files を完了後だけ検査する案は、自己承認済み成果を worker に作らせた後で +# 止めるため不採用。PreToolUse で patch 対象を決定的に検査する。 +# 対応: apply_patch の patch/input から Add/Update/Delete/Move 対象を抽出し、既存の path gate へ渡す。 +# tool_name はトップレベルと bridge 環境変数から取得。matcher 設定がずれても fail-open を避けるため空は通す。 +TOOL_NAME=$(printf '%s' "$HOOK_INPUT" | python3 -c "import json,os,sys; d=json.load(sys.stdin); print(d.get('tool_name') or d.get('toolName') or d.get('name') or os.environ.get('CLAUDE_TOOL_NAME',''))" 2>/dev/null || true) +if [ -n "$TOOL_NAME" ] && [ "$TOOL_NAME" != "apply_patch" ] && [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "MultiEdit" ] && [ "$TOOL_NAME" != "WriteFile" ] && [ "$TOOL_NAME" != "StrReplaceFile" ] && [ "$TOOL_NAME" != "Bash" ] && [ "$TOOL_NAME" != "Shell" ]; then + exit 0 +fi + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" + +# docs/ 配下なら絶対パス + docs/ からの相対パスを返す。配下でなければ空。 +normalize_docs_path() { + FP="$1" ROOT="$PROJECT_DIR" python3 - <<'PY' 2>/dev/null || true +import os +# [2026-06-01][fix] codex PR#65 指摘②: abspath は symlink を解決しないため、 +# docs/ 自体や中間ディレクトリが symlink の場合に承認制を回避できた。realpath で +# symlink と相対(..)を実体パスに正規化してから docs/ 配下判定を行う。比較対象の +# docs も realpath で揃え、新規ファイル(末端未存在)は既存接頭辞だけ解決される。 +fp = os.environ.get("FP", "") +root = os.path.realpath(os.environ.get("ROOT", ".")) +if not fp: + print("") +else: + target = fp if os.path.isabs(fp) else os.path.join(root, fp) + ap = os.path.realpath(target) + docs = os.path.realpath(os.path.join(root, "docs")) + if ap == docs or ap.startswith(docs + os.sep): + print(ap + "\t" + os.path.relpath(ap, docs)) + else: + print("") +PY +} + +is_gated_rel() { + local rel="$1" + local top="$2" + if [ "$rel" = ".ssot-allowlist" ]; then + return 0 + fi + if [ -z "$top" ]; then + return 0 + fi + local d + for d in $GATED_DIRS; do + [ "$top" = "$d" ] && return 0 + done + for d in $WORK_DIRS; do + [ "$top" = "$d" ] && return 1 + done + # 未登録 docs// は docs-structure-rules の「追加ディレクトリ禁止」に合わせて承認制。 + return 0 +} + +check_docs_path() { + local file_path="$1" + local normalized target_path rel top deny_msg + normalized="$(normalize_docs_path "$file_path")" + [ -z "$normalized" ] && return 0 + target_path="${normalized%% *}" + rel="${normalized#* }" + + if [ "$rel" = ".ssot-allowlist" ]; then + deny_msg="[hook:block-unauthorized-docs] docs/.ssot-allowlist の AI 編集をブロックしました。 + +docs/.ssot-allowlist は未承認 SSOT 作成を許可する台帳なので、AI が自分で追記すると承認制の抜け道になります。 +伸太郎殿に図解で必要性を説明し、承認後は伸太郎殿の手動更新として扱ってください。" + emit_deny_safe "$deny_msg" + fi + + # 既存ファイルの更新・上書きは自由(新規作成のみ承認制) + [ -e "$target_path" ] && return 0 + + # 第1階層ディレクトリを判定(docs/直下ファイルは TOP="" 扱い) + case "$rel" in + */*) top="${rel%%/*}" ;; + *) top="" ;; + esac + + is_gated_rel "$rel" "$top" || return 0 + + # docs/plan/ は廃止。プランはグローバル(~/.claude/plans/ 等)へ作る。専用メッセージで deny。 + if [ "$top" = "plan" ]; then + deny_msg="[hook:block-unauthorized-docs] docs/plan/ への新規ファイル作成をブロックしました: docs/${rel} + +docs/plan/ は廃止されました。プランファイルは docs/ ではなくグローバルに作成してください: + - Claude Code のプラン → ~/.claude/plans/(プランモードが自動で書き出す) + - 各PJの docs/plan/ には新規プランを置かない(古いファイルの堆積を防ぐため) + +docs/.ssot-allowlist に plan/... を追加しても docs/plan/ の新規作成は許可されません。" + emit_deny_safe "$deny_msg" + fi + + # baseline / allowlist のいずれかに該当すれば許可 + is_baseline_allowed "$rel" && return 0 + matches_allowlist_file "$rel" "$PROJECT_DIR/docs/.ssot-allowlist" && return 0 + + # 未承認の新規 SSOT → ブロック + deny_msg="[hook:block-unauthorized-docs] docs/ 配下への未承認の新規 SSOT ファイル作成をブロックしました: docs/${rel} + +docs/ 配下の SSOT は承認制です(推測でファイル名を決めて勝手に作らない)。次の手順を踏んでください: + 1. 図解(ASCII)で「なぜこのファイルが必要か」「なぜ既存の構成(prd-active.md 等)では不足か」を伸太郎殿に説明する + 2. 伸太郎殿の承認を得る + 3. 承認後、docs/.ssot-allowlist を伸太郎殿の手動更新として1行追記してから再作成する + +ブロックされないもの: 既存ファイルの更新・編集 / baseline 固定 SSOT(prd-active.md 等)/ design/ release-notes/ 等の作業用ディレクトリ。 +詳細: .claude/skills/dev-guardrails/references/docs-structure-rules.md §7" + + emit_deny_safe "$deny_msg" +} + +# apps//docs/ 配下の正規化。root docs/ とは別の階層なので独立した検査を行う。 +# apps//docs/ 配下でなければ空を返す。 +normalize_per_app_docs_path() { + FP="$1" ROOT="$PROJECT_DIR" python3 - <<'PY' 2>/dev/null || true +import os +fp = os.environ.get("FP", "") +root = os.path.realpath(os.environ.get("ROOT", ".")) +if not fp: + print("") +else: + target = fp if os.path.isabs(fp) else os.path.join(root, fp) + ap = os.path.realpath(target) + apps_dir = os.path.realpath(os.path.join(root, "apps")) + if ap == apps_dir or not ap.startswith(apps_dir + os.sep): + print("") + else: + rel = os.path.relpath(ap, root) + parts = rel.split(os.sep) + # apps//docs/... のみ対象 + if len(parts) >= 4 and parts[2] == "docs": + print(ap + "\t" + rel) + else: + print("") +PY +} + +# apps//docs/ 配下の新規 SSOT 検査。root docs/ ロジックとは独立して動作する。 +check_per_app_docs_path() { + local file_path="$1" + local normalized target_path rel deny_msg + normalized="$(normalize_per_app_docs_path "$file_path")" + [ -z "$normalized" ] && return 0 + target_path="${normalized%% *}" + rel="${normalized#* }" + + # 既存ファイルの更新・上書きは自由(新規作成のみ承認制) + [ -e "$target_path" ] && return 0 + + # baseline のいずれかに該当すれば許可 + is_per_app_baseline_allowed "$rel" && return 0 + + # 未承認の新規 SSOT → ブロック + deny_msg="[hook:block-unauthorized-docs] apps//docs/ 配下への未承認の新規 SSOT ファイル作成をブロックしました: ${rel} + +apps//docs/ 配下の SSOT は承認制です(推測でファイル名を決めて勝手に作らない)。次の手順を踏んでください: + 1. 図解(ASCII)で「なぜこのファイルが必要か」「なぜ既存の構成(-prd-active.md 等)では不足か」を伸太郎殿に説明する + 2. 伸太郎殿の承認を得る + 3. 承認後、docs/.ssot-allowlist を伸太郎殿の手動更新として1行追記してから再作成する + +ブロックされないもの: 既存ファイルの更新・編集 / baseline 固定 SSOT(-business-rules.md 等)。 +詳細: .claude/skills/dev-guardrails/references/docs-structure-rules.md §11" + + emit_deny_safe "$deny_msg" +} + +if [ "$TOOL_NAME" = "apply_patch" ]; then + PATCH_TEXT=$(extract_field patch) + [ -n "$PATCH_TEXT" ] || PATCH_TEXT=$(extract_field input) + [ -n "$PATCH_TEXT" ] || emit_deny_safe "[hook:block-unauthorized-docs] apply_patch の対象パスを検査できないため、安全側でブロックしました。" + PATCH_PATHS=$(printf '%s\n' "$PATCH_TEXT" | python3 -c ' +import re, sys +paths = [] +for line in sys.stdin.read().splitlines(): + match = re.match(r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", line) + if not match: + match = re.match(r"^\*\*\* Move to: (.+)$", line) + if match: + paths.append(match.group(1).strip()) +for path in dict.fromkeys(paths): + print(path) +') + [ -n "$PATCH_PATHS" ] || emit_deny_safe "[hook:block-unauthorized-docs] apply_patch の対象パスを解釈できないため、安全側でブロックしました。" + while IFS= read -r candidate; do + [ -z "$candidate" ] && continue + check_docs_path "$candidate" + check_per_app_docs_path "$candidate" + done <<< "$PATCH_PATHS" + exit 0 +fi + +if [ "$TOOL_NAME" = "Bash" ] || [ "$TOOL_NAME" = "Shell" ]; then + COMMAND=$(extract_field command) + CWD=$(extract_field cwd) + [ -z "$CWD" ] && CWD="$PROJECT_DIR" + [ -z "$COMMAND" ] && exit 0 + printf '%s' "$COMMAND" | grep -Eq '(^|[[:space:];|&])(:>|[0-9]*>{1,2}|&>{1,2}|touch|cat[[:space:]].*([0-9]*>{1,2}|&>{1,2})|cp|mv|install|mkdir|tee|sed[[:space:]].*-i|perl[[:space:]].*-pi)' || exit 0 + CANDIDATES=$( + COMMAND_TEXT="$COMMAND" CWD_TEXT="$CWD" PROJECT_DIR="$PROJECT_DIR" python3 - <<'PY' +import os +import re +import shlex + +cmd = os.environ.get("COMMAND_TEXT", "") +root = os.path.abspath(os.environ.get("PROJECT_DIR", ".")) +current_cwd = os.path.abspath(os.environ.get("CWD_TEXT") or root) +metachars = {";", "|", "&", "<", ">", ">>", "&>", "&>>", "&&", "||"} +paths = [] + + +def resolve_path(token, cwd): + if not token or token in metachars or token.startswith("-") or token.startswith("$"): + return "" + if os.path.isabs(token): + return os.path.normpath(token) + return os.path.normpath(os.path.join(cwd, token)) + + +def add_path(token, cwd=None): + path = resolve_path(token, cwd or current_cwd) + if path: + paths.append(path) + + +def add_copy_like_paths(segment): + if not segment: + return + destination = resolve_path(segment[-1], current_cwd) + if not destination: + return + if os.path.isdir(destination) and len(segment) > 1: + # [2026-06-19][fix] + # 背景: + # - `cp foo.md docs/prd/` のように宛先が既存ディレクトリの場合、 + # `docs/prd/` 自体は既存なので grandfather 判定で許可されていた。 + # - 守るべき業務ルール: 実際に作られる `docs/prd/foo.md` を検査し、 + # 未承認 SSOT の新規作成は同じく止める。 + # - 他案不採用理由: docs ディレクトリ宛てを全面 deny すると、 + # allowlist 済みファイルのコピーまで止まり運用が粗くなるため不採用。 + for source in segment[:-1]: + name = os.path.basename(source.rstrip("/")) + if name and name not in {".", ".."}: + paths.append(os.path.join(destination, name)) + return + paths.append(destination) + + +def copy_like_operands(command, raw_tokens): + option_args = { + "cp": {"-S", "-t", "--suffix", "--target-directory"}, + "mv": {"-S", "-t", "--suffix", "--target-directory"}, + "install": {"-g", "-m", "-o", "-S", "-t", "--group", "--mode", "--owner", "--suffix", "--target-directory"}, + } + target_directory = None + operands = [] + index = 0 + while index < len(raw_tokens): + token = raw_tokens[index] + if token == "--": + operands.extend(raw_tokens[index + 1 :]) + break + if token.startswith("--target-directory="): + target_directory = token.split("=", 1)[1] + index += 1 + continue + if token.startswith("--") and token != "--": + option = token.split("=", 1)[0] + if "=" not in token and option in option_args.get(command, set()): + if option == "--target-directory" and index + 1 < len(raw_tokens): + target_directory = raw_tokens[index + 1] + index += 2 + continue + index += 1 + continue + if token.startswith("-") and token != "-": + short = token[:2] + if token == short and short in option_args.get(command, set()): + if short == "-t" and index + 1 < len(raw_tokens): + target_directory = raw_tokens[index + 1] + index += 2 + continue + if token.startswith("-t") and len(token) > 2: + target_directory = token[2:] + index += 1 + continue + index += 1 + continue + operands.append(token) + index += 1 + if target_directory: + operands.append(target_directory) + return operands + + +try: + lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + tokens = list(lexer) +except Exception: + tokens = [] + +i = 0 +while i < len(tokens): + tok = tokens[i] + if tok == "cd" and i + 1 < len(tokens): + target = tokens[i + 1] + if target not in metachars and not target.startswith("$"): + next_cwd = resolve_path(target, current_cwd) + if next_cwd: + current_cwd = next_cwd + i += 2 + continue + if (tok in {">", ">>", "&>", "&>>"} or re.match(r"^(?:(?:\d*)>{1,2}|&>{1,2})$", tok)) and i + 1 < len(tokens): + add_path(tokens[i + 1]) + i += 2 + continue + if tok in {"touch", "tee", "mkdir"}: + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + add_path(candidate) + if tok in {"cp", "mv", "install"}: + raw_segment = [] + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + raw_segment.append(candidate) + segment = copy_like_operands(tok, raw_segment) + if segment: + add_copy_like_paths(segment) + # [2026-05-27][fix] R2 follow-up: sed/perl の in-place 編集ターゲットも検査対象にする。 + # 背景: 前段 grep は sed -i / perl -pi を作成・編集系として検知するが、ここで対象ファイルを + # paths に追加していなかったため docs/.ssot-allowlist の AI 編集が素通りしていた。 + # 守るべき業務ルール: docs/.ssot-allowlist は既存ファイルでも AI 編集を必ず deny する。 + # 他案不採用理由: fallback を常時 docs/ パス抽出に戻す案は、PR本文や commit message の + # docs/ 言及を再び作成ターゲットと誤認するため不採用。 + if tok in {"sed", "perl"}: + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + if candidate.startswith("-"): + continue + add_path(candidate) + i += 1 + +# [2026-05-27][fix] R2 誤検知: punctuation_chars lexer が 1 トークンも取れなかった +# (引用が壊れた・極端な複合コマンド) 場合に限り、最終手段として docs/ 明示パスを拾う。 +# 正常にトークン化できたコマンド (gh pr create --body "...docs/plan/..." / echo / git commit -m +# 等、説明テキストに docs/ を含むだけ) では作動させない。常時 fallback すると、PR 本文や +# コミットメッセージ中の docs/ 言及を作成ターゲットと誤認して deny してしまう (R2)。 +# 作成系のターゲットは上の operation-aware パス (リダイレクト/touch/tee/mkdir/cp/mv/install) が +# 既に網羅しており、トークン化が成功している限り fallback の追加カバレッジはノイズのみ。 +if not tokens: + try: + fallback_tokens = shlex.split(cmd, posix=True) + except Exception: + fallback_tokens = [] + for token in fallback_tokens: + if token.startswith("./docs/") or token.startswith("docs/"): + paths.append(token[2:] if token.startswith("./") else token) + for match in re.findall(r"(?:^|[\s\"'=<>])(\./docs/[^\s\"'`$;|&<>]+|docs/[^\s\"'`$;|&<>]+)", cmd): + paths.append(match[2:] if match.startswith("./") else match) +for path in dict.fromkeys(paths): + print(path) +PY + ) + while IFS= read -r candidate; do + [ -z "$candidate" ] && continue + check_docs_path "$candidate" + check_per_app_docs_path "$candidate" + done <<< "$CANDIDATES" + exit 0 +fi + +FILE_PATH=$(extract_file_path) +[ -z "$FILE_PATH" ] && exit 0 +check_docs_path "$FILE_PATH" +check_per_app_docs_path "$FILE_PATH" diff --git a/.cursor/hooks/scripts/block-unauthorized-docs-file.test.sh b/.cursor/hooks/scripts/block-unauthorized-docs-file.test.sh new file mode 100755 index 000000000..4edd38787 --- /dev/null +++ b/.cursor/hooks/scripts/block-unauthorized-docs-file.test.sh @@ -0,0 +1,329 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK_PATH="$SCRIPT_DIR/block-unauthorized-docs-file.sh" + +# 一時プロジェクトを作成(docs/ 構造・既存ファイル・allowlist を用意) +TMP_PROJECT="$(mktemp -d)" +trap 'rm -rf "$TMP_PROJECT"' EXIT +mkdir -p "$TMP_PROJECT/docs/prd/archives" \ + "$TMP_PROJECT/docs/architecture" \ + "$TMP_PROJECT/docs/operation" \ + "$TMP_PROJECT/docs/benchmark" \ + "$TMP_PROJECT/docs/plan" \ + "$TMP_PROJECT/docs/database" \ + "$TMP_PROJECT/src" \ + "$TMP_PROJECT/apps/koban-neko/docs/business" \ + "$TMP_PROJECT/apps/hyoka-wanko/docs/operation" \ + "$TMP_PROJECT/apps/chie-fukuro/docs/architecture" \ + "$TMP_PROJECT/apps/foo/docs/business" +# 既存ファイル(grandfather 対象) +: >"$TMP_PROJECT/docs/prd/prd-active.md" +: >"$TMP_PROJECT/docs/database/LEGACY_NOTES.md" # baseline 外だが既存 → 更新は許可される想定 +# allowlist 台帳(承認済みエントリ) +cat >"$TMP_PROJECT/docs/.ssot-allowlist" <<'EOF' +# 伸太郎殿承認済みの追加 SSOT +operation/INCIDENT_LOG.md +architecture/realtime-*.md +EOF + +run_hook() { + local tool_name="$1" + local file_path="$2" # 絶対パス推奨 + local payload + payload=$(python3 - "$tool_name" "$file_path" "$TMP_PROJECT" <<'PY' +import json +import sys +tool_name = sys.argv[1] +file_path = sys.argv[2] +tool_input = {} +if tool_name in {"Bash", "Shell"}: + tool_input["command"] = file_path + tool_input["cwd"] = sys.argv[3] if len(sys.argv) > 3 else "" +elif file_path: + tool_input["file_path"] = file_path +print(json.dumps({"tool_name": tool_name, "tool_input": tool_input}), end="") +PY +) + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +run_hook_raw() { + local payload="$1" + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +run_apply_patch_hook() { + local patch_text="$1" + local payload + payload=$(python3 - "$patch_text" <<'PY' +import json +import sys +print(json.dumps({"tool_name": "apply_patch", "tool_input": {"patch": sys.argv[1]}}), end="") +PY +) + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +assert_denied() { + local output="$1" + local label="$2" + if ! OUT="$output" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if not payload.get("permissionDecisionReason"): + sys.exit(1) +if "reason" in payload: + sys.exit(1) +PY + then + printf '[FAIL] %s : deny を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +assert_allowed() { + local output="$1" + local label="$2" + if [ -n "$output" ]; then + printf '[FAIL] %s : allow(無出力) を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +D="$TMP_PROJECT/docs" + +echo "1/21 prd/ への推測ファイル(next-action.md 新規)-> deny" +assert_denied "$(run_hook Write "$D/prd/next-action.md")" "prd/next-action.md" + +echo "2/21 prd/ baseline 固定ファイル(prd-future.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/prd/prd-future.md")" "prd/prd-future.md" + +echo "3/21 既存ファイル(prd-active.md 上書き)-> allow" +assert_allowed "$(run_hook Write "$D/prd/prd-active.md")" "prd/prd-active.md(existing)" + +echo "4/21 廃止された plan/ への新規 -> deny(docs/plan/ は廃止・~/.claude/plans へ)" +assert_denied "$(run_hook Write "$D/plan/2026-05-26-next-plan.md")" "plan/next-plan.md" + +echo "5/21 prd/archives/ スナップショット新規 -> allow" +assert_allowed "$(run_hook Write "$D/prd/archives/prd-active-2026-05.md")" "prd/archives/snapshot" + +echo "6/21 architecture/ baseline 外の新規(new-thing.md)-> deny" +assert_denied "$(run_hook Write "$D/architecture/new-thing.md")" "architecture/new-thing.md" + +echo "6a/21 database/ 未承認 root SSOT(NEW_RANDOM.md)-> deny" +assert_denied "$(run_hook Write "$D/database/NEW_RANDOM.md")" "database/NEW_RANDOM.md" + +echo "7/21 architecture/ baseline(database-design.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/database-design.md")" "architecture/database-design.md" + +echo "B1-1 architecture/auth-design.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/architecture/auth-design.md")" "architecture/auth-design.md" + +echo "B1-2 operation/PASSWORD_GATES.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/operation/PASSWORD_GATES.md")" "operation/PASSWORD_GATES.md" + +echo "B1-3 benchmark/PERFORMANCE_BASELINE.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/benchmark/PERFORMANCE_BASELINE.md")" "benchmark/PERFORMANCE_BASELINE.md" + +mkdir -p "$D/benchmark" +: >"$D/architecture/auth-design.md" +: >"$D/operation/PASSWORD_GATES.md" +: >"$D/benchmark/PERFORMANCE_BASELINE.md" + +echo "B1-4 architecture/auth-design.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/auth-design.md")" "architecture/auth-design.md(existing)" + +echo "B1-5 operation/PASSWORD_GATES.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Edit "$D/operation/PASSWORD_GATES.md")" "operation/PASSWORD_GATES.md(existing)" + +echo "B1-6 benchmark/PERFORMANCE_BASELINE.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Write "$D/benchmark/PERFORMANCE_BASELINE.md")" "benchmark/PERFORMANCE_BASELINE.md(existing)" + +echo "8/21 docs/ 直下の新規 SSOT(ROADMAP.md)-> deny" +assert_denied "$(run_hook Write "$D/ROADMAP.md")" "docs/ROADMAP.md" + +echo "9/21 docs/ 直下 baseline(FEATURE_FLAGS.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/FEATURE_FLAGS.md")" "docs/FEATURE_FLAGS.md" + +echo "9a/21 operation/PERMISSIONS.md(条件付き権限SSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/operation/PERMISSIONS.md")" "operation/PERMISSIONS.md" + +: >"$D/operation/PERMISSIONS.md" +echo "9b/21 operation/PERMISSIONS.md(条件付き権限SSOT・既存)-> allow" +assert_allowed "$(run_hook Edit "$D/operation/PERMISSIONS.md")" "operation/PERMISSIONS.md(existing)" + +echo "10/21 docs/ 外(src/foo.ts 新規)-> allow" +assert_allowed "$(run_hook Write "$D/../src/foo.ts")" "src/foo.ts" + +echo "11/21 allowlist 完全一致(operation/INCIDENT_LOG.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/operation/INCIDENT_LOG.md")" "operation/INCIDENT_LOG.md" + +echo "12/21 allowlist glob 一致(architecture/realtime-channels.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/realtime-channels.md")" "architecture/realtime-channels.md" + +echo "13/21 allowlist 台帳の新規/更新 -> deny" +assert_denied "$(run_hook Write "$D/.ssot-allowlist")" "docs/.ssot-allowlist" + +echo "13a/21 Kimi MultiEdit で allowlist 編集 -> deny" +assert_denied "$(run_hook MultiEdit "$D/.ssot-allowlist")" "MultiEdit docs/.ssot-allowlist" + +echo "13b/21 Kimi 旧 WriteFile で allowlist 編集 -> deny" +assert_denied "$(run_hook WriteFile "$D/.ssot-allowlist")" "WriteFile docs/.ssot-allowlist" + +echo "13c/21 Kimi 旧 StrReplaceFile で allowlist 編集 -> deny" +assert_denied "$(run_hook StrReplaceFile "$D/.ssot-allowlist")" "StrReplaceFile docs/.ssot-allowlist" + +echo "14/21 未登録 docs/ サブディレクトリへの新規 -> deny" +assert_denied "$(run_hook Write "$D/random/ROADMAP.md")" "docs/random/ROADMAP.md" + +echo "15/21 相対パスの既存ファイル更新(hook cwd がPJ外)-> allow" +(cd /tmp && assert_allowed "$(run_hook Write "docs/prd/prd-active.md")" "relative existing path") + +echo "16/21 Bash touch で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Bash "touch docs/prd/bash-next.md")" "bash touch docs/prd/bash-next.md" + +echo "17/21 Bash echo で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "echo architecture/foo.md >> docs/.ssot-allowlist")" "bash allowlist edit" + +echo "17a/21 Bash sed -i で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "sed -i.bak 's/foo/bar/' docs/.ssot-allowlist")" "bash sed allowlist edit" + +echo "17b/21 Bash perl -pi で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "perl -pi -e 's/foo/bar/' docs/.ssot-allowlist")" "bash perl allowlist edit" + +echo "17c/21 Bash cp で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp tmp-note.md docs/prd/")" "bash cp to docs/prd directory" + +echo "17d/21 Bash mv で既存 docs/architecture/ ディレクトリへ未承認SSOT移動 -> deny" +assert_denied "$(run_hook Bash "mv tmp-note.md docs/architecture/")" "bash mv to docs/architecture directory" + +echo "17e/21 Bash install で既存 docs/operation/ ディレクトリへ未承認SSOT配置 -> deny" +assert_denied "$(run_hook Bash "install tmp-note.md docs/operation/")" "bash install to docs/operation directory" + +mkdir -p "$TMP_PROJECT/tmp" +: >"$TMP_PROJECT/tmp/INCIDENT_LOG.md" +echo "17f/21 Bash install -m 644 で allowlist 済みSSOT配置 -> allow" +assert_allowed "$(run_hook Bash "install -m 644 tmp/INCIDENT_LOG.md docs/operation/")" "bash install mode allowlisted file" + +echo "17g/21 Bash install -m 644 で未承認SSOT配置 -> deny" +install_mode_output="$(run_hook Bash "install -m 644 tmp-note.md docs/operation/")" +assert_denied "$install_mode_output" "bash install mode to docs/operation directory" +if printf '%s' "$install_mode_output" | grep -q 'docs/operation/644'; then + printf '[FAIL] bash install mode option was treated as filename:\n%s\n' "$install_mode_output" >&2 + exit 1 +fi + +echo "17h/21 Bash cp -t で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp -t docs/prd tmp-note.md")" "bash cp -t docs/prd" + +echo "17i/21 Bash cp --target-directory= で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp --target-directory=docs/prd tmp-note.md")" "bash cp --target-directory docs/prd" + +echo "18/21 tool_name=Read(対象外)-> allow" +assert_allowed "$(run_hook Read "$D/prd/next-action.md")" "Read tool" + +echo "19/21 作業用ディレクトリ design/ への新規 -> allow(WORK_DIRS は維持)" +assert_allowed "$(run_hook Write "$D/design/new-mockup.md")" "design/new-mockup.md" + +echo "20/21 Bash cd 後の prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Bash "cd docs/prd && touch cd-next.md")" "bash cd docs/prd touch" + +echo "21/21 Kimi Shell で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Shell "touch docs/prd/shell-next.md")" "shell touch docs/prd/shell-next.md" + +echo "21a/21 Kimi toolInput camelCase で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook_raw '{"toolName":"Shell","toolInput":{"command":"touch docs/prd/kimi-toolinput-next.md","cwd":"'"$TMP_PROJECT"'"}}')" "kimi toolInput shell docs/prd" + +# --- R2 誤検知回帰テスト(2026-05-27): 説明テキスト中の docs/ 言及を作成ターゲットと誤認しない --- +echo "R2-1 gh pr create の --body に docs/plan/ 言及(touch 含む)-> allow" +assert_allowed "$(run_hook Bash 'gh pr create --title x --body "removes docs/plan/ legacy; touch up wording"')" "R2 gh pr create body docs mention" + +echo "R2-2 git commit -m に docs/prd/ 言及(> 含む)-> allow" +assert_allowed "$(run_hook Bash 'git commit -m "drop docs/prd/cleanup-notes.md > archive"')" "R2 git commit msg docs mention" + +echo "R2-3 実リダイレクトでの docs/ 新規作成は引き続き deny(保護が残っていること)" +assert_denied "$(run_hook Bash "printf hi > docs/architecture/brand-new.md")" "R2 real redirect still denied" + +echo "R2-4 数値付きリダイレクトでの docs/ 新規作成 -> deny" +assert_denied "$(run_hook Bash "printf hi 2> docs/architecture/fd-new.md")" "R2 numeric redirect denied" + +echo "R2-5 stdout/stderr リダイレクトでの docs/ 新規作成 -> deny" +assert_denied "$(run_hook Bash "printf hi &> docs/architecture/amp-new.md")" "R2 amp redirect denied" + +# --- per-app baseline 新命名テスト(2026-06-26) --- +echo "PA-1/7 per-app 新命名 business 許可: apps/koban-neko/docs/business/koban-neko-business-rules.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/koban-neko/docs/business/koban-neko-business-rules.md")" "per-app business new naming" + +echo "PA-2/7 per-app 新命名 operation 許可: apps/hyoka-wanko/docs/operation/hyoka-wanko-operations.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/hyoka-wanko/docs/operation/hyoka-wanko-operations.md")" "per-app operation new naming" + +echo "PA-3/7 per-app 新命名 architecture 許可: apps/chie-fukuro/docs/architecture/chie-fukuro-rag-design.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/chie-fukuro/docs/architecture/chie-fukuro-rag-design.md")" "per-app architecture new naming" + +echo "PA-4/7 per-app prd 既存パターン許可: apps/foo/docs/prd/foo-prd-active.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/prd/foo-prd-active.md")" "per-app prd pattern" + +echo "PA-5/7 per-app 旧 business 命名 grandfather 許可: apps/foo/docs/business/BUSINESS_RULES.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/business/BUSINESS_RULES.md")" "per-app old business naming grandfather" + +echo "PA-6/7 per-app 任意名 docs ファイルはブロック維持: apps/foo/docs/business/random-notes.md" +assert_denied "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/business/random-notes.md")" "per-app arbitrary name blocked" + +echo "PA-7/7 per-app app 名不一致はブロック: apps/koban-neko/docs/business/hyoka-wanko-business-rules.md" +assert_denied "$(run_hook Write "$TMP_PROJECT/apps/koban-neko/docs/business/hyoka-wanko-business-rules.md")" "per-app app name mismatch blocked" + +# --- Codex apply_patch hook 配線(2026-07-16) --- +echo "CX-1/5 Codex apply_patch で未承認 docs/prd 新規 -> deny" +assert_denied "$(run_apply_patch_hook $'*** Begin Patch\n*** Add File: docs/prd/codex-next.md\n+new\n*** End Patch')" "Codex apply_patch unauthorized docs" + +echo "CX-2/5 Codex apply_patch で docs/.ssot-allowlist 更新 -> deny" +assert_denied "$(run_apply_patch_hook $'*** Begin Patch\n*** Update File: docs/.ssot-allowlist\n@@\n+prd/codex-next.md\n*** End Patch')" "Codex apply_patch allowlist self-approval" + +echo "CX-3/5 Codex apply_patch で既存 docs/prd 更新 -> allow" +assert_allowed "$(run_apply_patch_hook $'*** Begin Patch\n*** Update File: docs/prd/prd-active.md\n@@\n+updated\n*** End Patch')" "Codex apply_patch existing docs" + +echo "CX-4/5 Codex apply_patch で src 新規 -> allow" +assert_allowed "$(run_apply_patch_hook $'*** Begin Patch\n*** Add File: src/codex.ts\n+export {};\n*** End Patch')" "Codex apply_patch non-docs" + +echo "CX-5/5 Codex apply_patch の対象欠損 -> deny" +assert_denied "$(run_hook_raw '{"tool_name":"apply_patch","tool_input":{}}')" "Codex apply_patch missing target" + +CODEX_HOOKS_JSON="$(cd "$SCRIPT_DIR/../.." && pwd)/hooks.json" +if [ -f "$CODEX_HOOKS_JSON" ]; then + echo "CX-REG Codex hooks.json で cross-runtime matcher 配線済み -> pass" + python3 - "$CODEX_HOOKS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + hooks = json.load(handle).get("hooks", {}).get("PreToolUse", []) +expected_tools = {"Bash", "Edit", "MultiEdit", "Shell", "StrReplaceFile", "Write", "WriteFile"} +registered = any( + expected_tools.issubset(set(entry.get("matcher", "").split("|"))) + and any( + "block-unauthorized-docs-file.sh" in hook.get("command", "") + for hook in entry.get("hooks", []) + ) + for entry in hooks +) +if not registered: + raise SystemExit("Codex hooks.json lacks the cross-runtime docs guard matcher set") +PY +fi + +echo "block-unauthorized-docs-file hook tests passed" diff --git a/.cursor/hooks/scripts/cursor-command-bridge.sh b/.cursor/hooks/scripts/cursor-command-bridge.sh new file mode 100755 index 000000000..fd0f75dea --- /dev/null +++ b/.cursor/hooks/scripts/cursor-command-bridge.sh @@ -0,0 +1,198 @@ +#!/bin/bash +# [2026-07-24][docs] +# 背景: +# - ユーザー依頼意図: 生成されたCursor command bridgeだけを見ても責務と正本を判断できるようにしたい。 +# - 守るべき業務ルール: bridgeはClaude hookとのスキーマ差だけを吸収し、この生成テンプレートを正本にする。 +# - 他案不採用理由: 生成先へ説明を手書きすると次回syncで消え、正本との二重管理になるため不採用。 +# 対応: 判断理由を生成テンプレートからbridgeへ埋め込む。 +set -euo pipefail + +RAW_INPUT="$(cat || true)" +COMMAND_STRING="${1:-}" + +if [ -z "$COMMAND_STRING" ]; then + echo "[cursor-command-bridge] command string is required" >&2 + exit 1 +fi + +extract_field() { + local field="$1" + if [ -z "$RAW_INPUT" ]; then + return 0 + fi + + HOOK_JSON="$RAW_INPUT" PY_FIELD="$field" command python3 - <<'PY' 2>/dev/null || true +import json +import os + +field = os.environ.get("PY_FIELD", "") +raw = os.environ.get("HOOK_JSON", "") + +aliases = { + "project_dir": ["project_dir", "projectDir", "projectRoot", "workspace_root", "workspaceRoot", "cwd"], + "cwd": ["cwd", "workingDirectory", "project_dir", "projectDir", "projectRoot"], + "tool_command": ["command", "shell_command", "shellCommand"], + "file_path": ["file_path", "filePath", "path", "target_path"], +} + +keys = aliases.get(field, [field]) +value = "" + +try: + payload = json.loads(raw) +except Exception: + payload = {} + +def lookup(obj): + if not isinstance(obj, dict): + return "" + for key in keys: + candidate = obj.get(key, "") + if isinstance(candidate, str) and candidate: + return candidate + tool_input = obj.get("tool_input") + if isinstance(tool_input, dict): + for key in keys: + candidate = tool_input.get(key, "") + if isinstance(candidate, str) and candidate: + return candidate + return "" + +value = lookup(payload) +print(value, end="") +PY +} + +# --- 入力スキーマ変換: Cursor → Claude Code 互換 --- +# Cursor の hook_event_name を読み取り、stop/subagentStop の場合は +# loop_count > 0 → stop_hook_active: true を注入する +transform_input() { + local input="$1" + if [ -z "$input" ]; then + return 0 + fi + + HOOK_JSON="$input" command python3 - <<'PY' 2>/dev/null || printf '%s' "$input" +import json +import os + +raw = os.environ.get("HOOK_JSON", "") + +try: + data = json.loads(raw) +except Exception: + print(raw, end="") + raise SystemExit(0) + +event = data.get("hook_event_name", "") + +if event in ("stop", "subagentStop"): + loop_count = data.get("loop_count", 0) + if isinstance(loop_count, int) and loop_count > 0: + data["stop_hook_active"] = True + else: + data["stop_hook_active"] = False + +print(json.dumps(data, ensure_ascii=False), end="") +PY +} + +# --- 出力スキーマ変換: Claude Code → Cursor 形式 --- +# フックスクリプトの出力を hook_event_name に応じて Cursor 形式に変換する +transform_output() { + local output="$1" + local event="$2" + if [ -z "$output" ]; then + return 0 + fi + + HOOK_OUTPUT="$output" HOOK_EVENT="$event" command python3 - <<'PY' 2>/dev/null || printf '%s' "$output" +import json +import os + +raw = os.environ.get("HOOK_OUTPUT", "") +event = os.environ.get("HOOK_EVENT", "") + +try: + data = json.loads(raw) +except Exception: + print(raw, end="") + raise SystemExit(0) + +decision = data.get("decision", "") +reason = data.get("reason", "") +hook_specific = data.get("hookSpecificOutput") +if not isinstance(hook_specific, dict): + hook_specific = {} +permission_decision = hook_specific.get("permissionDecision", "") +permission_reason = hook_specific.get("permissionDecisionReason", "") or hook_specific.get("reason", "") +if permission_reason and not reason: + reason = permission_reason + +if event in ("stop", "subagentStop"): + if (decision == "block" or permission_decision == "deny") and reason: + print(json.dumps({"followup_message": reason}, ensure_ascii=False), end="") + else: + print("{}", end="") +elif event in ("preToolUse",): + if permission_decision == "deny" or decision == "block": + perm = "deny" + elif permission_decision == "allow" or decision == "approve" or data.get("continue") is True: + perm = "allow" + else: + perm = "allow" + result = {"permission": perm} + if perm == "deny" and reason: + result["agent_message"] = reason + result["user_message"] = reason + print(json.dumps(result, ensure_ascii=False), end="") +else: + print(raw, end="") +PY +} + +PROJECT_DIR="${CURSOR_PROJECT_DIR:-}" +if [ -z "$PROJECT_DIR" ]; then + PROJECT_DIR="$(extract_field project_dir)" +fi +if [ -z "$PROJECT_DIR" ]; then + PROJECT_DIR="$(pwd)" +fi + +TOOL_COMMAND="$(extract_field tool_command)" +FILE_PATH="$(extract_field file_path)" +HOOK_CWD="$(extract_field cwd)" +HOOK_EVENT="$(extract_field hook_event_name)" + +export CURSOR_PROJECT_DIR="$PROJECT_DIR" +export CLAUDE_PROJECT_DIR="$PROJECT_DIR" +export CURSOR_HOOK_INPUT="$RAW_INPUT" + +if [ -n "$TOOL_COMMAND" ]; then + export CLAUDE_TOOL_INPUT="$TOOL_COMMAND" +fi + +if [ -n "$FILE_PATH" ]; then + export CLAUDE_FILE_PATH="$FILE_PATH" +fi + +if [ -n "$HOOK_CWD" ]; then + cd "$HOOK_CWD" 2>/dev/null || cd "$PROJECT_DIR" +else + cd "$PROJECT_DIR" +fi + +# 入力変換を適用してからフックスクリプトに渡し、出力変換を適用 +TRANSFORMED_INPUT="$(transform_input "$RAW_INPUT")" + +if [ -n "$TRANSFORMED_INPUT" ]; then + HOOK_OUTPUT="$(printf '%s' "$TRANSFORMED_INPUT" | bash -lc "$COMMAND_STRING")" +else + HOOK_OUTPUT="$(bash -lc "$COMMAND_STRING")" +fi + +if [ -n "$HOOK_EVENT" ] && [ -n "$HOOK_OUTPUT" ]; then + transform_output "$HOOK_OUTPUT" "$HOOK_EVENT" +else + printf '%s' "$HOOK_OUTPUT" +fi diff --git a/.cursor/hooks/scripts/freshness-gate.sh b/.cursor/hooks/scripts/freshness-gate.sh new file mode 100755 index 000000000..7359fb42a --- /dev/null +++ b/.cursor/hooks/scripts/freshness-gate.sh @@ -0,0 +1,266 @@ +#!/bin/bash + +# [2026-03-03][feat] +# 背景: 77スキル中2つだけ日付マーカーあり。サブエージェントはコピー時スナップショット。 +# 手動チェックは現実的に不可能なため、SessionStart hookで毎セッション自動検出が必要。 +# staleness_check.sh(skill-organizer)は手動実行のみだった。skill-audit は 2026-07-13 に +# 正式スキル化(skills/skill-audit/)し、単一スキルの契約遵守を三値判定する。 +# 対応: SessionStart hookで軽量鮮度チェックを実行。 +# (1) hookバージョン差分 (2) スキル鮮度 (3) 依存バージョン乖離を検出。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「鮮度チェックが安全に動作し、監査時に迂回経路を残さないこと」。 +# 業務ルールとして、フック内で外部入力(ファイルパス)をコード文字列に直埋めしてはならない。 +# 代替案としてPythonワンライナーへパスを直接埋め込む実装を維持すると、 +# 特殊文字を含むパスで任意コード実行に繋がるため不採用。 +# 対応: Python呼び出しを引数渡しへ変更し、文字列埋め込みを廃止。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOKS_DIR="$SCRIPT_DIR/.." + +extract_last_verified() { + local skill_md="$1" + python3 - "$skill_md" <<'PY' 2>/dev/null || true +import re +import sys +from pathlib import Path + +skill_path = Path(sys.argv[1]) +try: + text = skill_path.read_text(encoding='utf-8') +except Exception: + print('') + raise SystemExit(0) + +m = re.search(r'last_verified:\s*(\d{4}-\d{2}-\d{2})', text) +print(m.group(1) if m else '') +PY +} + +extract_interval_days() { + local skill_md="$1" + python3 - "$skill_md" <<'PY' 2>/dev/null || echo "60" +import re +import sys +from pathlib import Path + +skill_path = Path(sys.argv[1]) +try: + text = skill_path.read_text(encoding='utf-8') +except Exception: + print('60') + raise SystemExit(0) + +m = re.search(r'interval_days:\s*(\d+)', text) +print(m.group(1) if m else '60') +PY +} + +# --- hookバージョンチェック --- +check_hook_version() { + local version_file="$HOOKS_DIR/.hook-library-version" + local agent_hub_version_file + + # AGENT-HUBのパスを環境変数またはデフォルトから取得 + local agent_hub_path="${AGENT_HUB_PATH:-$HOME/business/AGENT-HUB}" + agent_hub_version_file="$agent_hub_path/hook-library/VERSION" + + if [ ! -f "$version_file" ]; then + echo " - hook-library: バージョン情報なし(未デプロイ or 旧形式)" >&2 + return + fi + + local deployed_version + deployed_version="$(head -1 "$version_file" | sed 's/^v//' | cut -d' ' -f1)" + + if [ -f "$agent_hub_version_file" ]; then + local latest_version + latest_version="$(cat "$agent_hub_version_file" | tr -d '[:space:]')" + + if [ "$deployed_version" != "$latest_version" ]; then + echo " - hook-library: v${latest_version} が利用可能です(現在 v${deployed_version})" >&2 + fi + fi +} + +# --- スキル鮮度チェック --- +check_skill_freshness() { + local skills_dir + + # CLAUDE_PROJECT_DIR が設定されていればそのプロジェクトのスキルをチェック + if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then + skills_dir="$CLAUDE_PROJECT_DIR/.claude/skills" + else + skills_dir="$(pwd)/.claude/skills" + fi + + if [ ! -d "$skills_dir" ]; then + return + fi + + local today_epoch + today_epoch=$(date +%s) + local stale_skills="" + + # 各スキルのSKILL.mdからlast_verifiedを抽出 + for skill_dir in "$skills_dir"/*/; do + [ -d "$skill_dir" ] || continue + local skill_md="$skill_dir/SKILL.md" + [ -f "$skill_md" ] || continue + + local skill_name + skill_name="$(basename "$skill_dir")" + + local last_verified + last_verified="$(extract_last_verified "$skill_md")" + + if [ -z "$last_verified" ]; then + continue # last_verified未設定のスキルはスキップ(Phase 2で順次追加) + fi + + # 経過日数を計算 + local verified_epoch + verified_epoch=$(date -j -f "%Y-%m-%d" "$last_verified" +%s 2>/dev/null || date -d "$last_verified" +%s 2>/dev/null || echo "0") + + if [ "$verified_epoch" = "0" ]; then + continue + fi + + local days_ago=$(( (today_epoch - verified_epoch) / 86400 )) + + # freshness_check.interval_days を取得(デフォルト60日) + local interval + interval="$(extract_interval_days "$skill_md")" + + if [ "$days_ago" -gt "$interval" ]; then + stale_skills="$stale_skills\n - ${skill_name}: ${days_ago}日前(閾値: ${interval}日)" + fi + done + + if [ -n "$stale_skills" ]; then + echo -e " スキル鮮度:$stale_skills" >&2 + fi +} + +# [2026-05-21][feat] / [2026-05-25][refactor] +# 背景: +# - ユーザー依頼意図: 大原則 A「PWAを消して再登録は絶対にしない」と大原則 B「ネイティブアプリ模倣」の +# SSOT 文書が欠落している場合に、SessionStart 時に警告して AI セッションへ必読を促す。 +# 2026-05-25: jtt-apps ローカル限定だった本チェックを hook-library 正本へ upstream +# (/insights deep-check の --diff で「full deploy 時に jtt-apps から消える」ローカル限定実装と判明したため)。 +# - 守るべき業務ルール: SessionStart hook は常に exit 0(ブロックしない、情報提供のみ)。 +# hook-library は複数 PJ で共有されるため、PWA プロジェクト(public/sw.js または public/manifest.json を持つ) +# でのみ発火し、非 PWA PJ(jtt-cms 等)では誤警告させない。 +# - 他案不採用理由: +# 1) Stop hook で AI 最終出力を grep する案は false positive リスクが高すぎる +# (正当な「キャッシュクリア」言及まで誤ブロック)ため不採用。 +# 2) jtt-apps 限定の無条件チェックのまま据え置く案は、full deploy で hook-library 版に巻き戻り +# check_pwa_principles が消えるため不採用(2026-05-25 の deploy --diff で検出)。 +# 3) 無条件で全 PJ に配布する案は、PWA を持たない PJ で毎セッション誤警告を出すため不採用。 +# PWA 検出ゲートで発火対象を PWA PJ に限定する。 +# 対応: PWA 検出(public/sw.js または public/manifest.json)でゲートし、検出時のみ +# PWA_OPERATION_PRINCIPLE.md / PWA_NATIVE_APP_PARITY_RULE.md の存在を確認。メッセージは PJ 非依存化。 +# --- PWA 大原則 SSOT 存在確認(PWA プロジェクトのみ) --- +check_pwa_principles() { + local project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" + + # PWA プロジェクト判定: service worker または manifest を持つ場合のみ発火(非 PWA PJ では誤爆させない) + if [ ! -f "$project_dir/public/sw.js" ] && [ ! -f "$project_dir/public/manifest.json" ]; then + return + fi + + local pwa_op_principle="$project_dir/.claude/rules/general/PWA_OPERATION_PRINCIPLE.md" + local pwa_parity_rule="$project_dir/.claude/rules/general/PWA_NATIVE_APP_PARITY_RULE.md" + + if [ ! -f "$pwa_op_principle" ]; then + echo " ⚠️ PWA_OPERATION_PRINCIPLE.md (.claude/rules/general/) が存在しません。PWA 運用大原則 A (「PWAを消して再登録は絶対にしない」) の SSOT が欠落しています。" >&2 + fi + + if [ ! -f "$pwa_parity_rule" ]; then + echo " ⚠️ PWA_NATIVE_APP_PARITY_RULE.md (.claude/rules/general/) が存在しません。PWA 大原則 B (「ネイティブアプリ模倣」) の SSOT が欠落しています。" >&2 + fi +} + +# [2026-08-02][feat] ローカル main の behind をセッション開始時に警告する(issue #1327)。 +# 背景: +# - ユーザー依頼意図: セッション開始時のシステムプロンプトにはローカルの git log が載るため、 +# AI が「最新」と誤認して古いベースにコミットを積み、push 拒否 → worktree 作り直し → +# 幽霊 hook 誤爆(#1230 と重複)の手戻り連鎖が実測された(2026-08-02 jtt-cafe-pj)。 +# `git fetch` を1回打っていれば全て回避できたため、SessionStart で機械化する。 +# - 守るべき業務ルール: 警告のみで block しない(SessionStart は情報提供・常に exit 0)。 +# オフライン・認証不能・遅延時は fail-open(既存チェックと同じ精神)。 +# macOS 標準に GNU timeout が無いため bg + poll + kill で上限を実装し、 +# GIT_TERMINAL_PROMPT=0 / ssh BatchMode で認証プロンプトの hang を封じる。 +# - 他案不採用理由: PreToolUse(add/commit 時)検知の案 B は、警告が作業途中に割り込み +# ベース選択の時点(worktree 作成)に間に合わない。システムプロンプト側への ahead/behind +# 併記(案 C)は Claude Code 本体の変更で当方から変更不能。 +check_main_behind() { + local repo_root behind fetch_pid waited + repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 + git -C "$repo_root" rev-parse --verify -q refs/heads/main >/dev/null 2>&1 || return 0 + git -C "$repo_root" remote get-url origin >/dev/null 2>&1 || return 0 + ( + export GIT_TERMINAL_PROMPT=0 + export GIT_SSH_COMMAND="ssh -oBatchMode=yes -oConnectTimeout=3" + exec git -C "$repo_root" fetch -q origin "+refs/heads/main:refs/remotes/origin/main" + ) >/dev/null 2>&1 & + fetch_pid=$! + waited=0 + while kill -0 "$fetch_pid" 2>/dev/null; do + if [ "$waited" -ge 50 ]; then + # 5秒(0.1s x 50)で fetch を打ち切り fail-open(オフライン・低速回線) + kill "$fetch_pid" 2>/dev/null || true + wait "$fetch_pid" 2>/dev/null || true + return 0 + fi + sleep 0.1 + waited=$((waited + 1)) + done + wait "$fetch_pid" 2>/dev/null || return 0 + behind="$(git -C "$repo_root" rev-list --count main..origin/main 2>/dev/null)" || return 0 + case "$behind" in ''|*[!0-9]*) return 0 ;; esac + if [ "$behind" -gt 0 ]; then + echo " ⚠ ローカル main が origin/main より ${behind} コミット遅れています(fetch 実行済み)。" >&2 + echo " 冒頭の Recent commits はローカル基準です。古いベースへのコミットを避けるため、" >&2 + echo " worktree / branch は origin/main から作成してください。" >&2 + fi + return 0 +} + +# --- メイン実行 --- +main() { + local warnings="" + + # 一時ファイルで警告を収集 + local tmp_file + tmp_file=$(mktemp) + trap "rm -f '$tmp_file'" EXIT + + check_hook_version 2>"$tmp_file" + warnings="$(cat "$tmp_file")" + + check_skill_freshness 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + check_pwa_principles 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + check_main_behind 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + if [ -n "$warnings" ]; then + echo "" >&2 + echo "🔍 [freshness-gate] 鮮度チェック結果:" >&2 + echo "$warnings" >&2 + echo "" >&2 + echo " 詳細: skills/skill-audit の audit_skill.py または staleness_check.sh で確認してください" >&2 + echo "" >&2 + fi + + # SessionStart hookは常にexit 0(ブロックしない、情報提供のみ) + exit 0 +} + +main diff --git a/.cursor/hooks/scripts/handover-preflight.sh b/.cursor/hooks/scripts/handover-preflight.sh new file mode 100755 index 000000000..df4a9af71 --- /dev/null +++ b/.cursor/hooks/scripts/handover-preflight.sh @@ -0,0 +1,353 @@ +#!/bin/bash +# UserPromptSubmit hook for Handover hints. +# Quiet by default. Prints only when the prompt asks for +# "続き", "引き継ぎ書つくって", "引き継ぎ", "作業終了", "終了整理", "Closeout整理", +# "ふり返り", "振り返り", "ふりかえり", +# "handover", compatibility "takeover", or when HANDOVER_PREFLIGHT_FORCE=1 is set. +# +# [2026-06-30][refactor] +# 背景: +# - ユーザー依頼意図: ユーザー向けの引き継ぎ名を Takeover から Handover へ寄せ、 +# plan / Typinator / hook の入口名を揃えたい。 +# - 守るべき業務ルール: 旧 `takeover` / `continuation` 発話、旧 env、旧 +# `~/.agent-hub/takeovers` の保存済みデータは壊さず、互換入口として残す。 +# - 他案不採用理由: 旧 hook を即削除する案は既存 settings の command を壊す。 +# 新旧を同格にする案は正本名が再び揺れるため不採用。 +# 対応: `handover-preflight` を正本にし、旧 `takeover-preflight` は wrapper から本ファイルを呼ぶ。 + +set -euo pipefail + +RAW_INPUT="$(cat || true)" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +HOOK_INPUT="$RAW_INPUT" command python3 - "$PROJECT_DIR" <<'PY' +import json +import os +import re +import sys +from pathlib import Path + +project_dir = Path(sys.argv[1]).resolve() +raw = os.environ.get("HOOK_INPUT", "") + + +def prompt_from_payload(text: str) -> str: + if not text.strip(): + return "" + try: + payload = json.loads(text) + except Exception: + return text + if not isinstance(payload, dict): + return "" + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str): + return value + nested = payload.get("tool_input") + if isinstance(nested, dict): + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = nested.get(key) + if isinstance(value, str): + return value + return "" + + +def unquote_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def candidate_alias_paths() -> list[Path]: + paths: list[Path] = [] + env_path = os.environ.get("HANDOVER_ALIASES_PATH", "").strip() + if env_path: + paths.append(Path(env_path).expanduser()) + compat_env = os.environ.get("TAKEOVER_ALIASES_PATH", "").strip() + if compat_env: + paths.append(Path(compat_env).expanduser()) + legacy_env = os.environ.get("AGENT_MEMORY_ALIASES_PATH", "").strip() + if legacy_env: + paths.append(Path(legacy_env).expanduser()) + paths.append(project_dir / "agent-memory" / "aliases.yaml") + paths.append(Path("/Users/shintaro/business/AGENT-HUB/agent-memory/aliases.yaml")) + return paths + + +def load_apps(aliases_path: Path) -> list[dict[str, object]]: + apps: list[dict[str, object]] = [] + current_project: str | None = None + current: dict[str, object] | None = None + in_aliases = False + + for raw_line in aliases_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].rstrip() + if not line.strip(): + continue + + project_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if project_match: + current_project = project_match.group(1) + current = None + in_aliases = False + continue + + app_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if app_match and current_project: + current = { + "project": current_project, + "canonical_name": app_match.group(1), + "aliases": [], + } + apps.append(current) + in_aliases = False + continue + + if current is None: + continue + + kv_match = re.match(r"^ ([A-Za-z0-9_]+):\s*(.*)$", line) + if kv_match: + key = kv_match.group(1) + value = unquote_scalar(kv_match.group(2)) + in_aliases = key == "aliases" + if key != "aliases": + current[key] = value + continue + + alias_match = re.match(r"^ -\s*(.+?)\s*$", line) + if in_aliases and alias_match: + aliases = current.setdefault("aliases", []) + if isinstance(aliases, list): + aliases.append(unquote_scalar(alias_match.group(1))) + + return apps + + +def find_aliases_path() -> Path | None: + for path in candidate_alias_paths(): + if path.is_file(): + return path + return None + + +TRIGGER_RE = re.compile( + r"(続き|終了整理|Closeout整理|ふり返り|振り返り|ふりかえり|引継ぎ書つくって|引き継ぎ|作業終了|handover|takeover|continuation|continuation-closeout)", + re.IGNORECASE, +) +NEGATED_CONTINUATION_RE = re.compile( + r"続き\s*(?:ではなくて|ではなく|ではない|でなく|でない|じゃなくて|じゃなく|じゃない|" + r"はなく|はない|は不要|不要|はいらない|いらない|なく|ない)" +) +NEGATED_CLOSEOUT_KEYWORDS = ("終了整理", "Closeout整理", "ふり返り", "振り返り", "ふりかえり", "作業終了") +NEGATED_CLOSEOUT_SUFFIXES = ( + "ではない", + "ではないです", + "ではなく", + "ではなくて", + "でない", + "でないです", + "じゃない", + "じゃないです", + "はない", + "はいらない", + "は不要", + "必要ない", + "不要", + "要らない", + "いらない", + "ない", +) +NEGATED_PUNCTUATION = re.compile(r"[\s、。.!?!?ー−‐\\-]") +MAX_ALIAS_TRIGGER_DISTANCE = 32 + + +def normalize_for_negation(value: str) -> str: + return NEGATED_PUNCTUATION.sub("", value.casefold()) + + +def is_negated_closeout_trigger(prompt: str, trigger: str) -> bool: + folded = normalize_for_negation(prompt) + normalized_trigger = normalize_for_negation(trigger) + index = 0 + while True: + index = folded.find(normalized_trigger, index) + if index < 0: + return False + tail = folded[index + len(normalized_trigger):] + for suffix in NEGATED_CLOSEOUT_SUFFIXES: + if tail.startswith(normalize_for_negation(suffix)): + return True + index += len(normalized_trigger) + + +def positive_trigger_spans(prompt: str) -> list[tuple[int, int]]: + spans: list[tuple[int, int]] = [] + for match in TRIGGER_RE.finditer(prompt): + tail = prompt[match.start() : match.end() + 12] + if match.group(1) == "続き" and NEGATED_CONTINUATION_RE.match(tail): + continue + if match.group(1) in NEGATED_CLOSEOUT_KEYWORDS and is_negated_closeout_trigger(prompt, match.group(1)): + continue + spans.append(match.span()) + return spans + + +def alias_near_trigger(prompt: str, name: str, trigger_spans: list[tuple[int, int]]) -> bool: + if not name: + return False + for name_match in re.finditer(re.escape(name), prompt, flags=re.IGNORECASE): + for trigger_start, trigger_end in trigger_spans: + if name_match.end() <= trigger_start: + distance = trigger_start - name_match.end() + else: + distance = name_match.start() - trigger_end + if 0 <= distance <= MAX_ALIAS_TRIGGER_DISTANCE: + return True + return False + + +def matched_app(prompt: str, apps: list[dict[str, object]]) -> dict[str, object] | None: + folded_prompt = prompt.casefold() + trigger_spans = positive_trigger_spans(prompt) + for app in apps: + names: list[str] = [] + for key in ("canonical_name", "display_name"): + value = app.get(key) + if isinstance(value, str): + names.append(value) + aliases = app.get("aliases") + if isinstance(aliases, list): + names.extend(str(alias) for alias in aliases) + + for name in names: + if trigger_spans and alias_near_trigger(prompt, name, trigger_spans): + return app + if name and name.casefold() in folded_prompt: + return app + return None + + +def project_from_cwd(path: Path) -> str: + if (path / "DISTRIBUTION.yaml").is_file() and (path / "hook-registry.yaml").is_file(): + return "AGENT-HUB" + text = str(path) + checks = [ + ("AGENT-HUB", "/AGENT-HUB"), + ("jtt-system", "/jtt-system"), + ("jtt-apps", "/jtt-apps"), + ("jtt-cms", "/jtt-cms"), + ("jtt-cafe-pj", "/jtt-cafe-pj"), + ("hermes", "/mac-mini-server/hermes"), + ] + for project, marker in checks: + if marker in text: + return project + if (path / "pnpm-workspace.yaml").is_file() and (path / "apps").is_dir(): + return "jtt-system" + return "non-pj" + + +def scope_from_cwd(project: str, path: Path) -> str: + parts = path.parts + if project == "jtt-system" and "apps" in parts: + idx = parts.index("apps") + if idx + 1 < len(parts): + return parts[idx + 1] + if project == "AGENT-HUB": + for marker in ("skills", "hook-library", "snippet-prompts", "agent-memory"): + if marker in parts: + idx = parts.index(marker) + if idx + 1 < len(parts): + return parts[idx + 1] + return marker + return "root" + + +def handover_path(project: str, scope: str) -> str: + return str(Path.home() / ".agent-hub" / "handovers" / project / scope / "current.md") + + +def legacy_path(project: str, scope: str) -> str: + return str(Path.home() / ".agent-hub" / "takeovers" / project / scope / "current.md") + + +PROJECT_CLAUDE_MEMORY_PATHS = { + "AGENT-HUB": "-Users-shintaro-business-AGENT-HUB", + "bank-payment-automator": "-Users-shintaro-business-bank-payment-automator", + "hermes": "-Users-shintaro-mac-mini-server-hermes", + "jtt-apps": "-Users-shintaro-Herd-jtt-apps", + "jtt-cafe-pj": "-Users-shintaro-business-jtt-cafe-pj", + "jtt-cms": "-Users-shintaro-LLM-Dev-jtt-cms", + "jtt-system": "-Users-shintaro-jtt-system", +} + + +def claude_memory_path(project: str, app: dict[str, object] | None) -> str: + if app is not None: + configured = app.get("claude_memory_path") + if isinstance(configured, str) and configured: + return configured + encoded = PROJECT_CLAUDE_MEMORY_PATHS.get(project) + if not encoded: + return "未登録" + return str(Path.home() / ".claude" / "projects" / encoded / "memory" / "MEMORY.md") + + +def print_hint(app: dict[str, object] | None, forced: bool) -> None: + manual_path = "skills/handover-manual/references/handover.md" + reflection_path = "agent-memory/registry/reflection-policy.md" + placement_path = "agent-memory/registry/placement-policy.md" + + if app is not None: + project = str(app.get("project") or project_from_cwd(project_dir)) + scope = str(app.get("canonical_name") or scope_from_cwd(project, project_dir)) + display = app.get("display_name") or scope + else: + project = project_from_cwd(project_dir) + scope = scope_from_cwd(project, project_dir) + display = scope + + print("handover preflight:") + print(f"- scope: {project}/{scope}") + print(f"- handover_path: {handover_path(project, scope)}") + print(f"- legacy_path: {legacy_path(project, scope)}") + print(f"- claude_memory: {claude_memory_path(project, app)}") + print(f"- manual: {manual_path}") + print(f"- reflection-policy: {reflection_path}") + print(f"- placement-policy: {placement_path}") + # [2026-07-18][fix] + # 背景: closeoutでPJ固有の短期状態までGBrain候補に混ざり、人間の判断原則と技術台帳の境界が曖昧だった。 + # 守るべき業務ルール: GBrain候補はユーザーしか判断できない原則へ抽象化し、技術/PJ情報はTech GBrainかSSOTへ置く。 + # 他案不採用理由: 候補を全件GBrainへ送る案は確認負荷と重複を増やすため不採用。 + print("- closeout: 未完了 / 次回やること / Tech G-Brain候補 / GBrain候補 / SSOT昇格候補を分ける") + print("- gbrain: 技術名・PJ固有名・短期状態は候補にせず、人間の判断原則へ抽象化") + print("- handover_update: 未完了がある時だけ current.md を更新") + if forced and app is None: + print("- alias: 未検出。cwdから推定") + elif app is not None: + print(f"- app: {display}") + + +prompt = prompt_from_payload(raw) +forced = ( + os.environ.get("HANDOVER_PREFLIGHT_FORCE", "0") == "1" + or os.environ.get("TAKEOVER_PREFLIGHT_FORCE", "0") == "1" + or os.environ.get("AGENT_MEMORY_PREFLIGHT_FORCE", "0") == "1" +) + +if not forced and not positive_trigger_spans(prompt): + raise SystemExit(0) + +aliases_path = find_aliases_path() +app = None +if aliases_path is not None: + try: + app = matched_app(prompt, load_apps(aliases_path)) + except Exception: + app = None + +print_hint(app, forced) +PY diff --git a/.cursor/hooks/scripts/handover-preflight.test.sh b/.cursor/hooks/scripts/handover-preflight.test.sh new file mode 100755 index 000000000..0dd3f95e1 --- /dev/null +++ b/.cursor/hooks/scripts/handover-preflight.test.sh @@ -0,0 +1,156 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +fi +HOOK="$SCRIPT_DIR/handover-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +extract_field() { + printf "%s\n" "$1" | sed -n "s/^-[[:space:]]*$2: //p" +} + +is_agent_hub_source_repo() { + [ -f "$REPO_ROOT/DISTRIBUTION.yaml" ] && [ -f "$REPO_ROOT/hook-registry.yaml" ] +} + +assert_exact_scope() { + local output="$1" + local expected="$2" + local scope + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + [ "$scope" = "$expected" ] || fail "scope が期待値と一致しない: $output" +} + +assert_scoped_path() { + local output="$1" + local category="$2" + local scope + local expected + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + expected="$HOME/.agent-hub/$category/$scope/current.md" + printf "%s\n" "$output" | grep -Fq "$expected" \ + || fail "$category が scope と一致しない: $output" +} + +assert_claude_memory_path() { + local output="$1" + local marker="$2" + local memory_path + + memory_path="$(extract_field "$output" "claude_memory")" + [ -n "$memory_path" ] || fail "claude_memory が取得できない: $output" + case "$memory_path" in + *"/.claude/projects/"*"/memory/MEMORY.md") + : + ;; + *) + fail "claude_memory の形式が想定外: $memory_path" + ;; + esac + case "$memory_path" in + *"$marker"*) + : + ;; + *) + fail "claude_memory が期待するPJを示していない: $memory_path" + ;; + esac +} + +run_hook() { + local prompt="$1" + local project_dir="${2:-$REPO_ROOT}" + printf '{"user_prompt": "%s"}' "$prompt" | CLAUDE_PROJECT_DIR="$project_dir" bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +negative_output="$(run_hook "評価わんこについて。続きではなく概要を教えて")" +[ -z "$negative_output" ] || fail "否定文は無音であるべき: $negative_output" + +negative_reflection_output="$(run_hook "ふり返りは不要です")" +[ -z "$negative_reflection_output" ] || fail "否定文は無音であるべき: $negative_reflection_output" + +negative_hiragana_reflection_output="$(run_hook "ふりかえりはいらない")" +[ -z "$negative_hiragana_reflection_output" ] || fail "ひらがな否定文は無音であるべき: $negative_hiragana_reflection_output" + +hyoka_output="$(run_hook "評価わんこの続き")" +echo "$hyoka_output" | grep -q "handover preflight:" \ + || fail "handover preflight が出ない: $hyoka_output" +assert_scoped_path "$hyoka_output" "handovers" +assert_scoped_path "$hyoka_output" "takeovers" +echo "$hyoka_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "handover manual が出ない: $hyoka_output" + +admin_output="$(run_hook "引継ぎ書つくって")" +assert_scoped_path "$admin_output" "handovers" + +closeout_output="$(run_hook "作業終了。今回の内容を Handover に整理して")" +echo "$closeout_output" | grep -q "placement-policy" \ + || fail "作業終了で placement-policy が出ない: $closeout_output" +echo "$closeout_output" | grep -q "reflection-policy" \ + || fail "作業終了で reflection-policy が出ない: $closeout_output" +echo "$closeout_output" | grep -q "未完了 / 次回やること / Tech G-Brain候補 / GBrain候補 / SSOT昇格候補" \ + || fail "分類分離の案内が出ない: $closeout_output" +echo "$closeout_output" | grep -q "未完了がある時だけ current.md を更新" \ + || fail "handover更新条件の案内が出ない: $closeout_output" + +jtt_apps_reflection_output="$(run_hook "jtt-appsにふり返りを依頼")" +echo "$jtt_apps_reflection_output" | grep -q "handover preflight:" \ + || fail "jtt-appsのふり返りで preflight が出ない: $jtt_apps_reflection_output" +echo "$jtt_apps_reflection_output" | grep -q "scope: jtt-apps/root" \ + || fail "jtt-appsのscopeが出ない: $jtt_apps_reflection_output" +assert_claude_memory_path "$jtt_apps_reflection_output" "Herd-jtt-apps" + +jtt_apps_hiragana_reflection_output="$(run_hook "jtt-appsのふりかえりをお願い")" +echo "$jtt_apps_hiragana_reflection_output" | grep -q "scope: jtt-apps/root" \ + || fail "jtt-appsのひらがなふりかえりでscopeが出ない: $jtt_apps_hiragana_reflection_output" + +jtt_cms_reflection_output="$(run_hook "ふり返りをお願い" "/Users/shintaro/LLM-Dev/jtt-cms")" +echo "$jtt_cms_reflection_output" | grep -q "handover preflight:" \ + || fail "jtt-cmsのふり返りで preflight が出ない: $jtt_cms_reflection_output" +echo "$jtt_cms_reflection_output" | grep -q "scope: jtt-cms/root" \ + || fail "jtt-cmsのscopeが出ない: $jtt_cms_reflection_output" +assert_scoped_path "$jtt_cms_reflection_output" "handovers" +assert_claude_memory_path "$jtt_cms_reflection_output" "LLM-Dev-jtt-cms" + +jtt_system_reflection_output="$(run_hook "ふり返りをお願い" "/Users/shintaro/jtt-system")" +echo "$jtt_system_reflection_output" | grep -q "scope: jtt-system/root" \ + || fail "jtt-systemのscopeが出ない: $jtt_system_reflection_output" + +if is_agent_hub_source_repo; then + agent_hub_reflection_output="$(run_hook "ふり返りをお願い" "$REPO_ROOT")" + assert_exact_scope "$agent_hub_reflection_output" "AGENT-HUB/root" + assert_scoped_path "$agent_hub_reflection_output" "handovers" +fi + +compat_output="$(run_hook "continuation-closeout")" +echo "$compat_output" | grep -q "handover preflight:" \ + || fail "continuation-closeout 互換 trigger が出ない: $compat_output" + +handover_output="$(run_hook "handover")" +echo "$handover_output" | grep -q "handover preflight:" \ + || fail "handover trigger が出ない: $handover_output" + +force_output="$(printf '{"user_prompt": "ただの相談"}' | HANDOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$force_output" | grep -q "handover preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "alias: 未検出" || fail "FORCE時に alias 推定が出ない: $force_output" + +compat_force_output="$(printf '{"user_prompt": "ただの相談"}' | TAKEOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$compat_force_output" | grep -q "handover preflight:" || fail "旧TAKEOVER_PREFLIGHT_FORCE時の preflight が出ない: $compat_force_output" + +echo "PASS: handover-preflight" diff --git a/.cursor/hooks/scripts/post-merge-gate.sh b/.cursor/hooks/scripts/post-merge-gate.sh new file mode 100755 index 000000000..c10c54ba7 --- /dev/null +++ b/.cursor/hooks/scripts/post-merge-gate.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) post-merge gate. +# `gh pr merge` の直接実行を止め、マージ担当者が ccprmerd 正本を読む wrapper へ誘導する。 +# [2026-06-20][feat] +# 背景: +# - ユーザー依頼意図: マージ担当者がマージ作業の中で必ず `;ccprmerd` 相当の +# Typinator 正本を読み、マージ後確認まで含めて進める運用にしたい。 +# - 守るべき業務ルール: マージ処理は「PRレビュー → マージ時チェックリスト読み込み → +# マージ → 同じチェックリストで反映確認」までを一連の作業として扱う。 +# - 他案不採用理由: SKILL.md に手順だけ書く案は、AI が直接 `gh pr merge` を叩く経路を残し、 +# ccprmerd 読み込み漏れを機械的に防げないため不採用。 +# 対応: PreToolUse(Bash) で直接 `gh pr merge` を deny し、`merge-pr.py` 経由へ誘導する。 +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: dirty cleanup のレビューで、`xargs gh pr merge` が直接マージ禁止を迂回できると判明した。 +# - 守るべき業務ルール: 実行ラッパーを挟んでも `gh pr merge` は merge-pr.py 経由へ統一する。 +# - 他案不採用理由: 単純な文字列検索は説明文を誤検知し、`xargs` 全面禁止は無関係な利用まで止めるため不採用。 +# 対応: xargs のオプションを除いた実行コマンドを既存の gh サブコマンド解析へ渡す。 +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: web2context のレビューで、`nohup gh pr merge` が直接マージ禁止を迂回できると判明した。 +# - 守るべき業務ルール: 実行方法を変える標準ラッパーを挟んでも merge-pr.py 経由を強制する。 +# - 他案不採用理由: `nohup` だけを個別検知する案は `setsid` / `nice` で同じ抜け道を残すため不採用。 +# 対応: 副作用のない実行ラッパー3種と各オプションを prefix parser で正規化する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +read_stdin + +COMMAND="$(extract_field command)" + +if [ -z "$COMMAND" ]; then + printf '{"continue":true}\n' + exit 0 +fi + +if [ "${AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE:-0}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass post-merge-gate allow '{"env":"AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE"}' 2>/dev/null || true + printf '{"continue":true}\n' + exit 0 +fi + +if COMMAND_TEXT="$COMMAND" python3 - <<'PY' +from __future__ import annotations + +import os +import re +import shlex +import sys + +command = os.environ.get("COMMAND_TEXT", "") +RESERVED_PREFIXES = {"if", "while", "until"} + +def normalize_newline_separators(text: str) -> str: + """Turn unquoted newlines into command separators before tokenization.""" + result: list[str] = [] + quote = None + escaped = False + for char in text: + if escaped: + result.append(char) + escaped = False + continue + if char == "\\" and quote != "'": + result.append(char) + escaped = True + continue + if quote is not None: + result.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + result.append(char) + continue + result.append(";" if char in "\r\n" else char) + return "".join(result) + +def split_segments(text: str) -> list[list[str]]: + try: + lexer = shlex.shlex(normalize_newline_separators(text), posix=True, punctuation_chars=";&|(){}") + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return [] + segments: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + if current: + segments.append(current) + current = [] + else: + current.append(token) + if current: + segments.append(current) + return segments + +def split_segments_with_dynamic_commands(text: str) -> list[list[str]]: + """Keep the normal parse and add a view where command expansions are one token.""" + masked = re.sub(r"\$\([^()\r\n]*\)", "$DYNAMIC_COMMAND", text) + masked = re.sub(r"\$\{[^{}\r\n]+\}", "$DYNAMIC_COMMAND", masked) + segments = split_segments(text) + if masked != text: + segments.extend(split_segments(masked)) + return segments + +def iter_backticks(text: str) -> list[str]: + chunks: list[str] = [] + start = None + escaped = False + quote = None + for index, char in enumerate(text): + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if quote == "'": + if char == "'": + quote = None + continue + if start is None and char in {"'", '"'}: + # 二重引用符内の ' は literal(single-quote モードに入れない)。 + # これを怠ると `echo "'`...`'"` で backtick command-sub を見逃す。 + if char == "'" and quote == '"': + continue + quote = None if quote == char else char + continue + if char != "`": + continue + if start is None: + start = index + 1 + else: + chunks.append(text[start:index]) + start = None + return chunks + +def iter_dollar_subshells(text: str) -> list[str]: + chunks: list[str] = [] + index = 0 + quote = None + escaped = False + while index < len(text): + char = text[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\": + escaped = True + index += 1 + continue + if char == "'" and quote != '"': + quote = None if quote == "'" else "'" + index += 1 + continue + if char == '"' and quote != "'": + quote = None if quote == '"' else '"' + index += 1 + continue + if quote == "'" or not text.startswith("$(", index): + index += 1 + continue + start = index + depth = 1 + cursor = start + 2 + inner_quote = None + inner_escaped = False + while cursor < len(text): + char = text[cursor] + if inner_escaped: + inner_escaped = False + elif char == "\\": + inner_escaped = True + elif inner_quote: + if char == inner_quote: + inner_quote = None + elif char in {"'", '"'}: + inner_quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + chunks.append(text[start + 2:cursor]) + break + cursor += 1 + index = cursor + 1 + return chunks + +def strip_prefix(tokens: list[str]) -> list[str]: + index = 0 + while index < len(tokens): + token = os.path.basename(tokens[index]) + if "=" in tokens[index] and tokens[index].split("=", 1)[0].replace("_", "A").isalnum(): + index += 1 + continue + if token in {"command", "builtin", "exec"}: + index += 1 + if index < len(tokens) and tokens[index] == "-p": + index += 1 + continue + if token == "time": + index += 1 + if index < len(tokens) and tokens[index] == "-p": + index += 1 + continue + if token == "sudo": + index += 1 + while index < len(tokens) and tokens[index].startswith("-"): + opt = tokens[index] + index += 1 + if opt in {"-u", "-g", "-h", "-p", "-C", "-T"} and index < len(tokens): + index += 1 + continue + if token == "env": + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt in {"-u", "--unset", "-C", "--chdir"} and index + 1 < len(tokens): + index += 2 + continue + if opt.startswith("-"): + index += 1 + continue + if "=" in opt and opt.split("=", 1)[0].replace("_", "A").isalnum(): + index += 1 + continue + break + continue + if token in {"nohup", "setsid"}: + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt == "--": + index += 1 + break + if not opt.startswith("-"): + break + index += 1 + continue + if token == "nice": + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt == "--": + index += 1 + break + if opt in {"-n", "--adjustment"} and index + 1 < len(tokens): + index += 2 + continue + if opt.startswith("--adjustment=") or (opt.startswith("-") and opt[1:].lstrip("+").isdigit()): + index += 1 + continue + break + continue + break + return tokens[index:] + +def gh_subcommand(tokens: list[str]) -> list[str]: + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "gh": + return [] + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "--": + index += 1 + break + if token in {"-R", "--repo", "--hostname", "--config"}: + index += 2 + continue + if token.startswith("-R") and len(token) > 2: + index += 1 + continue + if token.startswith("--repo=") or token.startswith("--hostname=") or token.startswith("--config="): + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def indirect_subcommand(tokens: list[str]) -> list[str]: + """Fail closed when a dynamic command position could expand to gh.""" + tokens = strip_prefix(tokens) + if not tokens: + return [] + command = tokens[0] + is_simple_var = command.startswith("$") and command[1:].replace("_", "A").isalnum() + is_dynamic_expansion = ( + (command.startswith("${") and command.endswith("}")) + or command.startswith("$(") + or command.startswith("`") + ) + if not (is_simple_var or is_dynamic_expansion): + return [] + # [2026-07-18][fix] + # 背景: + # - PR1018再レビューで `${GH:-gh}` / `${GH?err}` / `$(printf gh)` のような + # command-position expansionが単純変数判定を外れ、直接mergeを実行できると判明した。 + # - 守るべき業務ルール: 実行ファイルを静的に確定できない `pr merge` はfail-closedにする。 + # - 他案不採用理由: shell parameter expansionを評価してghか判定する案は、default/error演算子や + # command substitutionの実行環境を再実装することになり、別形式で再びfail-openするため不採用。 + # 対応: 動的command tokenの後ろからpr subcommand境界を探し、展開形式を限定せず拒否する。 + for index, token in enumerate(tokens[1:], start=1): + if token == "pr": + return tokens[index:] + return [] + +def strip_pr_options(tokens: list[str]) -> list[str]: + index = 0 + while index < len(tokens): + token = tokens[index] + if token in {"-R", "--repo", "--hostname", "--config"}: + index += 2 + continue + if token.startswith("-R") and len(token) > 2: + index += 1 + continue + if token.startswith("--repo=") or token.startswith("--hostname=") or token.startswith("--config="): + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def xargs_command(tokens: list[str]) -> list[str]: + """Return the command executed by xargs, or an empty list.""" + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "xargs": + return [] + options_with_value = { + "-a", "--arg-file", "-d", "--delimiter", "-E", "--eof", "-I", "--replace", "-J", + "-L", "--max-lines", "-n", "--max-args", "-P", "--max-procs", + "-R", "-S", "-s", "--max-chars", + } + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "--": + return tokens[index + 1:] + if token in options_with_value: + index += 2 + continue + if token.startswith("--") and "=" in token: + index += 1 + continue + if token.startswith(("-d", "-E", "-I", "-J", "-L", "-n", "-P", "-R", "-S", "-s")) and len(token) > 2: + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def find_exec_command(tokens: list[str]) -> list[str]: + """Return the command passed to find -exec/-execdir, or an empty list.""" + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "find": + return [] + for index, token in enumerate(tokens): + if token in {"-exec", "-execdir"}: + return tokens[index + 1:] + return [] + +def candidate_commands(segment: list[str]) -> list[list[str]]: + candidates = [segment] + for index, token in enumerate(segment[:-1]): + if token in RESERVED_PREFIXES: + candidates.append(segment[index + 1:]) + return candidates + +def contains_generated_shell_command(text: str, depth: int) -> bool: + """Detect a direct merge emitted by printf/echo inside command substitution.""" + for chunk in iter_dollar_subshells(text) + iter_backticks(text): + for segment in split_segments(chunk): + stripped = strip_prefix(segment) + if not stripped or os.path.basename(stripped[0]) not in {"echo", "printf"}: + continue + for token in stripped[1:]: + if contains_direct_merge(token, depth + 1): + return True + return False + +def contains_direct_merge(text: str, depth: int = 0) -> bool: + if depth > 3: + return False + for chunk in iter_backticks(text): + if contains_direct_merge(chunk, depth + 1): + return True + for chunk in iter_dollar_subshells(text): + if contains_direct_merge(chunk, depth + 1): + return True + for segment in split_segments_with_dynamic_commands(text): + for candidate in candidate_commands(segment): + commands = [candidate] + wrapped = xargs_command(candidate) + if wrapped: + commands.append(wrapped) + find_wrapped = find_exec_command(candidate) + if find_wrapped: + commands.append(find_wrapped) + for nested_tokens in (wrapped, find_wrapped): + if nested_tokens: + nested_text = " ".join(shlex.quote(token) for token in nested_tokens) + if contains_direct_merge(nested_text, depth + 1): + return True + for command_tokens in commands: + sub = gh_subcommand(command_tokens) + if not sub: + sub = indirect_subcommand(command_tokens) + if sub and sub[0] == "pr": + pr_sub = strip_pr_options(sub[1:]) + if pr_sub and pr_sub[0] == "merge": + return True + stripped = strip_prefix(segment) + if stripped and os.path.basename(stripped[0]) == "eval": + for token in stripped[1:]: + if contains_direct_merge(token, depth + 1): + return True + if stripped and os.path.basename(stripped[0]) in {"bash", "sh", "zsh"}: + for i, token in enumerate(stripped[1:], start=1): + if token in {"-c", "-lc"} and i + 1 < len(stripped): + payload = stripped[i + 1] + if contains_generated_shell_command(payload, depth) or contains_direct_merge(payload, depth + 1): + return True + return False + +sys.exit(0 if contains_direct_merge(command) else 1) +PY +then + # telemetry(harness-checkup): deny を記録(fail-open)。 + agent_hub_telemetry_log hook_deny post-merge-gate deny 2>/dev/null || true + # [2026-07-31][docs] Issue #1105: 回避策を deny メッセージに明示する + # 背景: + # - 報告は「PR 本文(--body)に説明目的でコマンド例を書いただけでブロックされる」だったが、実測すると + # ブロックされるのは **二重引用符内に backtick / $() で書いた場合だけ**で、これは bash が実際に + # コマンド置換として実行する形=真陽性だった(単一引用符・素のテキスト・--body-file は通る)。 + # - よって Issue の第一案「判定対象を実行される先頭コマンドに限定する」は採らない。採ると + # `--body "$(...)"` のような本物の実行経路を見逃し、正しい安全検査を弱めるため。 + # - 実際に不足していたのは「なぜ止まったか・どう書けば通るか」の案内なので、Issue の第二案 + # (メッセージへ回避策を明示)だけを実施する。 + emit_deny "[hook:post-merge-gate] 直接の gh pr merge は禁止です。マージ担当者が ccprmerd 正本を読むため、python3 ~/business/AGENT-HUB/skills/post-merge/scripts/merge-pr.py を使ってください。 +説明文・PR 本文にコマンド例を書いただけで止まった場合: 二重引用符の中の backtick や \$() は bash が実際に実行するため検知対象です。単一引用符で囲むか --body-file を使ってください。 +リリース昇格 / forward-merge(head が main 等の長寿命ブランチ)の PR は、既定の --squash だと履歴が乖離します。--method merge --no-delete-branch --no-cleanup を明示してください。 +緊急時のみ AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE=1 を明示できます。" +fi + +printf '{"continue":true}\n' diff --git a/.cursor/hooks/scripts/post-merge-gate.test.sh b/.cursor/hooks/scripts/post-merge-gate.test.sh new file mode 100755 index 000000000..b45a94b46 --- /dev/null +++ b/.cursor/hooks/scripts/post-merge-gate.test.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/post-merge-gate.sh" +PASS=0 +FAIL=0 + +run_hook() { + local command="$1" + printf '{"tool_name":"Bash","tool_input":{"command":%s}}\n' "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$command")" | bash "$SCRIPT" +} + +run_shell_hook() { + local command="$1" + printf '{"tool_name":"Shell","tool_input":{"command":%s}}\n' "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$command")" | bash "$SCRIPT" +} + +expect_block() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if "[hook:post-merge-gate]" not in payload.get("permissionDecisionReason", ""): + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue":true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_shell_block() { + local name="$1" + local command="$2" + local out + out="$(run_shell_hook "$command" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +data = json.loads(os.environ["OUT"]) +payload = data.get("hookSpecificOutput", {}) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block "direct gh pr merge" "gh pr merge 123 --squash --delete-branch" +expect_block "repo option gh pr merge" "gh --repo owner/repo pr merge 123 --squash" +expect_block "short repo option gh pr merge" "gh -Rowner/repo pr merge 123" +expect_block "pr-level repo option gh pr merge" "gh pr --repo owner/repo merge 123" +expect_block "pr-level short repo option gh pr merge" "gh pr -Rowner/repo merge 123" +expect_block "command prefix gh pr merge" "command gh pr merge 123" +expect_block "shell nested gh pr merge" "bash -lc 'gh pr merge 123 --squash'" +expect_block "wrapper mention does not bypass direct merge" "echo merge-pr.py && gh pr merge 123 --squash" +expect_block "if statement gh pr merge" "if gh pr merge 123 --squash; then echo ok; fi" +expect_block "while statement gh pr merge" "while gh pr merge 123; do break; done" +expect_block "eval gh pr merge" "eval \"gh pr merge 123 --squash\"" +expect_block "backtick gh pr merge" "echo \`gh pr merge 123\`" +expect_block "quoted dollar subshell gh pr merge" "echo \"\$(gh pr merge 123)\"" +expect_block "double-quoted single-quote dollar subshell bypass" "echo \"'\$(gh pr merge 123)'\"" +expect_block "double-quoted single-quote backtick bypass" "echo \"'\`gh pr merge 123\`'\"" +expect_block "pipe through xargs gh pr merge" "printf '123\\n' | xargs gh pr merge" +expect_block "xargs with options gh pr merge" "xargs -n1 gh pr merge <<<123" +expect_block "macOS xargs replacement gh pr merge" "xargs -J % gh pr merge % <<<123" +expect_block "macOS xargs size gh pr merge" "xargs -S 255 gh pr merge <<<123" +expect_block "macOS xargs replacements gh pr merge" "xargs -R 1 gh pr merge <<<123" +expect_block "GNU xargs delimiter gh pr merge" "printf '123\\n' | xargs -d '\\n' gh pr merge" +expect_block "newline separated gh pr merge" $'printf ok\ngh pr merge 123' +expect_block "shell generated gh pr merge" "bash -c \"\$(printf 'gh pr merge 123')\"" +expect_block "find exec gh pr merge" "find . -exec gh pr merge 123 {} \\;" +expect_block "xargs shell nested gh pr merge" "printf '123\\n' | xargs sh -c 'gh pr merge \"\$0\"'" +expect_block "find shell nested gh pr merge" "find . -exec sh -c 'gh pr merge 123' \\;" +expect_block "variable command gh pr merge" "GH=gh; \"\$GH\" pr merge 123" +expect_block "default parameter expansion gh pr merge" 'GH=gh; "${GH:-gh}" pr merge 123' +expect_block "error parameter expansion gh pr merge" 'GH=gh; "${GH?err}" pr merge 123' +expect_block "command substitution gh pr merge" '$(printf gh) pr merge 123' +expect_block "nohup gh pr merge" "nohup gh pr merge 123" +expect_block "setsid gh pr merge" "setsid -f gh pr merge 123" +expect_block "nice gh pr merge" "nice -n 5 gh pr merge 123" +expect_shell_block "Shell tool gh pr merge" "gh pr merge 123" + +expect_allow "pr view allowed" "gh pr view 123" +expect_allow "wrapper allowed" "python3 ~/business/AGENT-HUB/skills/post-merge/scripts/merge-pr.py 123 --confirm-read" +expect_allow "text mention allowed" "echo 'gh pr merge 123 should use wrapper'" +expect_allow "single quoted dollar subshell text allowed" "echo '\$(gh pr merge 123)'" +expect_allow "single quoted backtick text allowed" "echo '\`gh pr merge 123\`'" + +# [2026-07-31][test] Issue #1105: deny メッセージが回避策を案内することを固定する。 +# 実測の結果、ブロックされるのは二重引用符内の backtick / $()(bash が実際に実行する形=真陽性)だけで、 +# 単一引用符・素のテキスト・--body-file は上の expect_allow 群のとおり通る。よって判定ロジックは変えず、 +# 「なぜ止まったか・どう書けば通るか」を案内するメッセージだけを追加した。その回帰を固定する。 +expect_deny_message_contains() { + local name="$1" + local command="$2" + local needle="$3" + local out + out="$(run_hook "$command" 2>&1)" + if OUT="$out" NEEDLE="$needle" python3 - <<'PYCHECK' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +reason = data.get("hookSpecificOutput", {}).get("permissionDecisionReason", "") +sys.exit(0 if os.environ["NEEDLE"] in reason else 1) +PYCHECK + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_deny_message_contains "deny message points at --body-file workaround" "gh pr merge 123" "--body-file" +expect_deny_message_contains "deny message explains single quotes" "gh pr merge 123" "単一引用符" +expect_deny_message_contains "deny message still points at the wrapper" "gh pr merge 123" "merge-pr.py" + +printf 'post-merge-gate tests: %s passed, %s failed\n' "$PASS" "$FAIL" +test "$FAIL" -eq 0 diff --git a/.cursor/hooks/scripts/pre-implementation-check.sh b/.cursor/hooks/scripts/pre-implementation-check.sh new file mode 100755 index 000000000..4dc2732fd --- /dev/null +++ b/.cursor/hooks/scripts/pre-implementation-check.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# UserPromptSubmit フック — 軽量リマインダー(重い処理はしない) +# docs/ の構成を検出し、3層読み込み戦略のリマインダーを出力 +# +# 設置先: .claude/hooks/scripts/pre-implementation-check.sh +# トリガー: UserPromptSubmit +# タイムアウト: 5秒 +# +# [2026-03-21][fix] +# 背景: +# - ユーザー依頼意図: PR30レビューで、実装前リマインダーを Claude が次の行動判断に使える状態へ直したい。 +# - 守るべき業務ルール: UserPromptSubmit の非ブロッキング hook は、モデルへ渡したい文言を stdout に出す必要がある。 +# - 他案不採用理由: stderr へ出す方式のままでは、警告文が人間向けログに留まり、実装前コンテキストとして機能しない。 +# 対応: 非ブロッキング成功のまま stdout 出力へ統一し、プロジェクト構成に応じたリマインダーを Claude に渡す。 +# +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: AGENT-HUB の UserPromptSubmit hook が毎回大きなリマインダーを表示し、 +# hook失敗のように見えて作業体験を悪化させているため静かにしたい。 +# - 守るべき業務ルール: CaD確認自体はAGENT-HUB運用で必須。ただし通常プロンプトごとに可視出力して +# 失敗表示と混同させてはいけない。 +# - 他案不採用理由: +# 1) stderrへ戻す案はモデル文脈に渡らず、PR30で不採用済みのため不採用。 +# 2) settingsだけ残して実体を削除する案は hook 実行時の参照切れを再発させるため不採用。 +# 3) CaDリマインダーを完全削除する案は必須運用を失うため不採用。 +# 対応: 通常は無音成功にし、明示的に `AGENT_HUB_SHOW_PRE_IMPL_REMINDER=1` を指定した場合だけ stdout に出す。 + +# プロジェクトルートを検出 +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" + +if [ "${AGENT_HUB_SHOW_PRE_IMPL_REMINDER:-0}" != "1" ]; then + exit 0 +fi + +if [ -d "$PROJECT_DIR/docs/business" ]; then + # docs/business/ が存在する場合: 3層読み込み戦略リマインダー + cat <<'REMINDER' +⚠️ SSOT 3層読み込み戦略を実行せよ: +Layer 1: CLAUDE.md + rules + prd-active Context Summary +Layer 2: business-design.md / BUSINESS_RULES.md の目次→関係セクション特定 +Layer 3: 変更スコープに応じたSSOTの該当セクションだけ全文読み ++ CaD不採用パターンをブロックリスト化 → サブエージェントに引き渡し ++ PM Agent の直接実装禁止 → サブエージェントに委譲 +REMINDER +else + # docs/business/ が存在しない場合: CaD確認リマインダー + cat <<'REMINDER' +⚠️ CaD確認必須: 変更対象の不採用理由をブロックリスト化 → サブエージェントに引き渡し +REMINDER +fi diff --git a/.cursor/hooks/scripts/stop-quality-check.sh b/.cursor/hooks/scripts/stop-quality-check.sh new file mode 100755 index 000000000..05085b2c2 --- /dev/null +++ b/.cursor/hooks/scripts/stop-quality-check.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。薄いラッパーでlib/の共通ロジックを呼び出す。 +# 対応: Stop → lib/quality-check-common.sh の run_quality_check_hook を呼出。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/quality-check-common.sh" + +run_quality_check_hook \ + "stop-quality-check" \ + "$SCRIPT_DIR/.." \ + "No file changes detected - research/planning task, skipping quality check." diff --git a/.cursor/hooks/scripts/storage-url-pr-gate.sh b/.cursor/hooks/scripts/storage-url-pr-gate.sh new file mode 100755 index 000000000..8e8452887 --- /dev/null +++ b/.cursor/hooks/scripts/storage-url-pr-gate.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。PreToolUseでPR作成前にStorage URL全件検証。 +# 対応: jtt-cms storage-url-pr-gate.sh をポート。lib/hook-io.sh + lib/storage-url-common.py を使用。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「PR作成前ゲートが環境差で無効化されず、常に同じ判定になること」。 +# 業務ルールとして、セキュリティ/品質ゲートは fail-open(失敗時素通り)を禁止する。 +# 代替案として `origin/main` 固定 + `|| true` を維持すると、 +# ブランチ構成差やremote未設定時に検査がスキップされるため不採用。 +# 対応: ベースブランチ解決を動的化し、diff取得や検査失敗時は明示denyに変更。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +resolve_base_ref() { + local cwd="$1" + + # 1) origin/HEAD を優先 + local remote_head + remote_head="$(git -C "$cwd" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || true)" + if [ -n "$remote_head" ]; then + echo "${remote_head#refs/remotes/}" + return 0 + fi + + # 2) origin/main または origin/master + if git -C "$cwd" rev-parse --verify origin/main >/dev/null 2>&1; then + echo "origin/main" + return 0 + fi + if git -C "$cwd" rev-parse --verify origin/master >/dev/null 2>&1; then + echo "origin/master" + return 0 + fi + + # 3) 最後のフォールバック: ローカル main/master + if git -C "$cwd" rev-parse --verify main >/dev/null 2>&1; then + echo "main" + return 0 + fi + if git -C "$cwd" rev-parse --verify master >/dev/null 2>&1; then + echo "master" + return 0 + fi + + return 1 +} + +read_stdin +COMMAND=$(extract_field command) + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `gh pr create`(複数空白)や `gh --repo owner/repo pr create` のように +# gh のグローバルオプション付き呼び出しが固定文字列 `gh pr create` に一致せず +# fail-open(ゲートをスルー)する脆弱性を修正したい。 +# 守るべき業務ルール: セキュリティ/品質ゲートは fail-open 禁止(2026-03-04 CaD と同型)。 +# 他案不採用理由: +# 1) `grep -qF "gh pr create"` を維持しつつ空白を `[[:space:]]*` に変えるだけ → 長形式オプション +# (--repo, --base 等) を見逃すため不採用。 +# 2) コマンド全体を解析する案 → shlex が必要で bash のみより複雑。正規表現の方が保守しやすい。 +# 対応: grep -qE で gh のグローバルオプション(短形式 -R / 長形式 --repo 等)と複数空白を許容する正規表現に変更。 +# [2026-05-27][fix] review follow-up: +# --repo / -R のように値を別トークンで取るグローバルオプションも消費する。値なしオプションだけを +# 許容する旧パターンでは `gh --repo owner/repo pr create` が early exit して fail-open するため不採用。 +# [2026-05-28][fix] issue #210 / v3.5.6 regression fix: +# gh が許容する連結形式 `-Rowner/repo`(値を別トークンにせず短縮形へ glue)も消費する。 +# #201/#213 hardening で `-R[^[:space:]]+` 分岐が脱落し、`gh -Rowner/repo pr create` が +# GH_GLOBAL_OPTS にマッチせず early exit → storage URL gate を fail-open する退化が入っていた。 +# `-[A-Za-z]+` 分岐は `-Rowner/repo` の `/` で止まるため連結 repo 値を消費できない。実機検証で +# gh は `-Rowner/repo` を受理するため(git の連結 `-C/path` は逆に弾かれる)、本分岐の復活が必須。 +readonly GH_GLOBAL_OPTS='([[:space:]]+((-R|--repo|--hostname)[[:space:]]+[^[:space:]]+|-R[^[:space:]]+|--repo=[^[:space:]]+|--hostname=[^[:space:]]+|-[A-Za-z]+|--[A-Za-z0-9_-]+))*' +if ! echo "$COMMAND" | grep -qE "gh${GH_GLOBAL_OPTS}[[:space:]]+pr[[:space:]]+create"; then + exit 0 +fi + +CWD=$(extract_field cwd) +if [ -z "$CWD" ]; then + CWD="." +fi + +BASE_REF="" +if ! BASE_REF="$(resolve_base_ref "$CWD")"; then + emit_deny "[hook:storage-url-pr-gate] 比較対象ブランチ(origin/HEAD, main, master)を解決できません。ベースブランチを取得してから再実行してください。" +fi + +set +e +CHANGED_FILES=$(git -C "$CWD" diff --name-only --diff-filter=ACMR "$BASE_REF"...HEAD 2>/dev/null) +DIFF_STATUS=$? +set -e + +if [ "$DIFF_STATUS" -ne 0 ]; then + emit_deny "[hook:storage-url-pr-gate] 変更ファイル差分の取得に失敗しました(base: $BASE_REF)。リポジトリ状態を確認してください。" +fi + +if [ -z "$CHANGED_FILES" ]; then + exit 0 +fi + +MIGRATION_FILES=$(echo "$CHANGED_FILES" | grep -E '^supabase/migrations/.*\.sql$' || true) +if [ -z "$MIGRATION_FILES" ]; then + exit 0 +fi + +FILE_ARGS=() +while IFS= read -r mf; do + FILE_ARGS+=("$CWD/$mf") +done <<< "$MIGRATION_FILES" + +set +e +DENY_REASON=$(python3 "$SCRIPT_DIR/../lib/storage-url-common.py" gate "${FILE_ARGS[@]}" 2>/dev/null) +GATE_STATUS=$? +set -e + +if [ "$GATE_STATUS" -eq 0 ]; then + exit 0 +fi + +if [ "$GATE_STATUS" -eq 1 ] && [ -n "$DENY_REASON" ]; then + emit_deny "$DENY_REASON" +fi + +emit_deny "[hook:storage-url-pr-gate] Storage URL検証処理でエラーが発生しました。ログを確認して再実行してください。" diff --git a/.cursor/hooks/scripts/subagent-quality-check.sh b/.cursor/hooks/scripts/subagent-quality-check.sh new file mode 100755 index 000000000..795fe76bf --- /dev/null +++ b/.cursor/hooks/scripts/subagent-quality-check.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。薄いラッパーでlib/の共通ロジックを呼び出す。 +# 対応: SubagentStop → lib/quality-check-common.sh の run_quality_check_hook を呼出。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/quality-check-common.sh" + +run_quality_check_hook \ + "subagent-quality-check" \ + "$SCRIPT_DIR/.." \ + "No file changes detected - research/planning agent, skipping quality check." \ + "false" diff --git a/.cursor/hooks/scripts/takeover-preflight.sh b/.cursor/hooks/scripts/takeover-preflight.sh new file mode 100755 index 000000000..0a3cc6160 --- /dev/null +++ b/.cursor/hooks/scripts/takeover-preflight.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Compatibility wrapper. Handover is the canonical preflight name. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec bash "$SCRIPT_DIR/handover-preflight.sh" diff --git a/.cursor/hooks/scripts/takeover-preflight.test.sh b/.cursor/hooks/scripts/takeover-preflight.test.sh new file mode 100755 index 000000000..b388fa494 --- /dev/null +++ b/.cursor/hooks/scripts/takeover-preflight.test.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +fi +HOOK="$SCRIPT_DIR/takeover-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +extract_field() { + printf "%s\n" "$1" | sed -n "s/^-[[:space:]]*$2: //p" +} + +is_agent_hub_source_repo() { + [ -f "$REPO_ROOT/DISTRIBUTION.yaml" ] && [ -f "$REPO_ROOT/hook-registry.yaml" ] +} + +assert_exact_scope() { + local output="$1" + local expected="$2" + local scope + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + [ "$scope" = "$expected" ] || fail "scope が期待値と一致しない: $output" +} + +assert_scoped_path() { + local output="$1" + local category="$2" + local scope + local expected + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + expected="$HOME/.agent-hub/$category/$scope/current.md" + printf "%s\n" "$output" | grep -Fq "$expected" \ + || fail "$category が scope と一致しない: $output" +} + +run_hook() { + local prompt="$1" + printf '{"user_prompt": "%s"}' "$prompt" | CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +negative_output="$(run_hook "評価わんこについて。続きではなく概要を教えて")" +[ -z "$negative_output" ] || fail "否定文は無音であるべき: $negative_output" + +negative_finish_output="$(run_hook "終了整理は不要です")" +[ -z "$negative_finish_output" ] || fail "否定文は無音であるべき: $negative_finish_output" + +negative_closeout_output="$(run_hook "Closeout整理はいらない")" +[ -z "$negative_closeout_output" ] || fail "否定文は無音であるべき: $negative_closeout_output" + +negative_work_output="$(run_hook "作業終了ではないです")" +[ -z "$negative_work_output" ] || fail "否定文は無音であるべき: $negative_work_output" + +representative_prompt="作業終了。今回の内容を終了整理して。GBrain候補は僕の確認待ち、SSOTとTech G-Brainは自動判定で。未完了がある時だけTakeoverも更新して。" +representative_output="$(run_hook "$representative_prompt")" +echo "$representative_output" | grep -q "handover preflight:" \ + || fail "代表入力文で preflight が出ない: $representative_output" +echo "$representative_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "代表入力文で handover manual が出ない: $representative_output" + +closeout_word_output="$(run_hook "終了整理")" +echo "$closeout_word_output" | grep -q "handover preflight:" \ + || fail "終了整理単独で preflight が出ない: $closeout_word_output" + +closeout_compat_output="$(run_hook "Closeout整理")" +echo "$closeout_compat_output" | grep -q "handover preflight:" \ + || fail "Closeout整理で preflight が出ない: $closeout_compat_output" + +hyoka_output="$(run_hook "評価わんこの続き")" +echo "$hyoka_output" | grep -q "handover preflight:" \ + || fail "handover preflight が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q ".agent-hub/handovers/jtt-system/hyoka-wanko/current.md" \ + || fail "評価わんこの handover_path が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q ".agent-hub/takeovers/jtt-system/hyoka-wanko/current.md" \ + || fail "評価わんこの legacy_path が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "handover manual が出ない: $hyoka_output" + +admin_output="$(run_hook "引継ぎ書つくって")" +assert_scoped_path "$admin_output" "handovers" + +compat_output="$(run_hook "continuation-closeout")" +echo "$compat_output" | grep -q "handover preflight:" \ + || fail "continuation-closeout 互換 trigger が出ない: $compat_output" + +force_output="$(printf '{"user_prompt": "ただの相談"}' | TAKEOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$force_output" | grep -q "handover preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "alias: 未検出" || fail "FORCE時に alias 推定が出ない: $force_output" + +compat_force_output="$(printf '{"user_prompt": "ただの相談"}' | AGENT_MEMORY_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$compat_force_output" | grep -q "handover preflight:" || fail "旧AGENT_MEMORY_PREFLIGHT_FORCE 時の preflight が出ない: $compat_force_output" + +if is_agent_hub_source_repo; then + agent_hub_reflection_output="$(run_hook "ふり返りをお願い")" + assert_exact_scope "$agent_hub_reflection_output" "AGENT-HUB/root" + assert_scoped_path "$agent_hub_reflection_output" "handovers" +fi + +echo "PASS: takeover-preflight" diff --git a/.cursor/hooks/scripts/telemetry-lib.sh b/.cursor/hooks/scripts/telemetry-lib.sh new file mode 100755 index 000000000..163432ccf --- /dev/null +++ b/.cursor/hooks/scripts/telemetry-lib.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# telemetry-lib.sh — shared harness telemetry function. +# +# Provides: agent_hub_telemetry_log [meta_json] +# +# 絶対方針: fail-open。 +# - いかなるエラーでも exit 0・ブロックしない・stdout に出力しない。 +# - git / date / python3 / mkdir のいずれかが欠損・失敗しても黙って return 0。 +# - AGENT_HUB_TELEMETRY_DISABLE=1 で完全無効化(何もしない)。 +# - 外部ネットワーク不使用。ローカル JSONL 追記のみ。 +# +# 他 hook からの読み込み(配布先で lib が無くても壊さない no-op fallback): +# . "$(dirname "$0")/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } +# +# 出力先: ${AGENT_HUB_TELEMETRY_DIR:-$HOME/.agent-hub/telemetry}/YYYY-MM-DD.jsonl +# レコード: {"ts","tool","pj","event_type","name","outcome","meta"} + +# 注意: 本ファイルは他 hook から `source` されるため set -e を使わない。 +# 呼び出し元(block-main-commit.sh 等)が set -euo pipefail を設定済みの場合、 +# ここでの未定義変数や失敗コマンドは親の set -e で source 全体を中断しうる。 +# そのため全ての変数参照は ${VAR:-} 形式とし、外部コマンドは || true で包む。 + +agent_hub_telemetry_log() { + # fail-open: 無効化フック + [ "${AGENT_HUB_TELEMETRY_DISABLE:-0}" = "1" ] && return 0 + + local event_type="${1:-}" + local name="${2:-}" + local outcome="${3:-}" + local meta_json="${4:-}" + + # 引数不足でも黙って返す(ブロックしない) + [ -z "$event_type" ] && return 0 + + # 出力ディレクトリ解決(環境変数で上書き可。テスト用) + local base_dir="${AGENT_HUB_TELEMETRY_DIR:-${HOME:-}/.agent-hub/telemetry}" + local date_str + date_str="$(date +%Y-%m-%d 2>/dev/null || echo unknown)" + [ -z "$date_str" ] && date_str="unknown" + local out_file="$base_dir/$date_str.jsonl" + + # ディレクトリ作成(失敗は無視 → 後段の追記も失敗して return 0 に至る) + [ -d "$base_dir" ] || mkdir -p "$base_dir" 2>/dev/null || true + + # pj 解決(優先順: 環境変数 > CLAUDE_PROJECT_DIR > git root basename > PWD basename) + local pj="${AGENT_HUB_TELEMETRY_PJ:-}" + if [ -z "$pj" ]; then + if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then + pj="${CLAUDE_PROJECT_DIR##*/}" + else + local git_root="" + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ]; then + pj="${git_root##*/}" + else + pj="${PWD##*/}" + fi + fi + fi + [ -z "$pj" ] && pj="unknown" + + # ISO8601 UTC タイムスタンプ + local ts + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" + + # [2026-07-07][feat] harness phaseB: telemetry tool名を T_TOOL 由来で上書き可にする + local tool="${T_TOOL:-claude-code}" + + # JSON 1 行を組み立てて追記(python3 で値を escape-safe に)。 + # python3 が無い環境では純 bash で最小エスケープして追記する(fail-open)。 + if command -v python3 >/dev/null 2>&1; then + T_EVENT="$event_type" \ + T_NAME="$name" \ + T_OUTCOME="$outcome" \ + T_PJ="$pj" \ + T_TOOL="$tool" \ + T_TS="$ts" \ + T_META="$meta_json" \ + T_OUT="$out_file" \ + python3 - <<'PY' 2>/dev/null || true +import json +import os + + +def as_str(value: str) -> str: + return value if isinstance(value, str) else "" + + +meta_raw = os.environ.get("T_META", "") +meta_value = {} +if meta_raw: + try: + decoded = json.loads(meta_raw) + if isinstance(decoded, dict): + meta_value = decoded + else: + meta_value = {"value": decoded} + except Exception: + # JSON でなければ文字列として保持(破損させない) + meta_value = {"raw": meta_raw} + +record = { + "ts": as_str(os.environ.get("T_TS", "")), + "tool": as_str(os.environ.get("T_TOOL", "claude-code")), + "pj": as_str(os.environ.get("T_PJ", "")), + "event_type": as_str(os.environ.get("T_EVENT", "")), + "name": as_str(os.environ.get("T_NAME", "")), + "outcome": as_str(os.environ.get("T_OUTCOME", "")), + "meta": meta_value, +} + +out_path = os.environ.get("T_OUT", "") +if not out_path: + raise SystemExit(0) + +try: + with open(out_path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") +except Exception: + pass +PY + else + # python3 無し: JSON パーサ/シリアライザが無いため meta_json を構造化オブジェクトとして + # 安全に組み込めない。 + # [2026-07-04][fix] Codexレビュー対応(PR #670 🟡1): + # 背景: 旧実装は meta_json(呼び出し元が渡す生JSON片、例 {"label":"foo"})に対して + # 文字列用の _telemetry_escape をそのまま適用したうえで "meta":%s (無クォート)へ + # 埋め込んでいた。meta_json 内にダブルクォート/バックスラッシュが含まれると + # エスケープと JSON 構造が二重に競合し、不正な JSONL 行になり得た。 + # 守るべき業務ルール: telemetry は fail-open かつ JSONL を絶対に壊さない。 + # 他案不採用理由: meta_json を素朴な文字列置換で「JSON オブジェクトとして」再構築する案は、 + # ネスト・エスケープの全パターンを網羅できずシェルだけでの安全な JSON 生成は非現実的なため不採用。 + # 対応: python3 無し環境では meta は常に空オブジェクト{}に固定し、元データは + # meta_raw に「文字列値」として安全にエスケープして退避する(構造は壊さず、情報も欠落させない)。 + _telemetry_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/ }" + s="${s//$'\r'/ }" + s="${s//$'\t'/ }" + printf '%s' "$s" + } + if [ -n "$meta_json" ]; then + printf '{"ts":"%s","tool":"%s","pj":"%s","event_type":"%s","name":"%s","outcome":"%s","meta":{},"meta_raw":"%s"}\n' \ + "$(_telemetry_escape "$ts")" \ + "$(_telemetry_escape "$tool")" \ + "$(_telemetry_escape "$pj")" \ + "$(_telemetry_escape "$event_type")" \ + "$(_telemetry_escape "$name")" \ + "$(_telemetry_escape "$outcome")" \ + "$(_telemetry_escape "$meta_json")" \ + >> "$out_file" 2>/dev/null || true + else + printf '{"ts":"%s","tool":"%s","pj":"%s","event_type":"%s","name":"%s","outcome":"%s","meta":{}}\n' \ + "$(_telemetry_escape "$ts")" \ + "$(_telemetry_escape "$tool")" \ + "$(_telemetry_escape "$pj")" \ + "$(_telemetry_escape "$event_type")" \ + "$(_telemetry_escape "$name")" \ + "$(_telemetry_escape "$outcome")" \ + >> "$out_file" 2>/dev/null || true + fi + fi + + return 0 +} diff --git a/.cursor/hooks/scripts/telemetry-log.sh b/.cursor/hooks/scripts/telemetry-log.sh new file mode 100755 index 000000000..f154df33a --- /dev/null +++ b/.cursor/hooks/scripts/telemetry-log.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# telemetry-log.sh — Claude Code hook entry for harness telemetry. +# +# Claude Code の PreToolUse / PostToolUse / SessionStart / Stop / SubagentStop で呼ばれる。 +# stdin の hook JSON を読み、ツール名/イベントから event_type を判定して 1 行 JSON を追記する。 +# +# 絶対方針: fail-open。 +# - いかなる入力・エラーでも exit 0・ブロックしない・stdout には何も出さない +# (PreToolUse で空 stdout = 許可継続。テレメトリが原因でツールを止めない)。 +# - AGENT_HUB_TELEMETRY_DISABLE=1 で完全無効化。 +# - 外部ネットワーク不使用。 +# +# event_type マッピング: +# tool_name=Skill → skill_fire, name=スキル名 +# tool_name=Task/Agent → subagent_start, name=subagent_type +# hook_event=SessionStart → session_start +# hook_event=Stop → session_stop +# hook_event=SubagentStop → subagent_stop +# その他の tool_name 付きツール → tool_use, name=tool_name +# (event/tool ともに取れない場合は記録しない) + +# set -e を使わない(fail-open 優先)。 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# 共有関数を読み込み。lib が無い配布先でも壊れない no-op fallback。 +. "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } + +# stdin を1回だけ読む(hook JSON)。 +input="$(cat 2>/dev/null || true)" + +# 無効化フックはここでも早抜け(呼び出しコストを避ける)。 +if [ "${AGENT_HUB_TELEMETRY_DISABLE:-0}" = "1" ]; then + exit 0 +fi + +# hook JSON を解析して event_type / name / outcome を決定し、lib 関数へ渡す。 +# python3 が読めれば解析、なければ何もしない(fail-open)。 +parsed="$(HOOK_INPUT="$input" python3 - <<'PY' 2>/dev/null || true +import json +import os +import sys + + +def get_string(data, *keys): + for key in keys: + value = data.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def get_tool_input(data): + ti = data.get("tool_input") + if isinstance(ti, dict): + return ti + ti = data.get("toolInput") + if isinstance(ti, dict): + return ti + return {} + + +raw = os.environ.get("HOOK_INPUT", "") +try: + data = json.loads(raw) if raw else {} +except Exception: + data = {} + +if not isinstance(data, dict): + data = {} + +hook_event = get_string(data, "hook_event_name", "hookEventName") +tool_name = get_string(data, "tool_name", "toolName") +agent_type = get_string(data, "agent_type", "agentType") +ti = get_tool_input(data) + +event_type = "" +name = "" + +if hook_event == "SessionStart": + event_type = "session_start" + name = "session" +elif hook_event == "Stop": + event_type = "session_stop" + name = "session" +elif hook_event == "SubagentStop": + # [2026-07-04][fix] Codexレビュー対応(PR #670 🟡2): + # 背景: ファイル冒頭コメントは SubagentStop でも呼ばれる前提だったが、 + # tool_name を伴わない SubagentStop はどの分岐にも一致せず event_type が + # 空のまま記録漏れ(サブエージェント終了が観測されない)になっていた。 + # 対応: SubagentStop を明示的に subagent_stop として記録する。 + event_type = "subagent_stop" + name = "subagent" +elif tool_name == "Skill": + event_type = "skill_fire" + # [2026-07-23][fix] + # 背景: 現行runtimeが tool_input.skill へ変わり、旧nameだけでは空観測になった。 + # 守る契約: skillを正本として読み、旧name/skill_nameは互換入力として維持する。 + # 他案不採用: 旧キー専用へ戻すと現行payloadを再び欠損させるため採らない。 + name = get_string(ti, "skill", "name", "skill_name") +elif tool_name in ("Task", "Agent"): + event_type = "subagent_start" + name = get_string(ti, "subagent_type", "subtype") or agent_type +elif tool_name: + event_type = "tool_use" + name = tool_name + +if not event_type: + # 記録対象が無い → 何も出力しない + sys.exit(0) + +# タブ区切りで shell へ返す(name にタブが含まれる可能性は低いが、念のため除去) +name = name.replace("\t", " ").replace("\n", " ") +print("\t".join([event_type, name, "ok"])) +PY +)" + +# python3 が何も返さなければテレメトリ追記しない(fail-open)。 +if [ -n "$parsed" ]; then + event_type="${parsed%%$'\t'*}" + rest="${parsed#*$'\t'}" + name="${rest%%$'\t'*}" + outcome="${rest#*$'\t'}" + agent_hub_telemetry_log "$event_type" "$name" "$outcome" 2>/dev/null || true +fi + +exit 0 diff --git a/.cursor/hooks/scripts/telemetry-log.test.sh b/.cursor/hooks/scripts/telemetry-log.test.sh new file mode 100755 index 000000000..e1afcb38e --- /dev/null +++ b/.cursor/hooks/scripts/telemetry-log.test.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# telemetry-log.test.sh — telemetry-log.sh のフックエントリ専用回帰テスト。 +# +# 背景(jtt-apps PR #964 の Codex レビュー起点): +# telemetry-log.sh / telemetry-lib.sh 自体の網羅テストは scripts/test-telemetry-hook.sh +# (AGENT-HUB 自身の CI・.github/workflows/ci.yml「Hook integration tests」で実行)が担う。 +# 一方、hook-library/scripts/*.test.sh は「配布先 PJ に script_map 経由で同梱し、配布後の +# hook 単体を再検証できる」サイドカーの規約(block-main-commit.test.sh 等と同型)。 +# telemetry-log だけこのサイドカーが無く、配布先で telemetry-log.sh 単体の動作を +# 再確認する手段が欠けていたため新設する。 +# +# 検証内容(3点。scripts/test-telemetry-hook.sh の該当項目のサブセット): +# 1. Skill ツールの hook JSON を stdin に与えると JSONL が1行増える +# 2. AGENT_HUB_TELEMETRY_DISABLE=1 で何も書かず exit 0 +# 3. 壊れた JSON 入力でも exit 0(fail-open) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/telemetry-log.sh" + +PASS=0 +FAIL=0 + +pass() { printf '[PASS] %s\n' "$1"; PASS=$((PASS + 1)); } +fail() { printf '[FAIL] %s: %s\n' "$1" "$2" >&2; FAIL=$((FAIL + 1)); } + +# telemetry-lib.sh の出力先は AGENT_HUB_TELEMETRY_DIR で上書き可能(テスト用)。 +# 本物の ~/.agent-hub/telemetry/ を汚さないよう一時ディレクトリへ差し替える。 +TEST_TMP="$(mktemp -d)" +trap 'rm -rf "$TEST_TMP"' EXIT +export AGENT_HUB_TELEMETRY_DIR="$TEST_TMP/telemetry" +unset AGENT_HUB_TELEMETRY_DISABLE || true +unset AGENT_HUB_TELEMETRY_PJ || true + +count_lines() { + local files + files="$(ls -1 "$AGENT_HUB_TELEMETRY_DIR"/*.jsonl 2>/dev/null || true)" + if [ -z "$files" ]; then + echo 0 + return + fi + cat $files 2>/dev/null | wc -l | tr -d '[:space:]' +} + +last_line() { + local files + files="$(ls -1 "$AGENT_HUB_TELEMETRY_DIR"/*.jsonl 2>/dev/null || true)" + if [ -z "$files" ]; then + echo "" + return + fi + cat $files 2>/dev/null | tail -n1 +} + +# ── 1/3: Skill ツールの hook JSON → JSONL が1行増える ──────────────── +BEFORE=$(count_lines) +printf '{"hook_event_name":"PreToolUse","tool_name":"Skill","tool_input":{"name":"plan-approval"}}' \ + | bash "$HOOK" 2>/dev/null +AFTER=$(count_lines) +if [ "$AFTER" -gt "$BEFORE" ]; then + LAST_LINE="$(last_line)" + if printf '%s' "$LAST_LINE" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["event_type"]=="skill_fire" and d["name"]=="plan-approval"' 2>/dev/null; then + pass "Skill発火のhook JSONでJSONLが1行増える" + else + fail "Skill発火のJSONL内容" "想定外の内容: $LAST_LINE" + fi +else + fail "Skill発火でJSONLが増える" "行数が増えなかった(before=$BEFORE after=$AFTER)" +fi + +# ── 2/3: AGENT_HUB_TELEMETRY_DISABLE=1 で何も書かず exit 0 ─────────── +BEFORE=$(count_lines) +DISABLE_OUT="$(printf '{"hook_event_name":"PreToolUse","tool_name":"Skill","tool_input":{"name":"nope"}}' \ + | AGENT_HUB_TELEMETRY_DISABLE=1 bash "$HOOK" 2>/dev/null; echo "rc=$?")" +AFTER=$(count_lines) +if [ "$BEFORE" = "$AFTER" ] && printf '%s' "$DISABLE_OUT" | grep -q 'rc=0'; then + pass "AGENT_HUB_TELEMETRY_DISABLE=1で何も書かずexit 0" +else + fail "AGENT_HUB_TELEMETRY_DISABLE=1" "行数変化(before=$BEFORE after=$AFTER) または非0終了: $DISABLE_OUT" +fi + +# ── 3/3: 壊れた JSON でも exit 0(fail-open) ─────────────────────────── +BROKEN_OUT="$(printf 'not json at all {{{' | bash "$HOOK" 2>/dev/null; echo "rc=$?")" +if printf '%s' "$BROKEN_OUT" | grep -q 'rc=0'; then + pass "壊れたJSON入力でもexit 0(fail-open)" +else + fail "壊れたJSON入力" "exit 0 にならなかった: $BROKEN_OUT" +fi + +# 空 stdin も fail-open で exit 0 であることも併せて確認(壊れたJSON系の代表的な派生形)。 +EMPTY_OUT="$(printf '' | bash "$HOOK" 2>/dev/null; echo "rc=$?")" +if printf '%s' "$EMPTY_OUT" | grep -q 'rc=0'; then + pass "空stdinでもexit 0(fail-open)" +else + fail "空stdin" "exit 0 にならなかった: $EMPTY_OUT" +fi + +echo "" +echo "=== telemetry-log.test.sh: $PASS passed, $FAIL failed ===" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 000000000..d3b8017dd --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,42 @@ +{ + "mcpServers": { + "agentmemory": { + "type": "stdio", + "command": "/bin/bash", + "args": [ + "/Users/shintaro/business/AGENT-HUB/scripts/agentmemory-mcp-remote.sh", + "agentmemory" + ] + }, + "ai-worker-mcp": { + "type": "stdio", + "command": "/Users/shintaro/business/AGENT-HUB/tools/ai-worker-mcp/bin/ai-worker-mcp" + }, + "codebase-context-engine": { + "url": "http://shintaros-mac-mini:8847/mcp", + "headers": { + "X-API-Key": "${env:CODEBASE_CONTEXT_ENGINE_MCP_API_KEY}" + } + }, + "context7": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp@3.2.0" + ] + }, + "shintaro-gbrain": { + "url": "https://gbrain-mcp.jtt.cafe/mcp" + }, + "stitch": { + "url": "https://stitch.googleapis.com/mcp", + "headers": { + "X-Goog-Api-Key": "${env:STITCH_API_KEY}" + } + }, + "tech-gbrain": { + "url": "https://gbrain-mcp.jtt.cafe/mcp" + } + } +} diff --git a/.cursor/rules/10-runtime-sync.mdc b/.cursor/rules/10-runtime-sync.mdc new file mode 100644 index 000000000..9d26bc903 --- /dev/null +++ b/.cursor/rules/10-runtime-sync.mdc @@ -0,0 +1,168 @@ +--- +description: "Claude Code の正本から生成される Cursor 用ランタイム同期ルール。直接編集しない。" +alwaysApply: true +--- + + + +# Cursor Runtime Sync + +このファイルは `sync-cursor-from-cc` で生成された派生物。 +直接編集しないこと。 + +## 正本 + +- `CLAUDE.md` +- `.claude/rules/` +- `AGENTS.md` +- `.claude/settings.json` +- `.claude/hooks/` +- `.claude/agents/` + +## 運用ルール + +- Claude Code 側の正本を最優先で扱う +- `.cursor/` 配下は派生物として扱う +- `CLAUDE.md` または `.claude/rules/` に変更が入ったら、`sync-cursor-from-cc` を再実行する +- hooks / agents も Cursor 側で手編集せず、Claude 側ソースから再同期する +- `.claude/commands/` と `.claude/skills/` / `.cursor/skills/` を優先して使う(AGENT-HUB 正本への symlink。dev-guardrails 必須) +- `.cursor/rules/*.mdc` は `.claude/rules/*.md` からの生成物として扱う +- `.cursorrules` は legacy 扱いのため生成しない(2026-05-04 以降) +- hook 対応範囲は `.cursor/hooks.json` の生成結果と公式ドキュメントを基準に確認し、未対応イベントは warnings に残す +- `rulesync` は使わない + +## 生成元情報 + +- warnings: `10` +- source_files: +- `CLAUDE.md` +- `.claude/rules/general/ai-model-selection.md` +- `.claude/rules/general/branch-rule.md` +- `.claude/rules/general/constructive-dissent.md` +- `.claude/rules/general/hooks-structure-rule.md` +- `.claude/rules/general/latest-stack-context7.md` +- `.claude/rules/general/mandate-registry.md` +- `.claude/rules/general/mcp-key-management.md` +- `.claude/rules/general/memory-lookups.md` +- `.claude/rules/general/plan-approval-gate.md` +- `.claude/rules/general/plan-commitment-tracking.md` +- `.claude/rules/general/reference-over-hardcode.md` +- `.claude/rules/general/response-style.md` +- `.claude/rules/general/responsive-both-viewports.md` +- `.claude/rules/general/settings-protection-coexistence.md` +- `.claude/rules/general/sub-agent-scope-contract.md` +- `.claude/rules/general/ui-stitch-mandatory.md` +- `.claude/rules/general/visual-progress-map.md` +- `.claude/rules/general/worktree-rule.md` +- `AGENTS.md` +- `.claude/settings.json` +- `.claude/hooks/.hook-library-version` +- `.claude/hooks/lib/code-quality-check.md` +- `.claude/hooks/lib/hook-io.sh` +- `.claude/hooks/lib/quality-check-common.sh` +- `.claude/hooks/lib/storage-url-common.py` +- `.claude/hooks/scripts/block-destructive-git.sh` +- `.claude/hooks/scripts/block-destructive-git.test.sh` +- `.claude/hooks/scripts/block-main-commit.sh` +- `.claude/hooks/scripts/block-main-commit.test.sh` +- `.claude/hooks/scripts/block-skill-reverse-edit.sh` +- `.claude/hooks/scripts/block-skill-reverse-edit.test.sh` +- `.claude/hooks/scripts/block-unauthorized-docs-file.sh` +- `.claude/hooks/scripts/block-unauthorized-docs-file.test.sh` +- `.claude/hooks/scripts/freshness-gate.sh` +- `.claude/hooks/scripts/handover-preflight.sh` +- `.claude/hooks/scripts/handover-preflight.test.sh` +- `.claude/hooks/scripts/post-merge-gate.sh` +- `.claude/hooks/scripts/post-merge-gate.test.sh` +- `.claude/hooks/scripts/pre-implementation-check.sh` +- `.claude/hooks/scripts/stop-quality-check.sh` +- `.claude/hooks/scripts/storage-url-pr-gate.sh` +- `.claude/hooks/scripts/subagent-quality-check.sh` +- `.claude/hooks/scripts/takeover-preflight.sh` +- `.claude/hooks/scripts/takeover-preflight.test.sh` +- `.claude/hooks/scripts/telemetry-lib.sh` +- `.claude/hooks/scripts/telemetry-log.sh` +- `.claude/hooks/scripts/telemetry-log.test.sh` +- `.claude/agents/backend-architect.md` +- `.claude/agents/backend-developer.md` +- `.claude/agents/chatgpt-image-creator.md` +- `.claude/agents/document-writer.md` +- `.claude/agents/frontend-developer.md` +- `.claude/agents/implementation-auditor.md` +- `.claude/agents/qa-reviewer.md` +- `.claude/agents/quality-engineer.md` +- `.claude/agents/stitch-screen-creator.md` +- `.claude/agents/technical-writer.md` +- `.claude/agents/test-runner.md` +- generated_rules: +- `.cursor/rules/general/ai-model-selection.mdc` +- `.cursor/rules/general/branch-rule.mdc` +- `.cursor/rules/general/constructive-dissent.mdc` +- `.cursor/rules/general/hooks-structure-rule.mdc` +- `.cursor/rules/general/latest-stack-context7.mdc` +- `.cursor/rules/general/mandate-registry.mdc` +- `.cursor/rules/general/mcp-key-management.mdc` +- `.cursor/rules/general/memory-lookups.mdc` +- `.cursor/rules/general/plan-approval-gate.mdc` +- `.cursor/rules/general/plan-commitment-tracking.mdc` +- `.cursor/rules/general/reference-over-hardcode.mdc` +- `.cursor/rules/general/response-style.mdc` +- `.cursor/rules/general/responsive-both-viewports.mdc` +- `.cursor/rules/general/settings-protection-coexistence.mdc` +- `.cursor/rules/general/sub-agent-scope-contract.mdc` +- `.cursor/rules/general/ui-stitch-mandatory.mdc` +- `.cursor/rules/general/visual-progress-map.mdc` +- `.cursor/rules/general/worktree-rule.mdc` +- `.cursor/rules/10-runtime-sync.mdc` +- generated_hooks: +- `.cursor/hooks.json` +- `.cursor/hooks/.hook-library-version` +- `.cursor/hooks/lib/code-quality-check.md` +- `.cursor/hooks/lib/hook-io.sh` +- `.cursor/hooks/lib/quality-check-common.sh` +- `.cursor/hooks/lib/storage-url-common.py` +- `.cursor/hooks/scripts/block-destructive-git.sh` +- `.cursor/hooks/scripts/block-destructive-git.test.sh` +- `.cursor/hooks/scripts/block-main-commit.sh` +- `.cursor/hooks/scripts/block-main-commit.test.sh` +- `.cursor/hooks/scripts/block-skill-reverse-edit.sh` +- `.cursor/hooks/scripts/block-skill-reverse-edit.test.sh` +- `.cursor/hooks/scripts/block-unauthorized-docs-file.sh` +- `.cursor/hooks/scripts/block-unauthorized-docs-file.test.sh` +- `.cursor/hooks/scripts/cursor-command-bridge.sh` +- `.cursor/hooks/scripts/freshness-gate.sh` +- `.cursor/hooks/scripts/handover-preflight.sh` +- `.cursor/hooks/scripts/handover-preflight.test.sh` +- `.cursor/hooks/scripts/post-merge-gate.sh` +- `.cursor/hooks/scripts/post-merge-gate.test.sh` +- `.cursor/hooks/scripts/pre-implementation-check.sh` +- `.cursor/hooks/scripts/stop-quality-check.sh` +- `.cursor/hooks/scripts/storage-url-pr-gate.sh` +- `.cursor/hooks/scripts/subagent-quality-check.sh` +- `.cursor/hooks/scripts/takeover-preflight.sh` +- `.cursor/hooks/scripts/takeover-preflight.test.sh` +- `.cursor/hooks/scripts/telemetry-lib.sh` +- `.cursor/hooks/scripts/telemetry-log.sh` +- `.cursor/hooks/scripts/telemetry-log.test.sh` +- generated_agents: +- `.cursor/agents/backend-architect.md` +- `.cursor/agents/backend-developer.md` +- `.cursor/agents/chatgpt-image-creator.md` +- `.cursor/agents/document-writer.md` +- `.cursor/agents/frontend-developer.md` +- `.cursor/agents/implementation-auditor.md` +- `.cursor/agents/qa-reviewer.md` +- `.cursor/agents/quality-engineer.md` +- `.cursor/agents/stitch-screen-creator.md` +- `.cursor/agents/technical-writer.md` +- `.cursor/agents/test-runner.md` +- generated_mcp: +- なし +- generated_skills: +- なし diff --git a/.cursor/rules/general/ai-model-selection.mdc b/.cursor/rules/general/ai-model-selection.mdc new file mode 100644 index 000000000..4479aaa58 --- /dev/null +++ b/.cursor/rules/general/ai-model-selection.mdc @@ -0,0 +1,85 @@ +--- +description: Claude 正本 `ai-model-selection.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# AI モデル選定指標(GLM 5.2 / Kimi K2.7・K3) + +全 PJ 共通。コード実装をAIエージェントに任せる際の初期ヒューリスティック。 + +> ⚠️ これは**法則ではなく初期判断**。母数が小さい(初期 n=4 + 追加観測・人が見ながら実行)。矛盾する観測が出たら現状を優先し、実測ログ(references)を更新すること。 + +--- + +## 0-bis. Codex 指名時の固定ルール + +- **`codexで実装` / `Codexで実装` / `codex実装` / `Codex実装`** と言われた時だけ、Codex 実装として扱う。 +- Codex 実装の正式設定名は **model = `gpt-5.3-codex-spark`**, **model_reasoning_effort = `high`**(既定。旧既定 `medium`。SWE-Bench Pro 実測で high→xhigh の上げ幅は1pt未満のため常時 xhigh は費用対効果が低い)。 +- 起動例は `codex exec -m gpt-5.3-codex-spark -c model_reasoning_effort=high`。 +- **`xhigh` はユーザーが明示指定した時だけ使う**。軽微タスクは `medium` を明示指定する。AI が自動・既定・推測で `xhigh` を選ばない。 +- **「実装」だけでは Codex 固定にしない**。Cursor / Kimi / GLM / Claude / Codex のどれで進めるかを文脈で判断し、不明なら確認する。 +- **Spark は AI Worker MCP の auto routing 候補に対等参加する**(適材適所+残量バランス・絶対優先ではない)。原因不明バグ・設計判断・DB移行・大規模リファクタ・コンテキストが大きい仕事は Spark に固執せず、auto が適材適所で他 worker(GLM/Kimi/Gemini)へ回避する。 +- **「レビュー」または「codexでレビュー」** は既存の `codex-review` 導線を使う。実装専用の `gpt-5.3-codex-spark` 固定には巻き込まない。 + +--- + +## 4. 使い分けガイド(第一候補) + +| タスク種別 | 第一候補 | 理由 | +|-----------|---------|------| +| 仕様が明確・クリーンさ重視・UI/結線・お手本コード | **GLM 5.2** | 簡潔・範囲内に収まりやすい・速い | +| 複雑・セキュリティ/堅牢性が重要なバックエンド | **Kimi K2.7 Code** | 安全性を自力で深掘り・テスト厚い | +| どちらでも可 | いずれか | ただし下記ガードを必ず付ける | + +### Kimi 内モデル選択(決定論的) + +`agents.yaml.worker_delegation.kimi_model_routing` を正本とし、優先順は、明示 `provider_model` → 長大/推定不能な巨大contextの `k3` → 明示的な速度優先かつ3倍quota許容時の `kimi-for-coding-highspeed` → 通常の `kimi-for-coding` とする。 + +- K3条件: `requires_long_context=true`、推定contextが212,992 token超、または推定不能かつraw UTF-8が512KiB超。`max`、上限1,048,576 token。 +- 選定結果: `reason_code` / `selected_model` / `estimated_context` / `fallback_reason` を必ず残す。 +- K3切替: 新sessionを開始し、必要情報の要約だけを渡す。履歴を丸ごと移送しない。 + +GLM 5.2 の正式運用は high / max のみ(デフォルト high・他の値はルーティングのバリデーションで拒否される)。母数は n=4 の初期観測であり法則ではない(冒頭⚠️参照)。 + +--- + +## 5. 運用上の必須ガード(モデルの弱点を相殺する) + +- **完了の定義を検証可能に**(Kimi の過大申告対策): 「スクショは git にコミット」「テストは緑のログを示す」等、"やったと言うだけ"を許さない。 +- **スコープを超えるなを明示**(Kimi の過剰実装対策): 「指定範囲のみ。追加の堅牢化は別 PR」。 +- **長時間タスクは声がけ / 自動継続**(GLM の停滞対策)。 +- **リポの前提を渡す**(GLM の取り違え対策): 言語・パッケージ管理の前提を明記。 +- **既存 CaD コメント規約に倣わせる**: 新規関数・ブロック追加時は対象ファイルの既存様式(日付・種別・背景3点)に倣うと明記する。 + +--- + +## 6. 候補提案とディスパッチ + +実装委譲・並列実装の話題が出たら §4 を根拠に「GLM 5.2 向き / Kimi向き」を 1 行理由つきで先に提案し、Kimi内のK2.7/K3は上記契約で選ぶ。ディスパッチ実行は `agent-dispatch` スキルへ(未導入環境では §4・§5 のみ使う)。役割分担: 方針選定・委譲・進捗確認・結果回収 = Claude / Codex。実行は `agents.yaml` の有効 provider だけを AI Worker MCP 経由で行う。プロンプトには §5 の必須ガードを必ず織り込む。 + +詳細手順は `skills/agent-dispatch/` を参照(本ルールは方針、skill は手順=DRY)。 + +--- + +## 8. 関連 + +- `skills/agent-dispatch/` — `agents.yaml` と AI Worker MCP を使う worker 委譲手順(本ルールの実行系) +- `skills/kimi-sync/` — Kimi CLI のPJアタッチ(`sync-kimi-from-cc.py`) +- `.claude/rules/general/response-style.md` — 出力簡潔性 +- `.claude/rules/general/visual-progress-map.md` — 進捗可視化 +- `dotfiles/kimi/config.toml.base` — Kimi Code CLI の loop/permission 既定(`max_steps_per_turn` 等) +- 実測ログ・スコアカード・OpenCode Go 選定指標の全文: `/skills/agent-dispatch/references/model-selection-evidence.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: 実測ログ・スコアカードは references(上記)へ追記し、本ルールには足さない(再肥大化防止)。** diff --git a/.cursor/rules/general/branch-rule.mdc b/.cursor/rules/general/branch-rule.mdc new file mode 100644 index 000000000..7218cfd6f --- /dev/null +++ b/.cursor/rules/general/branch-rule.mdc @@ -0,0 +1,109 @@ +--- +description: Claude 正本 `branch-rule.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# ブランチ運用ルール + +## main ブランチへの直接コミット・プッシュ + +AI エージェントの通常作業では、**main ブランチへの直接コミット・プッシュは禁止**。 + +Markdown、`sync-state.json`、AI ツール設定、AGENT-HUB 運用設定、MCP 台帳などの軽量変更でも、 +AI は main へ直接 commit / push しない。必ず専用 worktree + feature branch を作成し、PR 経由でマージする。 + +人間が明示的に「今回は main に直接反映してよい」と承認した場合、または初回 repo 作成直後で +PR 導線がまだ存在しない場合だけ例外になりうる。AI はこの例外を自己判断で使わず、理由を作業ログに残す。 + +(過去に運用設定・hook配布物等を段階的に allowlist で main 直接許可した経緯があるが、2026-06-23〜2026-07-01 +で全撤回済み。allowlist 変遷史の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照)。 + +## 理由 + +- main checkout は複数 AI / 複数セッションで共有されやすく、軽量変更でも HEAD を掴むと競合や cleanup 失敗の原因になる +- Markdown や設定だけでも、PR にするとレビュー履歴・CI・merge 後確認・worktree cleanup が同じ型で残る +- ツールごとに例外を残すと、Claude / Codex / Cursor / Kimi / Antigravity 間で運用がずれる +- main の最新化は `git pull` ではなく、fetch-only と detached HEAD / 専用 verify worktree で確認すれば足りる + + +## AGENT-HUB の CI とマージ根拠(2026-07-30 STEP 4) + +AGENT-HUB の CI は `workflow_dispatch` + `ci/light` ラベル方式(pull_request 自動トリガーは 2026-07-24 に削除済み)。 +PR に checks が無い場合のマージ根拠は `merge-pr.py` のローカル軽量ゲート(`registries/merge-gate-suite.yaml`)。 +台帳未整備のリポでは従来どおり checks 0 件で通す(詳細: `skills/post-merge/SKILL.md`)。 + +## 配布クローズアウト責任 + +AGENT-HUB から各 PJ へ配布した差分は、配布を実行した AI / 担当者が最後まで閉じる。 + +対象: `scripts/deploy-agent-bundle.py` / `scripts/deploy-hooks.py` / `scripts/sync-agents.py` / +`scripts/bootstrap-skills.py` / `scripts/deploy-skills.py` / `scripts/deploy-rules.py` / +`/publish-deploy` など、上記を呼ぶ配布コマンド。 + +配布先 PJ に tracked 差分が出た場合は、feature branch 作成 → 配布差分だけ commit → PR 作成 → CI/review 確認 → +`merge-pr` でマージ → fetch-only + detached HEAD / verify worktree で取り込み確認 → worktree/branch cleanup → +`git status --short` clean 確認、まで一連で完了する(詳細な完了条件・禁止・例外の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` 参照)。 + +禁止: 「これは自分が修正したファイルではない」として配布差分を放置する/未コミットのまま終了する/ +main 直接 push で済ませる/`--push` の成功だけで完了扱いにする。 + +例外(dry-run のみ・差分なし・既存WIPで安全に branch できない・権限やCI failureで merge できない)の場合も、 +対象 PJ・残っている差分・止めた理由・次の安全な一手を報告する。 + +## 事前計画ステップ + +タスク開始時、変更を伴う作業か確認する(コード変更・JSON/YAML変更・`*.sh`変更・Markdown/sync-state/AIツール設定などの軽量変更)。 +AI 作業で変更がある場合、**最初に専用 worktree + feature branch を作成**してから編集を始める。AI 作業では `main` を checkout しない。 +コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +読み取りだけの場合、またはすでに専用 worktree / feature branch 内にいる場合は新規 worktree を作らなくてよい。 +AGENT-HUB から各 PJ へ配布した tracked 差分も「配布クローズアウト責任」に従う。 + +## pre-commit hook 違反後のピボット + +万一 hook(`hook-library/scripts/block-main-commit.sh`)にブロックされた場合は、変更を退避(stash/patch)→ +専用 worktree で feature branch 作成 → 変更復元 → commit/push → PR 作成、の順で復旧する。main の HEAD は +無変更のまま維持されることを確認する。詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 関連フック + +`hook-library/scripts/block-main-commit.sh` が上記ルールを自動判定・ブロックする。 + +## 関連ルール + +- `.claude/rules/general/worktree-rule.md` — 並列セッション時の worktree 利用 +- `.claude/rules/general/sub-agent-scope-contract.md` — サブエージェント delegate 時の制約 +- `~/business/AGENT-HUB/docs/worktree-operations.md` — allowlist変遷史・配布クローズアウト責任詳細・事前計画コマンド列・pre-commit hookピボット手順の正本 + +--- + +**追記ルール: 実測事例・変遷史・長文手順は `~/business/AGENT-HUB/docs/worktree-operations.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.cursor/rules/general/constructive-dissent.mdc b/.cursor/rules/general/constructive-dissent.mdc new file mode 100644 index 000000000..49ca6741e --- /dev/null +++ b/.cursor/rules/general/constructive-dissent.mdc @@ -0,0 +1,68 @@ +--- +description: Claude 正本 `constructive-dissent.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# 建設的異議(言いなり禁止・グローバル憲法) + +## 原則 + +AI は**言いなりにならない**。ユーザー指示が現実・制約・過去の不採用判断(CaD)と衝突するとき、迎合せず次の 3 点を必ず行う。 + +1. **現実的制約の明確な指摘** — 無理なものは「無理です」と根拠付きで言う(時間・技術・運用・既存 SSOT・過去の不採用理由)。 +2. **根拠付きの代替案** — 達成したい意図を保ちつつ、実行可能な別ルートを 2〜3 択で提示する(推奨を 1 行添える)。 +3. **保守・メンテナンス観点の改善提案** — 指示に従うだけでなく、「こういう仕組みを入れるべき」と AI から先出しする(出生登録・正本参照・陳腐化防止など)。 + +**最終決定は常にユーザー**。AI は異見を述べたうえで、ユーザーが選んだ方向に従う。 + +## 発火場面 + +| フェーズ | 異議の出し方 | +|---------|-------------| +| **提案・設計** | plan-approval の HTML プランに「🤔 AI の異見」欄で記載(テンプレ側は別 PR で欄追加予定)。プラン提示前に衝突があれば先に異議を出す | +| **実装** | 着手前または実装中に制約・不採用判断との衝突を検知したら、実装を止めて代替案を提示 | +| **レビュー** | codex-review 等の指摘が個人開発スケールに過剰なときも、レビュー結果に対して異議・優先度の再整理を提案できる | + +判断に迷う場合は**異議を出す側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 作法 + +- **根拠必須**: 「良くない」だけでなく、なぜ無理か・何が起きるかを平易語で 1〜2 文。 +- **平易語 + 選択肢**: visual-progress-map §5 に従い、技術用語だけで問わない。速さ・安全・見た目への影響など、ユーザーが判断できる軸に翻訳する。 +- **推奨を添える**: 2〜3 択のうち推奨を明示(「(推奨)」+ 理由 1 行)。 +- **短い同意への再確認**: ユーザーが「お願い」「はい」だけ返したとき、次の一手を 1 文で要約してから進める(response-style と整合)。 + +## 個人開発スケールと例外 + +- **前提**: 本リポ群は個人開発(1 人・非エンジニアオーナー)。大規模チーム向けのプロセス・過度な抽象化・仮想的大規模負荷対策を**無条件で推奨しない**。 +- **過剰エンタープライズ提案への異議**: 「全 PJ に同じ監査パイプライン」「専用 infra チーム前提の運用」等は、意図が明確でない限り異議を唱える。 +- **例外(厳格維持)**: + - **(a)** セキュリティ・データ消失・金銭に関わる指摘はスケールに関係なく常に厳格。 + - **(b)** 顧客向けシステム(jtt-cms の予約・お客様導線・決済・個人情報を扱う画面/API)はエンタープライズ相当の厳格さを維持。 + +codex-review のレビュー観点にも同校正が内蔵されている(プロンプト文字列参照)。 + +## メンテナンス観点の先出し例 + +- 新スキル・hook・ドキュメントを作るとき → `checkup-registry.yaml` への出生登録を提案。 +- 手順・閾値・API 名をハードコードしそうなとき → 正本参照(ライブ読み・SSOT symlink)を提案。 +- 外部 API・ライブラリ版数を書くとき → 最終確認日の記載を提案。 + +## 関連 + +- `.claude/rules/general/response-style.md` — 出力簡潔性・確認の書き方 +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・技術判断の平易化(§5) +- `.claude/rules/general/plan-approval-gate.md` — 実装前 HTML プラン承認(🤔 AI の異見欄と接続) +- `.claude/rules/general/plan-commitment-tracking.md` — 承認済みプラン条項の実行追跡 +- `skills/adversarial-review/SKILL.md` — **本ルールの手順 SSOT**(dev / business の 2 モード・発火条件・証拠水準・自己反証・分布点検)。本ルールは義務、スキルは手順の二段構えとし、手順本文をここへ複製しない +- `skills/codex-review/SKILL.md` — レビュー時の個人開発スケール校正 diff --git a/.cursor/rules/general/hooks-structure-rule.mdc b/.cursor/rules/general/hooks-structure-rule.mdc new file mode 100644 index 000000000..f3ac6f392 --- /dev/null +++ b/.cursor/rules/general/hooks-structure-rule.mdc @@ -0,0 +1,81 @@ +--- +description: Claude 正本 `hooks-structure-rule.md` から生成される Cursor rule。直接編集しない。 +globs: +- hook-library/** +- hook-registry.yaml +- scripts/deploy-hooks.py +--- + + + +# hooks 構造ルール + + + +## チェックリストMDの配置 + +| 正しい配置 | 禁止 | +| ----------------------------------------------------- | -------------------------- | +| `hook-library/lib/code-quality-check.md` | `hook-library/prompts/` | +| `hook-library/checklists/security/security-review-check.md` | 任意の新規サブディレクトリ | + +配布後の PJ 側でも同じ規約に従う: + +| 正しい配置 | 禁止 | +| -------------------------------------------- | -------------------------- | +| `.claude/hooks/lib/code-quality-check.md` | `.claude/hooks/prompts/` | +| `.claude/hooks/lib/security-review-check.md` | 任意の新規サブディレクトリ | + +## 禁止事項 + +- `prompts/` ディレクトリの作成・復活(AGENT-HUB / 配布先 PJ いずれも) +- `quality-check-common.sh` のチェックリスト参照パスを `lib/` 以外に変更 +- `supabase-sql-review.md` の復活(`security-review-check.md` と重複していた削除済みファイル) +- `ui-quality-gate.json` の復活(`type: "prompt"` でレビュー LLM に丸投げする方式は失敗時にプロンプト原文がチャットに漏れるため廃止。UI 品質チェックは `code-quality-check.md` の `ui-quality-jp` domain を `subagent-quality-check.sh` / `stop-quality-check.sh` がファイル参照型で reason に出す形で完結する) +- `hook-registry.yaml` の `checklist.security` に `KNOWN_SECURITY_CHECKLISTS` allow-list 外の名前を書くこと(`scripts/deploy-hooks.py` が fail-fast で拒否する) +- 対象PJを明示せずに hook を追加・配布すること。新規 hook は「必要な PJ」「不要な PJ」「Codex/Augment へ載せるか」を AGENT-HUB セッションで決めてから `hook-registry.yaml` に登録する。 +- `/hook-publish` の復活。project 配布applyは + `scripts/sync-agents.py --project --project-root ` だけを公開入口とし、物理 hook writerを単独実行しない。 + +## hook 追加・配布フロー + +1. AGENT-HUB セッションで hook の目的と対象PJを決める。 +2. `hook-library/scripts/`、`hook-library/settings/`、`scripts/deploy-hooks.py` の script map、`hook-registry.yaml` を同一PRで更新する。 +3. `scripts/sync-agents.py --project --dry-run` で全 surface の同一generation差分を確認する。 +4. 実配布が必要ならcleanな専用linked worktreeを明示してfull applyする。複数PJでも明示リストを1件ずつ処理する。 +5. 個別 writer の `--all` は使わない。全PJの一括同期は別の明示承認とscope確認を必要とする。 + +## チェックリスト注入方式 + +| 方式 | 説明 | +| ------------------------ | --------------------------------------------------------- | +| ファイル参照型(採用) | reason にファイルパスを記載し、AIがReadツールで読む | +| インライン注入型(廃止) | reason にチェックリスト全文を埋め込む(チャットが埋まる) | + +reason にチェックリスト全文を埋め込まないこと。AI が Read ツールでファイルを読む形にすることで、ユーザーのチャット視認性を確保する。 + +## 配布スクリプトによる強制ガード(`scripts/deploy-hooks.py:merge_settings()`) + +| ガード | 役割 | +| --- | --- | +| `_strip_prompt_type_hooks()` | `type: "prompt"` の hook をマージ時に強制除去。インライン注入型の混入を配布パイプラインで遮断する | +| `_dedupe_hooks_by_command()` | `(matcher, paths, command)` ベースで dedupe。dict 完全一致比較が空白・キー順差で破綻し、過去 jtt-cms に重複 4 件(`prettier-format` / `seo-check` / `storage-url-check` / `block-main-commit`)が混入した実績の再発防止 | + +これらのガードと `--all --confirm-all-hook-scope` の安全弁を外す変更は禁止。検証スクリプト `scripts/test-deploy-hooks-merge-settings.sh` がガードの挙動を回帰チェックする。 + +## 理由 + +`scripts/deploy-hooks.py`(テンプレート配布スクリプト)は配布先 PJ の `lib/` にチェックリストMDをデプロイする。`quality-check-common.sh`(runtime)が異なるパスを参照すると、新規 PJ セットアップ後に品質チェックリストが見つからず approve が素通りする。 + +`security-review-check.md` の内容は SECURITY DEFINER / RLS / `crm.` schema 等 Supabase + Postgres 専用のため、Supabase を使わない PJ には配布しない(registry の `checklist.security` を空配列にする)。 diff --git a/.cursor/rules/general/latest-stack-context7.mdc b/.cursor/rules/general/latest-stack-context7.mdc new file mode 100644 index 000000000..349f8c1d1 --- /dev/null +++ b/.cursor/rules/general/latest-stack-context7.mdc @@ -0,0 +1,63 @@ +--- +description: Claude 正本 `latest-stack-context7.md` から生成される Cursor rule。直接編集しない。 +globs: +- '**/*.ts' +- '**/*.tsx' +- '**/*.js' +- '**/*.jsx' +- '**/*.mjs' +- '**/*.vue' +- '**/*.svelte' +- package.json +- next.config.* +- tailwind.config.* +- drizzle.config.* +- vite.config.* +- '**/sw.ts' +--- + + + +# 最新スタック確認ルール(context7 必須) + +## 対象ライブラリ(AI カットオフ後・急速更新) + +以下を**実装・デバッグ・設定変更する前に必ず** context7 で最新 docs を取得する。 +記憶だけで書かない(古い API を使うと動かない・型エラー・ビルド失敗を引き起こす)。 + +| ライブラリ / フレームワーク | 主な罠 | +|----------------------------|--------| +| **Next.js 16+** | `middleware` → `proxy.ts` に改名(Next15→16)、`cookies()`/`headers()` は非同期=`await` 必須(Next15で async 化・16で同期アクセス廃止)、App Router キャッシュ挙動変更 | +| **React 19+** | Next15 以降は React19 前提。`use()`, Server Actions の型・挙動変更 | +| **@serwist/next** / **serwist** | SW ビルド設定・`defaultCache` API が頻繁変更。Turbopack 非対応(`--webpack` 必須) | +| **motion 12+** (`motion/react`) | `motion-plus` API、`AnimatePresence`・`useSpring` 型変更 | +| **Tailwind CSS v4+** | `@config` 廃止・CSS ファースト設定に移行(`tailwind.config.js` 非推奨) | +| **drizzle-orm** | マイグレーション API・スキーマ定義が毎 minor で変わりやすい | +| **vaul** | ドロワー API・`snapPoints` 型が変わっている可能性 | +| **sonner** | `toast()` オプション・`Toaster` props の更新 | + +## 必須手順 + +1. `mcp__context7__resolve-library-id` でライブラリの context7 ID を取得 +2. `mcp__context7__query-docs` で最新 docs を取得してから実装 +3. context7 が使えない環境は `WebFetch` で公式 docs を取得(記憶補完のみでの実装禁止) + +``` +例: Next.js 16 の proxy.ts (旧 middleware) を実装する前に + → resolve-library-id "next.js" → query-docs "proxy middleware" +例: serwist defaultCache を設定する前に + → resolve-library-id "@serwist/next" → query-docs "defaultCache" +``` + +## 古い API の使用禁止 + +- **Next15 以前の同期 `cookies()`**: Next16 では非推奨。`await cookies()` を前提に書く(context7 で確認) +- **`middleware.ts`(Next16 では `proxy.ts`)**: 名前が変わった。context7 で確認してから書く +- **Pages Router 前提のコード**: App Router が前提。`getServerSideProps` 等を新規に書かない +- **React18 前提の型**: React19 の型変化(`children: ReactNode` の必須化等)を確認してから書く +- **旧 `motion/react` 型**: `motion-plus` の型は memory だけで書かない + +## 関連 + +- `skills/dev-guardrails` — フェーズ別ワークフロー・品質ゲート +- `skills/pwa-guardrails` — serwist 配線・PWA 品質チェックリスト(context7 が必要になる代表例を列挙) diff --git a/.cursor/rules/general/mandate-registry.mdc b/.cursor/rules/general/mandate-registry.mdc new file mode 100644 index 000000000..9519059c3 --- /dev/null +++ b/.cursor/rules/general/mandate-registry.mdc @@ -0,0 +1,66 @@ +--- +description: Claude 正本 `mandate-registry.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# 横断チェック台帳(mandate-registry)への登録ルール + +## 原則 + +「これは全アプリで必要だ」という横断的な気づきは、ルール追記だけで終わらせず**台帳へ1行登録する**。 + +理由: ルールファイルへの追記は**新規開発にしか効かない**。既存アプリへの適用漏れは、機械が乖離を提示しない限り再指摘が起きるまで発火しない。台帳へ登録しておけば `mandate-audit.py` が未対応アプリを一覧化し、記憶や注意力に頼らず気づける。 + +## 発火条件(トリガー) + +伸太郎殿が以下のような**横断指摘**をしたとき: + +- 「これは全アプリで必要」 +- 「横展開すべき」 +- 「他のアプリでも同じ対応が要る」 + +判断に迷う場合は**登録する側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 必須手順 + +1. **重複確認**: `registries/mandate-registry.yaml` を `id` / `title_ja` で grep し、同種の項目が既に無いか確認する(複数 AI による二重登録防止)。 +2. **1行登録**: 無ければ台帳へ1エントリ追加する。`reason` には経緯1行+日付を必須で入れる。 +3. **報告**: 登録したことを利用者へ報告する(黙って追加しない)。 + +## 監査 + +「横断監査して」等の発話で `python3 scripts/mandate-audit.py` を実行し、結果を提示する。作業対象アプリが決まっているセッションでは `--app ` で絞り込む。 + +## 回答の記録 + +台帳の `status` フィールドは利用者の回答をそのまま反映する: + +| 利用者の回答 | 記録する値 | +|------|-----------| +| 「後で」 | `snoozed:YYYY-MM-DD` | +| 「対象外」 | `na` | +| 対応 PR がマージされた | `done` | + +## 限界の明示 + +`check: manual` の項目は**目視消込**であり、**監査が緑でも全部 OK を意味しない**。機械(`mandate-audit.py`)が見えるのは台帳に記録された静的な項目だけであり、実装が実際にルールへ適合しているかは別途確認が要る。 + +## スキーマ・規約の正本 + +台帳のフィールド定義・規約①②(`check:script` の実行前提・登録前の重複確認義務)は `registries/mandate-registry.yaml` のヘッダコメントが正本。本ルールへ複製しない。 + +## 試行フェーズ + +2026-08-17 目安で、登録実績・提案件数・`status` 更新のコストを振り返る。セッション開始 hook による自動提案の採否は、その振り返りを踏まえて別プランで判断する(今は hook 化しない)。 + +--- + +**追記ルール: 実測事例・長文手順は台帳ヘッダ/別 doc へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.cursor/rules/general/mcp-key-management.mdc b/.cursor/rules/general/mcp-key-management.mdc new file mode 100644 index 000000000..62b1a684c --- /dev/null +++ b/.cursor/rules/general/mcp-key-management.mdc @@ -0,0 +1,104 @@ +--- +description: Claude 正本 `mcp-key-management.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# MCP API キー管理規範(AGENT-HUB SSOT) + +JTT 関連の MCP(asana-mcp / jtt-smaregi-mcp / smaregi-docs / google-chat-mcp / google-docs-mcp / jtt-spreadsheet-mcp 等)の API キーは **AGENT-HUB を SSOT として一元管理**する。 + +詳細手順(復旧・ローテーション・実装経緯・実例)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。本ルールは義務・禁止事項だけを持つ。 + +## SSOT + +| 役割 | 場所 | 状態 | +|------|------|------| +| 実値(秘匿) | `~/.config/agent-hub/.env` | コミット対象外、各マシンで作成 | +| 名前テンプレート(公開) | `~/business/AGENT-HUB/dotfiles/.env.example` | git 管理、新マシン bootstrap で参照 | +| 環境変数 export | `~/.zshrc.local` の `set -a; source ~/.config/agent-hub/.env; set +a` | bootstrap.sh が初期セットアップ | + +## スコープ振り分け規範 + +| MCP 種別 | 配布先 | 同期スクリプト | +|---------|--------|---------------| +| 全 PJ 共通で必要な MCP | `asset_contract.global.include.mcp` から各clientの宣言surfaceへ | manifestが所有者として指す単一writer | +| harness type共通の MCP(例: Laravel Boost) | `asset_contract.harness_types..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 固有の業務 MCP | `asset_contract.projects..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 個別環境の例外(例: Supabase stg/prod) | project layerと明示local-exception契約へ宣言 | writerが保護・描画。生成surfaceの手編集は禁止 | + +理由: User scope に PJ 固有 MCP を入れると「使わない PJ でも表示・接続試行・認証エラー表示」が起きる。PJ別の使用意図はmanifestのproject layerが表現し、client別catalogは選択根拠にしない。 + +**Gmail の扱い(2026-05-25 更新 / 2026-07-20選択経路更新)**: 自前 gmail-mcp は 2026-05-21 に一度凍結したが、公式 Gmail のツール不足(ラベル CRUD / Triage / 添付取得欠如)が判明し **2026-05-25 に Project scope (jtt-cafe-pj) で復活**。接続definitionはStreamable HTTP `/mcp` + X-API-Keyを維持する。採否はjtt-cafe-pjのmanifest project layer、client対応可否は同じeffective MCPに対するsurface契約で判定する。 + +**Supabase の stg / prod 2 環境並列 (jtt-cms)**: `supabase-prod` / `supabase-stg` の2 assetを命名規約として必須にする(`supabase` 単独名・env-agnostic な `mcp__supabase__*` 表記は禁止)。実例・OAuth手順の詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## Claude Code の `${VAR}` 補間仕様 + +**Claude Code は `mcpServers[*].headers["X-API-Key"]` 等の値を `${VAR}` 補間しない**(User scope / Project scope どちらも同じ)。 + +→ sync スクリプトは `~/.config/agent-hub/.env` から実値を読み出し、`/.mcp.json` / `~/.claude.json` には実値を書き込む。 + +→ よって `.mcp.json` は **gitignore 必須**(実値がコミットされないように)。AGENT-HUB の SSOT は環境変数名のみ保持し、各マシンで sync 実行時に実値展開する。同じ理由で `~/.claude.json` / `.gemini/settings.json` / `.cursor/mcp.json` / `.kimi-code/mcp.json` も全て gitignore 必須(対象ファイルと生成元の gitignore 必須リストは `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。 + +## 禁止事項 + +1. **`~/mcp-servers//.env` へ直書き禁止**。`asana-mcp/.env` `jtt-smaregi-mcp/.env` 等に API キーを置かない。発見次第 `~/.config/agent-hub/.env` へ移行し、ローカル `.env` は `# moved to ~/.config/agent-hub/.env (AGENT-HUB SSOT)` のコメントだけ残す +2. **`~/.zshrc.local` に `_mcp_load_key_from_env` のような分散ロード関数を新設禁止**。AGENT-HUB SSOT の bootstrap フロー(`set -a; source ~/.config/agent-hub/.env; set +a`)を使う +3. **git 管理対象ファイルに API キー実値を平文で書かない**。ドキュメント(README / SKILL.md / 設計書)では `` または env 変数名 `${ASANA_MCP_API_KEY}` で表記する +4. **ローカル生成物へ手作業で API キー実値を書かない**。`.mcp.json` / `~/.claude.json` は gitignore 済みであることを前提に、sync スクリプトだけが `~/.config/agent-hub/.env` から実値展開して書き込む +5. **管理対象ファイルでの URL クエリパラメータ方式(`?api_key=...`)禁止**。Cloud Run の監査ログに URL ごとキーが残るため、`.mcp.json` / `~/.claude.json` / `.codex/config.toml` など AGENT-HUB が生成する設定は `headers: {"X-API-Key": "${...}"}` のヘッダー方式に統一する + +**Claude.ai 例外**: Claude.ai コネクタで `X-API-Key` ヘッダーを設定できない場合のみ、asana-mcp は `https://asana-mcp-vaibinqqva-an.a.run.app/mcp?api_key=` 形式を使ってよい。この例外は Claude.ai 手動登録専用で、AGENT-HUB の生成物には書かない。 + +## 再発防止: sync スクリプトのハードエラー化 + +`scripts/sync-claude-global-mcp.py`、`scripts/sync-claude-project-mcp.py`、`scripts/sync-codex-mcp-configs.py`、`scripts/sync-cursor-mcp-configs.py`、`skills/{gemini,kimi,opencode,augment}-sync/scripts/sync-*-from-cc.py` は、env_key が未解決(`~/.config/agent-hub/.env` に無い/空文字)の場合に **literal `${VAR}` を書き込まず exit 1** すること。 + +理由・過去の実害(jtt-cms で `smaregi-docs` MCP の認証エラーが反復した根本原因)は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## 再発防止: MANAGED block の重複キー除去 + TOML 検証(Codex / 2026-06-02〜) + +`scripts/lib/user_mcp_sync_lib.py` の `replace_managed_block` は、①同名野良エントリの自動除去 ②書き込み前 TOML パース検証、を担保する(MANAGED 対象でない手書き MCP は保護する)。実装経緯・障害の症状は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` の「Codex config TOML 重複キー」節を参照。 + +## 復旧手順(MCP Auth エラー時) + +詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md`。要旨: ①env が読めているか確認 ②`~/.claude.json` の literal `${VAR}` 残存検査 ③対象projectを公開入口から再同期 ④Claude Code を再起動。 + +## ローテーション手順 + +API キーローテーション時の 7 ステップ(新キー発行 → SSOT 更新 → dry-run 確認 → full apply → 個別sync禁止 → 各PJ再起動 → 旧キー失効)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。旧キーを `dotfiles/.env.example` のコメントに「廃止済み」として残してはいけない。 + +## User scope MCP 同期フレームワーク (2026-05-21〜) + +User scope MCP (`~/./...`) の SSOT 一元管理は **user-mcp スキル**が管轄する(User scope / Project scope の設計と担当 sync スクリプトの対応表は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。新エージェント追加 5 ステップ (CLAUDE.md 9-13 参照): `skills/user-mcp/SKILL.md`。 + +## 関連 + +- `dotfiles/.env.example` — 名前テンプレート +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 復旧手順・ローテーション手順・実装経緯・User scope同期フレームワーク対応表の詳細正本 +- `skills/user-mcp/SKILL.md` — User scope MCP 5 ツール統一管理スキル(sync スクリプト一覧はここに集約) +- `scripts/lib/user_mcp_sync_lib.py` — 5 sync 共通 lib (env / registry / MANAGED block / 検証) +- `scripts/sync-claude-project-mcp.py` — Project scope 同期 +- `scripts/codex-mcp-remote-with-env.sh` — Codex 用 SSE → stdio bridge +- `~/business/AGENT-HUB/docs/codex-mcp-registry.yaml` `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — 台帳 +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 障害復旧ランブック +- `docs/reference/project-roots.md` — プロジェクトルート規約 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.cursor/rules/general/memory-lookups.mdc b/.cursor/rules/general/memory-lookups.mdc new file mode 100644 index 000000000..772da055b --- /dev/null +++ b/.cursor/rules/general/memory-lookups.mdc @@ -0,0 +1,74 @@ +--- +description: Claude 正本 `memory-lookups.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# メモリ参照ルール + + + +## 基本方針 + +memory は、前回までの作業状態・人物名・用語・過去の判断を思い出すための**参照補助**である。 +売上・タスク・勤怠・予約・確定ルールの正本ではない。 + +以下のケースに該当するとき、応答を出す前に `~/.claude/projects/*/memory/MEMORY.md` および同階層の個別メモリファイルを検索する: + +- **人名・略称・愛称**に遭遇したとき(読み方・関係性が記録されている可能性) +- **PJ 固有用語・コードネーム**に遭遇したとき +- **過去の不採用判断**を覆そうとしているとき +- ユーザーが「あの〜」「以前話した〜」等の指示語で参照しているとき + +## 検索手順 + +`~/.claude/projects/*/memory/` 配下を検索し、`MEMORY.md` のインデックスから該当する個別ファイルを特定して読み、応答に反映する。 + +## 該当メモリがあった場合 + +- メモリの内容を踏まえて応答する +- メモリの記述が古い可能性がある場合は、現在の状態(コード・設定・正本MCP・Markdown SSOT)と突き合わせる +- 矛盾があれば**現状を優先**し、メモリの更新を提案する + +## JTT 業務情報の正本 + +| 情報 | 正本 | +|------|------| +| 売上・取引・商品実績 | スマレジ / `jtt-smaregi-mcp` | +| 施策・担当・期限・進捗 | Asana / `asana-mcp` | +| 勤怠・シフト・出勤者 | 出パンダ / 将来の Depanda MCP | +| 予約・来店予定 | よやくま / 将来の Yoyakuma MCP | +| 確定した方針・ルール・議事録 | プロジェクトの Markdown SSOT | +| 横断分析・再利用する学び | G-Brain | +| 作業途中の短期文脈 | Claude / Codex / Hermes の memory | + +memory と正本が矛盾する場合は、正本を優先する。G-Brain は検索・分析・要約の層であり、MCP から取得した生データの保管先にはしない。 + +**Asana のどこに何があるか**(workspace / project gid / section 構造 / 周期 PJ の命名規則)は +`~/business/AGENT-HUB/docs/reference/asana-project-map.md` が地図。gid を推測せず、まずこの地図を引く。 +地図には参照先だけがあり、タスクの中身は載せない(中身は `asana-mcp` でその場で取る)。 + +## 該当メモリがなかった場合 + +- 推測で補完せず、ユーザーに直接確認する +- 確認後、必要に応じて新規メモリとして記録する(auto memory ルール参照) + +## 関連 + +- グローバル auto memory: `/Users/shintaro/.claude/CLAUDE.md` の「auto memory」セクション +- PJ 別 auto memory: `~/.claude/projects//memory/` diff --git a/.cursor/rules/general/plan-approval-gate.mdc b/.cursor/rules/general/plan-approval-gate.mdc new file mode 100644 index 000000000..7b2b904f5 --- /dev/null +++ b/.cursor/rules/general/plan-approval-gate.mdc @@ -0,0 +1,54 @@ +--- +description: Claude 正本 `plan-approval-gate.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + +# 実装前に HTML プランで承認を仰ぐルール(強制) + +## 原則 + +中規模以上の**実装に着手する前に、必ず HTML で実装プランを提示し、利用者の明示承認(「この実装でいい」)を得てから着手する**。テキストだけで合意したつもりにならない。 + +理由: 非エンジニアの利用者には「どの画面がどう変わるか」がテキストでは伝わりにくく、着手後に手戻りが多発する。給与v1の説明 HTML のような見せ方を毎回・自動で出して認識を合わせ「手戻りゼロ」を狙う。 + +これは `ui-stitch-mandatory` と同じく**ルールが義務を担い、手順はスキルに置く**二段構え。手順 SSOT は `skills/plan-approval/SKILL.md`(本ルールは手順を複製せず参照する)。 + + + +## 必須手順 + +1. **プラン作成基準をライブ読み**: `skills/plan-approval` が `resolve-pj-prompt.py --phase plan` を実行し、PJ 別のプラン基準(`snippet-prompts/Typinator/plan/`。専用未作成 PJ は汎用 `dev-plan`)を読む。 +2. **HTML プランを作る(固定テンプレを必ず使う・独自デザイン禁止)**: 正本テンプレをコピーし中身だけ差し替える(通常=`plan-template.html`、AI worker 委譲時=`plan-template-aiworker.html`)。必須のビジュアル要素は下記「中身」節を参照。 +3. **提示して承認を待つ(iPhone でも PC でも、両方の届け方を毎回使う)**: HTML プランは**必ず Write ツールで実体の `.html` ファイルとして作成する**。**禁止**: ① HTML 本文をチャットに貼り付ける、② Bash ヒアドキュメントで書き出す(どちらも iPhone で生コードになる)。作成後は毎回 `open ` で PC ブラウザにも表示する。**タップ用ファイルカード作成と open による PC ブラウザ表示の両方を毎回必須とする**。末尾に「この実装でいいですか?(進めて / 直す / やめる)」を置き、**承認なしに実装へ進まない**。短い同意だけで進めず、次の一手を1文に要約して再確認する。保存先は作業中 PJ の gitignore 済み一時パス(`claude-plans/` 等)、slug は短く、共有 URL は1行で提示する(詳細: `skills/plan-approval/SKILL.md` §5)。 +4. **承認直後に 📋 コミットメント台帳を全件タスク化する**: HTML プランの台帳の各行を、着手前に `TaskCreate` で 1 行 = 1 タスク化してから実装へ進む。台帳が全消化(実施済み or 明示保留)になるまで「完了」と宣言しない。詳細は `.claude/rules/general/plan-commitment-tracking.md`。 + - **AI worker を 1 度でも使う計画は必須**: 「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れ、タスク化する(テンプレに既定行として焼き込み済み・消さない)。 +5. **承認後は標準パイプラインを通す**: 実装(dev-guardrails)→ codexレビュー → 実装監査 → CI → SSOT 同期確認 → マージ。本番投入は人間ゲート。 + +## HTMLプランの中身 + +中身の構成部品(🎯目的・🖼️前後比較・🗺️ユーザーストーリー・🔀画面遷移図・📚メニュー構成図・🧩変える物一覧・🤔AI の異見・🔭一段上の視点・📋コミットメント台帳 等の必須要素一覧)はテンプレ正本(`references/plan-template.html` の `parts` マニフェスト)と `skills/plan-approval/SKILL.md` が正本。ここに複製しない。 + +## 適用トリガー + +新機能・新画面、データの形を変える変更(DB 構造変更)、複数ファイルにまたがる実装、画面の見た目・挙動が変わる変更。判断に迷う場合は提示する側に倒す。 + +## 例外(HTMLプラン不要) + +- 誤字・1行修正など、見た目・挙動の方針が変わらないもの +- 純粋な調査・質問への回答、会話だけで完結する話 +- 利用者が「今回は要らない」と明示したとき +- UI に変化を伴わない純粋なロジック修正(ただし複数ファイル・データ変更を伴うなら提示する) + +## 接続・関連 + +手順: `skills/plan-approval/SKILL.md`(テンプレ・保存規約・承認ループ・3層視覚化)。プラン基準: `snippet-prompts/Typinator/plan/[PLAN-INPUT]_-plan.md`。進捗: `visual-progress-map.md`。UI確定: `ui-stitch-mandatory.md`。承認後: `skills/dev-guardrails/SKILL.md`(各フェーズは `resolve-pj-prompt.py` 同一リゾルバ)。モデル委譲: `ai-model-selection.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/.cursor/rules/general/plan-commitment-tracking.mdc b/.cursor/rules/general/plan-commitment-tracking.mdc new file mode 100644 index 000000000..93b94d18f --- /dev/null +++ b/.cursor/rules/general/plan-commitment-tracking.mdc @@ -0,0 +1,52 @@ +--- +description: Claude 正本 `plan-commitment-tracking.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + + + +# プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) + +## 原則 + +承認済みプラン本文に書かれた**全ての commitment / 条項**を、実装着手前に **1 項目 = 1 タスク**へ起票する。 +プランの箱(HTML / テキスト)を静的ドキュメントで終わらせない。承認は一度きりの儀式ではなく、 +実行ループ全体で参照し続ける**生きたチェックリスト(plan-as-live-checklist)**として扱う。 + +## なぜ + +プランに「〜不具合時は正本を直す」等の条項があっても、主要タスクだけ起票すると、長い実行ループ(`/compact` でプラン本文が能動コンテキストから外れる)で**一度も発火せず**未実施のまま「完了」と誤宣言する。これは**全 PJ で再発し得る構造欠陥**。実例は `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。 + +## 必須手順 + +1. **承認直後に台帳化**: プラン本文の commitment / 条項(「〜不具合時」「〜したら」「最後に〜」「後で」「別プラン」「TODO」「フォローアップ」「要〜判断」の類)を全て抽出し、着手前に **TaskCreate で 1 項目 = 1 タスク**化する。**📋 コミットメント台帳セクションが空でないのに、未タスク化のまま実装へ進まない**。 + - **AI worker を 1 度でも使う計画なら、「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れる**(テンプレ既定行・消さない)。無ければ台帳は未完成。 +2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 +3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 + - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 +4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 +5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 +6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。 + - **`/goal` 等の反復発火チェッカーへの対応**: 人間ゲート行に反復発火する場合、AI は §6 の優先(明示保留=全消化・虚偽✓禁止)を **1 度だけ根拠付きで提示して停止**し、以後は最小限の再表明に留める(無限反復・迎合的な虚偽✓化をしない)。実測は `commitment-examples.md`。 + +## 恒久原則(proactive) + +繰り返す同種の摩擦・失敗は、**利用者の指摘を待たず**観測した時点で「最後に正本を直す」を既定の最終ステップとして計画へ自分から組み込む。委譲ジョブの失敗・失速もコミット監視だけに頼らず能動的にポーリングして検知する(`skills/agent-dispatch` の「失敗の能動検知」と対)。 + +## 接続 + +義務: `plan-approval-gate.md`。手順: `skills/plan-approval/SKILL.md`。実例: `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。進捗可視化: `visual-progress-map.md`。能動検知: `skills/agent-dispatch/SKILL.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/.cursor/rules/general/reference-over-hardcode.mdc b/.cursor/rules/general/reference-over-hardcode.mdc new file mode 100644 index 000000000..1cd3827fd --- /dev/null +++ b/.cursor/rules/general/reference-over-hardcode.mdc @@ -0,0 +1,56 @@ +--- +description: Claude 正本 `reference-over-hardcode.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# ハードコード排除・参照型設計(グローバル憲法) + +## 原則 + +**ハードコード(直書き)をしない。** 設定値・手順・API 名・パス・思想・スタイルなど、2 箇所以上で必要になる情報は、**正本(SSOT)を 1 箇所に置き、他はそれをライブ参照する**(参照型設計)。 + +- どうしても直書きが必要な場合は、**1 箇所に集約**し、なぜ集約先を作ったのかを CaD コメント等に残す。 +- 「そこだけ直書き」を積み重ねると、後から値がずれる・改訂が反映されない・矛盾が生まれる。これは規模の大小を問わず起きる。 + +この原則は、サブエージェント作成・SSOT 構築・hook 実装・YAML 台帳・skill・rule のどの作業でも同じ扱いにする。特定のフェーズだけに適用される限定ルールではない。 + +## 参照型の実例 + +- **設計思想**: `~/business/AGENT-HUB/docs/design/design-philosophy.md` に集約し、UI/デザインに関わる各所(stitch-screen-creator 等のサブエージェント、UI 作成フロー)からライブ参照する。思想を各 PJ の rule や skill に複製しない。 +- **MCP キー**: `~/.config/agent-hub/.env` に実値を集約し、各 PJ の `.mcp.json` や sync スクリプトはそこから展開する(`.claude/rules/general/mcp-key-management.md`)。`~/mcp-servers//.env` への直書きは禁止。 +- **スキル手順**: 各 rule は手順を複製せず、手順 SSOT(skill)をライブ読みで参照する(例: `plan-approval-gate.md` が `skills/plan-approval/SKILL.md` を参照する二段構え)。 + +## 発火場面 + +- 新しい設定値・API 名・閾値・手順・文言などを**2 箇所以上に書きそうになった時**。 +- 既存の rule / skill / doc の内容を**別ファイルにコピーして使いたくなった時**(コピーせず参照にする)。 +- サブエージェントや AI worker への delegate プロンプトに、正本にある情報を**そのまま貼り付けたくなった時**(正本のパスを渡し、読ませる方を優先する)。 +- **配布物(他 PJ へ配る rule / agent / skill)から AGENT-HUB 専用ファイルを参照する時**: 相対パス(`docs/design/...`)ではなく**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)で書く。配布先 PJ の実行 cwd はその PJ 自身であり、相対パスでは正本を解決できず参照が壊れる(2026-07-07 実装監査が検出)。 + +## 個人開発スケールとの両立 + +`.claude/rules/general/constructive-dissent.md`「個人開発スケールと例外」節を参照(同根の原則・過剰な抽象化を避け素朴な解決を優先する基準)。 + +## 関連 + +- `.claude/rules/general/constructive-dissent.md` — 言いなり禁止・グローバル憲法(同種の常時ロード規範。メンテナンス観点の先出し提案として「正本参照」を挙げている) +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 参照型設計の実例(G-Brain 正本からの派生ドキュメント) +- `.claude/rules/general/mcp-key-management.md` — MCP API キー一元管理(参照型設計の実例) + +この原則は G-Brain の上流原則 `principle-single-source-of-truth-reference`(伸太郎殿の開発大原則)と同根である。 diff --git a/.cursor/rules/general/response-style.mdc b/.cursor/rules/general/response-style.mdc new file mode 100644 index 000000000..8ac49a439 --- /dev/null +++ b/.cursor/rules/general/response-style.mdc @@ -0,0 +1,63 @@ +--- +description: Claude 正本 `response-style.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# 出力簡潔性ルール + +## 基本方針 + +- 中間状態(「これからこうします」「次にこれを実行します」)の冗長な説明を避ける +- 概念的な説明より具体例・差分・コマンドを優先する +- 段落より bullet list を優先する +- 同じ情報を 2 回繰り返さない(タスクツールで進捗を可視化している場合は、テキストで重ねて述べない) + +## 避けるべき出力パターン + +- 「〜について説明します」「以下に〜します」等の予告フレーズ +- 完了済みタスクの再要約(diff や git log が SSOT) +- 「もし〜の場合は〜」の仮定列挙(実行結果を待ってから判断する) +- 「これは〜という意味です」型の自明な解説 + +## 確認するときの書き方 + +ユーザーに判断を仰ぐときは、選択肢を簡潔に列挙し、推奨案を 1 行で示す: + +良い例: `Tier 2 まで実行 / REN-1 のみ / 全部 のどれにしますか?推奨: Tier 2 まで` + +悪い例(冗長すぎ): 長文で各選択肢のメリット・デメリットを 3 段落ずつ説明 + +## 完了報告の書き方 + +- 変更したファイル一覧(path のみ) +- 主な変更点(1 行ずつ) +- 確認してほしい点(あれば、1-2 件) + +過剰な「お疲れさまでした」「素晴らしい結果でした」等の挨拶は不要。 + + + +## URL・リンクの出力 + +AI が URL やファイルパスを出力するとき、**URL の直後に全角括弧・句読点(`)` `。` `、` `」` など)を隣接させない**。ターミナルや Markdown のリンク解釈がその記号まで URL に取り込み、リンクが壊れる(404)ため。 + +- URL は原則**独立した行**に置く(前後に説明文があっても URL 単独の行にする)。 +- 文中に置く場合は URL の直後に**半角スペースか改行**を入れ、全角記号を隣接させない。必要なら `< >` または バッククォートで囲む。 +- 悪い例: `詳細は https://example.com/path)。`(`)。` まで URL に食われて 404)。 +- 良い例: 説明文の後に改行して `https://example.com/path` を単独行で出す。 diff --git a/.cursor/rules/general/responsive-both-viewports.mdc b/.cursor/rules/general/responsive-both-viewports.mdc new file mode 100644 index 000000000..de96d9a3e --- /dev/null +++ b/.cursor/rules/general/responsive-both-viewports.mdc @@ -0,0 +1,45 @@ +--- +description: Claude 正本 `responsive-both-viewports.md` から生成される Cursor rule。直接編集しない。 +globs: +- '**/*.tsx' +- '**/*.jsx' +- '**/*.js' +- '**/*.vue' +- '**/*.svelte' +- '**/*.astro' +- '**/*.blade.php' +- '**/*.html' +- '**/*.css' +- '**/*.scss' +- '**/components/**' +--- + + + +# レスポンシブ UI は全 viewport に足す(普遍ルール・常時適用) + +## 原則(絶対・例外なし) + +レスポンシブな画面にボタン・リンク・ナビ等の UI 要素を**新規に足す**ときは、**モバイル表示とデスクトップ表示の両方**(存在する全ブレークポイント)に足す。片方だけに足すと、もう片方の画面幅でその要素が**消える**。これは開発の普遍ルールであり、条件付きにしない。 + +多くのレスポンシブ実装は同じ内容を画面幅で出し分ける: +- モバイル: 上部バー等(例 `.appbar`)を表示し、サイドバーを隠す +- デスクトップ(例 `@media (min-width:1024px)`): サイドバー等(例 `.side-*`)を表示し、上部バーを隠す + +このとき片方の枠にだけ要素を足すと、もう片方では `display:none` により非表示になる。 + +## 必須 + +1. UI 要素を足すとき、**全 viewport バリアント**(モバイル枠 / デスクトップ枠 / その他ブレークポイント)の**すべて**に足す。 +2. 追加要素が**各画面幅で実際に表示される**ことを確認してから完了にする(該当 CSS の `display:none` / media query の出し分けを読み、隠れる枠だけに足していないか確認する)。 +3. コードレビュー・実装監査でも「新規 UI 要素が全 viewport で見えるか」を必須確認項目にする。 + +## 実例(この規則ができた経緯) + +2026-07-05 cron-dashboard で「🩺 健診」ナビを最初モバイルの上部バー(`.appbar`)だけに足した結果、`.appbar` が `@media (min-width:1024px)` で `display:none` になるため **PC 幅で恒久的に非表示**になり、実装監査がブロッカーとして検出した。デスクトップのサイドバー(`.side-brand` 隣)にも足して解消。片側だけ追加は完了ではない。 + +## 接続 + +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・現在地マップ +- `.claude/rules/general/ui-stitch-mandatory.md` — UI/デザインは Stitch を通す +- `skills/dev-guardrails/SKILL.md` — 実装ガードレール diff --git a/.cursor/rules/general/settings-protection-coexistence.mdc b/.cursor/rules/general/settings-protection-coexistence.mdc new file mode 100644 index 000000000..d6d1a9259 --- /dev/null +++ b/.cursor/rules/general/settings-protection-coexistence.mdc @@ -0,0 +1,71 @@ +--- +description: Claude 正本 `settings-protection-coexistence.md` から生成される Cursor rule。直接編集しない。 +globs: +- .claude/** +- .codex/** +- .cursor/** +- .gemini/** +- .kimi-code/** +- .augment/** +- .opencode/** +- .githooks/** +- tests/test_handover_manual.py +- skills/handover-manual/scripts/resolve-handover-path.py +--- + + + + + +# settings.json 等の保護テストと正当な配線変更の共存ルール + +## 原則 + +`.claude/` `.codex/` `.cursor/` `.gemini/` `.kimi-code/` `.augment/` `.opencode/` `.githooks/` +`claude-plans/` `node_modules/` 配下および `.env` / `.env.*` は、`tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` +が **プレフィックス一致で広く保護対象と判定**する(実装: `skills/handover-manual/scripts/resolve-handover-path.py` の `is_protected_path()`、 +`PROTECTED_PREFIXES`)。テストは `origin/main` とのマージベース以降 + working tree + staged の変更ファイルを走査し、 +保護対象なのに `allowed_managed_placements`(テスト内のallowlist)に無いパスがあれば **fail** する。 + +この判定は粗い(ディレクトリ丸ごと保護)ため、telemetry 配線・新規ルール追加・hook 再配備など +**正当な変更でも毎回検知される**。これは仕様であり、バグではない。正当な変更を安全に通す手順は以下。 + +## 必須手順 + +1. **まず中央配布経路で済まないか確認する**: project harness は `scripts/sync-agents.py --project --dry-run` + で全surfaceを確認し、承認後だけcleanな専用linked worktreeへfull applyする。個別writerを手で連結しない。 +2. **どうしても直接編集が必要なら、同一PRで `allowed_managed_placements` に追加する**: + `tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` 内のセットへ、 + 変更した具体パスと日付・理由コメントを添えて追記する(例: `# [YYYY-MM-DD][fix] PR#nnn で〜が弾かれた。理由。`)。 + 既存の fix-forward 例(PR#637 `ai-model-selection.md` / PR#666 `constructive-dissent.md` / + PR#695 `responsive-both-viewports.md` / PR#736 `dotfiles/.env.example`)と同じパターンに倣う。 +3. **保護テストの検査ロジック自体を弱めない**: `is_protected_path()` のプレフィックス判定や + `changed_paths_for_protected_check()` の走査範囲を変更・無効化しない。許可は必ず allowlist の + 個別パス追加で行う(一括 skip・正規表現の緩和は禁止)。 +4. **テスト赤のままマージしない**: `python3 -m pytest tests/test_handover_manual.py -q` を PR 作成前に + ローカル実行し green を確認する。CI の同テストが赤の状態での merge は `branch-rule.md` の + CI 緑ゲートに反する(#742 の再発防止)。 + +## 関連 + +- `.claude/rules/general/branch-rule.md` — main 直接コミット禁止・CI緑ゲート +- `.claude/rules/general/plan-commitment-tracking.md` — workaround 自問(正本修正を先送りしない) +- `.claude/rules/general/hooks-structure-rule.md` — hook 配置の隣接ルール(配布経由の管理配置) +- `tests/test_handover_manual.py` — 保護テスト本体・allowlist 実体 +- `skills/handover-manual/scripts/resolve-handover-path.py` — `is_protected_path()` / `PROTECTED_PREFIXES` 実装 diff --git a/.cursor/rules/general/sub-agent-scope-contract.mdc b/.cursor/rules/general/sub-agent-scope-contract.mdc new file mode 100644 index 000000000..ba7d5d1d8 --- /dev/null +++ b/.cursor/rules/general/sub-agent-scope-contract.mdc @@ -0,0 +1,82 @@ +--- +description: Claude 正本 `sub-agent-scope-contract.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# サブエージェント Scope Contract + +サブエージェント(Task / Agent tool)に作業を委譲するとき、delegate 元のプロンプトに**必ず以下 3 項目(コード探索を伴う場合は §4、UI/デザインを伴う場合は §5 を足す)を含める**。制定経緯・テンプレート全文は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。 + +## 1. allowed_files(編集を許可するファイル) + +委譲先が編集してよいファイルパスを明示的に列挙する。 + +例: `「allowed_files: src/api/auth.ts のみ。他は read-only」` + +## 2. forbidden_actions(禁止する操作) + +委譲先が**してはいけない**操作を明示する。よくある禁止例: + +- `auto-format で quote replacement や import 並び替えを実行しない` +- `スコープ外のファイルを編集しない(読み取りは可)` +- `テストの skip / xit を追加しない` +- `existing CaD コメントを削除しない` + +## 3. verify before return(返却前の検証手順) + +委譲先が作業完了を報告する前に実行する検証を指定する。 + +例: +- `git diff --name-only で編集ファイル一覧が allowed_files と一致することを確認` +- `lint / typecheck を実行してエラーが出ないことを確認` +- `想定外の編集があった場合は revert してから報告` + +## 4. context-engine first(コード探索を伴う委譲・Explore 含む) + +委譲タスクが**コードの場所・関数・route・呼び出し関係・影響範囲の探索**を含むなら、prompt に必ず入れる: + +- 「まず `codebase-context-engine` を使う(`grep`/`Read` を先に走らせない)。遅延ツールは + `select:mcp__codebase-context-engine__list_projects,hybrid_search,search_graph,get_code_snippet` でロード」 +- **解決済みの `project` 名を親が渡す**(親が `list_projects` を見て明示)。 + `preferred_project` がある場合はそれを使う。 + `project_scope: ambiguous_worktrees` の場合は、現在の cwd と一致する `root_path` / `preferred_project_candidates` を親が選んでから渡す。 + subagent に `private-tmp-cbm-...` の長いミラー名を推測させない。 +- 「索引はミラー=当日新規/変更したファイルは未反映なので、その分だけ `Read` 併用」 + +理由: 候補圧縮で速く・低コスト(多数 grep/Read を回避)。subagent は本ルールを自動継承しないため親が prompt 注入必須(追加経緯は詳細ドキュメント参照)。 + +## 5. design-philosophy first(UI/デザインを伴う委譲時) + +委譲タスクが**UI・画面・デザイン・レイアウト・コンポーネントの作成/変更**を含むなら、親が prompt に必ず入れる: + +- 「まず `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)を Read してから着手する」を**必読指定**する。 +- 必ず該当ファイルの**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)を渡す(委譲先の実行 cwd は消費先PJであり、相対パスでは解決不能なため)。 +- Stitch を使う画面作成は、`stitch-screen-creator` グローバルエージェント(設計思想を step0 で必読にしている)へ委譲するのが既定。 + +理由: AI Worker(Kimi/Codex/Cursor/GLM 等)自身にデザインセンスが無くても、親が設計思想 doc を必読で渡せば思想に沿った画面を作れる。渡さないと委譲先が自己流判断でずれる。 + +## delegate プロンプトのテンプレート・親側の verify ステップ + +テンプレート全文と、親セッションが `git diff --stat` / `git diff -- ` で確認する verify コマンド列は +`~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。allowed_files 外に変更が混入していた場合は +revert し、delegate にやり直しを指示する。 + +--- + +**追記ルール: 制定経緯・テンプレート全文の詳細は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` へ書き、本ルールには義務・トリガーだけ足す(再肥大化防止)。** diff --git a/.cursor/rules/general/ui-stitch-mandatory.mdc b/.cursor/rules/general/ui-stitch-mandatory.mdc new file mode 100644 index 000000000..db584a034 --- /dev/null +++ b/.cursor/rules/general/ui-stitch-mandatory.mdc @@ -0,0 +1,86 @@ +--- +description: Claude 正本 `ui-stitch-mandatory.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# UI / デザインは必ず Stitch を通すルール(強制) + +制定経緯(2026-05-27 新設判断・2026-07-20 MCP選択正本切替)は `skills/stitch/SKILL.md` の +「ui-stitch-mandatory 制定経緯」節を参照。 + +## 原則 + +UI / 画面 / デザイン / レイアウト / コンポーネントの**新規作成・見た目の変更**依頼は、**必ず Stitch**(`skills/stitch` + Stitch MCP `mcp__stitch__*`)でデザインを生成し、**伸太郎殿が実物を見て確定してから実装に進む**。 + +理由: UI は AI とユーザーの言語的意思疎通が難しく、テキストだけで合意したつもりで実装すると手戻りが多発する。Stitch で生成した実物を見て双方の認識を合わせることで「手戻りゼロ」を狙う。 + +## 必須手順 + +1. **Stitch でデザイン案を生成**(**最低 3・最大 5(ケースバイケース)**)。1 案だけ出して進めるのは**禁止**。 +2. **伸太郎殿が Stitch Web(プロジェクト URL)で比較・確定**する。 +3. **確定したデザインだけ**を基に実装する(`.stitch/` 出力 / DESIGN.md を参照)。 + +## 適用トリガー + +「UI を作って」「画面作って」「デザイン(して)」「レイアウト変更」「コンポーネント新規」など(`skills/stitch` の triggers と整合)。判断に迷う場合は Stitch を通す側に倒す。 + +## データ格納ルール(リポジトリルート汚染防止) + +Stitch 由来のファイルを散らかさないため、保存先を固定する: + +| データ | 置き場所 | +|--------|---------| +| ① デザイン案の比較 | **Stitch Web(プロジェクト URL)で見る** → 全候補をローカル保存しない | +| ② 確定したデザイン | `.stitch/<システム名>/<画面名>/`(`code.html` + `screen.png`)にだけ Export | +| ③ MCP 取得データの一時保存 | **temp ディレクトリ**(その PJ の `/tmp/` 等・gitignored) | +| ④ リポジトリルート直下・任意の場所 | **保存禁止**(ゴミファイル堆積を防ぐ) | + +- `.stitch/` は**Stitch を使う PJ ごとに gitignore する**(生成物はコミットしない)。配布先 PJ へ広げる場合は、その PJ 側の `.gitignore` 変更を別途同じ変更束に含める。 +- 「とりあえずルートに HTML を置く」は**禁止**。必ず上記 ① 〜 ③ のいずれかに収める。 + +## 例外(Stitch 不要) + +- 既存 UI の微修正(typo 修正・1 色だけ変更など、**見た目の方針が変わらない**もの)。 +- UI に変化を伴わない純粋なロジック修正。 + +## MCP 前提 + +Stitch MCPの接続definitionは`~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml`、project採否は +`registries/harness-manifest.yaml#asset_contract` のeffective `mcp` setを正とする。 +未接続時は `scripts/sync-agents.py --project --dry-run` で継承・surface・envを確認し、apply後にfresh clientでruntime proofを取る。 + +## 接続 + +- 手順 SSOT: `skills/stitch/SKILL.md`(プロンプトテンプレ・`.stitch/` 規約・DESIGN.md 抽出・MCP 前提)。本ルールは手順を複製せず参照する。 +- dev フローの普遍 UI ルール(Tailwind 等)は `skills/dev-guardrails/SKILL.md`(2-10 ほか)の上に乗る。業務 PJ は `skills/business-guardrails/SKILL.md`。 +- 要件固め・実装フローでの発火点: `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md`。 +- Stitch でデザインを作る際は `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)に従うこと。本ルールは思想本文を複製せず参照する。 +- 「Stitchで作って」の委譲は `agents/global/stitch-screen-creator.md`(着手前に設計思想 doc を必読)が実行役を担う。 + +## 関連 + +- `skills/stitch/SKILL.md` — Stitch ワークフロー SSOT +- `skills/dev-guardrails/SKILL.md` / `skills/business-guardrails/SKILL.md` — ガードレール +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 発火フロー +- `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — Stitch MCPのtransport / 認証definition +- `registries/harness-manifest.yaml` — global / harness type / projectの採否とsurface契約 +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 伸太郎殿の設計思想 SSOT +- `agents/global/stitch-screen-creator.md` — Stitch 画面作成グローバルエージェント + +--- + +**追記ルール: 制定経緯・実測詳細は `skills/stitch/SKILL.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** diff --git a/.cursor/rules/general/visual-progress-map.mdc b/.cursor/rules/general/visual-progress-map.mdc new file mode 100644 index 000000000..91c615e08 --- /dev/null +++ b/.cursor/rules/general/visual-progress-map.mdc @@ -0,0 +1,131 @@ +--- +description: Claude 正本 `visual-progress-map.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# 図解・現在地マップ・非エンジニア用語ルール + +全 AI・全作業共通の SSOT。ユーザー(非エンジニア)が現在地・ゴール・次の一手を必ず把握できる状態を保つための図解描画ルール。**通常の実装・Issue/PR/PRD 確認・調査でも、§1-bis のトリガーに該当したら skill 抜きで図解を出す**。 + +**テンプレ・実例・置換表の全文は references へ。本ルールは義務とトリガーだけ(再肥大化防止)。** + +## 0. モード判定(開発 / 業務) + +この図解は **2 モード**を持つ。テンプレは共通で、語彙は references の置換表で読み替える(DRY)。 + +| モード | 対象 PJ(デフォルト) | 性質 | ペア guardrails | +|--------|---------------------|------|----------------| +| **開発** | jtt-apps / jtt-cms / jtt-shift-mobile-app / *-mcp 等 | GitHub PR フロー中心 | dev-guardrails | +| **業務** | jtt-cafe-pj / non-pj | 戦略・施策・KPI 中心 | business-guardrails | + +- `jtt-cafe-pj` は business PJ。曖昧なら §5 に従い平易語で確認してから描く(推測しない)。 +- 最小読み替え: PR/Issue/merge/本番投入 → 戦略スコープ/KPI/意思決定/本番運用。詳細は references。 + +## 1. 地図描画タイミング + +| タイミング | 出すもの | +|-----------|---------| +| セッション開始直後 | `.claude/parallel-run-state/*.json` があれば冒頭で全体地図を ASCII 表示(複数あれば選択を仰ぐ) | +| /brainstorm 各フェーズ遷移時 | Phase 1→2→3 移行直前にミニ地図(§3) | +| /parallel-run 各ステップ完了時 | Step 完了報告+次 Step 前に全体地図を再描画 | +| 通常作業中 | §1-bis 該当時は skill 抜きでも L1 ASCII 図解を出す | +| オンデマンド | 「地図」「現在地」「進捗」の発話で即時再描画 | + +`gh pr list --state all` は開始時1回+オンデマンド時のみ呼ぶ(API節約)。再描画は状態ファイルのキャッシュを優先。 + +## 1-bis. skill 非依存の常時発火トリガー(バランス型) + +skill 非起動時でも、以下のいずれかに該当したら L1 ASCII 図解を出す(指示なしで出るのが本ルール最大の目的)。 + +| トリガー | 出す図の例 | +|---------|-----------| +| ① 3 つ以上の要素・手順・選択肢の説明 | 箇条マップ / 比較表 / フロー | +| ② 「今どこ・次どこ」の現在地・進捗 | 5 段階地図 / ミニ地図 | +| ③ Issue/PR/PRD/仕様書を読んで方針を伝える | 関係図 / 要約マップ / フェーズ図 | +| ④ バグ修正の「原因 → 対処」説明 | 原因 → 対処フロー | +| ⑤⑥ 複数ファイル横断の整理・依存関係説明 | 依存ツリー / フロー図 | +| ⑦ 進捗・週次レビュー・残り作業 | **ゴール地図(§2-bis)**。羅列で終わらせない | +| ⑧ AI Worker MCP へ複数 provider 委譲/状態確認 | **AI Worker 進捗図**(references)。provider名でなく作業内容・現在地を主役にする | + +議論を伴う説明・プランはチャットの L1 要点図解を基本とする。L2 HTMLカードは見た目の比較が必要な時、またはユーザー希望時だけ使う(実装承認プランは plan-approval-gate.md 優先)。③④⑦も専用skill化せず本ルールで発火。 + +### 出さない場面(うるささ回避) + +- 単純な一問一答、1 ステップで完結する短い事実回答、「図はいらない」明示時 + +図形式は自由。**重い L2/L3 は使わず L1 ASCII をデフォルト**にし、図を要約として使う。 + +## 2-bis. ゴール地図(骨子) + +§1-bis⑦で出す。やったこと羅列で終わらせず、計画全体・残り・次の一手・ゴール妥当性を同時に出す。 + +必須 7 要素: ①🎯最終ゴール+達成条件 ②全体スコープ ③✅済 ④⬜未(漏れ) ⑤◀次の一手 ⑥残数 ⑦⚠️ゴール妥当性レビュー。 + +短絡禁止: 「実装が終わった=ゴール達成」「施策を打った=成果(KPI)達成」と書かない(本番運用・撤退基準判定まで未達)。骨子: 📍ゴール/つくる→テスト→🚧本番投入→🏁本番=ゴール/✅済・⬜未・◀次の一手。 + +全体スコープ・未着手は PRD / Issue / git log を実読して埋める(推測禁止)。フルテンプレは references 参照。 + +## 3. ミニ地図テンプレート(/brainstorm 用) + +``` +[ 現在地 ] /brainstorm Phase X/3 +✅ Phase 1: 要件聞き取り +🔵 Phase 2: 不明点深掘り ← 今ここ +⬜ Phase 3: 実装方針提示 + +次にやること: <1 文> +``` + +## 4-bis. 視覚化の 3 層(L1/L2/L3)の使い分け + +図解は内容に応じ 3 層を使い分ける。実行手段の SSOT は `skills/visual-companion/SKILL.md`。本ルールは L1 ASCII と判定基準のみ持つ。 + +| 層 | 何を出すか | 手段 | いつ | +|----|-----------|------|------| +| **L1 ASCII** | 進捗・現在地マップ | ASCII 地図(ゼロ依存) | **デフォルト・常時** | +| **L2 ブラウザ HTML** | mockup・レイアウト比較 | `start-server.sh` | 見た目の比較(オプトイン) | +| **L3 ターミナル画像** | HTML を CLI で目視 | `html-to-terminal.sh` | ブラウザを開かず見たい時 | + +判定: 「読むより見た方が理解できるか?」。テキストで足りる選択は L1、見た目の比較は L2/L3。 + +## 5. 非エンジニア用語ルール + +### 原則 + +- 技術用語は**初回登場時のみ**括弧で平易語を併記、以降はそのまま使う(完全置換はしない) +- 短い同意(「お願い」「はい」)だけで進めない + +代表例(全 12 語は references 参照): PR=変更提案 / merge=本番に取り込む / migration=DB 構造変更 / staging=テスト環境 / worktree=別フォルダ作業領域。 + +### 短い同意への応答 + +「お願い」「はい」「OK」だけ返った時は**次の一手を 1 文で要約してから**再確認する。 + +### 技術判断を仰ぐ時(平易語 + 選択肢で聞く) + +**技術判断は技術用語で聞かない**。①平易語(速さ・安全性・見た目への影響)で説明②2〜3択で提示(可能なら AskUserQuestion)③推奨理由を1文添える。実例は references 参照。 + +## 6. 状態ファイル schema + +`.claude/parallel-run-state/.json` に保管(kebab-case slug、各PJの `.gitignore` へ追加)。フィールド定義・モード別 schema・`gh pr list` 合成手順の全文は `/skills/visual-companion/references/state-file-schema.md` を参照。 + +## 7. 関連ルール + +- `.claude/rules/general/response-style.md` / `sub-agent-scope-contract.md` / `branch-rule.md` +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 各フェーズ・Step 遷移時に参照 +- `commands/brainstorm.md` / `commands/parallel-run.md` — 手動発火ラッパー +- 全文: `/skills/visual-companion/references/progress-map-templates.md`, `state-file-schema.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: テンプレ・実例・置換表は references へ書き、本ルールには足さない(再肥大化防止)。** diff --git a/.cursor/rules/general/worktree-rule.mdc b/.cursor/rules/general/worktree-rule.mdc new file mode 100644 index 000000000..14cceefc4 --- /dev/null +++ b/.cursor/rules/general/worktree-rule.mdc @@ -0,0 +1,121 @@ +--- +description: Claude 正本 `worktree-rule.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + + + + + +# Worktree 利用ルール + +## いつ worktree を使うか + +AI が変更を加える通常作業では、git worktree を作成して別ディレクトリで作業する。 +特に以下のいずれかに該当するときは必須: + +- **並列セッション**: Claude Code / Codex CLI / Cursor 等を同時に複数立ち上げて別タスクを進める +- **複数 PR 同時進行**: 同一リポジトリで 2 本以上の feature branch を行き来する +- **長期 feature branch**: main から離れて 1 日以上滞在する作業(途中で main を hotfix する可能性がある) +- **軽量変更を含む AI 作業**: 例外なし。詳細は branch-rule.md 参照 + +例: +``` +git worktree add ../jtt-cms-feat-xyz -b feat/xyz +cd ../jtt-cms-feat-xyz +``` + +## いつ新規 worktree を作らなくてよいか + +以下は新規 worktree なしでよい: + +- 読み取りだけでファイル変更・commit・push がない場合 +- 既に feature branch にチェックアウト済みで、別タスクを差し挟まない場合 +- 既にこのタスク専用の worktree / branch にいる場合 +- 人間が明示承認した main 直接反映や初回 repo 作成など、branch-rule.md の注記に該当する例外の場合 + +## 機密ファイル(MCP / .env)の自動 symlink + +worktree 作成時、git 追跡外の機密ファイル(`.mcp.json` / `.env` 系)は main worktree の実体へ**自動 symlink**される(git post-checkout hook 由来)。追加操作は不要。仕組み・手動再設置手順・非破壊の詳細は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## Mac mini ContextEngine mirror の自動追従 + +Mac Studio 側の worktree は Mac mini の ContextEngine mirror が自動追従する(対象: jtt-cms / jtt-apps / jtt-system / AGENT-HUB / hermes)。索引はミラーであり当日の新規変更は未反映のことがある。詳細・stale削除・semantic強化ジョブは +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## branch contamination が発生した場合の復旧 + +別セッションのブランチに誤ってコミットした場合は、誤コミット特定 → 正しいブランチへ `cherry-pick` → 復旧用退避作成、の順で対応する。 +**`git reset --hard` と force-push はデフォルト禁止。必ずユーザー承認を得てから実行する。** +詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## AI セッションから worktree へ commit / push する方法(block-main-commit 対策) + +block-main-commit hook は cwd 変更を伴う複合コマンドでの main 直 commit を fail-closed で deny する。AI セッション(cwd=main)から worktree の feature branch へ commit / push する時は: + +1. **`isolation: "worktree"` 付きサブエージェントに委譲する**(正攻法)。 +2. isolation 指定ができない場合のみ、GitHub API / connector で remote feature branch commit → PR → CI → merge の fallback を使う(main 直更新は禁止のまま)。 +3. commit/push を含まない操作(`git add` / `git status` / `gh pr create` 等)はメインセッションから直接 `cd && ...` してよい。 +4. hook 検査を `bash -c` 等で素通りさせる回避は**禁止**。 + +サブエージェントの worktree が古いベース(origin/main 以前)から切られる問題への対処、外側隔離 worktree の残存・cleanup 手順、Codex fallback の実測経緯は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 共有 checkout / main 非占有ルール(全 PJ・全 AI ツール共通) + +対象ルート: `~/LLM-Dev/` `~/business/` `~/Herd/` `~/mac-mini-server/` `~/mcp-servers/` `~/jtt-system/`。Claude / Codex / Cursor / Kimi / OpenCode / Antigravity 全て同じ意味で読む。 + +**AI セッションは、他者や他エージェントが使う可能性のある `main` checkout を掴まない。** 共有 checkout で merge / pull / cleanup を実行すると、並行セッションとブランチ・HEAD を奪い合って競合する。背景・実測実害は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### 必須:1 タスク = 1 連の完了フロー(PR を出して放置しない) + +**専用 worktree 作成 → 編集/commit/push → PR 作成 → マージ → fetch-only / detached 確認 → clean(worktree/branch 削除)まで、必ず一連で最後まで閉じる。** 「PR を出した」「マージした」で止めない。詳細コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### AI が `main` で「やらないこと / 代わりにやること」 + +- **やらない**: `git checkout main` / `git switch main` / `git pull` while on `main` / `git branch -f main`。 +- **やる**: `git fetch origin +refs/heads/main:refs/remotes/origin/main` で remote tracking ref を更新する。確認が必要な時は `git worktree add --detach origin/main` で detached 確認。 +- merge は worktree 内から `gh` / `skills/post-merge/scripts/merge-pr.py --confirm-read` で行う。 +- **cleanup は自分が作った worktree / branch だけ**削除する。`git worktree list --porcelain` で他セッションのものを確認し**温存する**。 +- allowlist 対象の生成 config を main 直コミットする時の stale-main 注意は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +要するに「編集だけ worktree、merge/pull は共有 checkout」をやめる。**着手から cleanup まで一貫して専用 worktree**で閉じる。例外的に人間が明示して main checkout を使う場合は、AI が占有している状態でないことと例外理由を作業ログへ残す。 + +## 既存 worktree の確認 + +```bash +git worktree list +``` + +`~/Herd/jtt-apps` 配下には `jtt-apps-api-rate-limit-guards` / `jtt-apps-wt` / `jtt-apps-worktrees` 等の既存 worktree がある(CLAUDE.md `## プロジェクトルート規約` 参照)。新規作成前に既存 worktree の再利用可否を確認すること。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/.cursor/sync-state.json b/.cursor/sync-state.json new file mode 100644 index 000000000..e6fb92c7a --- /dev/null +++ b/.cursor/sync-state.json @@ -0,0 +1,167 @@ +{ + "generated_at": "2026-08-03T14:47:25.931397+00:00", + "source_commit": "09cea494bfbccdb3a8fc8470f639e80495b117b6", + "source_rule_files": [ + ".claude/rules/general/ai-model-selection.md", + ".claude/rules/general/branch-rule.md", + ".claude/rules/general/constructive-dissent.md", + ".claude/rules/general/hooks-structure-rule.md", + ".claude/rules/general/latest-stack-context7.md", + ".claude/rules/general/mandate-registry.md", + ".claude/rules/general/mcp-key-management.md", + ".claude/rules/general/memory-lookups.md", + ".claude/rules/general/plan-approval-gate.md", + ".claude/rules/general/plan-commitment-tracking.md", + ".claude/rules/general/reference-over-hardcode.md", + ".claude/rules/general/response-style.md", + ".claude/rules/general/responsive-both-viewports.md", + ".claude/rules/general/settings-protection-coexistence.md", + ".claude/rules/general/sub-agent-scope-contract.md", + ".claude/rules/general/ui-stitch-mandatory.md", + ".claude/rules/general/visual-progress-map.md", + ".claude/rules/general/worktree-rule.md" + ], + "source_hook_files": [ + ".claude/hooks/.hook-library-version", + ".claude/hooks/lib/code-quality-check.md", + ".claude/hooks/lib/hook-io.sh", + ".claude/hooks/lib/quality-check-common.sh", + ".claude/hooks/lib/storage-url-common.py", + ".claude/hooks/scripts/block-destructive-git.sh", + ".claude/hooks/scripts/block-destructive-git.test.sh", + ".claude/hooks/scripts/block-main-commit.sh", + ".claude/hooks/scripts/block-main-commit.test.sh", + ".claude/hooks/scripts/block-skill-reverse-edit.sh", + ".claude/hooks/scripts/block-skill-reverse-edit.test.sh", + ".claude/hooks/scripts/block-unauthorized-docs-file.sh", + ".claude/hooks/scripts/block-unauthorized-docs-file.test.sh", + ".claude/hooks/scripts/freshness-gate.sh", + ".claude/hooks/scripts/handover-preflight.sh", + ".claude/hooks/scripts/handover-preflight.test.sh", + ".claude/hooks/scripts/post-merge-gate.sh", + ".claude/hooks/scripts/post-merge-gate.test.sh", + ".claude/hooks/scripts/pre-implementation-check.sh", + ".claude/hooks/scripts/stop-quality-check.sh", + ".claude/hooks/scripts/storage-url-pr-gate.sh", + ".claude/hooks/scripts/subagent-quality-check.sh", + ".claude/hooks/scripts/takeover-preflight.sh", + ".claude/hooks/scripts/takeover-preflight.test.sh", + ".claude/hooks/scripts/telemetry-lib.sh", + ".claude/hooks/scripts/telemetry-log.sh", + ".claude/hooks/scripts/telemetry-log.test.sh" + ], + "source_agent_files": [ + ".claude/agents/backend-architect.md", + ".claude/agents/backend-developer.md", + ".claude/agents/chatgpt-image-creator.md", + ".claude/agents/document-writer.md", + ".claude/agents/frontend-developer.md", + ".claude/agents/implementation-auditor.md", + ".claude/agents/qa-reviewer.md", + ".claude/agents/quality-engineer.md", + ".claude/agents/stitch-screen-creator.md", + ".claude/agents/technical-writer.md", + ".claude/agents/test-runner.md" + ], + "generated_rule_files": [ + ".cursor/rules/10-runtime-sync.mdc", + ".cursor/rules/general/ai-model-selection.mdc", + ".cursor/rules/general/branch-rule.mdc", + ".cursor/rules/general/constructive-dissent.mdc", + ".cursor/rules/general/hooks-structure-rule.mdc", + ".cursor/rules/general/latest-stack-context7.mdc", + ".cursor/rules/general/mandate-registry.mdc", + ".cursor/rules/general/mcp-key-management.mdc", + ".cursor/rules/general/memory-lookups.mdc", + ".cursor/rules/general/plan-approval-gate.mdc", + ".cursor/rules/general/plan-commitment-tracking.mdc", + ".cursor/rules/general/reference-over-hardcode.mdc", + ".cursor/rules/general/response-style.mdc", + ".cursor/rules/general/responsive-both-viewports.mdc", + ".cursor/rules/general/settings-protection-coexistence.mdc", + ".cursor/rules/general/sub-agent-scope-contract.mdc", + ".cursor/rules/general/ui-stitch-mandatory.mdc", + ".cursor/rules/general/visual-progress-map.mdc", + ".cursor/rules/general/worktree-rule.mdc" + ], + "generated_hook_files": [ + ".cursor/hooks.json", + ".cursor/hooks/.hook-library-version", + ".cursor/hooks/lib/code-quality-check.md", + ".cursor/hooks/lib/hook-io.sh", + ".cursor/hooks/lib/quality-check-common.sh", + ".cursor/hooks/lib/storage-url-common.py", + ".cursor/hooks/scripts/block-destructive-git.sh", + ".cursor/hooks/scripts/block-destructive-git.test.sh", + ".cursor/hooks/scripts/block-main-commit.sh", + ".cursor/hooks/scripts/block-main-commit.test.sh", + ".cursor/hooks/scripts/block-skill-reverse-edit.sh", + ".cursor/hooks/scripts/block-skill-reverse-edit.test.sh", + ".cursor/hooks/scripts/block-unauthorized-docs-file.sh", + ".cursor/hooks/scripts/block-unauthorized-docs-file.test.sh", + ".cursor/hooks/scripts/cursor-command-bridge.sh", + ".cursor/hooks/scripts/freshness-gate.sh", + ".cursor/hooks/scripts/handover-preflight.sh", + ".cursor/hooks/scripts/handover-preflight.test.sh", + ".cursor/hooks/scripts/post-merge-gate.sh", + ".cursor/hooks/scripts/post-merge-gate.test.sh", + ".cursor/hooks/scripts/pre-implementation-check.sh", + ".cursor/hooks/scripts/stop-quality-check.sh", + ".cursor/hooks/scripts/storage-url-pr-gate.sh", + ".cursor/hooks/scripts/subagent-quality-check.sh", + ".cursor/hooks/scripts/takeover-preflight.sh", + ".cursor/hooks/scripts/takeover-preflight.test.sh", + ".cursor/hooks/scripts/telemetry-lib.sh", + ".cursor/hooks/scripts/telemetry-log.sh", + ".cursor/hooks/scripts/telemetry-log.test.sh" + ], + "generated_agent_files": [ + ".cursor/agents/backend-architect.md", + ".cursor/agents/backend-developer.md", + ".cursor/agents/chatgpt-image-creator.md", + ".cursor/agents/document-writer.md", + ".cursor/agents/frontend-developer.md", + ".cursor/agents/implementation-auditor.md", + ".cursor/agents/qa-reviewer.md", + ".cursor/agents/quality-engineer.md", + ".cursor/agents/stitch-screen-creator.md", + ".cursor/agents/technical-writer.md", + ".cursor/agents/test-runner.md" + ], + "generated_mcp_files": [], + "generated_skill_files": [], + "command_name": "sync-cursor-from-cc", + "generator_version": "2.3.1", + "rulesync_used": false, + "mcp_status": { + "status": "managed", + "reason": "scripts/sync-cursor-mcp-configs.py", + "project_id": null, + "registry_path": null, + "definitions_path": null + }, + "warning_summary": { + "total": 9, + "unsupported_events": [], + "unsupported_matcher_tokens": [ + "Shell", + "StrReplaceFile", + "WriteFile", + "Agent", + "Skill", + "Task" + ], + "other": [] + }, + "warnings": [ + "unsupported_cursor_cli_pretooluse_matcher_token:Shell", + "unsupported_cursor_cli_pretooluse_matcher_token:StrReplaceFile", + "unsupported_cursor_cli_pretooluse_matcher_token:WriteFile", + "unsupported_cursor_cli_pretooluse_matcher_token:Agent", + "unsupported_cursor_cli_pretooluse_matcher_token:Skill", + "unsupported_cursor_cli_pretooluse_matcher_token:Task", + "unsupported_cursor_cli_posttooluse_matcher_token:Agent", + "unsupported_cursor_cli_posttooluse_matcher_token:Skill", + "unsupported_cursor_cli_posttooluse_matcher_token:Task" + ] +} diff --git a/.gemini/hooks/.hook-library-version b/.gemini/hooks/.hook-library-version new file mode 100644 index 000000000..8d5bf551a --- /dev/null +++ b/.gemini/hooks/.hook-library-version @@ -0,0 +1 @@ +v3.6.37 | profile: agentmemory diff --git a/.gemini/hooks/lib/code-quality-check.md b/.gemini/hooks/lib/code-quality-check.md new file mode 100644 index 000000000..384cad9b1 --- /dev/null +++ b/.gemini/hooks/lib/code-quality-check.md @@ -0,0 +1,205 @@ +# Code Quality Checklist(SubagentStop / Stop hook用) + + + +サブエージェントの作業完了時に、以下の観点で品質チェックを実施する。 +対象: 直前のサブエージェントが**新規作成・変更した**ファイルのみ。 + +--- + + +## コメント品質(Code as ドキュメント) + +### 必須コメント + +| 対象 | ルール | +|------|--------| +| 関数・メソッド | JSDoc / PHPDoc で @param, @returns を記載 | +| 複雑なロジック | 条件分岐3つ以上、正規表現 → 「なぜ」のコメント | +| マジックナンバー | 定数化 or コメントで意味を説明 | +| TODO / FIXME | 理由と期限を記載(// TODO(2025-03): ○○対応後に削除) | + +### 変更コメントの必須フォーマット + +既存コードに意味のある変更を加えた場合、以下のフォーマットでコメントを残すこと。 +**目的:** 次にAIがこの領域を修正する際に同じ過ちを繰り返さないための判断基準を残す。 + +``` +// [YYYY-MM-DD][fix|feat|refactor] +// 背景: ユーザーがその修正を依頼した理由・意図 +// 守るべき業務ルール・ブランド基準 +// 他の実装方法ではダメな理由の判断根拠 +// 対応: 実施した変更内容 +``` + +**背景に含めるべき3要素:** +1. ユーザーがその修正を依頼した理由・意図 +2. その領域で守るべき業務ルール・ブランド基準 +3. なぜ他の実装方法ではダメなのかの判断根拠 + +- [ ] 変更箇所に `[YYYY-MM-DD][fix|feat|refactor]` コメントがあるか +- [ ] 背景に「ユーザー意図」「業務ルール」「不採用理由」が含まれるか +- [ ] 次のAIが同じ判断ミスをしない情報が残っているか + + +--- + + +## 重複機能の禁止(DRY原則) + +| チェック項目 | 基準 | +|-------------|------| +| 既存検索義務 | 新コンポーネント・関数作成前に既存コードベースを検索したか | +| 適用範囲 | ロジック・スタイル定義・色・文言すべてに適用 | +| 類似機能の扱い | 新規作成ではなく既存を拡張・共通化すること | +| パラメータ化 | 同目的のコンポーネントは1つに統合しprops/パラメータで切替 | + +- [ ] 新規関数・コンポーネント作成前にGrep検索で既存を確認したか +- [ ] 同様のロジック・スタイル・文言が既に存在しないか +- [ ] 類似機能がある場合、新規作成ではなく既存を拡張したか + + +--- + + +## ハードコード防止 + +DB由来データ(店舗名、ロール、ステータス等)がコード内にリテラルで直書きされていないかチェックする。 + +### データ管理の優先順位 + +| 優先度 | 方法 | 対象 | +|--------|------|------| +| 1(最優先) | DBから取得 | 変更頻度があるもの: 店舗名、ブランドカラー、設定値、営業時間等 | +| 2 | 定数ファイルに定義 | 環境に依存しない固定値: ステータスEnum、カテゴリ種別等 | +| 3(最終手段) | ハードコード | ①②が不可能な場合のみ。理由をコメントに明記すること | + +**追加ルール:** 同じ値が2箇所以上に出現する場合、必ず①または②で一元管理すること。 + +### チェック項目 + +| 対象 | ルール | +|------|--------| +| ビジネスデータ直書き | 店舗名・ロール名・ステータス等がリテラル文字列で記述されていないか | +| 既存定数の未使用 | プロジェクトにModel定数・Enum・ValueObjectがあるのに文字列比較していないか | +| フロントのマスタデータ | コンポーネント内に選択肢リストがハードコードされていないか(propsまたはAPI経由にする) | +| 固有名詞の条件分岐 | `name.includes('固有名詞')` のような分岐がないか(IDまたはフラグで判定する) | +| TODO_DB / PLACEHOLDER | DB由来データを暫定的に書く場合、`// TODO_DB(YYYY-MM\|ISSUE-123): テーブル名.カラム名` または `// PLACEHOLDER(YYYY-MM\|ISSUE-123): 理由` が付いているか | + +### 許可パターン(チェック対象外) + +- Model / Enum / ValueObject 内の定数定義 +- テストファイル・Seeder・Factory +- config/ 配下の設定ファイル +- 定数ファイル(constants.ts 等) + +- [ ] 定数・設定値がDB or 定数ファイルから取得されているか +- [ ] 同じ値が2箇所以上にハードコードされていないか +- [ ] やむを得ないハードコードに理由コメントがあるか + + +--- + + +## 破壊的変更の事前確認 + +| チェック項目 | 基準 | +|-------------|------| +| 参照洗い出し | 関数・コンポーネント・スタイルの変更/削除前にgrep等で全参照箇所を特定 | +| 整合性修正 | 参照箇所が見つかった場合、全箇所を整合性を保って修正 | +| 報告義務 | 変更した全ファイルと箇所のサマリーをユーザーに報告 | + +- [ ] 変更・削除した関数の全参照箇所をGrepで確認したか +- [ ] 参照箇所を整合性を保って全て修正したか +- [ ] 変更ファイルと箇所のサマリーを報告したか + + +--- + + +## メタ情報コメント(Serena MCP検索対応) + +新規作成ファイルの冒頭に、検索可能なメタ情報コメントがあるか確認する。 +**既存ファイルへの軽微な修正(1-2行の変更)は対象外。** + +### TypeScript / JavaScript / React / React Native / Next.js + +```ts +/** + * @module モジュール名(PascalCase) + * @description 日本語で1行の概要。Serenaのsearch_for_patternで引っかかるキーワードを含める + * @related 関連モジュール名をカンマ区切り + * @stack react-native | react | nextjs ← プロジェクトのスタックを明記 + */ +``` + +### PHP / Laravel + +```php +/** + * @module モジュール名 + * @description 日本語で1行の概要 + * @related 関連クラス・モデル名 + * @stack laravel + */ +``` + +### 対象外(メタ情報コメント不要) +- 設定ファイル(.env, tailwind.config.*, tsconfig.json, composer.json等) +- テストファイル(テスト名が十分なドキュメント) +- 自動生成ファイル(migration以外のartisan generate等) +- package.json, Gemfile, requirements.txt等の依存定義 + +### 命名・配置 + +| チェック項目 | 基準 | +|-------------|------| +| シンボル命名 | 検索しやすい名前か(略語を避ける。ResCtrl → ReservationController) | +| ファイル配置 | プロジェクトの標準ディレクトリに配置されているか | + + +--- + + +## 型チェック(Code as Documentの土台) + +| スタック | ツール | 基準 | +|---------|--------|------| +| TypeScript | `tsc --noEmit` | strict mode必須。any禁止 | +| Laravel | PHPStan | Level 8以上(目標: Level 10) | +| React Native | `tsc --noEmit` | strict mode必須 | + +### チェック項目 + +| 対象 | ルール | +|------|--------| +| 関数の引数・戻り値 | 型アノテーション必須(any / mixed 禁止) | +| API レスポンス | Zod / FormRequest で型を定義 | +| Props | TypeScript interface / PHPDoc @param で明示 | +| 状態管理 | useState / typed Collection で型付け | + +**型が曖昧なコード = ドキュメントとして読めないコード**。AIが推測に頼る原因になるため、型は厳格に。 + + +--- + + +--- + +## 判定基準 + +- 全項目OK → {"decision": "approve", "reason": "品質基準を満たしています"} +- 1つでもNG → {"decision": "block", "reason": "【具体的な指摘と修正指示をここに書く】"} +- stop_hook_activeがtrueの場合 → 無限ループ防止のため必ずapprove + + +- Codex Stop hook の全項目OK / stop_hook_active=true → {"continue": true} diff --git a/.gemini/hooks/lib/hook-io.sh b/.gemini/hooks/lib/hook-io.sh new file mode 100755 index 000000000..101b8a1c4 --- /dev/null +++ b/.gemini/hooks/lib/hook-io.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: jtt-cms Gen 3 のhook-io.shをAGENT-HUBのhook-libraryにポート。 +# PreToolUse/PostToolUse共通のJSON解析・出力関数を一元管理。 +# 3PJで同一ロジックが重複しており、修正時の漏れを防止するためコンポーネント化。 +# 対応: jtt-cms hook-io.sh をそのままポート。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「フック判定が環境差(Node有無)で揺れず、同じ入力なら同じ結果になること」。 +# 業務ルールとして、JSON抽出はエスケープ文字や改行を含む実データでも破綻してはならない。 +# 代替案として sed ベースの簡易抽出を維持すると、文字列中の引用符で誤抽出が起きるため不採用。 +# 対応: Node未導入時は Python JSON パースを使う安全フォールバックへ変更。 + +# --- stdin読み込み --- +# stdinからJSON入力を読み込み、HOOK_INPUT変数に格納する。 +# 各フックのエントリポイントで最初に呼ぶこと。 +read_stdin() { + HOOK_INPUT="$(cat)" +} + +# --- JSON フィールド抽出 (PreToolUse用) --- +# tool_input内の文字列フィールドを抽出する。Node.js優先、sed fallback。 +# 使用例: COMMAND=$(extract_field command) +extract_field() { + local field="$1" + if command -v node >/dev/null 2>&1; then + printf '%s' "$HOOK_INPUT" | node -e ' + const fs = require("fs"); + const field = process.argv[1]; + const raw = fs.readFileSync(0, "utf8"); + let value = ""; + try { + const parsed = JSON.parse(raw); + const source = + parsed && typeof parsed.tool_input === "object" && parsed.tool_input !== null + ? parsed.tool_input + : parsed && typeof parsed.toolInput === "object" && parsed.toolInput !== null + ? parsed.toolInput + : parsed; + if (source && typeof source[field] === "string") { + value = source[field]; + } + } catch {} + process.stdout.write(value); + ' "$field" 2>/dev/null || true + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + PY_FIELD="$field" HOOK_JSON="$HOOK_INPUT" python3 - <<'PY' 2>/dev/null || true +import json +import os + +field = os.environ.get("PY_FIELD", "") +raw = os.environ.get("HOOK_JSON", "") +value = "" +try: + parsed = json.loads(raw) + source = ( + parsed.get("tool_input") + or parsed.get("toolInput") + or parsed + if isinstance(parsed, dict) + else {} + ) + candidate = source.get(field, "") if isinstance(source, dict) else "" + if isinstance(candidate, str): + value = candidate +except Exception: + pass +print(value, end="") +PY + return 0 + fi + + # Node / Python が未導入の場合のみ簡易フォールバック(誤抽出リスクあり) + echo "$HOOK_INPUT" | sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -1 || true +} + +# --- file_path抽出 (PostToolUse用) --- +# tool_inputからfile_path(またはpath)を抽出する。Python3使用。 +# 使用例: filepath=$(extract_file_path) +extract_file_path() { + printf '%s' "$HOOK_INPUT" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + ti = data.get('tool_input') or data.get('toolInput') or {} + print(ti.get('file_path', ti.get('path', ''))) +except Exception: + print('') +" 2>/dev/null || echo "" +} + +# --- deny JSON出力 (PreToolUse用) --- +# hookSpecificOutput形式のdeny JSONを出力し、exit 0で終了する。 +# 使用例: emit_deny "ブロック理由メッセージ" +# [2026-06-19][fix] +# 背景: +# - Claude/Codex/Kimi の hook deny 出力で旧 `reason` キーが混在すると、 +# 新しい権限UIで理由が表示されない環境がある。 +# - 守るべき業務ルール: deny 理由は `permissionDecisionReason` に統一し、 +# JSON 文字列は Python で escape して壊れた hook 出力を防ぐ。 +# - 他案不採用理由: 各 hook で個別に printf する案は schema 差分と escape 漏れが再発するため不採用。 +emit_deny() { + local reason="$1" + HOOK_REASON="$reason" python3 - <<'PY' +import json +import os + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": os.environ.get("HOOK_REASON", ""), + } +}, ensure_ascii=False, separators=(",", ":"))) +PY + exit 0 +} diff --git a/.gemini/hooks/lib/quality-check-common.sh b/.gemini/hooks/lib/quality-check-common.sh new file mode 100755 index 000000000..acb337b4b --- /dev/null +++ b/.gemini/hooks/lib/quality-check-common.sh @@ -0,0 +1,973 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: jtt-cms Gen 3 (403行) をAGENT-HUBのhook-libraryにポート。 +# 3PJで独立進化したhookを統一するため、最先端のGen 3をSSOTとして抽出。 +# 各PJ個別実装だと変更が伝播せず重複が増え続けるため、コンポーネント化して +# deploy-hooks.pyで全PJに配布する設計。 +# 対応: jtt-cms quality-check-common.sh をhook-library/lib/にポート。 +# パス解決をscripts/サブディレクトリ構成に対応させ、 +# チェックリストパスをproject_dir起点に変更。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「transcript解析がPython 3.8環境でも失敗せず動くこと」。 +# 業務ルールとして、品質ゲート共通ライブラリはPJ間で同一挙動を保つ必要がある。 +# 代替案として `set[str]` 型注釈を維持すると、3.8で構文エラーになり判定が抜けるため不採用。 +# 対応: 埋め込みPythonの型注釈を `typing.Set` ベースへ変更。 + +set -euo pipefail + +# telemetry(harness-checkup): quality-gate 系(stop/subagent)の deny を記録。 +# 本 lib は hook-library/lib/ に在り、telemetry-lib.sh は hook-library/scripts/ にある。 +# 配布先でも同じ相対構成(.claude/hooks/lib/ と .claude/hooks/scripts/)のため ../scripts/ で解決できる。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも quality-gate 本体を絶対に壊さない。 +_quality_common_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$_quality_common_dir/../scripts/telemetry-lib.sh" ]; then + . "$_quality_common_dir/../scripts/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: jtt-apps の /brainstorm 質問のみセッションで Stop hook が誤発火する事故 (B1) を、git diff fallback がバックグラウンド同期で書き換わった untracked 派生物 (.opencode/sync-state.json 等) を「変更ファイル」と誤認することで起きる問題として根治したい。 +# - 守るべき業務ルール: 配布先 PJ 側で sync スクリプトが書き換える派生物 (.opencode/, .cursor/, .gemini/, .augment/, .codex/hooks/, .agent/, sync-state.json) は AI のツール呼び出し由来ではないため品質ゲートの対象外にする。 +# - 他案不採用理由: +# 1) .gitignore に追加して回避する案 → 検出ロジックの欠陥は残ったまま、新しい派生物ディレクトリが増えるたびに各 PJ で .gitignore を直す必要があり SSOT 原則違反。 +# 2) git diff fallback を完全廃止する案 → Bash 経由 (sed -i / cat > / tee 等) の書き換えを救う最後の砦が消える。 +# 対応: DOC_SKIP_PATTERNS に sync 派生物パターンを追加し多重防御。主防御は run_quality_check_hook の transcript 判定変更で行う。 +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: business profile の PJ (jtt-cafe-pj / non-pj) は議事録・PRD・戦略などの .md/docs が +# 成果物そのもの。従来は全 PJ 共通で .md/docs を skip していたため、business PJ がローカルで DOC_SKIP を +# 書き換える drift が発生していた (hook-library v3.4.11 配布で露見・Codex 指摘)。SSOT で一元解決したい。 +# - 守るべき業務ルール: 同期派生物 (.opencode/ 等・ツール生成物) は全 profile で skip。文書 (.md/docs 等) は +# code profile では skip、business profile では品質チェック対象にする。配布時に deploy-hooks.py が +# business profile のみ DOC_TYPE_SKIP を外す。配布物のローカル編集 (drift) は禁止のため SSOT 側で分岐させる。 +# - 他案不採用理由: (1) 各 business PJ で DOC_SKIP をローカル編集 → 配布物改変禁止に反し再 drift。 +# (2) runtime で checklist md の文言から profile 推定 → 文言変更で静かに壊れる。 +# 対応: パターンを DOC_TYPE_SKIP (文書) と SYNC_DERIVATIVE_SKIP (同期派生物) に分割。deploy-hooks.py は +# business profile 配布時に下の結合行を `readonly DOC_SKIP_PATTERNS="${SYNC_DERIVATIVE_SKIP}"` へ置換する。 +# [2026-07-09][fix] +# 背景: +# - ユーザー依頼意図: `.brv/` と Kimi 系生成物が同期派生物なのに品質チェック対象へ入り、実作業の本質と +# 無関係な検出ノイズになるのを防ぎたい。 +# - 守るべき業務ルール: AI ツール CLI 派生物は SSOT から再生成・同期されるため、quality check の本文対象ではなく +# SYNC_DERIVATIVE_SKIP に集約する。文書本文の品質チェック分岐は既存の DOC_TYPE_SKIP と分けたまま維持する。 +# - 他案不採用理由: 各 PJ の `.gitignore` へ個別追加する案は配布先ごとの drift を増やすため不採用。 +# DOC_TYPE_SKIP 側へ混ぜる案は business profile の文書チェック分岐を壊すため不採用。 +# 対応: SYNC_DERIVATIVE_SKIP に `.brv/`、`.kimi-code/`、`.kimi/` を追加する。 +# [2026-07-30][fix] +# 背景: +# - ユーザー依頼意図: jtt-apps の実装セッション(シフト確定 v2.5.2)で、Stop hook が +# `.claude/hooks/.hook-library-version` を「変更されたコードファイル」として毎回検知し、 +# 品質チェック済みでも完了報告のたびに block を繰り返した。セッション由来でない配布物で止めたくない。 +# - 守るべき業務ルール: `.claude/hooks/**` は deploy-hooks.py が hook-library 正本から生成する配布物であり、 +# 配布先での直接編集は禁止(settings-protection-coexistence)。よって配布先 PJ で品質チェックの +# 対象にする意味がなく、正本側(AGENT-HUB `hook-library/`)でチェックすべき対象である。 +# 既に `^\.codex/hooks/` は除外済みで、Claude 側だけが抜けていた非対称性が原因。 +# - 他案不採用理由: +# 1) git diff fallback で追跡変更を拾うのを止める案 → Bash 経由(sed -i / cat >)の実コード変更を +# 見逃し品質ゲートが弱くなるため不採用(2026-05-26 の判断を維持)。 +# 2) `.hook-library-version` だけをファイル名で除外する案 → 同じ配布物である `lib/*.sh` や +# `scripts/*.sh` のドリフトで再発するため対症療法。ディレクトリ単位で `.codex/hooks/` と揃える。 +# 3) 配布先 PJ の drift をその都度コミットして消す案 → 配布のたびに日付スタンプで再発するため恒久解にならない。 +# 対応: SYNC_DERIVATIVE_SKIP に `^\.claude/hooks/|/\.claude/hooks/` を追加し、`.codex/hooks/` と対称にする。 +readonly DOC_TYPE_SKIP='\.md$|\.prd$|\.txt$|^docs/|/docs/|\.template$|CLAUDE\.md|README|CHANGELOG' +readonly SYNC_DERIVATIVE_SKIP='^\.opencode/|/\.opencode/|^\.cursor/|/\.cursor/|^\.gemini/|/\.gemini/|^\.augment/|/\.augment/|^\.claude/hooks/|/\.claude/hooks/|^\.codex/hooks/|/\.codex/hooks/|^\.agent/|/\.agent/|^\.brv/|/\.brv/|^\.kimi-code/|/\.kimi-code/|^\.kimi/|/\.kimi/|sync-state\.json$' +# DEPLOY-MARKER(business): deploy-hooks.py は business profile でこの行を SYNC_DERIVATIVE_SKIP のみへ置換する。 +readonly DOC_SKIP_PATTERNS="${DOC_TYPE_SKIP}|${SYNC_DERIVATIVE_SKIP}" +# [2026-03-17][refactor] +# 背景: +# - ユーザー依頼意図: hookのblock reasonにチェックリスト全文(395行)が毎回チャットに出力され、 +# 視認性が悪くコンテキストウィンドウを圧迫するため、最小限の出力に変更したい。 +# - 守るべき業務ルール: dev-guardrails SKILL.md Section 9「発火フロー」に記載の +# 「ファイルパス参照指示を block reason に記載 → AIが Read ツールで code-quality-check.md を +# 読み込み品質チェック実施」方式をランタイムで実現すること。 +# - 他案不採用理由: (1) チェックリスト全文のインライン注入は視認性を壊す(現状の問題そのもの)。 +# (2) 要約版を別ファイルで管理する案はDRY違反で同期漏れを再発させるため不採用。 +# (3) block reasonを完全に空にする案はAIが何をすべきか分からなくなるため不採用。 +# 対応: block reasonにはファイルパス+変更ファイル一覧のみ出力し、 +# AIにReadツールでチェックリストを読ませる方式に変更。 +readonly BLOCK_PREFIX='作業完了前に品質チェックを実施してください。指定されたチェックリストファイルを Read ツールで読み込み、各項目を確認してください。問題があれば修正してから再度完了を報告してください。' +readonly CODE_FILE_PATTERNS='\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|sql|php|py|sh|yaml|yml|toml|ini|mdx?)$' + +emit_json() { + local decision="$1" + local reason="$2" + + PY_DECISION="$decision" PY_REASON="$reason" python3 - <<'PY' +import json +import os + +print( + json.dumps( + {"decision": os.environ["PY_DECISION"], "reason": os.environ["PY_REASON"]}, + ensure_ascii=False, + ) +) +PY +} + +is_codex_hook_root() { + local hook_root="$1" + local normalized_hook_root + + normalized_hook_root="$(cd "$hook_root" 2>/dev/null && pwd || printf '%s\n' "$hook_root")" + + case "$normalized_hook_root" in + */.codex/hooks|*/.codex/hooks/) return 0 ;; + *) return 1 ;; + esac +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: Codex Stop hook が2回目停止時に +# "hook returned invalid stop hook JSON output" で失敗する問題を、配布元の正本で直したい。 +# - 守るべき業務ルール: hook-library は Claude Code / Codex CLI の共通正本なので、 +# Codex だけに必要な出力差分は配布先 hook_root で分岐し、Claude 側の既存応答を維持する。 +# - 他案不採用理由: 共通ライブラリ全体を `decision: approve` のままにする案は Codex Stop で再発する。 +# 逆に全環境を `continue: true` に変える案は Claude Code 側の既存運用に不要な互換リスクを持ち込むため不採用。 +# 対応: `.codex/hooks` 配下で動く approve 相当分岐だけ `{"continue": true}` を返す。 +emit_approval_json() { + local hook_root="$1" + local reason="$2" + + if is_codex_hook_root "$hook_root"; then + python3 - <<'PY' +import json + +print(json.dumps({"continue": True})) +PY + return 0 + fi + + emit_json "approve" "$reason" +} + +resolve_project_dir() { + local hook_root="$1" + local inferred_dir git_root + + if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}" ]; then + printf '%s\n' "$CLAUDE_PROJECT_DIR" + return + fi + + # hook_root is .claude/hooks/ → go up 2 levels to project root + inferred_dir="$(cd "$hook_root/../.." && pwd)" + if git -C "$inferred_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '%s\n' "$inferred_dir" + return + fi + + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ]; then + printf '%s\n' "$git_root" + return + fi + + printf '%s\n' "$inferred_dir" +} + +extract_stop_hook_active() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + print(str(data.get('stop_hook_active', False)).lower()) +except Exception: + print('error') +" <<<"$input" 2>/dev/null || echo "error" +} + +extract_transcript_path() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + value = data.get('transcript_path', '') + print(value if isinstance(value, str) else '') +except Exception: + print('') +" <<<"$input" 2>/dev/null || true +} + +extract_agent_type() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + print(data.get('agent_type', '')) +except Exception: + print('') +" <<<"$input" 2>/dev/null || echo "" +} + +extract_agent_transcript_path() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + value = data.get('agent_transcript_path', '') + print(value if isinstance(value, str) else '') +except Exception: + print('') +" <<<"$input" 2>/dev/null || true +} + +# [2026-03-17][fix] +# 背景: +# - ユーザー依頼意図: PR429レビューで、hook が変更ファイル一覧を誤判定せず、 +# 品質チェックの block/approve 判定を安定して行える状態にしたい。 +# - 守るべき業務ルール: Git 管理下の合法パス(前後空白や改行を含む名前を含む)でも +# 品質ゲートが誤検知・見逃しを起こさないこと。品質ゲートの誤作動は +# 「本来 block すべき変更を素通しする」「関係ない変更で block する」の両面で運用事故になる。 +# - 他案不採用理由: (1) 改行区切りのまま扱う案は改行入りパスで分裂する。 +# (2) strip で前後空白を落とす案は合法パスを別名に変えてしまう。 +# (3) 特殊ケースを無視する案は次回AIが同じバグを再発させるため不採用。 +# 対応: 変更ファイル一覧は JSON 配列で受け渡しし、表示時だけ安全に整形する。 +extract_changed_files_from_input() { + local input="$1" + + python3 -c " +import json +import sys + +PATH_KEYS = {'file_path', 'path', 'new_path', 'old_path', 'target_path'} + +def walk(node, out): + if isinstance(node, dict): + for key, value in node.items(): + if key.lower() in PATH_KEYS and isinstance(value, str) and value != '': + out.add(value) + walk(value, out) + return + if isinstance(node, list): + for item in node: + walk(item, out) + +paths = set() +try: + payload = json.load(sys.stdin) + walk(payload, paths) +except Exception: + pass + +print(json.dumps(sorted(paths), ensure_ascii=False)) +" <<<"$input" 2>/dev/null || echo "[]" +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: /brainstorm のような質問のみセッション (AI が Write/Edit を一切呼ばない) で Stop hook が誤発火する問題 (B1) の主防御。 +# - 守るべき業務ルール: transcript が読み取れた状態で Write 系ツールが 0 件なら、コード変更は本会話由来ではないと判定し git diff fallback を呼ばずに approve する。 +# - 他案不採用理由: +# 1) extract_changed_files_from_transcript の戻り値だけで判定する案 → "[]" が「読めて 0件」と「読めなかった」を区別できず、Bash 経由書き換え時に fallback が呼ばれなくなる。 +# 2) 戻り値に sentinel 文字列を混ぜる案 → 呼び出し側のパース処理が複雑化し、JSON との混在で誤判定リスク。 +# 対応: transcript_path の読み取り可否を別関数で boolean 返却し、呼び出し側で 3 状態 (paths あり / 読めて 0件 / 読めなかった) に分岐する。 +transcript_was_readable() { + local transcript_path="$1" + if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then + echo "true" + else + echo "false" + fi +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: PR87レビューで、transcript が読める状態の Bash 書き込み +# (`cat > file`, `tee`, `sed -i` 等) が Write/Edit 0件扱いで品質ゲートを素通りする問題を直したい。 +# - 守るべき業務ルール: /brainstorm の質問のみセッションでは誤発火させない一方で、git diff fallback は +# Bash 経由書き換えを救う最後の砦として残す必要がある。 +# - 他案不採用理由: +# 1) git diff fallback を完全廃止する案 → Bash 経由書き換え検出が失われるため不採用。 +# 2) transcript 内に Bash があるだけで fallback する案 → `git status` だけの質問セッションで再発火しやすいため不採用。 +# 対応: transcript の Bash command から書き込み系パターンだけを検出し、その時だけ fallback に進める。 +# +# [2026-04-28][fix] +# 背景: +# - ユーザー依頼意図: 読取専用セッション(gh / git 系コマンドのみ)で Stop hook が連続誤発火し、 +# タイポディレクトリ `.claire/` 配下の untracked ファイルを「変更コード」と誤検出する事故が再発した。 +# - 守るべき業務ルール: シェルリダイレクト `2>&1` / `1>&2` はファイル書き込みではない。 +# `&>/dev/null` / `&>>/dev/null` も破棄目的の診断出力であり、WRITE 判定に含めると +# 診断目的の `gh ... 2>&1` 連発で git diff fallback が誤起動し、 +# 別 worktree や typo ディレクトリの差分まで拾ってしまう。 +# - 他案不採用理由: +# 1) WRITE_COMMAND_RE から `>` を完全削除する案 → 真の `cmd > file` 書き込みを見逃すため不採用。 +# 2) DOC_SKIP_PATTERNS に `.claire` を足す案 → 対症療法。次の typo に対応できないため不採用。 +# 3) 実行時に shlex で AST パースする案 → bash heredoc / 複合コマンドで誤動作しやすく過剰実装。 +# 対応: FD複製 (`2>&1`) と `/dev/null` 破棄だけを除外し、`1>file` / `2>file` / `&>file` は +# 真のファイル書き込みとして検出する。 +transcript_has_bash_write_command() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "false" + return 0 + fi + + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "false" +import json +import re +import sys +from typing import Any + +# `>` / `>>` はFD複製 (`2>&1`) と `/dev/null` 破棄だけを除外する。 +# これにより `1>file`, `2>file`, `&>file` は検出し、`2>&1`, `1>&2`, `&>/dev/null` は除外する。 +WRITE_COMMAND_RE = re.compile( + r"((?:^|[\s;|])(?:\d*)?>>?(?!&)(?!\s*/dev/null\b)\s*|(?:^|[\s;|])&>{1,2}(?!>)(?!\s*/dev/null\b)\s*|\btee\b|\bsed\s+-i\b|\bperl\s+-pi\b|\bcp\b|\bmv\b|\brm\b|\btouch\b|\bmkdir\b|\bcat\s+<<)" +) + + +def tool_name(node: Any) -> str: + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + value = node.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def command_text(node: Any) -> str: + if not isinstance(node, dict): + return "" + if isinstance(node.get("command"), str): + return node["command"] + nested = node.get("input") + if isinstance(nested, dict) and isinstance(nested.get("command"), str): + return nested["command"] + tool_input = node.get("tool_input") + if isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str): + return tool_input["command"] + return "" + + +def has_bash_write(node: Any) -> bool: + if isinstance(node, dict): + name = tool_name(node) + if name == "Bash" and WRITE_COMMAND_RE.search(command_text(node)): + return True + return any(has_bash_write(value) for value in node.values()) + if isinstance(node, list): + return any(has_bash_write(item) for item in node) + return False + + +try: + content = open(sys.argv[1], encoding="utf-8", errors="ignore").read() +except Exception: + print("false") + raise SystemExit(0) + +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + if has_bash_write(json.loads(line)): + print("true") + raise SystemExit(0) + except SystemExit: + raise + except Exception: + pass + +try: + result = has_bash_write(json.loads(content)) +except Exception: + result = False + +print("true" if result else "false") +PY +} + +extract_changed_files_from_transcript() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "[]" + return 0 + fi + + # Layer 3: 書き込みツール(Write/Edit/NotebookEdit/MultiEdit)のfile_pathのみ収集。 + # Read/Grep/Globなどの読み取り専用ツールのfile_pathを「変更」と誤認しない。 + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "[]" +import json +import sys +from typing import Any, Set + +WRITE_TOOLS = frozenset({"Write", "Edit", "NotebookEdit", "MultiEdit"}) +PATH_KEYS = frozenset({"file_path", "path", "new_path", "old_path", "target_path"}) + +transcript_path = sys.argv[1] +paths: Set[str] = set() + + +def collect_paths(node: Any) -> None: + """Collect file paths from a node known to belong to a write tool.""" + if isinstance(node, dict): + for key, value in node.items(): + if key.lower() in PATH_KEYS and isinstance(value, str) and value != "": + paths.add(value) + collect_paths(value) + return + if isinstance(node, list): + for item in node: + collect_paths(item) + + +def find_tool_name(node: Any) -> str: + """Extract tool name from a dict node.""" + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + val = node.get(key, "") + if isinstance(val, str) and val: + return val + return "" + + +def process_entry(entry: Any) -> None: + """Walk an entry and only collect paths from write tool invocations.""" + if not isinstance(entry, dict): + return + tool_name = find_tool_name(entry) + if tool_name in WRITE_TOOLS: + collect_paths(entry) + # Recurse into nested structures (content, messages, etc.) + for key in ("content", "messages", "tool_use", "input"): + child = entry.get(key) + if isinstance(child, list): + for item in child: + process_entry(item) + elif isinstance(child, dict): + process_entry(child) + + +try: + with open(transcript_path, encoding="utf-8", errors="ignore") as f: + content = f.read() +except Exception: + print("[]") + sys.exit(0) + +# JSON Lines format +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + process_entry(json.loads(line)) + except Exception: + pass + +# Single JSON object format +try: + process_entry(json.loads(content)) +except Exception: + pass + +print(json.dumps(sorted(paths), ensure_ascii=False)) +PY +} + +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: Bash の作成系コマンド (`cat > f` / `tee f` / `touch f` / `> f`) の +# ターゲットパスを transcript から抽出し、git diff フォールバックで「セッションが作成した +# 未追跡ファイルだけ」を拾えるようにする。 +# - 守るべき業務ルール: 移動・削除系 (mv / cp / rm) は新規コード作成の判定に使わない。 +# `mv tmp dest` のような plumbing を作成扱いすると、他セッション WIP の誤検知 (R1) を再発させる。 +# - 他案不採用理由: +# 1) WRITE_COMMAND_RE の boolean 判定を流用する案は、ターゲットパスが取れず未追跡の絞り込みができない。 +# 2) 正規表現だけでパスを分割する案は、`cat > "src/space file.ts"` のような引用符付きパスを見逃す。 +# 対応: shlex で Bash コマンドの引用符を解釈し、作成系リダイレクト/コマンドのターゲットだけを抽出する。 +extract_bash_created_paths_from_transcript() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "[]" + return 0 + fi + + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "[]" +import json +import os +import re +import shlex +import sys +from typing import Any + +REDIRECT_TOKEN_RE = re.compile(r"^(?:(?:\d*)>{1,2}|&>{1,2})$") +METACHARS = {";", "|", "&", "<", ">", ">>", "&>", "&>>", "&&", "||"} + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `cd scripts && cat > foo.py` のように Bash の cwd が変わった後の +# ファイル作成を transcript から抽出するとき、cwd を無視して相対パスのまま返すため +# `git ls-files --others` の `scripts/foo.py` と一致せず未追跡ファイルを見逃す問題を修正したい。 +# 守るべき業務ルール: 移動・削除系 (mv / cp / rm) は作成扱いしない(R1 誤検知防止)。 +# 変数展開を含む `cd "$VAR"` は追跡不能で、従来どおり相対のまま許容する。 +# cwd 正規化は `detect_changed_files()` 内の created_rel 変換と対称に行う。 +# 他案不採用理由: +# 1) cwd を環境変数で渡す案 → Bash ノード間で状態が引き継がれず `cd && cmd` のケースを処理できない。 +# 2) shlex の AST パース案 → bash heredoc / 複合コマンドで誤動作しやすく過剰実装。 +# 対応: `cwd_from_node()` を追加してノードの cwd フィールドを取得。 +# `harvest()` に cwd 引数を追加し `cd ` を検出したら current_cwd を更新。 +# `add_target()` に cwd 引数を追加して絶対パス正規化を行う。 + +targets = set() + + +def cwd_from_node(node): + """Bash ノードの cwd フィールドを取得する。複数のキー名に対応。""" + if not isinstance(node, dict): + return "" + # 直接フィールド + v = node.get("cwd") + if isinstance(v, str) and v: + return v + # tool_input.cwd + ti = node.get("tool_input") + if isinstance(ti, dict): + v = ti.get("cwd") + if isinstance(v, str) and v: + return v + # input.cwd + inp = node.get("input") + if isinstance(inp, dict): + v = inp.get("cwd") + if isinstance(v, str) and v: + return v + return "" + + +def add_target(tok, cwd=""): + tok = tok.strip() + # フラグ (-a 等)・FD複製 (&1)・破棄先 (/dev/null) は作成ターゲットではない。 + if not tok or tok.startswith(("-", "&")) or tok == "/dev/null" or tok.endswith("/dev/null"): + return + if os.path.isabs(tok): + targets.add(tok) + elif cwd: + targets.add(os.path.normpath(os.path.join(cwd, tok))) + else: + targets.add(tok) + + +def shell_tokens(cmd): + try: + lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + return list(lexer) + except Exception: + return [] + + +def harvest(cmd, cwd=""): + tokens = shell_tokens(cmd) + current_cwd = cwd + for i, tok in enumerate(tokens): + # `cd ` を検出して current_cwd を更新 + if tok == "cd" and i + 1 < len(tokens): + new_dir = tokens[i + 1] + # 変数展開 ($VAR 等) は追跡不能なのでスキップ + if not new_dir.startswith("$") and new_dir not in METACHARS: + if os.path.isabs(new_dir): + current_cwd = new_dir + elif current_cwd: + current_cwd = os.path.normpath(os.path.join(current_cwd, new_dir)) + else: + current_cwd = new_dir + continue + # 作成系リダイレクト `> f` / `1> f`。`2>&1` や `/dev/null` は add_target 側で除外。 + if (tok in {">", ">>", "&>", "&>>"} or REDIRECT_TOKEN_RE.match(tok)) and i + 1 < len(tokens): + add_target(tokens[i + 1], current_cwd) + continue + if tok in {"tee", "touch"}: + for candidate in tokens[i + 1 :]: + if candidate in METACHARS: + break + add_target(candidate, current_cwd) + + +def tool_name(node): + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + v = node.get(key) + if isinstance(v, str) and v: + return v + return "" + + +def command_text(node): + if not isinstance(node, dict): + return "" + if isinstance(node.get("command"), str): + return node["command"] + for key in ("input", "tool_input"): + nested = node.get(key) + if isinstance(nested, dict) and isinstance(nested.get("command"), str): + return nested["command"] + return "" + + +def walk(node: Any) -> None: + if isinstance(node, dict): + if tool_name(node) in {"Bash", "Shell"}: + node_cwd = cwd_from_node(node) + harvest(command_text(node), node_cwd) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for item in node: + walk(item) + + +try: + content = open(sys.argv[1], encoding="utf-8", errors="ignore").read() +except Exception: + print("[]") + raise SystemExit(0) + +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + walk(json.loads(line)) + except Exception: + pass + +try: + walk(json.loads(content)) +except Exception: + pass + +print(json.dumps(sorted(targets), ensure_ascii=False)) +PY +} + +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: jtt-cafe-pj の /insights リフレッシュ作業終了時、Stop hook が +# 別セッションの未追跡 WIP (.claude/skills/dev-guardrails/** 等) を「変更コードファイル」 +# として誤検知し block する事象が実発火した (R1)。クリーンに直したい。 +# - 守るべき業務ルール: git diff フォールバックは transcript 検出 (Write/Edit の file_path) が +# 失敗した時の最終手段。未追跡ファイルは git 履歴がなくセッション帰属を判定できないため、 +# 無条件に拾うと他セッションの WIP・スクラッチ・他ツール生成物を誤検知する。 +# - 他案不採用理由: +# 1) 未追跡検出を完全除去する案 → Bash で新規作成したコードファイル (`cat > scripts/foo.py`) を +# フォールバックで見逃し品質ゲートが弱くなる (Codex レビュー指摘) ため不採用。 +# 2) DOC_SKIP_PATTERNS にディレクトリを足し続ける案 → 「次の untracked に対応できない対症療法」のため不採用。 +# 対応: 追跡変更 (git diff / --cached) は常に対象。未追跡ファイルは +# 「このセッションが Bash 作成系で書いたターゲット」(created_paths) に一致するものだけ対象にする。 +# created_paths が空 (transcript 読めない等) の場合は未追跡を一切拾わない (帰属不能なため安全側)。 +detect_changed_files() { + local project_dir="$1" + local created_paths_json="${2:-[]}" + + python3 - "$project_dir" "$CODE_FILE_PATTERNS" "$created_paths_json" <<'PY' 2>/dev/null || echo "[]" +import json +import os +import re +import subprocess +import sys + +project_dir = sys.argv[1] +code_file_pattern = re.compile(sys.argv[2], re.IGNORECASE) +try: + created = json.loads(sys.argv[3]) + if not isinstance(created, list): + created = [] +except Exception: + created = [] + +# セッションが Bash 作成系で書いたターゲットを project_dir 相対パスに正規化。 +# basename 一致は使わない(別ディレクトリの同名未追跡ファイルを誤検知するため。Codex レビュー指摘)。 +created_rel = set() +for t in created: + if not isinstance(t, str) or not t: + continue + norm = t + if os.path.isabs(t): + try: + norm = os.path.relpath(t, project_dir) + except Exception: + norm = t + if norm.startswith("./"): + norm = norm[2:] + created_rel.add(norm) + +paths = set() + +# 追跡ファイルの変更は常に対象。 +for command in ( + ["git", "-C", project_dir, "diff", "--name-only", "-z", "--diff-filter=ACMR"], + ["git", "-C", project_dir, "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"], +): + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False) + for raw_path in result.stdout.split(b"\0"): + if raw_path: + paths.add(raw_path.decode("utf-8", errors="surrogateescape")) + +# 未追跡は「このセッションが作成したターゲット」に相対パス完全一致するコードファイルだけ対象にする。 +if created_rel: + result = subprocess.run( + ["git", "-C", project_dir, "ls-files", "--others", "--exclude-standard", "-z"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, + ) + for raw_path in result.stdout.split(b"\0"): + if not raw_path: + continue + path = raw_path.decode("utf-8", errors="surrogateescape") + if not code_file_pattern.search(path): + continue + if path in created_rel: + paths.add(path) + +print(json.dumps(sorted(paths), ensure_ascii=False)) +PY +} + +json_file_list_is_empty() { + local files_json="$1" + + python3 -c " +import json +import sys + +try: + print('true' if not json.load(sys.stdin) else 'false') +except Exception: + print('true') +" <<<"$files_json" 2>/dev/null || echo "true" +} + +filter_non_doc_files() { + local files_json="$1" + + python3 -c " +import json +import re +import sys + +pattern = re.compile(sys.argv[1], re.IGNORECASE) + +try: + files = json.loads(sys.argv[2]) +except Exception: + print('[]') + sys.exit(0) + +print(json.dumps([path for path in files if not pattern.search(path)], ensure_ascii=False)) +" "$DOC_SKIP_PATTERNS" "$files_json" 2>/dev/null || echo "[]" +} + +json_file_list_contains_sql() { + local files_json="$1" + + python3 -c " +import json +import re +import sys + +try: + files = json.loads(sys.argv[1]) +except Exception: + print('false') + sys.exit(0) + +print('true' if any(re.search(r'\\.sql$', path, re.IGNORECASE) for path in files) else 'false') +" "$files_json" 2>/dev/null || echo "false" +} + +format_file_list_for_display() { + local files_json="$1" + + python3 -c " +import json +import sys + +try: + files = json.loads(sys.argv[1]) +except Exception: + sys.exit(0) + +for path in files: + print(json.dumps(path, ensure_ascii=False)) +" "$files_json" 2>/dev/null || true +} + +# --- メインエントリーポイント --- +# 引数: +# $1: hook_name - ログ用の識別子 (例: "subagent-quality-check") +# $2: hook_root - hookルートディレクトリ (.claude/hooks/) +# $3: no_file_change_reason - ファイル変更なし時の理由メッセージ +# $4: use_git_diff_fallback - git diffフォールバック使用 (default: true) +run_quality_check_hook() { + local hook_name="$1" + local hook_root="$2" + local no_file_change_reason="$3" + local use_git_diff_fallback="${4:-true}" + local input project_dir checklist_path stop_hook_active input_changed_files transcript_path transcript_changed_files transcript_readable transcript_bash_write bash_created_paths changed_files non_doc_files formatted_non_doc_files block_reason sql_files security_checklist_path is_codex_hook + + is_codex_hook="false" + if is_codex_hook_root "$hook_root"; then + is_codex_hook="true" + fi + + # [2026-04-26][fix] + # 背景: + # - ユーザー依頼意図: Codex Stop hook の stdout/stderr 混在で JSON パース失敗を疑う状態をなくしたい。 + # - 守るべき業務ルール: Codex hook の通常出力は JSON だけに固定し、診断ログは明示的なデバッグ時だけ出す。 + # - 他案不採用理由: 常時 stderr にログを出す案は、Codex 側の厳密な Stop hook JSON 判定で + # invalid JSON 扱いの再発要因になり得るため不採用。 + # 対応: Codex 配布先では CODEX_HOOK_DEBUG=1 の時だけ stderr ログを出す。Claude 側は既存どおりログを出す。 + log() { + if [ "$is_codex_hook" != "true" ] || [ "${CODEX_HOOK_DEBUG:-}" = "1" ]; then + echo "[hook:${hook_name}] $*" >&2 + fi + } + + if ! command -v python3 >/dev/null 2>&1; then + log "python3 command is missing, approving as safe fallback" + if is_codex_hook_root "$hook_root"; then + echo '{"continue":true}' + else + echo '{"decision":"approve","reason":"python3 is required for quality hook. Approved as safe fallback."}' + fi + return 0 + fi + + input="$(cat)" + project_dir="$(resolve_project_dir "$hook_root")" + checklist_path="$hook_root/lib/code-quality-check.md" + + log "hook invoked for project: $project_dir" + + if [ ! -f "$checklist_path" ]; then + log "checklist not found at $checklist_path, approving" + emit_approval_json "$hook_root" "No quality checklist found, skipping." + return 0 + fi + + stop_hook_active="$(extract_stop_hook_active "$input")" + if [ "$stop_hook_active" = "true" ]; then + log "stop_hook_active=true, approving to prevent infinite loop" + emit_approval_json "$hook_root" "Already in quality check loop, approving to prevent infinite loop." + return 0 + fi + if [ "$stop_hook_active" = "error" ]; then + log "WARNING: failed to parse stop_hook_active from input JSON, approving as fallback" + emit_approval_json "$hook_root" "Could not parse hook input JSON, approving as safety fallback." + return 0 + fi + + # Layer 1: agent_type による読み取り専用エージェント即時判定 + # Explore/Plan等はWrite/Editツールを持たない(公式仕様で除外)ため、 + # コード変更は構造的に不可能。ファイル検出を一切行わずapproveする。 + local agent_type + agent_type="$(extract_agent_type "$input")" + case "$agent_type" in + Explore|Plan|feature-dev:code-reviewer|feature-dev:code-architect|feature-dev:code-explorer|claude-code-guide) + log "read-only agent type '$agent_type', approving without quality check" + emit_approval_json "$hook_root" "Read-only agent type ($agent_type), quality check not applicable." + return 0 + ;; + esac + + input_changed_files="$(extract_changed_files_from_input "$input")" + if [ "$(json_file_list_is_empty "$input_changed_files")" = "false" ]; then + changed_files="$input_changed_files" + log "detected changed files from hook input" + else + # Layer 2: agent_transcript_path を優先使用 + # SubagentStopでは agent_transcript_path(サブエージェント固有の履歴)を使い、 + # transcript_path(メインセッション全履歴)へのフォールバックで親の書き込みを誤検知しない。 + transcript_path="$(extract_agent_transcript_path "$input")" + if [ -z "$transcript_path" ]; then + transcript_path="$(extract_transcript_path "$input")" + fi + transcript_changed_files="$(extract_changed_files_from_transcript "$transcript_path")" + transcript_readable="$(transcript_was_readable "$transcript_path")" + transcript_bash_write="$(transcript_has_bash_write_command "$transcript_path")" + if [ "$(json_file_list_is_empty "$transcript_changed_files")" = "false" ]; then + changed_files="$transcript_changed_files" + log "detected changed files from transcript_path" + elif [ "$transcript_readable" = "true" ] && [ "$transcript_bash_write" = "true" ] && [ "$use_git_diff_fallback" = "true" ]; then + # 未追跡はセッションが Bash 作成系で書いたターゲットだけに絞る(他セッション WIP の誤検知 R1 防止) + bash_created_paths="$(extract_bash_created_paths_from_transcript "$transcript_path")" + changed_files="$(detect_changed_files "$project_dir" "$bash_created_paths")" + log "transcript has bash write command, falling back to git diff (untracked limited to session-created targets)" + elif [ "$transcript_readable" = "true" ]; then + # [2026-04-26][fix] + # transcript が読めて Write/Edit/MultiEdit/NotebookEdit が 0 件 → /brainstorm 等の質問のみセッション。 + # git diff fallback を呼ぶとバックグラウンド同期で書き換わった派生物 (.opencode/sync-state.json 等) を + # 「変更ファイル」と誤認するため、ここで approve に進む。 + changed_files="$transcript_changed_files" # = "[]" + log "transcript readable but no write tool invocations, approving (B1 fix)" + elif [ "$use_git_diff_fallback" = "true" ]; then + changed_files="$(detect_changed_files "$project_dir")" + log "transcript unreadable, falling back to git diff" + else + changed_files="" + log "git diff fallback disabled, no input/transcript file changes found" + fi + fi + + if [ "$(json_file_list_is_empty "$changed_files")" = "true" ]; then + log "no file changes detected, approving" + emit_approval_json "$hook_root" "$no_file_change_reason" + return 0 + fi + + non_doc_files="$(filter_non_doc_files "$changed_files")" + if [ "$(json_file_list_is_empty "$non_doc_files")" = "true" ]; then + log "only document files changed, approving" + emit_approval_json "$hook_root" "Document file change - skipping code quality check." + return 0 + fi + + formatted_non_doc_files="$(format_file_list_for_display "$non_doc_files")" + log "code files changed, blocking for quality check: $(echo "$formatted_non_doc_files" | tr '\n' ', ')" + + block_reason="${BLOCK_PREFIX}"$'\n\n'"チェックリスト: ${checklist_path}" + + # SQLファイル変更時はセキュリティレビューチェックリストのパスも追加 + sql_files="$(json_file_list_contains_sql "$non_doc_files")" + if [ "$sql_files" = "true" ]; then + security_checklist_path="$hook_root/lib/security-review-check.md" + if [ -f "$security_checklist_path" ]; then + block_reason="${block_reason}"$'\n'"セキュリティチェックリスト: ${security_checklist_path}" + log "SQL files detected, adding security review checklist path" + fi + fi + + block_reason="${block_reason}"$'\n\n'"変更されたコードファイル:"$'\n'"${formatted_non_doc_files}" + # telemetry(harness-checkup): quality-gate deny を記録(fail-open)。 + agent_hub_telemetry_log hook_deny "$hook_name" deny 2>/dev/null || true + emit_json "block" "$block_reason" + return 0 +} diff --git a/.gemini/hooks/lib/storage-url-common.py b/.gemini/hooks/lib/storage-url-common.py new file mode 100644 index 000000000..08305bb99 --- /dev/null +++ b/.gemini/hooks/lib/storage-url-common.py @@ -0,0 +1,190 @@ +# [2026-05-16][refactor] +# 背景: +# - ユーザー依頼意図: gmail-mcp へ配布された hook-library の Python ファイルも、配布先の CaD ルールに合う形へ揃えたい。 +# - 守るべき業務ルール: Python ファイル冒頭には shebang 直後または冒頭に # 形式の CaD ヘッダーを置く。 +# - 他案不採用理由: docstring 内の履歴だけに残す案は、配布先の CaD 検査で冒頭ヘッダーとして認識されないため不採用。 +# 対応: 既存 docstring 履歴を残したまま、冒頭に配布共通の CaD ヘッダーを追加。 +""" +Storage URL検証の共通ロジック。 +storage-url-check.sh (PostToolUse) と storage-url-pr-gate.sh (PreToolUse) から呼び出される。 + +[2026-03-03][refactor] +背景: jtt-cms Gen 3 のstorage-url-common.pyをAGENT-HUBのhook-libraryにポート。 + Supabase Storage URLの存在検証をPJ横断で共有するためコンポーネント化。 +対応: jtt-cms storage-url-common.py をそのままポート。 + +[2026-03-04][fix] +背景: ユーザー意図は「Python実行環境差でチェックが無効化されないこと」。 + 業務ルールとして、共通ライブラリは最低運用環境でも構文エラーなく動作する必要がある。 + 代替案として Python 3.9+ 専用型ヒントを維持すると、3.8系でゲートが素通りするため不採用。 +対応: 型ヒントを typing.List/Set/Tuple へ置換し、互換性を確保。 + +使い方: + python3 lib/storage-url-common.py [ ...] + mode: "check" (PostToolUse用) または "gate" (PreToolUse用) + +- check モード: 最大5URL検証、未アップロードがあれば stderr + exit 2 +- gate モード: 最大10URL検証(並列)、未アップロードがあれば deny理由を stdout + exit 1 +""" + +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import List, Set, Tuple + +# --- 定数 --- +CURL_TIMEOUT_SECONDS = 2 +MAX_URLS_CHECK_MODE = 5 +MAX_URLS_GATE_MODE = 10 + +STORAGE_URL_PATTERN = re.compile( + r"https://[a-z0-9]+\.supabase\.co/storage/v1/object/public/[^\x22\x27\s,)\]}\x60]+" +) + + +def remove_sql_comments(content: str) -> str: + """SQLコメントを除去する。コメント内のURLを誤検知しないため。""" + content = re.sub(r"--[^\n]*", "", content) + content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) + return content + + +def extract_storage_urls(filepaths: List[str]) -> List[str]: + """ファイル群からStorage URLを抽出し、重複排除・ソートして返す。""" + all_urls: Set[str] = set() + for fp in filepaths: + if not os.path.isfile(fp): + continue + try: + content = open(fp, encoding="utf-8").read() + except Exception: + continue + cleaned = remove_sql_comments(content) + all_urls.update(STORAGE_URL_PATTERN.findall(cleaned)) + return sorted(all_urls) + + +def check_url_head(url: str) -> Tuple[str, str]: + """curl HEAD でURLの存在を検証し、(url, HTTPステータス) を返す。""" + try: + result = subprocess.run( + [ + "curl", "-sI", + "--max-time", str(CURL_TIMEOUT_SECONDS), + "-o", "/dev/null", + "-w", "%{http_code}", + url, + ], + capture_output=True, + text=True, + timeout=CURL_TIMEOUT_SECONDS + 3, + ) + return (url, result.stdout.strip()) + except Exception: + return (url, "error") + + +def build_upload_hints(missing_urls: List[Tuple[str, str]]) -> List[str]: + """未アップロードURLからバケット名・パスを逆算し、アップロードコマンドを生成する。""" + hints: List[str] = [] + for url, _ in missing_urls: + m = re.search(r"https://[^/]+/storage/v1/object/public/([^/]+)/(.+)", url) + if m: + hints.append(f" pnpm upload:storage {m.group(1)} {m.group(2)}") + return hints + + +def run_check_mode(filepaths: List[str]) -> None: + """PostToolUse用: 逐次検証、未アップロードがあればstderr + exit 2。""" + urls = extract_storage_urls(filepaths) + if not urls: + sys.exit(0) + + check_urls = urls[:MAX_URLS_CHECK_MODE] + remaining = max(0, len(urls) - MAX_URLS_CHECK_MODE) + + missing: List[Tuple[str, str]] = [] + for url in check_urls: + url, status = check_url_head(url) + if status != "200": + missing.append((url, status)) + + if not missing: + sys.exit(0) + + msg = "\n[hook:storage-url-check] 未アップロードのStorage画像を検出しました:\n" + for url, status in missing: + msg += f" - {url} -> HTTP {status}\n" + if remaining > 0: + msg += f" (他に{remaining}件のURLが未検証です)\n" + + hints = build_upload_hints(missing) + msg += "\nアップロード方法:\n" + if hints: + msg += "\n".join(hints) + "\n" + else: + msg += " Supabase DashboardまたはMCP経由でStorage画像をアップロードしてください。\n" + msg += "\nアップロード完了後、再度ファイルを保存してください。\n" + + sys.stderr.write(msg) + sys.exit(2) + + +def run_gate_mode(filepaths: List[str]) -> None: + """PreToolUse用: 並列検証、未アップロードがあればdeny理由をstdout + exit 1。""" + urls = extract_storage_urls(filepaths) + if not urls: + sys.exit(0) + + check_urls = urls[:MAX_URLS_GATE_MODE] + remaining = max(0, len(urls) - MAX_URLS_GATE_MODE) + + missing: List[Tuple[str, str]] = [] + with ThreadPoolExecutor(max_workers=MAX_URLS_GATE_MODE) as executor: + futures = {executor.submit(check_url_head, url): url for url in check_urls} + for future in as_completed(futures): + url, status = future.result() + if status != "200": + missing.append((url, status)) + + if not missing: + sys.exit(0) + + parts = [ + "[hook:storage-url-pr-gate] 未アップロードのStorage画像があります。" + "PR作成前にアップロードしてください。\\n\\n未検証URL:" + ] + for url, status in sorted(missing): + parts.append(f" - {url} -> HTTP {status}") + + if remaining > 0: + parts.append(f" (他に{remaining}件のURLが未検証です)") + + hints = build_upload_hints(sorted(missing)) + parts.append("\\nアップロード方法:") + if hints: + parts.extend(hints) + else: + parts.append(" Supabase DashboardまたはMCP経由でStorage画像をアップロードしてください。") + + print("\\n".join(parts)) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} [file2 ...]", file=sys.stderr) + sys.exit(1) + + mode = sys.argv[1] + files = sys.argv[2:] + + if mode == "check": + run_check_mode(files) + elif mode == "gate": + run_gate_mode(files) + else: + print(f"Unknown mode: {mode}", file=sys.stderr) + sys.exit(1) diff --git a/.gemini/hooks/scripts/block-destructive-git.sh b/.gemini/hooks/scripts/block-destructive-git.sh new file mode 100755 index 000000000..ee13c308f --- /dev/null +++ b/.gemini/hooks/scripts/block-destructive-git.sh @@ -0,0 +1,1963 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) destructive git guard. +# AI/自動化が tracked local changes を暗黙に破棄する事故を止める。 +# [2026-06-14][feat] +# 背景: +# - ユーザー依頼意図: AI 横断作業中の `git reset --hard` / `git clean -f` / `git checkout --` による +# tracked local changes の暗黙破棄を止めたい。 +# - 守るべき業務ルール: ローカル変更の破棄は、差分確認後に明示許可した復旧作業だけに限定する。 +# - 他案不採用理由: 破壊的 git をルール文だけで禁止する案は、別セッション WIP の事故を機械的に止められないため不採用。 +# 対応: 安全な dry-run / unstage は許可し、作業ツリーを破棄する git 操作だけを PreToolUse でブロックする。 + +set -uo pipefail + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +. "$(dirname "$0")/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } + +input="$(cat)" + +command="$( + INPUT_JSON="${input}" python3 - <<'PY' 2>/dev/null || true +import json +import os + +try: + data = json.loads(os.environ.get("INPUT_JSON", "{}")) +except json.JSONDecodeError: + data = {} +tool_input = {} +if isinstance(data.get("tool_input"), dict): + tool_input = data["tool_input"] +elif isinstance(data.get("toolInput"), dict): + tool_input = data["toolInput"] +print(tool_input.get("command") or "") +PY +)" + +allow_json() { + printf '{"continue": true}\n' +} + +# [2026-08-03][fix] deny メッセージを「コマンド行の先頭に書けば通る」という誤った案内から、 +# 実際に効く手順(セッションの環境変数として設定)へ正す。 +# 背景: +# - ユーザー依頼意図: 2026-08-03 jtt-cms 作業中、旧メッセージの案内どおり +# `AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 git ...` をコマンド行の先頭に書いて再実行したが、 +# 再びブロックされた。66行目の bypass 判定はこの hook プロセス自身の環境変数だけを見ており、 +# Bash ツールは呼び出しごとに cwd がリセットされるため実際の再実行はほぼ必ず +# `cd && AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 git ...` の形になる。1745行目付近の +# inline bypass はコマンド全体の最初のトークンが裸の代入直後の `git` である場合だけしか +# 救済せず、`cd &&` 等が前に付くと機能しない(実測で再現・恒久的に効く手段ではない)。 +# - 守るべき業務ルール: AI エージェントは自己判断で破壊的 git を通せてはならない。bypass は +# 利用者がセッションの環境変数として明示設定した場合だけに限定する設計を維持する +# (bypass 判定ロジック自体は変更しない・本対応はメッセージ文言のみ)。 +# - 他案不採用理由: 「コマンド行の先頭に書けば常に効くようにする」案は、AI が自分の発行する +# コマンド文字列だけで bypass を成立させられてしまい、破壊的 git を自己判断で通す抜け道になる +# ため不採用。メッセージを正直にし、実際に効く手段(利用者へのセッション環境変数設定の依頼、 +# または hook にかからない代替コマンド)を案内する方針を採る。 +block_json() { + local label="$1" + # telemetry(harness-checkup): deny を記録。fail-open(記録失敗は無視)。 + agent_hub_telemetry_log hook_deny block-destructive-git deny "{\"label\":\"$label\"}" 2>/dev/null || true + HOOK_LABEL="$label" python3 - <<'PY' +import json +import os + +label = os.environ.get("HOOK_LABEL", "") +reason_lines = [ + f"[hook:block-destructive-git] destructive git command blocked: {label}。", + "ローカル変更を暗黙に破棄しないため停止しました。", + ( + "AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 は、このセッションの環境変数として設定されている" + "必要があります。コマンド行の先頭に書くだけでは効きません" + "(cd 等が前に付くと届かないため)。" + ), + ( + "AI はこの環境変数を自分で設定できません。復旧が必要な場合は、利用者に" + "「このセッションで AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 を設定してください」と依頼してください。" + ), + ( + "単一ファイルを HEAD の内容へ戻すだけなら、この hook にかからない " + "`git show HEAD: > ` で足りることが多いです。" + ), +] +reason = "\n".join(reason_lines) +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } +}, ensure_ascii=False)) +PY +} + +if [ -z "${command}" ]; then + allow_json + exit 0 +fi + +if [ "${AGENT_HUB_ALLOW_DESTRUCTIVE_GIT:-0}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass block-destructive-git allow '{"env":"AGENT_HUB_ALLOW_DESTRUCTIVE_GIT"}' 2>/dev/null || true + allow_json + exit 0 +fi + +# [2026-08-02][fix] path-qualified git executable を token 境界で裸の `git` に正規化する。 +# 背景: +# - ユーザー依頼意図: `/usr/bin/git` や空白を含む引用符付き path でも、`reset --hard` / +# `clean` 等を取り逃がさないようにする。 +# - 守るべき業務ルール: 実行 token の basename が `git` の場合だけ、裸の `git` と同じく +# fail-closed で止める。`/tmp/git tools/notgit` のような非 git executable は許可する。 +# - 他案不採用理由: Bash regex で path の slash・quote・空白を列挙する案は token 境界を失い、 +# 新しい path 表記や git を含む別 executable の誤検出を招く。PJ ごとの hook 手修正も不採用。 +# 対応: Python 標準 `shlex` で実行 token を解決し、`os.path.basename(token) == "git"` のときだけ +# その token を `git` に置換してから、後段の Bash 判定へ渡す。 +# [2026-08-02][fix] nice/nohup を安全に解析し、未知・解決不能な前置きを fail-closed にする。 +# 背景: +# - ユーザー依頼意図: 標準ラッパー経由の `nice git ...` / `nohup git ...` でも破壊的 Git を止めたい。 +# - 守るべき業務ルール: 既知の引数だけを消費し、曖昧な option や欠落した command は許可しない。 +# - 他案不採用理由: 任意の `-...` を無条件に読み飛ばす案は、未知 option の後ろの Git を取り逃がすため不採用。 +# 対応: nice の数値 option と nohup の `--` だけを明示的に消費し、未知・解決不能時は marker を出して停止する。 +readonly GIT_BIN='git' +readonly GIT_GLOBAL_OPT='(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--config-env[[:space:]]+[^[:space:]]+|--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--namespace(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--exec-path(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)?|--paginate|--no-pager|--no-replace-objects|--bare|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--help|--version|--html-path|--man-path|--info-path|-p)' +readonly GIT_GLOBAL_OPTS="([[:space:]]+${GIT_GLOBAL_OPT})*" +readonly SUDO_OPT='((-u|-g|-h|-p|-C|-T)[[:space:]]+[^[:space:]]+|-[^[:space:]]+)' +readonly ENV_OPT='((-u|--unset|-C|--chdir)[[:space:]]+[^[:space:]]+|-[^[:space:]]+)' +readonly GIT_PREFIX_TOKEN='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+|!|if|then|else|elif|do|while|until|command([[:space:]]+-p)?|builtin|exec|time([[:space:]]+-p)?|sudo([[:space:]]+'"${SUDO_OPT}"')*)' +readonly ENV_BIN='(/([^[:space:]/]+/)*env|env)' +readonly ENV_PREFIX="${ENV_BIN}"'([[:space:]]+'"${ENV_OPT}"')*([[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+)*' +readonly GIT_SEGMENT_START='^[[:space:]]*(('"${GIT_PREFIX_TOKEN}"'|'"${ENV_PREFIX}"')[[:space:]]+)*'"${GIT_BIN}" + +command_segments="$( + COMMAND_TEXT="$command" python3 - <<'PY' 2>/dev/null || true +import os +import re +import shlex + +cmd = os.environ.get("COMMAND_TEXT", "") + +CONTROL_WORDS = {"!", "if", "then", "else", "elif", "do", "while", "until"} +UNRESOLVED_WRAPPER = -1 + +def executable_basename(token: str, *, decoded: bool = False): + if decoded: + return os.path.basename(token) + try: + lexer = shlex.shlex(token, posix=True) + lexer.whitespace_split = True + words = list(lexer) + except ValueError: + return None + if len(words) != 1: + return None + return os.path.basename(words[0]) + +# [2026-08-02][fix] command wrapperと実行名は静的に確定できる場合だけ許可する。 +# 背景: +# - ユーザー依頼意図: env/time/exec/sudo/eval 等を挟んだ場合や、変数・command substitutionで +# 実行名を組み立てた場合も、破壊的Git操作を同じ基準で止める。 +# - 守るべき業務ルール: wrapper後の実行ファイルを静的に確定できない場合は許可しない。 +# posix lexerでdecode済みのtokenは再度shell parseせず、実ファイル名のquoteをliteralとして扱う。 +# - 他案不採用理由: 各OS・wrapperの全optionを推測して許可すると、引数をcommandとして +# 再解釈するoptionや将来追加されたoptionが新しい迂回経路になる。decode済みtokenの再shlexは +# quoteを含む有効なpathを構文エラーに変え、basename=gitの検出を失うため不採用。 +# 対応: 安全性を確認したoptionだけをwhitelistし、eval・未知option・再分割option・動的実行名は +# unresolved markerへ送る。decode済みtokenのbasenameは文字列から直接取得する。 +def command_executable_index(segment: list[str], *, decoded: bool = False): + sudo_short_options_with_arg = {"-u", "-g", "-h", "-p", "-C", "-T", "-D", "-R", "-r", "-t", "-U"} + sudo_short_options_no_arg = set("ABbEeHiKklnPSsVv") + sudo_long_options_with_arg = { + "--user", "--group", "--host", "--prompt", "--close-from", + "--chdir", "--chroot", "--command-timeout", "--other-user", + "--login-class", "--role", "--type", + } + sudo_long_options_no_arg = { + "--askpass", "--background", "--bell", "--edit", "--help", "--login", + "--list", "--non-interactive", "--preserve-env", "--remove-timestamp", + "--reset-timestamp", "--set-home", "--shell", "--stdin", "--validate", "--version", + } + index = 0 + while index < len(segment): + wrapper_start = index + while index < len(segment): + token = segment[index] + if token in CONTROL_WORDS or re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token): + index += 1 + continue + break + if index >= len(segment): + return None + + executable = executable_basename(segment[index], decoded=decoded) + if executable in {"command", "builtin"}: + index += 1 + while index < len(segment): + if segment[index] == "--": + index += 1 + break + if segment[index] == "-p": + index += 1 + continue + break + elif executable == "eval": + # eval reparses every remaining argument as shell source. + return UNRESOLVED_WRAPPER + elif executable == "exec": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option == "-a": + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("-a") and option != "-a": + index += 1 + continue + if re.fullmatch(r"-[cl]+", option): + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + elif executable == "time": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"-o", "-f", "--output", "--format"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("--output=") or option.startswith("--format="): + index += 1 + continue + if option in {"--append", "--verbose", "--portability", "--quiet"}: + index += 1 + continue + if re.fullmatch(r"-[ahlpv]+", option): + index += 1 + continue + if re.fullmatch(r"-(?:o|f).+", option): + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + # [2026-08-02][fix] timeout wrapper の後段 command を限定解析する。 + # 背景: + # - ユーザー依頼意図: 全PJへ配布する破壊的Git guardで、`timeout 5 git reset --hard` の + # ような標準wrapper経由の実行も直接実行と同じ基準で止める。 + # - 守るべき業務ルール: timeoutの既知optionと必須durationだけを消費し、その直後の + # commandを再帰的に検査する。未知option・値不足・command不足はfail-closedにする。 + # - 他案不採用理由: timeout以下を通常引数として許可する案は破壊操作を見逃し、全optionを + # 無条件に読み飛ばす案は将来の再解釈optionで同じ迂回を再発させるため不採用。 + # 対応: GNU timeoutの副作用を持たない既知optionだけを許可し、durationを1語消費して + # 後段commandへ解析を継続する。hook自身はtimeoutや対象commandを実行しない。 + elif executable == "timeout": + duration_pattern = r"(?:\d+(?:\.\d*)?|\.\d+)(?:s|m|h|d)?" + signal_pattern = r"(?:SIG)?[A-Za-z0-9]+" + + def static_timeout_value(token: str, pattern: str) -> bool: + if token_has_unresolved_executable_expansion(token): + return False + try: + value = token if decoded else decode_shell_command(token) + except (TypeError, ValueError): + return False + return re.fullmatch(pattern, value) is not None + + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"--preserve-status", "--foreground", "--verbose", "-v"}: + index += 1 + continue + if option in {"-k", "--kill-after", "-s", "--signal"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + value_pattern = duration_pattern if option in {"-k", "--kill-after"} else signal_pattern + if not static_timeout_value(segment[index + 1], value_pattern): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("-k") and option != "-k": + if not static_timeout_value(option[2:], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("-s") and option != "-s": + if not static_timeout_value(option[2:], signal_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("--kill-after="): + if not static_timeout_value(option.split("=", 1)[1], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("--signal="): + if not static_timeout_value(option.split("=", 1)[1], signal_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + # timeout requires one duration token followed by a command. + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + if not static_timeout_value(segment[index], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + elif executable == "nice": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option == "-n" or option == "--adjustment": + index += 1 + if index >= len(segment) or not re.fullmatch(r"[+-]?\d+", segment[index]): + return UNRESOLVED_WRAPPER + index += 1 + continue + if re.fullmatch(r"-n[+-]?\d+", option) or re.fullmatch(r"-\+?\d+", option): + index += 1 + continue + if re.fullmatch(r"--adjustment=[+-]?\d+", option): + index += 1 + continue + if option in {"--help", "--version"}: + return None + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + if index >= len(segment): + return UNRESOLVED_WRAPPER + elif executable == "nohup": + index += 1 + if index < len(segment) and segment[index] == "--": + index += 1 + elif index < len(segment) and segment[index].startswith("-"): + return UNRESOLVED_WRAPPER + if index >= len(segment): + return UNRESOLVED_WRAPPER + elif executable == "sudo": + index += 1 + terminated = False + while index < len(segment) and segment[index].startswith("-"): + option = segment[index] + if option == "--": + index += 1 + terminated = True + break + if option.startswith("--"): + if "=" in option: + option_name, _ = option.split("=", 1) + has_attached_value = True + else: + option_name = option + has_attached_value = False + if option_name not in sudo_long_options_with_arg and option_name not in sudo_long_options_no_arg: + return UNRESOLVED_WRAPPER + index += 1 + if ( + not has_attached_value + and option_name in sudo_long_options_with_arg + ): + if index >= len(segment): + return UNRESOLVED_WRAPPER + index += 1 + continue + option_name = option[:2] + if option_name in sudo_short_options_with_arg: + index += 1 + if len(option) == 2: + if index >= len(segment): + return UNRESOLVED_WRAPPER + index += 1 + continue + if all(char in sudo_short_options_no_arg for char in option[1:]): + index += 1 + continue + return UNRESOLVED_WRAPPER + if not terminated: + while index < len(segment) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", segment[index]): + index += 1 + # [2026-08-02][fix] xargs を wrapper として解析し、実行 command へ検査を継続する。 + # 背景: + # - ユーザー依頼意図: `printf 'HEAD' | xargs -n1 git reset --hard` のように xargs 経由で + # 破壊的 Git を起動すると、git が引数位置に見えて検査から漏れていた + # (jtt-cms PR #1542 の codex-review が検出した Critical)。 + # - 守るべき業務ルール: timeout / env と同じく、副作用と再解釈の無い既知 option だけを + # whitelist で消費し、直後の command を通常の検査へ流す。引数が任意個の option + # (GNU の bare -l / -i / -e、--replace 単独等)は静的に境界を確定できないため + # unresolved(fail-closed)に送る。command 無しの xargs は既定 echo のため安全。 + # - 他案不採用理由: xargs を一律 unresolved にする案は、`ls | xargs rm` 等の非 git 用途 + # まで全 deny し誤検知摩擦(#1313 で解消したクラス)を再発させる。全 option の + # 読み飛ばしは将来の再解釈 option で迂回を再発させる(timeout の CaD と同判断)。 + elif executable == "xargs": + xargs_long_with_arg = { + "--arg-file", "--delimiter", "--eof", "--max-args", "--max-chars", + "--max-lines", "--max-procs", "--process-slot-var", + } + xargs_no_arg = { + "-0", "--null", "-p", "--interactive", "-r", "--no-run-if-empty", + "-t", "--verbose", "-x", "--exit", "-o", "--open-tty", + } + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"-n", "-L", "-s", "-P", "-a", "-d", "-E", "-J", "-I"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option in xargs_long_with_arg: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if any(option.startswith(name + "=") for name in xargs_long_with_arg | {"--replace"}): + index += 1 + continue + if re.fullmatch(r"-[nLsPadEJIi].+", option): + # 値が密着した短形(-n1 / -I{} / -i{} / -d\n 等) + index += 1 + continue + if option in xargs_no_arg or re.fullmatch(r"-[0prtxo]+", option): + index += 1 + continue + if option.startswith("-"): + # bare -l / -i / -e / --replace 等の任意引数 option・未知 option + return UNRESOLVED_WRAPPER + break + elif executable == "env": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"-S", "--split-string"} or option.startswith("-S") or option.startswith("--split-string="): + # split-string reparses one token into a complete command. + return UNRESOLVED_WRAPPER + if option in {"-u", "--unset", "-C", "--chdir"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("--unset=") or option.startswith("--chdir="): + index += 1 + continue + if re.fullmatch(r"-(?:u|C).+", option): + index += 1 + continue + if option in {"-", "-i", "--ignore-environment", "-0", "--null", "--debug"}: + index += 1 + continue + if option in {"--help", "--version"}: + return None + if option.startswith("-"): + return UNRESOLVED_WRAPPER + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", option): + index += 1 + continue + break + else: + return index + + if index <= wrapper_start: + return None + return None + +def token_has_unresolved_executable_expansion(token: str) -> bool: + """Return whether an executable word requires shell expansion to resolve.""" + if token.startswith("="): + # zsh expands a leading equals command name to an absolute executable path. + return True + quote = None + index = 0 + while index < len(token): + char = token[index] + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + return True + if char == "$" and index + 1 < len(token): + next_char = token[index + 1] + if next_char in "{([?*!#@$-0123456789_" or next_char.isalpha(): + return True + if char in "<>" and index + 1 < len(token) and token[index + 1] == "(": + return True + if quote is None and char in "*?": + return True + if ( + quote is None + and char in "[{" + and index + 1 < len(token) + and not token[index + 1].isspace() + and token[index + 1] not in ";&|" + ): + return True + if quote is None and char in "@+!" and index + 1 < len(token) and token[index + 1] == "(": + return True + if ( + quote is None + and char == "(" + and index > 0 + and not token[index - 1].isspace() + and token[index - 1] not in ";&|(<" + ): + return True + index += 1 + return False + +def has_unresolved_command_start(text: str) -> bool: + """Inspect raw command-start words without evaluating shell syntax.""" + try: + # Keep parentheses inside words so `$(...)`, extglob, and zsh qualifiers + # remain visible. The regular parser separately handles grouping syntax. + lexer = shlex.shlex(text, posix=False, punctuation_chars=";&|") + # Real shell comments were removed by + # collapse_shell_line_continuations(). Keep `#` inside parameter + # expansions such as `${#name}` visible to the lexer. + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return True + + segment: list[str] = [] + for token in tokens + [";"]: + if token in {"(", ")", "{", "}"} or (token and all(char in ";&|" for char in token)): + if segment: + executable_index = command_executable_index(segment) + if executable_index == UNRESOLVED_WRAPPER: + return True + if ( + executable_index is not None + and executable_index >= 0 + and token_has_unresolved_executable_expansion(segment[executable_index]) + ): + return True + segment = [] + else: + segment.append(token) + return False + +def emit_segment_line(text: str) -> None: + if has_unresolved_command_start(text): + print("__UNRESOLVED_COMMAND_WRAPPER__") + try: + lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + for segment in re.split(r"[;&|(){}]+", text): + segment = segment.strip() + if segment: + print(segment) + return + + segment = [] + + def flush() -> None: + if segment: + # `segment` came from a posix=True lexer, so quoted arguments with + # spaces are already one token. Replace those spaces only for the + # executable parser; nested shell bodies keep their original quotes. + parser_segment = [re.sub(r"\s+", "__ARG_SPACE__", token) for token in segment] + executable_index = command_executable_index(parser_segment, decoded=True) + if executable_index == UNRESOLVED_WRAPPER: + print("__UNRESOLVED_COMMAND_WRAPPER__") + segment.clear() + return + # [2026-08-02][fix] grouping 構文の内側でも動的 executable を fail-closed にする。 + # 背景: + # - ユーザー依頼意図: `{ "$G" reset --hard; }`、subshell、function body のように + # command start が grouping token の後ろにある場合も、破壊的 Git を取り逃がさない。 + # - 守るべき業務ルール: 実行ファイル名を静的に `git` 以外と確定できない command segment は + # grouping の深さに関係なく unresolved marker へ送り、既存の fail-close 契約を保つ。 + # - 他案不採用理由: 外側の raw scanner だけで grouping 全体を一つの command とみなす案は、 + # brace/subshell/function の内側にある実際の executable 境界を失うため不採用。 + # 対応: decoded parser が抽出した各 segment の executable token も検査し、変数または + # command substitution を含む場合は unresolved marker を出す。 + if ( + executable_index is not None + and executable_index >= 0 + and ( + parser_segment[executable_index] == "$" + or token_has_unresolved_executable_expansion(parser_segment[executable_index]) + ) + ): + # The decoded parser also sees command starts inside brace/paren + # groups and function bodies that the outer raw segment begins + # with grouping syntax rather than the eventual executable. + print("__UNRESOLVED_COMMAND_WRAPPER__") + segment.clear() + return + if ( + executable_index is not None + and executable_index >= 0 + and executable_basename(parser_segment[executable_index], decoded=True) == "git" + ): + segment[:] = ["git"] + segment[executable_index + 1:] + # shlex は引用を外すため、空白入り `git -C "/tmp/a b"` をそのまま join すると + # 後段の正規表現が git global option の引数境界を誤る。判定に不要な内部空白だけ + # sentinel に寄せ、実コマンドの語順は保ったまま検査する。 + normalized = [re.sub(r"\s+", "__ARG_SPACE__", token) for token in segment] + print(" ".join(normalized).strip()) + segment.clear() + + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + flush() + else: + segment.append(token) + flush() + +# [2026-08-02][fix] dash / ksh も shell receiver として再帰検査する(issue #1344)。 +# 背景: +# - ユーザー依頼意図: dash / ksh へ here-doc(quoted 'EOF' 区切り)で流し込んだ破壊的 Git が +# receiver 集合の漏れで再帰検査されず素通りしていた(PR #1343 の codex-review が検出)。 +# ※このコメントに here-doc 演算子そのものを書かないこと: 本 Python は bash の $( ) 置換内の +# quoted heredoc に埋まっており、bash の置換パーサはコメント内でも演算子を解釈して壊れる。 +# - 守るべき業務ルール: shell として本文を実行する受け手は全て同じ fail-close 再帰へ送る。 +# - 他案不採用理由: 任意の実行ファイルを receiver 扱いする案は、非 shell の cat/tee まで +# 本文をコマンド検査して誤検知を増やすため不採用(shell 実体の列挙を維持し不足だけ足す)。 +shells = {"sh", "bash", "zsh", "dash", "ksh"} +MAX_SHELL_DEPTH = 4 +UNRESOLVED_COMMAND_MARKER = "__UNRESOLVED_COMMAND_WRAPPER__" + +# [2026-08-02][fix] 引用内改行で論理行を分断しない(issue #1313 誤検知ファミリー)。 +# 背景: +# - ユーザー依頼意図: `git commit -m "<複数行メッセージ>"` / `gh pr create --body "<複数行>"` が +# text.splitlines() の引用非対応分割で引用途中に千切れ、unresolved 判定→deny になっていた +# (1セッション3〜6回の実測摩擦。値は実行されないデータであり真陽性ではない)。 +# - 守るべき業務ルール: shell の行分割は引用外の改行だけがコマンド区切り。引用内・$( ) / +# backtick 内の改行はトークン/置換本文の一部として同じ論理行に留める。未終端の引用・置換は +# 従来どおり None を返し fail-closed(unresolved)へ倒す。$( ) / backtick の本文検査は +# shell_substitution_bodies 側が従来どおり再帰実施するため、検知力は変えない。 +# - 他案不採用理由: -m/--body 等の「データ引数の値」を走査対象から除外する案は、値の中の +# $( ) 置換(shell が実際に実行する)まで免除しかねず、緩和面が広い。引用対応の分割は +# 誤検知3ケースを同時に解消しつつ既存の置換再帰検査を一切変えない最小修正のため採用。 +def split_shell_logical_lines(text: str): + """Split on newlines that are outside quotes / $() / backticks. None if unterminated. + + Context stack model: 'sq' (single quote), 'dq' (double quote), 'sub' + ($() or bare paren inside a substitution), 'bt' (backtick). Newlines break + logical lines only when the stack is empty (= plain command position). + """ + BACKTICK = chr(96) # 字面のバッククォートは外側 bash の置換スキャナを壊すため chr で持つ + lines = [] + current = [] + stack: list[str] = [] + index = 0 + length = len(text) + while index < length: + char = text[index] + state = stack[-1] if stack else None + if state == "sq": + # 単一引用内の backslash+改行は「continuation に見える難読化」の既存保守契約を + # 維持するため unresolved(None)へ倒す(test: single quoted continuation)。 + if char == "\\" and index + 1 < length and text[index + 1] in "\r\n": + return None + current.append(char) + if char == "'": + stack.pop() + index += 1 + continue + if char == "\\": + # escape consumes next char in normal / dq / sub / bt contexts + current.append(char) + if index + 1 < length: + current.append(text[index + 1]) + index += 2 + else: + index += 1 + continue + if state == "dq": + if char == '"': + stack.pop() + elif char == "$" and index + 1 < length and text[index + 1] == "(": + # NOTE: dollar+開き括弧のリテラルを1トークンで書かない。外側 bash の + # 置換スキャナが引用内でも入れ子置換の開始と解釈して構文崩壊するため、 + # 2文字に分けて append する(本ファイル特有の制約)。 + current.append("$") + current.append("(") + stack.append("sub") + index += 2 + continue + elif char == BACKTICK: + stack.append("bt") + current.append(char) + index += 1 + continue + if state == "bt": + if char == BACKTICK: + stack.pop() + current.append(char) + index += 1 + continue + # state is None (top level) or 'sub' — both accept openers + if char == "'": + stack.append("sq") + current.append(char) + index += 1 + continue + if char == '"': + stack.append("dq") + current.append(char) + index += 1 + continue + if char == BACKTICK: + stack.append("bt") + current.append(char) + index += 1 + continue + if char == "$" and index + 1 < length and text[index + 1] == "(": + stack.append("sub") + # NOTE: dollar+開き括弧のリテラルは2文字に分けて append(上の分岐と同じ理由)。 + current.append("$") + current.append("(") + index += 2 + continue + if state == "sub": + if char == "(": + stack.append("sub") + elif char == ")": + stack.pop() + current.append(char) + index += 1 + continue + if char == "\n": + lines.append("".join(current)) + current = [] + index += 1 + continue + current.append(char) + index += 1 + if stack: + return None + lines.append("".join(current)) + return [line for line in lines if line.strip()] or [""] + + +# [2026-08-02][fix] git 無縁と静的に確定できる nested body だけ unresolved deny を免除する +# (issue #1313 案3の安全部分集合・下の呼び出し元 CaD と対)。 +# 背景: +# - ユーザー依頼意図: 変数や特殊パラメータを含むだけの非 git body(例: exit status 表示付きの +# script 実行)が unresolved 扱いで deny される摩擦を解消したい。 +# - 守るべき業務ルール: 既存の fail-closed 契約(変数 executable / glob・brace・class による +# git 難読化 / 引用継続の難読化は deny)を 1 件も後退させない。判定は「安全と証明できた +# 場合のみ許可」の片側条件とし、証明できない形は全て従来どおり deny に落とす。 +# - 他案不採用理由: body へ再帰降下する案は、glob executable(g?t 等)を非 git と誤読する。 +# git 文字列の有無だけで判定する案は、変数 executable(PAYLOAD 経由)を素通りさせる。 +def nested_body_safely_non_git(body: str) -> bool: + """True only when the body is provably inert w.r.t. destructive git. + + 条件(全て満たす時だけ許可・1つでも証明できなければ False = 従来の deny): + 1. body に "git" 文字列が無い(大文字小文字無視・部分一致で安全側) + 2. brace / backtick / 置換開始(dollar+開き括弧)が無い + 3. dollar 展開は単文字特殊パラメータ(? $ ! #)のみ($VAR / ${...} は + eval や interpreter の引数経由で任意コマンド化しうるため一律 deny) + 4. 各 command segment の実行子がプレーンリテラルで、shell でも + 実行 wrapper(eval / exec / env / sudo / xargs 等・引数を実行する類)でもない + """ + lowered = body.lower() + if "git" in lowered: + return False + if "{" in body or "}" in body: + # brace expansion は tokenizer が区切りとして分解し executable 難読化 + # (/usr/bin/g{it} 等)を見えなくするため、含む body は証明不能として deny 側 + return False + if chr(96) in body: + # backtick 置換は静的解決不能 + return False + # [2026-08-02][fix] PR #1354 codex-review Critical 対応: $VAR / ${...} を含む body は + # `eval $PAYLOAD` / `python3 -c $CODE` 等の引数経由で任意コマンド化するため許可しない。 + # 実行時に値が確定済みで不活性なのは単文字特殊パラメータだけ、という許可リストへ縮小する。 + position = body.find("$") + while position != -1: + follower = body[position + 1:position + 2] + if follower not in {"?", "$", "!", "#"}: + return False + position = body.find("$", position + 2) + segments = segment_tokens(body) + if segments is None: + return False + plain_executable = re.compile(r"[A-Za-z0-9_./-]+") + # 引数を新たなコマンドとして実行しうる wrapper。列挙は原理的に完全にならないため、 + # ここに無い未知 wrapper への防御は上の「$VAR 全面 deny」(引数が静的リテラルなら + # wrapper 経由でも body 内に "git" が現れ 1. で deny)と組み合わせて成立させる。 + exec_wrappers = { + "eval", "exec", "command", "builtin", "source", ".", + "env", "sudo", "doas", "su", "xargs", "nohup", "nice", + "time", "timeout", "setsid", "script", "watch", "caffeinate", + } + for segment in segments: + index = command_executable_index(segment) + if index == UNRESOLVED_WRAPPER: + return False + if index is None: + # 実行子なし(純 assignment 等)は破壊操作を持たない + continue + if index < 0 or index >= len(segment): + return False + token = segment[index] + if plain_executable.fullmatch(token) is None: + return False + basename = executable_basename(token) + if basename in shells or basename in exec_wrappers: + # nested-nested shell / 実行 wrapper は本関数で安全証明できないため deny 側 + return False + return True + + +def decode_shell_command(token: str) -> str: + lexer = shlex.shlex(token, posix=True) + lexer.whitespace_split = True + words = list(lexer) + if len(words) != 1: + raise ValueError("invalid shell command argument") + return words[0] + +def segment_tokens(text: str): + try: + # Keep the outer quote around `bash -c`/`sh -c` bodies so the nested + # command can be decoded once without losing its own quoted path tokens. + lexer = shlex.shlex(text, posix=False, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return None + segments: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + if current: + segments.append(current) + current = [] + else: + current.append(token) + if current: + segments.append(current) + return segments + +def shell_start_index(segment: list[str]): + index = command_executable_index(segment) + return index if index is not None and index >= 0 and executable_basename(segment[index]) in shells else None + + +# [2026-08-02][fix] nested shell の動的 body を fail-closed にする。 +# 背景: +# - ユーザー依頼意図: `PAYLOAD="git reset --hard"; bash -c "$PAYLOAD"` のように、 +# shell `-c` の body を変数・command substitution・process substitution で組み立てる +# 経路でも、破壊的 Git の静的検査を迂回させない。 +# - 守るべき業務ルール: hook が安全に確定できない nested body は許可せず、必ず deny する。 +# hook 自身が変数展開や command substitution を実行して body を得ることは禁止する。 +# - 他案不採用理由: body を実行して展開結果を得る案は hook の副作用・コマンドインジェクションを +# 招く。正規表現だけで全ての shell 展開を再現する案は quote/escape 境界を取り違えるため不採用。 +# 対応: shlex で decode 済みの body を小さな quote-aware scanner で確認し、未解決の `$` 展開、 +# backtick、`$()`、process substitution、pathname/brace展開を marker に変換する。 +# 静的 body の再帰検査は従来どおり行う。 +# [2026-08-02][fix] double quote 中の single quote で scanner state を切り替えない。 +# 背景: shell では double quote 内の `'` は literal だが、旧 scanner は single quote 開始と誤認し、 +# 後続の `$PAYLOAD` を「展開されない文字列」として見逃し得た。scanner 単体でも shell semantics と +# 一致させる必要がある。quote 全文を正規表現へ戻す案は既存の escape 境界を失うため不採用。 +# 対応: double quote state は `"` だけで終了し、その中の `'` は通常文字として扱う。 +def has_unresolved_shell_expansion(text: str) -> bool: + """Return whether a nested shell body contains expansion we must not evaluate.""" + quote = None + index = 0 + + def parameter_expansion_at(position: int) -> bool: + if position + 1 >= len(text): + return False + next_char = text[position + 1] + if next_char in "{([?*!#@$-0123456789_": + return True + return next_char.isalpha() + + while index < len(text): + char = text[index] + if quote == "'": + # Single-quoted shell text has no expansion semantics. + if char == "'": + quote = None + index += 1 + continue + + if char == "\\": + # In unquoted/double-quoted text, an escaped next character is literal. + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + return True + if char == "$" and parameter_expansion_at(index): + return True + if char in "<>" and index + 1 < len(text) and text[index + 1] == "(": + return True + if quote is None and char in "*?": + return True + if ( + quote is None + and char in "[{" + and index + 1 < len(text) + and not text[index + 1].isspace() + and text[index + 1] not in ";&|" + ): + return True + if ( + quote is None + and char in "@+!" + and index + 1 < len(text) + and text[index + 1] == "(" + ): + # Bash extglob such as @(git) can synthesize the executable name. + return True + if ( + quote is None + and char == "(" + and index > 0 + and not text[index - 1].isspace() + and text[index - 1] not in ";&|(<" + ): + # zsh glob qualifiers such as /usr/bin/git(.) are attached to a word. + return True + index += 1 + return False + + +# [2026-08-02][fix] 通常 command の引数内にある shell substitution も再帰検査する。 +# 背景: +# - ユーザー依頼意図: `printf '%s' "$(git reset --hard)"` のように、外側の executable が +# `git` でなくても実行される破壊的 Git を取り逃がさない。 +# - 守るべき業務ルール: command / process / backtick substitution の body は、引用位置に関係なく +# 実際に shell が実行する範囲だけを静的に抽出し、既存と同じ fail-close 判定へ渡す。 +# - 他案不採用理由: substitution を含む command を一律 deny すると `$(pwd)` 等の安全な開発操作まで +# 止める。shell 展開を実行して body を得る案は副作用と command injection を招くため不採用。 +# 対応: single quote と escape を尊重する小さな scanner で `$()` / `<()` / `>()` / backtick の +# body を抽出する。対応できない構文・不均衡・深すぎる再帰は unresolved marker へ送る。 +def shell_substitution_bodies(text: str): + """Return executable substitution bodies, or ``None`` when ambiguous.""" + + def backtick_end(start: int): + position = start + 1 + while position < len(text): + if text[position] == "\\": + position += 2 + continue + if text[position] == chr(96): + return position + position += 1 + return None + + def paren_end(open_index: int): + depth = 1 + quote = None + position = open_index + 1 + while position < len(text): + char = text[position] + if quote == "'": + if char == "'": + quote = None + position += 1 + continue + if char == "\\": + position += 2 + continue + if quote == '"': + if char == '"': + quote = None + position += 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "{": + # Parameter expansion patterns may legally contain `)` and + # make a hand-written parenthesis matcher terminate early. + return None + if char == "$" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == chr(96): + nested_end = backtick_end(position) + if nested_end is None: + return None + position = nested_end + 1 + continue + position += 1 + continue + if char in {"'", '"'}: + quote = char + position += 1 + continue + if ( + char == "#" + and ( + position == open_index + 1 + or text[position - 1].isspace() + or text[position - 1] in ";&|({}" + ) + ): + # An unquoted shell comment hides every `)` through the newline. + newline = text.find("\n", position + 1) + if newline < 0: + return None + position = newline + 1 + continue + if char == chr(96): + nested_end = backtick_end(position) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "{": + return None + if char == "<" and position + 1 < len(text) and text[position + 1] == "<": + # Skip a here-doc inside `$()` so a later `)` / command remains visible. + # Example: `$(cat <" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return position + position += 1 + return None + + bodies = [] + quote = None + index = 0 + while index < len(text): + char = text[index] + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + end = backtick_end(index) + if end is None: + return None + body = text[index + 1:end] + # Inside legacy backticks, an escaped backtick opens/closes a nested + # command substitution. Until that grammar is decoded losslessly, + # preserve the documented fail-close boundary instead of treating it + # as a literal escape and dropping the nested executable. + if chr(92) + chr(96) in body: + return None + bodies.append(body) + index = end + 1 + continue + if char == "$" and index + 1 < len(text) and text[index + 1] == "(": + end = paren_end(index + 1) + if end is None: + return None + body = text[index + 2:end] + if body.startswith("("): + # Arithmetic expansion is not itself a command, but may contain one. + nested = shell_substitution_bodies(body) + if nested is None: + return None + bodies.extend(nested) + else: + # A case-pattern `)` is indistinguishable from the substitution + # terminator in this deliberately small scanner. Never infer + # safety from a later `esac` string: it may be pattern data before + # the prematurely matched `)` rather than the closing keyword. + case_start = r"(?:^|[;&|({\n]|\b(?:then|do|else)\b)\s*case\b" + if re.search(case_start, body): + return None + bodies.append(body) + index = end + 1 + continue + if quote is None and char in "<>" and index + 1 < len(text) and text[index + 1] == "(": + end = paren_end(index + 1) + if end is None: + return None + bodies.append(text[index + 2:end]) + index = end + 1 + continue + index += 1 + if quote is not None: + return None + return bodies + + +# [2026-08-02][fix] shell tokenizationより先にline continuationを論理行へ戻す。 +# 背景: +# - ユーザー依頼意図: `g\\\nit reset --hard` のように物理改行で executable を分割しても、 +# 実行時に `git` へ戻る破壊操作を取り逃がさない。 +# - 守るべき業務ルール: shell がtokenize前に行うbackslash-newline除去を静的に再現し、 +# command substitution内外で同じfail-close判定へ渡す。single quote内のliteralは変更しない。 +# - 他案不採用理由: shell自体を実行して展開結果を得る案は、副作用とcommand injectionを招く。 +# 物理行を別々に検査する旧方式は、改行をまたいだ実行tokenを原理的に復元できない。 +# 対応: quote-awareな標準Python処理でLF/CRLF continuationだけを除去し、その後に既存scannerを使う。 +def collapse_shell_line_continuations(text: str) -> str: + """Collapse continuations and remove real comments before ``shlex``.""" + result = [] + quote = None + in_comment = False + index = 0 + while index < len(text): + char = text[index] + if in_comment: + # Backslash-newline is literal comment text here; the physical newline + # still ends the comment before the next command. + if char == "\n": + result.append(char) + in_comment = False + index += 1 + continue + if quote == "'": + result.append(char) + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + if index + 1 < len(text) and text[index + 1] == "\n": + index += 2 + continue + if index + 2 < len(text) and text[index + 1:index + 3] == "\r\n": + index += 3 + continue + result.append(char) + if index + 1 < len(text): + result.append(text[index + 1]) + index += 2 + else: + index += 1 + continue + if ( + quote is None + and char == "#" + and not (len(result) >= 2 and result[-2:] == ["$", "{"]) + and ( + not result + or result[-1].isspace() + or result[-1] in ";&|({}" + ) + ): + in_comment = True + index += 1 + continue + if quote == '"' and char == '"': + quote = None + elif quote is None and char in {"'", '"'}: + quote = char + result.append(char) + index += 1 + return "".join(result) + + + +# [2026-08-02][fix] here-doc 本文は受信コマンドのデータであり、行分割して再検査しない。 +# 背景: +# - ユーザー依頼意図: `git commit -F -` への here-doc や `gh ... --body "$(cat <= len(text) or text[lt_index:lt_index + 2] != "<<": + return None + pos = lt_index + 2 + strip_tabs = False + if pos < len(text) and text[pos] == "-": + strip_tabs = True + pos += 1 + while pos < len(text) and text[pos] in " \t": + pos += 1 + if pos >= len(text) or text[pos] == "\n": + return None + + quoted = False + if text[pos] == "\\": + quoted = True + pos += 1 + if pos >= len(text): + return None + start = pos + while pos < len(text) and (text[pos].isalnum() or text[pos] == "_"): + pos += 1 + delimiter = text[start:pos] + elif text[pos] in {"'", '"'}: + quoted = True + quote = text[pos] + pos += 1 + start = pos + while pos < len(text) and text[pos] != quote: + if text[pos] == "\\" and quote == '"': + pos += 2 + continue + pos += 1 + if pos >= len(text): + return None + delimiter = text[start:pos] + pos += 1 + else: + start = pos + while pos < len(text) and (text[pos].isalnum() or text[pos] in "_-"): + pos += 1 + delimiter = text[start:pos] + if not delimiter: + return None + + newline = text.find("\n", pos) + if newline < 0: + return None + body_pos = newline + 1 + while body_pos <= len(text): + next_nl = text.find("\n", body_pos) + line = text[body_pos:] if next_nl < 0 else text[body_pos:next_nl] + compare = line.lstrip("\t") if strip_tabs else line + if compare == delimiter: + end = len(text) if next_nl < 0 else next_nl + 1 + return end, quoted + if next_nl < 0: + return None + body_pos = next_nl + 1 + return None + + + +def extract_heredocs(text: str): + """Split here-doc bodies from command text. + + Returns ``(without_bodies, shell_bodies, unquoted_bodies)``. + + Here-docs are recognized outside single quotes. Double quotes are ignored as + a quoting barrier so ``"$(cat <= end: + without.append(text[index:end]) + index = end + continue + without.append(text[index : delim_line_end + 1]) + body = text[delim_line_end + 1 : end] + body_lines = body.splitlines(keepends=True) + body_content = "".join(body_lines[:-1]) if body_lines else "" + receiver_line = "".join(without[line_start:]) + text[index:delim_line_end] + try: + lexer = shlex.shlex(receiver_line, posix=True, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + tokens = [] + exec_index = command_executable_index(tokens, decoded=True) if tokens else None + is_shell = ( + exec_index is not None + and exec_index >= 0 + and executable_basename(tokens[exec_index], decoded=True) in shells + ) + if is_shell: + shell_bodies.append(body_content) + elif not quoted and body_content.strip(): + unquoted_bodies.append(body_content) + index = end + if index > 0 and text[index - 1] == "\n": + line_start = len(without) + continue + if char == "\n": + without.append(char) + index += 1 + line_start = len(without) + continue + without.append(char) + index += 1 + return "".join(without), shell_bodies, unquoted_bodies + + + + +def _strip_quoted_heredocs_completely(body: str): + """Remove quoted here-docs entirely from a ``$()`` body. + + Returns ``(remaining, True)`` when every here-doc used a quoted delimiter. + Returns ``(None, False)`` when an unquoted/incomplete here-doc is present + (caller must not collapse — trailing commands or expansions may remain). + """ + sq = chr(39) + remaining = [] + in_single = False + index = 0 + saw_heredoc = False + while index < len(body): + char = body[index] + if in_single: + remaining.append(char) + if char == sq: + in_single = False + index += 1 + continue + if char == "\\": + remaining.append(char) + if index + 1 < len(body): + remaining.append(body[index + 1]) + index += 2 + else: + index += 1 + continue + if char == sq: + in_single = True + remaining.append(char) + index += 1 + continue + if char == "<" and index + 1 < len(body) and body[index + 1] == "<": + end_info = heredoc_skip_end(body, index) + if end_info is None: + return None, False + end, quoted = end_info + if not quoted: + return None, False + saw_heredoc = True + index = end + continue + remaining.append(char) + index += 1 + if not saw_heredoc: + return None, False + return "".join(remaining), True + + +def collapse_data_substitutions(text: str): + """Collapse data-command substitutions that only feed quoted here-doc text. + + Only ``$(cat <<'EOF' ... EOF)`` style payloads collapse. Unquoted here-docs, + trailing ``; cmd``, pipelines, and nested substitutions are left intact so + later scanners still see real executable git. + """ + data_commands = {"cat", "printf", "echo", "head", "tail", "base64", "wc", "true"} + dq = chr(34) + sq = chr(39) + open_sub = "$" + "(" + token = dq + "__HOOK_STATIC_HEREDOC_DATA__" + dq + separators = {";", "&", "|", "||", "&&", "(", ")", "{", "}"} + out = [] + index = 0 + while index < len(text): + dollar = text.find(open_sub, index) + if dollar < 0: + out.append(text[index:]) + break + prefix = text[index:dollar] + in_single = False + p = 0 + while p < len(prefix): + ch = prefix[p] + if in_single: + if ch == sq: + in_single = False + p += 1 + continue + if ch == "\\": + p += 2 + continue + if ch == sq: + in_single = True + p += 1 + if in_single: + out.append(text[index:dollar + len(open_sub)]) + index = dollar + len(open_sub) + continue + depth = 1 + pos = dollar + len(open_sub) + replaced = False + while pos < len(text) and depth: + ch = text[pos] + if ch == "\\": + pos += 2 + continue + if ch == sq: + pos += 1 + while pos < len(text) and text[pos] != sq: + pos += 1 + pos += 1 + continue + if ch == dq: + pos += 1 + while pos < len(text) and text[pos] != dq: + if text[pos] == "\\": + pos += 2 + continue + pos += 1 + pos += 1 + continue + if ch == "<" and pos + 1 < len(text) and text[pos + 1] == "<": + skipped = heredoc_skip_end(text, pos) + if skipped is None: + break + pos = skipped[0] + continue + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + body = text[dollar + len(open_sub):pos] + remaining, ok = _strip_quoted_heredocs_completely(body) + if ok and remaining is not None: + nested = shell_substitution_bodies(remaining) + if nested == []: + try: + lexer = shlex.shlex( + remaining, posix=True, punctuation_chars=";&|(){}" + ) + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + tokens = [] + if tokens and not any(tok in separators for tok in tokens): + exec_index = command_executable_index( + tokens, decoded=True + ) + if ( + exec_index is not None + and exec_index >= 0 + and executable_basename( + tokens[exec_index], decoded=True + ) + in data_commands + ): + start = dollar + endpos = pos + 1 + if ( + start > 0 + and endpos < len(text) + and text[start - 1] == dq + and text[endpos] == dq + ): + start -= 1 + endpos += 1 + out.append(text[index:start]) + out.append(token) + index = endpos + replaced = True + break + pos += 1 + if not replaced: + if pos >= len(text) and depth: + out.append(text[index:]) + break + out.append(text[index:dollar + len(open_sub)]) + index = dollar + len(open_sub) + return "".join(out) + + +def emit_segments(text: str, depth: int = 0) -> None: + """Emit a shell command and recursively inspect every ``*-c`` body. + + Shells can be nested arbitrarily (for example ``bash -c 'sh -c ...'``). + Bound the static expansion so an adversarially deep or malformed payload + becomes an unresolved marker instead of silently bypassing the hook. + """ + if depth > MAX_SHELL_DEPTH: + print(UNRESOLVED_COMMAND_MARKER) + return + + text = collapse_shell_line_continuations(text) + + # Collapse quoted data here-docs inside $() BEFORE stripping, otherwise the + # opener remains and collapse can no longer find the terminator. + text = collapse_data_substitutions(text) + + # Here-doc bodies are data for the receiving command. Do not line-split them + # into fake top-level commands. Shell receivers still re-inspect the body. + stripped, shell_heredocs, unquoted_heredocs = extract_heredocs(text) + if stripped is None: + print(UNRESOLVED_COMMAND_MARKER) + return + text = stripped + + # 引用/置換の内側の改行で論理行を千切らない(split_shell_logical_lines の CaD 参照)。 + # 未終端の引用・置換は None → 従来どおり unresolved で fail-closed。 + lines = split_shell_logical_lines(text) + if lines is None: + print(UNRESOLVED_COMMAND_MARKER) + return + if not lines: + emit_segment_line(text) + else: + for line in lines: + emit_segment_line(line) + + for heredoc_body in shell_heredocs: + if heredoc_body.strip(): + emit_segments(heredoc_body, depth + 1) + + # Unquoted here-doc bodies expand $()/backticks; inspect those only. + for heredoc_body in unquoted_heredocs: + expansion_bodies = shell_substitution_bodies(heredoc_body) + if expansion_bodies is None: + print(UNRESOLVED_COMMAND_MARKER) + return + for body in expansion_bodies: + emit_segments(body, depth + 1) + + substitution_bodies = shell_substitution_bodies(text) + if substitution_bodies is None: + print(UNRESOLVED_COMMAND_MARKER) + return + for body in substitution_bodies: + emit_segments(body, depth + 1) + + segments = segment_tokens(text) + if segments is None: + print(UNRESOLVED_COMMAND_MARKER) + return + if depth > 0 and not text.strip(): + print(UNRESOLVED_COMMAND_MARKER) + return + if depth > 0 and text.strip() and not segments: + print(UNRESOLVED_COMMAND_MARKER) + return + + for segment in segments: + index = shell_start_index(segment) + if index is None: + continue + lookahead = index + 1 + tokens = segment + while lookahead < len(tokens) and tokens[lookahead].startswith("-"): + option_token = tokens[lookahead] + option = option_token.lstrip("-") + if not option_token.startswith("--") and "c" in option: + command_index = lookahead + 1 + if command_index < len(tokens) and tokens[command_index] == "--": + command_index += 1 + if command_index >= len(tokens): + print(UNRESOLVED_COMMAND_MARKER) + break + try: + nested_command = decode_shell_command(tokens[command_index]) + except Exception: + # A malformed nested shell argument must fail closed. + print(UNRESOLVED_COMMAND_MARKER) + break + if has_unresolved_shell_expansion(nested_command): + # Do not evaluate shell variables/substitutions in the hook. The body may + # resolve to destructive Git after the hook returns, so static inspection is + # impossible without executing untrusted input. + # + # [2026-08-02][fix] git 無縁の nested body まで deny しない(issue #1313 案3の + # 安全部分集合)。 + # 背景: + # - ユーザー依頼意図: `bash -c '... echo "exit=$?" ...'` のような、git を + # 一切含まない body が $? / $VAR だけで unresolved 扱いされ deny される + # 摩擦を解消したい(実測: 1セッション3回)。 + # - 守るべき業務ルール: 本 hook の守備範囲は破壊的 Git のみ(冒頭 CaD)。 + # "git" が現れない body は展開後も git になり得る余地を静的に持たない + # (g${X}it 型の難読化は変数側に "git" が現れないが、その場合 body 内に + # substring "git" が無くても executable 難読化は既存の変数 executable + # fail-close が上流で拾う)。substring 判定(大文字小文字無視・単語境界 + # なし)を使い、"digital" 等を含む body も deny 側へ倒す(安全側の過剰)。 + # - 他案不採用理由: unresolved wrapper 全面緩和(案3全体)は影響範囲が + # 読めず不採用。データ引数の値の除外は $( ) 置換の免除リスクがあり不採用。 + # git 文字列の有無だけの判定は変数 executable を素通りさせるため不採用 + # (安全証明は nested_body_safely_non_git に集約)。 + if not nested_body_safely_non_git(nested_command): + print(UNRESOLVED_COMMAND_MARKER) + break + emit_segments(nested_command, depth + 1) + break + if option_token in {"-o", "-O", "--rcfile", "--init-file"} and lookahead + 1 < len(tokens): + lookahead += 2 + continue + lookahead += 1 + +emit_segments(cmd) +PY +)" + +if printf '%s\n' "${command_segments}" | grep -Fqx '__UNRESOLVED_COMMAND_WRAPPER__'; then + block_json "unresolved command wrapper" + exit 0 +fi + +# [2026-08-02][fix] inline override は実行対象の git に直結する assignment だけを許可する。 +# 背景: +# - ユーザー依頼意図: `printf` / `echo` の引数や別 segment に書かれた +# `AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1` を、破壊的 git 復旧の許可と誤認しないようにする。 +# - 守るべき業務ルール: 破壊的 Git 操作は fail-closed で止め、明示的な復旧時だけ +# inherited env または実行対象 `git` の直前 assignment による inline override を許可する。 +# - 他案不採用理由: +# 1) コマンド全体 grep の継続は、文字列・引数・別 segment の偽装を検出できず Critical を再発させる。 +# 2) 生成された PJ 側 hook の手修正は中央正本を迂回して再発する。 +# 3) `git clean` の設定値列挙やルール文だけの禁止は、迂回経路を原理的に閉じない。 +# 4) 新規外部依存や全面的な shell parser 導入は、配布対象を増やし保守境界を曖昧にする。 +# 対応: Python 標準 `shlex` で単一の shell segment を tokenize し、segment 全体が実コマンド先頭の +# assignment token 直後の裸の `git` の場合だけ inline bypass を許可する。separator・改行・引用符・ +# 通常引数・別 segment の文字列は許可せず、tokenize 失敗時は `0` を返して安全側に倒す。 +inline_bypass="$( + COMMAND_TEXT="${command}" python3 - <<'PY' 2>/dev/null || printf '0' +import os +import re +import shlex + +BYPASS = "AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1" +ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$") +PUNCTUATION = ";&|(){}" +text = os.environ.get("COMMAND_TEXT", "") + +def validate_punctuation(tokens): + expected = [] + pairs = {"(": ")", "{": "}"} + for token in tokens: + if not token or not all(char in PUNCTUATION for char in token): + continue + for char in token: + if char in pairs: + expected.append(pairs[char]) + elif char in {")", "}"} and (not expected or expected.pop() != char): + raise ValueError("unbalanced shell punctuation") + if expected: + raise ValueError("unbalanced shell punctuation") + +try: + # posix=True validates quoting/escaping; posix=False retains quote markers + # so a quoted assignment cannot become an override token. + validator = shlex.shlex(text, posix=True, punctuation_chars=PUNCTUATION) + validator.whitespace_split = True + list(validator) + lexer = shlex.shlex(text, posix=False, punctuation_chars=PUNCTUATION) + lexer.whitespace_split = True + tokens = list(lexer) + validate_punctuation(tokens) + if "\n" in text or any(token and all(char in PUNCTUATION for char in token) for token in tokens): + print("0") + raise SystemExit +except Exception: + print("0") + raise SystemExit + +index = 0 +while index < len(tokens) and ASSIGNMENT.fullmatch(tokens[index]): + index += 1 +if index > 0 and index < len(tokens): + if tokens[index] == "git" and tokens[index - 1] == BYPASS: + print("1") + raise SystemExit + +print("0") +PY +)" +if [ "${inline_bypass}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass block-destructive-git allow '{"env":"AGENT_HUB_ALLOW_DESTRUCTIVE_GIT"}' 2>/dev/null || true + allow_json + exit 0 +fi + +reset_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+reset([[:space:]][^;&|()]*)?[[:space:]]--hard([[:space:]]|$)" || true)" +if [ -n "${reset_segments}" ]; then + block_json "git reset --hard" + exit 0 +fi + +clean_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+clean([[:space:]]|$)" || true)" +if [ -n "${clean_segments}" ]; then + while IFS= read -r segment; do + [ -z "${segment}" ] && continue + clean_args="$(printf '%s' "${segment}" | sed -E "s#${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+clean([[:space:]]|$)##")" + if printf '%s' "${clean_args}" | grep -Eq '(^|[[:space:]])(--dry-run|-n|-n[a-zA-Z]*|-[a-zA-Z]*n[a-zA-Z]*)([[:space:]]|$)'; then + continue + fi + # [2026-08-01][fix] `-f` の有無で判定すると設定経由で迂回できる(codex-review Critical)。 + # 背景: + # - ユーザー依頼意図: 破壊的 git 操作ガードが「実際に消せるコマンド」を取り逃がさないようにする。 + # - 守るべき業務ルール: `git clean` は `clean.requireForce=false` を渡すと `-f` 無しで + # 未追跡ファイルを削除できる。`git -c clean.requireForce=false clean -dx` は `.env` 等の + # ローカル秘匿ファイルまで消すため、`-f` を探す実装では素通りする(実測で PASS を確認)。 + # - 他案不採用理由: + # 1) `-c clean.requireForce=false` を追加でパターン検出する案は、`GIT_CONFIG_*` 環境変数や + # `--config-env`、既存の repo/global 設定でも同じ状態を作れるため、列挙が原理的に閉じない。 + # 2) 実際の設定値を読んで判定する案は、hook が対象 repo を確定できない場面(複合コマンド・ + # `--git-dir` 指定)で誤判定するため不採用。 + # 対応: dry-run でない `git clean` は一律 deny する。dry-run は上の continue で通過済み。 + block_json "git clean(--dry-run / -n 以外)" + exit 0 + done <" + exit 0 +fi + +# [2026-08-02][fix] `--` なし checkout の曖昧な位置引数を fail-closed にする。 +# 背景: +# - ユーザー依頼意図: 全PJ共通の破壊的Git guardで、`git checkout README.md` や +# `git checkout .` によるtracked変更の暗黙破棄も確実に止める。 +# - 守るべき業務ルール: checkoutの単一位置引数はbranch名とpathspecを静的に完全判別できないため、 +# 読み取りだけでpathだと断定できない場合も安全側へ倒す。branch移動には`git switch`を使う。 +# - 他案不採用理由: 拡張子・`/`・実在pathだけを列挙する案は、拡張子のないfile、glob、 +# `git -C`先のpathを取り逃がす。hook内でGitのref/path解決を実行する案はrepo/cwd境界を誤る。 +# 対応: 明示的な新規branch作成(`-b` / `--orphan`)、detach、help/versionだけを許可し、 +# `-p` / `--ours` / `--theirs` / `-B` 等を含む残りのcheckoutは一律denyする。 +# 安全optionは最初の位置引数より前にある場合だけ許可し、pathspec後ろのoptionで +# branch作成/detachへ見せかける並び替えは許可しない。安全モード後も引数個数を固定する。 +checkout_ambiguous_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+checkout([[:space:]]|$)" || true)" +if [ -n "${checkout_ambiguous_segments}" ]; then + while IFS= read -r segment; do + [ -z "${segment}" ] && continue + checkout_args="$(printf '%s' "${segment}" | sed -E "s#${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+checkout([[:space:]]|$)##")" + checkout_tokens=() + if [ -n "${checkout_args}" ]; then + read -r -a checkout_tokens <<< "${checkout_args}" + fi + checkout_count="${#checkout_tokens[@]}" + checkout_index=0 + checkout_safe=0 + checkout_invalid=0 + while (( checkout_index < checkout_count )); do + checkout_token="${checkout_tokens[checkout_index]}" + case "${checkout_token}" in + -q|--quiet|-m|--merge) + checkout_index=$((checkout_index + 1)) + ;; + --help|--version) + if (( checkout_index + 1 == checkout_count )); then + checkout_safe=1 + else + checkout_invalid=1 + fi + break + ;; + -b|--orphan) + if (( checkout_index + 2 == checkout_count )); then + checkout_branch="${checkout_tokens[checkout_index + 1]}" + if [ -n "${checkout_branch}" ] && [[ "${checkout_branch}" != -* ]]; then + checkout_safe=1 + else + checkout_invalid=1 + fi + else + checkout_invalid=1 + fi + break + ;; + --detach) + if (( checkout_index + 1 == checkout_count )); then + checkout_safe=1 + elif (( checkout_index + 2 == checkout_count )); then + checkout_ref="${checkout_tokens[checkout_index + 1]}" + if [ -n "${checkout_ref}" ] && [[ "${checkout_ref}" != -* ]]; then + checkout_safe=1 + else + checkout_invalid=1 + fi + else + checkout_invalid=1 + fi + break + ;; + *) + checkout_invalid=1 + break + ;; + esac + done + if (( checkout_safe == 1 && checkout_invalid == 0 )); then + continue + fi + block_json "git checkout(pathspec ambiguity; use git switch for branches)" + exit 0 + done <" + exit 0 + fi + if printf '%s' "${restore_args}" | grep -Eq '(^|[[:space:]])(--staged|-S|-[A-Za-z]*S[A-Za-z]*)([[:space:]]|$)'; then + continue + fi + block_json "git restore " + exit 0 + done <&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) + +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +reason = payload.get("permissionDecisionReason", "") +if "[hook:block-destructive-git]" not in reason: + sys.exit(1) +if "reason" in payload: + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue": true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow_inherited_env() { + local name="$1" + local command="$2" + local out + out="$(AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue": true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_raw() { + local name="$1" + local payload="$2" + local out + out="$(run_hook_raw "$payload" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) + +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if "[hook:block-destructive-git]" not in payload.get("permissionDecisionReason", ""): + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_embedded_python_py39() { + local name="embedded Python blocks parse as Python 3.9" + local out + if out="$(HOOK_SCRIPT="$SCRIPT" python3 - <<'PY' 2>&1 +import ast +import os +import re + +source = open(os.environ["HOOK_SCRIPT"], encoding="utf-8").read() +blocks = re.findall(r"<<'PY'[^\n]*\n(.*?)\nPY(?:\n|$)", source, re.S) +if not blocks: + raise SystemExit("no embedded Python blocks found") +for index, block in enumerate(blocks, 1): + try: + tree = ast.parse(block, feature_version=(3, 9)) + except SyntaxError as exc: + raise SystemExit(f"block {index}: {exc}") + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + returns = node.returns + if isinstance(returns, ast.BinOp) and isinstance(returns.op, ast.BitOr): + raise SystemExit(f"block {index}: Python 3.10 union return annotation") +PY +)"; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_double_quote_single_quote_scanner() { + local name="double quote内single quote後のvariable expansionを検出" + if HOOK_SCRIPT="$SCRIPT" python3 - <<'PY' +import os +import re + +source = open(os.environ["HOOK_SCRIPT"], encoding="utf-8").read() +blocks = re.findall(r"<<'PY'[^\n]*\n(.*?)\nPY(?:\n|$)", source, re.S) +scanner_blocks = [block for block in blocks if "def has_unresolved_shell_expansion" in block] +if len(scanner_blocks) != 1: + raise SystemExit(f"expected one scanner block, got {len(scanner_blocks)}") +namespace = {} +exec(scanner_blocks[0], namespace) +scanner = namespace["has_unresolved_shell_expansion"] +if not scanner('echo "\'"; $PAYLOAD'): + raise SystemExit("variable expansion after a single quote inside double quotes was missed") +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s\n' "$name" + FAIL=$((FAIL + 1)) + fi +} + +expect_embedded_python_py39 +expect_double_quote_single_quote_scanner +expect_block "git reset --hard deny" "git reset --hard" +expect_block "/usr/bin/git reset --hard deny" "/usr/bin/git reset --hard" +expect_block "command /usr/bin/git clean -fd deny" "command /usr/bin/git clean -fd" +expect_block "quoted /usr/bin/git reset --hard deny" "\"/usr/bin/git\" reset --hard" +expect_block "quoted ./bin/git clean -fd deny" "'./bin/git' clean -fd" +expect_block "quoted path with spaces git reset --hard deny" "\"/tmp/git tools/git\" reset --hard" +expect_block "consecutive-slash /usr//bin/git reset --hard deny" "/usr//bin/git reset --hard" +expect_block "consecutive-slash ./bin//git clean -fd deny" "./bin//git clean -fd" +expect_block "git -C reset --hard deny" "git -C /tmp/repo reset --hard origin/main" +expect_block "git -C path with spaces reset --hard deny" "git -C '/tmp/repo with spaces' reset --hard origin/main" +expect_block "git --git-dir/--work-tree path with spaces clean deny" "git --git-dir='/tmp/repo with spaces/.git' --work-tree '/tmp/repo with spaces' clean -fd" +expect_block "command git reset --hard deny" "command git reset --hard" +expect_block "env git clean -fd deny" "env git clean -fd" +expect_block "/usr/bin/env git clean -fd deny" "/usr/bin/env git clean -fd" +expect_block "/usr/bin/env -u FOO git reset --hard deny" "/usr/bin/env -u FOO git reset --hard" +expect_block "env assignment git checkout -f deny" "env FOO=bar git checkout -f main" +expect_block "sudo git reset --hard deny" "sudo git reset --hard" +expect_block "exec git reset --hard deny" "exec git reset --hard" +expect_block "exec alternate argv0 still finds git" "exec -a harmless /usr/bin/git reset --hard" +expect_block "exec unknown option fail closed" "exec --future-option /usr/bin/git reset --hard" +expect_block "sudo git clean -fd deny" "sudo -n git clean -fd" +expect_block "sudo -u root git reset --hard deny" "sudo -u root git reset --hard" +expect_block "sudo --user root path git reset --hard deny" "sudo --user root /usr/bin/git reset --hard" +expect_block "sudo --user=root path git reset --hard deny" "sudo --user=root /usr/bin/git reset --hard" +expect_block "sudo -- terminator path git reset --hard deny" "sudo -- /usr/bin/git reset --hard" +expect_block "sudo short chdir option still finds git" "sudo -D /tmp /usr/bin/git reset --hard" +expect_block "sudo long chdir option still finds git" "sudo --chdir /tmp /usr/bin/git clean -fd" +expect_block "sudo unknown option fail closed" "sudo --future-option /usr/bin/git reset --hard" +expect_block "sudo env path git reset --hard deny" "sudo env /usr/bin/git reset --hard" +expect_block "command env path git clean -fd deny" "command env /usr/bin/git clean -fd" +expect_block "env -u FOO git clean -fd deny" "env -u FOO git clean -fd" +expect_block "env -S reset payload fail closed" "env -S '/usr/bin/git reset --hard'" +expect_block "env --split-string clean payload fail closed" "env --split-string='/usr/bin/git clean -fd'" +expect_block "env unknown option before git fail closed" "env --future-option /usr/bin/git reset --hard" +expect_block "env ignore-environment still finds git" "env -i /usr/bin/git reset --hard" +expect_block "env attached unset still finds git" "env --unset=FOO /usr/bin/git clean -fd" +expect_block "env option terminator still finds git" "env -- /usr/bin/git reset --hard" +expect_block "time macOS long report still finds git" "/usr/bin/time -l /usr/bin/git reset --hard" +expect_block "time output option still finds git" "/usr/bin/time -o /tmp/timing.txt /usr/bin/git clean -fd" +expect_block "time unknown option before git fail closed" "/usr/bin/time --future-option /usr/bin/git reset --hard" +expect_block "eval path git reset fail closed" "eval /usr/bin/git reset --hard" +expect_block "eval quoted git reset fail closed" "eval 'git reset --hard'" +expect_block "nested eval quoted git reset fail closed" "bash -c 'eval \"git reset --hard\"'" +expect_block "nested eval git clean fail closed" "bash -c 'eval git clean -fd'" +expect_block "variable executable path fail closed" 'G=/usr/bin/git; "$G" reset --hard' +expect_block "variable executable basename fail closed" 'GIT=git; $GIT clean -fd' +expect_block "command substitution executable fail closed" '$(printf /usr/bin/git) reset --hard' +expect_block "zsh equals executable reset fail closed" "=git reset --hard" +expect_block "zsh equals executable clean fail closed" "=git clean -fd" +expect_block "brace group variable executable fail closed" '{ "$G" reset --hard; }' +expect_block "brace group command substitution executable fail closed" '{ $(printf git) clean -fd; }' +expect_block "paren group variable executable fail closed" '( "$G" reset --hard )' +expect_block "function body variable executable fail closed" 'danger(){ "$G" reset --hard; }; danger' +expect_block "function body command substitution executable fail closed" 'danger(){ $(printf git) clean -fd; }; danger' +expect_block "quoted argument command substitution reset deny" 'printf '\''%s'\'' "$(git reset --hard)"' +expect_block "quoted argument command substitution clean deny" 'echo "$(git clean -fd)"' +expect_block "argument process substitution restore deny" 'cat <(git restore src/app.ts)' +expect_block "argument backtick checkout deny" 'printf '\''%s'\'' "`git checkout -f main`"' +nested_backtick_argument='echo "`echo \`git reset --hard\``"' +expect_block "nested legacy backtick reset fail closed" "$nested_backtick_argument" +expect_block "nested argument substitution reset deny" 'printf '\''%s'\'' "$(printf '\''%s'\'' "$(git reset --hard)")"' +expect_block "arithmetic nested substitution clean deny" 'printf '\''%s'\'' "$((1 + $(git clean -fd)))"' +expect_block "case pattern esac text cannot hide reset" 'printf '\''%s'\'' "$(case esac in *esac*) git reset --hard ;; esac)"' +comment_substitution="printf '%s' \"\$( # ) +git reset --hard)\"" +expect_block "comment close paren cannot hide reset" "$comment_substitution" +comment_continuation_substitution="printf '%s' \$(echo ok # ) +g\\ +it reset --hard)" +expect_block "comment and line continuation cannot hide reset" "$comment_continuation_substitution" +comment_line_continuation="printf x # foo\\ +git reset --hard" +expect_block "comment line continuation cannot swallow next reset" "$comment_line_continuation" +comment_after_separator="printf x; # foo\\ +git clean -fd" +expect_block "separator comment continuation cannot swallow next clean" "$comment_after_separator" +comment_crlf_continuation=$'printf x # foo\\\r\n\tgit reset --hard' +expect_block "CRLF comment continuation cannot swallow next reset" "$comment_crlf_continuation" +comment_multiple_continuation=$'printf x # foo\\\ng\\\ni\\\nt clean -fd' +expect_block "comment with multiple continuations cannot hide clean" "$comment_multiple_continuation" +parameter_length_continuation="x=value; : \${#x}; g\\ +it reset --hard" +expect_block "parameter length hash is not a comment" "$parameter_length_continuation" +expect_allow "real comment remains inert" 'printf x # git reset --hard' +continued_git="g\\ +it reset --hard" +expect_block "line continuation executable reset deny" "$continued_git" +continued_git_multiple="g\\ +i\\ +t clean -fd" +expect_block "multiple line continuations executable clean deny" "$continued_git_multiple" +continued_git_quoted="printf '%s' \"\$(g\\ +it reset --hard)\"" +expect_block "double quoted continuation executable reset deny" "$continued_git_quoted" +single_quoted_continuation="printf '%s' 'g\\ +it reset --hard'" +expect_block "single quoted continuation remains conservative deny" "$single_quoted_continuation" +expect_block "parameter pattern close paren cannot hide reset" 'printf '\''%s'\'' "$(x=x; : ${x%)}; git reset --hard)"' +heredoc_substitution="printf '%s' \"\$(cat < /tmp/a.txt 2>&1; echo "exit=$?"; tail -3 /tmp/a.txt'\''' +expect_allow "chained add and multiline commit" 'git add -A && git commit -m "one +two"' +expect_block "commit -m command substitution still denied" 'git commit -m "$(git reset --hard)"' +# push --force は本 hook の守備範囲外(ローカル変更破壊系のみ)のため、置換ペイロードは +# 守備範囲内の reset --hard で「データ引数内の置換も deny」を固定する +expect_block "gh body command substitution still denied" 'gh pr create --body "$(git reset --hard)"' +expect_block "bash -c variable body still denied after relaxation" 'bash -c "$BODY"' +# PR #1354 codex-review Critical: 実行 wrapper の引数経由で任意コマンド化する経路を deny 固定 +expect_block "bash -c eval variable payload denied" 'bash -c '\''eval $PAYLOAD'\''' +expect_block "bash -c env variable payload denied" 'bash -c '\''env $PAYLOAD'\''' +expect_block "bash -c interpreter variable code denied" 'bash -c '\''python3 -c $CODE'\''' +dash_heredoc='dash <<'\''EOF'\'' +git reset --hard +EOF' +expect_block "dash heredoc destructive body deny" "$dash_heredoc" +ksh_heredoc='ksh <<'\''EOF'\'' +git clean -fd +EOF' +expect_block "ksh heredoc destructive body deny" "$ksh_heredoc" +expect_block "dash -c destructive body deny" "dash -c 'git reset --hard'" + +# [2026-08-02][test] xargs wrapper 経由の破壊的 Git 検査(jtt-cms PR #1542 codex-review Critical)。 +# 背景: +# - ユーザー依頼意図: xargs 経由の破壊的 Git が引数位置に見えて素通りしていた迂回を deny 固定し、 +# 非 git 用途の xargs(rm 等)や安全 subcommand を巻き込まないことを対で固定する。 +# - 守るべき業務ルール: 任意引数 option(bare -l 等)は静的境界不能として fail-closed。 +# - 他案不採用理由: deny 側のみのテストは、whitelist 縮小で日常 xargs が全滅しても気づけない。 +expect_block "xargs -n1 destructive reset deny" "printf 'HEAD\n' | xargs -n1 git reset --hard" +expect_block "xargs -I replace destructive clean deny" "xargs -I{} git clean -fd" +expect_block "xargs bare optional-arg option fail closed" "xargs -l git reset --hard" +expect_allow "xargs non-git command stays allowed" "ls | xargs -n1 rm -f" +expect_allow "xargs safe git subcommand stays allowed" "printf 'x\n' | xargs git log --oneline" + +TOTAL=$((PASS + FAIL)) +printf '\n=== block-destructive-git.test.sh: %d/%d PASS ===\n' "$PASS" "$TOTAL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.gemini/hooks/scripts/block-main-commit.sh b/.gemini/hooks/scripts/block-main-commit.sh new file mode 100755 index 000000000..d4c01aac0 --- /dev/null +++ b/.gemini/hooks/scripts/block-main-commit.sh @@ -0,0 +1,956 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: +# 依頼意図: AIがmainに直接プッシュする事故の再発防止。 +# ルール記載(branch-rule.md)だけでは防げなかった実績があり(F2直接プッシュ事故)、 +# 技術的強制力を追加する必要があった。 +# 業務ルール: mainマージ = 本番DB即時適用 + 本番デプロイ発火のため、 +# レビューなし変更は業務リスクが高い。 +# 不採用理由: ルール記載のみでは実際の事故を防げなかった実績がある。 +# git hookよりもClaude Code PreToolUseの方が実行パスに近く確実にブロックできる。 +# 対応: jtt-cms block-main-commit.sh をポート。lib/hook-io.sh を使用。 + +# [2026-04-10][fix] +# 背景: +# 依頼意図: `git push origin main` が.mdファイルのみでもブロックされるバグの修正。 +# 守るべき業務ルール: .mdのみの変更はmainで直接コミット・プッシュ可能(branch-rule.md)。 +# 他案不採用理由: Path Aを削除する案はrefspec経由の非docs pushを見逃すため不採用。 +# 軽量変更判定をインライン展開する案はPath Bとの重複(DRY違反)のため不採用。 +# 対応: 軽量変更 push 判定を is_push_lightweight_only() に関数化し、Path A/B両方から呼び出し。 +# 撤回: 2026-07-01 に AI hook 経由の main 直接 commit/push 例外は全廃。上記は履歴のみ。 + +# [2026-04-18][fix] +# 背景: +# 依頼意図: エージェント環境で origin/main が未解決のとき Markdown のみの push まで拒否される。 +# Cursor/CLI の PreToolUse が同じスクリプトを通すため、比較基準 ref の解決を強化したい。 +# 守るべき業務ルール: main 直 push の例外は「Markdown 系ドキュメント + sync-state.json のみ」(CLAUDE.md / branch-rule.md)。 +# 他案不採用理由: 非 .md コードを許可する案は本番自動適用リスクのため不採用。 +# 対応: 比較 ref を origin/main → refs/remotes/origin/main → main@{upstream} の順で解決。 +# 許可拡張子に .mdc / .mdx を含める(Cursor ルール・MDX ドキュメント)。 +# AGENT-HUB: jtt-cms 正本と同一内容を hook-library に同期(docs/prd/prd-active.md 参照)。 +# 撤回: 2026-07-01 に Markdown / sync-state 等の main 直 push 例外は全廃。上記は履歴のみ。 +# +# [2026-04-27][fix] +# 背景: +# 依頼意図: .codex/sync-state.json のような同期状態ファイルだけで main 直コミットが止まるのは運用上のノイズ。 +# 守るべき業務ルール: sync-state.json はツール自動生成の状態ファイルとして Markdown 系ドキュメントと同じ軽量変更扱いにする。 +# 他案不採用理由: .json 全体を許可する案は package.json や設定 JSON までレビューなしで通すため不採用。 +# 対応: main 直コミット/プッシュの例外に sync-state.json だけを追加し、commit/push で共通判定を使う。 +# 撤回: 2026-07-01 に sync-state.json を含む軽量変更例外は全廃。上記は履歴のみ。 + +# [2026-05-05][fix] +# 背景: +# 依頼意図: Issue #123 で、PR #121 内で Revert された Issue #122 対応を安全に再導入したい。 +# 複合コマンド検知ブロックに +# 軽量変更バイパスが未適用のまま main に残っている。.md のみの変更でも `git switch main && git push` で deny される。 +# 守るべき業務ルール: main 直 push の例外は「Markdown 系ドキュメント + sync-state.json のみ」(branch-rule.md)。 +# 3つの検知パス(複合コマンド、push refspec、mainブランチ)は対称に保つ。 +# 他案不採用理由: +# 1) 複合コマンド検知ブロックを削除する案は、refspec 経由の非 docs push を見逃すため不採用(2026-04-10 と同型)。 +# 2) staged diff 判定をインライン展開する案は、mainブランチ検知ブロックとの重複(DRY違反)のため不採用。 +# 3) `scripts/` 配下のローカル hook を併用する案は、比較 ref と許可拡張子が分岐し SSOT が壊れるため不採用。 +# 対応: `is_commit_lightweight_only()` を新設し、mainブランチ検知から呼び出す。 +# 複合コマンド検知では switch 後の target ref (`main`) を比較対象にし、commit を含む場合は安全側で deny。 +# 撤回: 2026-07-01 に軽量変更バイパスは全廃。上記は履歴のみ。 + +# [2026-05-16][feat] +# 背景: +# 依頼意図: ccrec 運用で GitHub Actions / Claude Code クレジットを節約するため、 +# 人手レビュー価値の薄い運用設定ファイル(agents.yaml / typinator-sync.yaml / +# MCP 台帳等)も main 直接 push 可にする。 +# 守るべき業務ルール: ソースコード・hook 本体(*.sh)・CI 定義(.github/workflows)・ +# Web ビルド設定(package.json / tsconfig.json / composer.json)・hook 登録設定は引き続き PR 必須。 +# 許可は事前定義した allowlist のファイル名・パターンに限定する。 +# 他案不採用理由: +# 1) .json / .yaml 拡張子全体を許可: package.json / tsconfig.json / composer.json / +# src/**/*.json までレビューなしで通るため不採用(2026-04-27 と同型の理由)。 +# 2) 拡張子許可 + denylist: denylist 漏れが致命的になるため allowlist で明示する方が安全。 +# 3) AGENT-HUB 限定で CWD 分岐: 配布先 PJ の AI ツール設定もツール再同期で書き換わるため、 +# 全 PJ 一律許可が運用整合的(ユーザー判断 2026-05-16)。 +# 4) .github/workflows/*.yml を許可: CI 挙動を無レビューで変えるリスクのため不採用。 +# 5) *.sh を許可: hook スクリプト挙動を無レビューで変えるリスクのため不採用。 +# 6) 外部設定ファイル化(allowlist を YAML に切り出す): 比較 ref と許可判定の SSOT が +# 分岐するため不採用(2026-05-05 と同型)。 +# 7) hook 登録設定(.claude/settings.json / .codex/hooks.json 等)を許可: block-main-commit +# 自体をレビューなしで弱められるため不採用。 +# 対応: is_allowed_main_direct_path() に case 文 allowlist を追加し、hook 登録を含まない AI ツール設定 / +# AGENT-HUB ルート運用設定 / codex-mcp 台帳を許可する。 +# 撤回: 2026-07-01 に運用設定 allowlist も全廃。上記は履歴のみ。 + +# [2026-05-21][feat] +# 背景: +# 依頼意図: .codex/config.toml と .gemini/hooks/.hook-library-version は Kimi Code MCP 設定 / .cursor/mcp.json +# と同等の sync 完全自動生成ファイル(手動編集 0 行)だが、2026-05-16 拡張時に取りこぼされていた。 +# 対称性を回復して、sync 実行のたびに main 直 push が deny されて GitHub Actions / Claude Code クレジットを +# 消費する状況を解消したい。 +# 守るべき業務ルール: +# - .codex/config.toml は全 PJ で MANAGED CODEX MCP START/END block の完全自動生成のみ。 +# 将来 managed block 外の手動編集領域が追加された場合は branch-rule.md を再評価する。 +# - .gemini/settings.json は hook 登録設定(BeforeTool/AfterTool/BeforeAgent)と MCP を混在で持つため +# allowlist には載せない(hook 登録設定の許可は 2026-05-16 [feat] 不採用理由 7 と同型で禁止)。 +# なお全 PJ で .gemini/settings.json は gitignore のため commit 経路自体が無く、本 hook へ到達しない。 +# 他案不採用理由: +# 1) .gemini/settings.json も同時許可: hook 登録を含む混在ファイルのため、settings.json + bridge スクリプト +# の同時変更で block-main-commit を弱められる経路を作ってしまう(2026-05-16 不採用理由 7 と同型)。 +# 2) .gemini/hooks/{lib,scripts}/*.sh / *.py を許可: hook ロジック本体の無レビュー変更を許す +# (2026-05-16 不採用理由 5 と同型)。 +# 3) .toml 拡張子全体を許可: dotfiles/codex/config.toml.base(features.apps 保護対象)まで通る +# ため不採用(2026-05-16 不採用理由 1 と同型)。 +# 対応: is_allowed_main_direct_path() の case 文に .codex/config.toml と +# .gemini/hooks/.hook-library-version を対称順で追加する。 + +# [2026-06-05][feat] .codex/hooks.json を main 直接 allowlist に追加(ユーザー承認・過去判断の変更) +# 背景: +# - ユーザー依頼意図: Codex hook の user-level 移行(PR #284)で各PJの .codex/hooks.json を +# 縮小版へ再配布する。この派生物コミットを毎回 PR にするのは負荷が高く、伸太郎殿の +# 「AIエージェント設定ファイルだけの変更を毎回PRに出したくない」要望(2026-06-05)に応える。 +# - 守るべき業務ルール: .codex/hooks.json は deploy-hooks.py が hook-registry.yaml から生成する +# sync 自動生成の派生物(手編集禁止、codex-sync.md)。hook 挙動は AGENT-HUB 側 PR で既にレビュー済み。 +# - 他案不採用理由(過去の不採用判断を覆す根拠): +# 2026-05-21 [feat] 不採用理由1 / 2026-05-16 [feat] 不採用理由7 で「hook 登録設定 +# (.claude/settings.json / .codex/hooks.json 等)は block-main-commit 自体を無レビューで +# 弱められるため allowlist 禁止」としていた。今回 .codex/hooks.json のみ覆すのは、 +# (a) deploy-hooks 生成物に限定され手編集しない運用が確立、(b) block-main-commit は +# Claude(.claude/settings.json は allowlist 据え置き=PR必須)でも効くため Codex 側を弱めても +# main 保護の実効性が残る、(c) Codex は補助ツール、の3点でリスク限定的と伸太郎殿が判断したため。 +# .claude/settings.json(hook登録の中核)は引き続き allowlist に入れない(PR必須維持)。 +# 対応: is_allowed_main_direct_path() の case に .codex/hooks.json を追加。.claude/settings.json は据え置き。 + +# [2026-06-05][feat] deploy-hooks 配布物(各PJ .claude/hooks/ ・ .codex/hooks/ の scripts/lib)を allowlist 追加 +# 背景: +# - ユーザー依頼意図: Phase E(PR #283) + Codex 移行(PR #284) + allowlist(PR #285)を全PJへ実配布する際、 +# 各PJの hook 配布物(block-main-commit.sh / block-skill-reverse-edit.sh / lib 等)を毎回 PR にするのは +# 16PJ規模で非現実的。「設定・配布物の機械的更新を毎回PRにしたくない」要望(2026-06-05)に応える。 +# - 守るべき業務ルール: 各PJ .claude/hooks/ ・ .codex/hooks/ 配下の scripts/lib は deploy-hooks.py が +# hook-library(SSOT)から配布する派生物。hook 挙動の変更は hook-library 本体の AGENT-HUB PR でレビュー +# 済み。各PJで人が直接編集する運用はなく、drift は sync-reconcile.py が検出する。 +# - 他案不採用理由(覆した過去判断): +# 2026-05-16 #5 で「*.sh(hook ロジック本体)は allowlist 禁止」としていた。今回 .claude/hooks/scripts/ ・ +# .codex/hooks/scripts/ ・ lib/ 配下の配布物のみ覆すのは、(a) これらは hook-library からの機械配布物で +# SSOT 本体(hook-library/scripts/)は PR 必須のまま、(b) sync-reconcile で drift 検出可能、(c) 各PJ実配布の +# 運用負荷が許容外、の3点。settings.json(block-main-commit の matcher 登録を含む hook 登録の中核)は +# 許可しない(main 保護自体を無レビューで外せてしまうため。2026-05-16 #7 維持)。hook-library/scripts/ +# (SSOT 本体)も別パスのため PR 必須を維持。 +# 対応: is_allowed_main_direct_path() の case に .claude/hooks/{scripts,lib}/ ・ .codex/hooks/{scripts,lib}/ を +# 追加。settings.json と hook-library/scripts/ は据え置き。 + +# [2026-06-23][refactor] 配布差分放置防止のため 2026-06-05 の main 直接 allowlist を撤回 +# 背景: +# - ユーザー依頼意図: AGENT-HUB から hook / skill / rule / agent 派生物を各PJへ配布した後、 +# AI が「これは私の修正したファイルではない」として配布先差分を放置する事故を防ぐ。 +# 配布を実行した担当者が PR 作成・レビュー・マージ・cleanup・clean 確認まで責任を持つ。 +# - 守るべき業務ルール: 機械配布物でも、配布先 PJ の tracked 差分は作った担当者が閉じる。 +# .codex/hooks.json と .claude/.codex hooks scripts/lib は main 直接 push ではなく PR 経由に戻す。 +# - 他案不採用理由: +# 1) ルール文書だけの更新は hook allowlist が残り、main 直 push で closeout を迂回できるため不採用。 +# 2) --push を即削除する案は既存運用互換の破壊が大きいため、まず hook 側で main 直許可を撤回する。 +# 対応: is_allowed_main_direct_path() から .codex/hooks.json と .claude/.codex hooks scripts/lib を削除。 + +# [2026-06-15][fix] worktree/別リポへの refspec 省略 bare push を許可(PR #369 の取りこぼし修正) +# 背景: +# 依頼意図: `cd && git push --force-with-lease`(refspec 省略の bare push)が +# PR #369 後も deny される。ハーネスは Bash cwd を毎回 main 直下に戻すため worktree への push は +# refspec 省略の bare push になることが多く(upstream に任せる常用フロー)、worktree 並行開発が成立しない。 +# 守るべき業務ルール: main 直 push/commit の保護は厳密(fail-closed)に維持する。本番デプロイ=main push のため。 +# 根本原因: has_unsafe_push() が「remote/refspec 欠落の push」を宛先不明として無条件 unsafe にしていた。 +# しかし実効ターゲット(先頭の単一 cd 先)のカレントブランチは判明済み(非 main)で、bare push はその +# カレントブランチを push するだけ。一律 unsafe は過剰だった。 +# 他案不採用理由: +# 1) bare push を実効ブランチ非 main なら無条件許可: push.default=matching(全 matching ブランチ=main 波及) +# や push.default=upstream で upstream が main のとき main を押す経路が残るため不採用。 +# 2) 何もしない案: refspec 省略の worktree push(ユーザーの主要フロー)が不能のままで不便。 +# 対応: dir 解決を effective_target_dir() に関数化し、has_unsafe_push() に eff_dir を渡す。bare/remote-only +# push は eff_dir の push.default + @{upstream} を解決し、matching / upstream→main / 解決不能のみ unsafe、 +# simple(既定)/current 等は非 main カレントブランチのみ push として安全に許可する。明示的 main 宛て / +# --all/--mirror/wildcard/複数 ref は従来どおり deny。汎用設計のため worktree 以外の別リポにも同様に効く。 + +# [2026-07-01][refactor] AI hook 経由の main 直接 commit / push 例外を完全撤回 +# 背景: +# 依頼意図: 文書ルールだけでなく、PreToolUse hook 実体でも Markdown / sync-state.json / +# agents.yaml / typinator-sync.yaml 等の軽量変更 allowlist を閉じ、全ディレクトリ・全 AI で +# main checkout を掴まない運用を強制したい。 +# 守るべき業務ルール: AI の通常作業では main branch の commit / push は軽量変更でも deny。 +# 非 main branch / 専用 worktree の commit / push は従来どおり許可し、PR 作成フローを壊さない。 +# 他案不採用理由: +# 1) allowlist を文書上だけ廃止して hook に残す案は、AI が実際には main 直 commit / push できるため不採用。 +# 2) 環境変数 override を追加する案は、AI が自己判断で例外を使う経路になるため不採用。 +# 3) 初回 repo 作成や人間明示承認を hook が推測して許可する案は、安全側で判定できないため不採用。 +# 対応: is_allowed_main_direct_path は常に deny にし、main branch 検知・main refspec push 検知では +# 軽量差分判定を呼ばず即 deny する。worktree feature branch の早期許可は維持。 + +# [2026-07-18][fix] git標準ラッパーと先頭空白によるmain保護迂回を防止 +# 背景: +# - ユーザー依頼意図: dirty cleanup PRのレビューで `env git commit` / `command git push` / +# 先頭空白付きgitが検出から漏れ、main直操作を許可できることが判明した。 +# - 守るべき業務ルール: 標準ラッパーや整形上の空白でmain保護の強さを変えない。 +# - 他案不採用理由: `env` 後の任意トークンを許す正規表現は `env echo git ...` まで誤検知するため不採用。 +# 対応: command/envの標準形とenv代入だけをコマンド位置で消費し、その後のgitサブコマンドを既存判定へ渡す。 + +set -euo pipefail + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `git -C path push origin main` や `git -c k=v push origin main` のように +# グローバルオプション付きで git を呼び出すと、既存の正規表現 `git[[:space:]]+push` が +# マッチせず main 直 push/commit をスルーしてしまう脆弱性を修正したい。 +# 守るべき業務ルール: main 直 push/commit のブロックは確実でなければならない。 +# false positive(許可ケースを誤拒否)を増やさないこと。 +# 他案不採用理由: +# 1) オプション列を貪欲に `.*` で許可 → セミコロン区切りの複合コマンドで誤マッチしやすい。 +# `[^[:space:]]+` で空白終端を保証する設計の方が安全。 +# 2) `-C` / `-c` だけを許可する案 → `git --no-pager push` が fail-open し、 +# main 保護の目的を満たせないため不採用。 +# 対応: スクリプト先頭に共通定数 GIT_GLOBAL_OPTS を定義し、値あり/値なしの代表的な +# git グローバルオプションを消費してから push/commit/switch/checkout を検知する。 +# git グローバルオプションを 0個以上許容する共通パターン。 +readonly GIT_GLOBAL_OPT='(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--config-env[[:space:]]+[^[:space:]]+|--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--namespace(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--exec-path(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)?|--super-prefix[[:space:]]+[^[:space:]]+|--paginate|--no-pager|--no-replace-objects|--bare|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--help|--version|--html-path|--man-path|--info-path|-p)' +readonly GIT_GLOBAL_OPTS="([[:space:]]+${GIT_GLOBAL_OPT})*" +readonly GIT_ENV_VALUE="([^[:space:];&|()'\"]+|'[^']*'|\"([^\"\\\\]|\\\\.)*\")+" +readonly GIT_ENV_ASSIGN="[A-Za-z_][A-Za-z0-9_]*=${GIT_ENV_VALUE}" +readonly GIT_ENV_PREFIX="(${GIT_ENV_ASSIGN}[[:space:]]+)*" +readonly ENV_OPT_WITH_VALUE='(-u|--unset|-C|--chdir|-P|--path|-S|--split-string)[[:space:]]+[^[:space:];&|()]+' +readonly GIT_COMMAND_WRAPPER="(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+|env([[:space:]]+((${ENV_OPT_WITH_VALUE})|-[^[:space:];&|()]+|${GIT_ENV_ASSIGN}))*[[:space:]]+)?" +readonly GIT_CMD="(^|[;&|()])[[:space:]]*${GIT_ENV_PREFIX}${GIT_COMMAND_WRAPPER}${GIT_ENV_PREFIX}git${GIT_GLOBAL_OPTS}" + +# [2026-07-18][fix] +# 背景: +# - PR1018再レビューで、環境変数代入をenv/command wrapperの前に置くとGIT_CMDがgit writeを見失った。 +# - 守るべき業務ルール: POSIXで有効なprefix順序の違いでmain保護の強さを変えない。 +# - 他案不採用理由: FOO=1だけを文字列denyする案は変数名ごとに再発するため不採用。 +# 対応: 環境変数prefixをwrapperの前後どちらにも許容し、その後のgit commit/pushを同じ判定へ渡す。 + +# [2026-07-18][fix] +# 背景: +# - PR1018最終レビューで、空白を含む引用済み環境変数値がGIT_ENV_PREFIXを分断し、 +# main上の `FOO='a b' git commit` をgit writeなしとして許可できると判明した。 +# - 守るべき業務ルール: shellで有効な引用・escapeを含む代入でもmain保護をfail-openにしない。 +# - 他案不採用理由: quoteを含む行を一律denyすると、説明文やfeature branchの通常操作まで誤拒否する。 +# 対応: 環境変数値をunquoted/single-quoted/double-quotedのshell wordとして認識し、wrapper内外で共通利用する。 + +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: PR1018再レビューで、feature cwdから `env -C
` を使うと +# hook入力のcwd側ブランチだけを見てmain commit/pushを許可し得る経路が見つかった。 +# - 守るべき業務ルール: 実効cwdを確実に解決できないcommit/pushはfail-closedにする。 +# - 他案不採用理由: env chdir先の完全解決は相対path・複数wrapper・複合commandで誤許可を生むため不採用。 +# 対応: env -C/--chdir(=形式を含む)とgit commit/pushが同じ入力にある場合は安全側で拒否する。 +# [2026-08-02][fix] env と -C/-S の間に許すトークンを env 自身のオプション/代入に限定する(issue #1344)。 +# 背景: +# - ユーザー依頼意図: 旧パターンの `env([[:space:]]+[^;&|()]*)?` は貪欲で、 +# `env FOO=bar git -C commit` の **git の -C** まで env の -C(chdir)と誤認し、 +# 正当な feature worktree commit/push を fail-closed で誤 deny していた +# (PR #1343 codex-review 検出・再現ドライバで実測)。 +# - 守るべき業務ルール: env 実行系(-C/--chdir/-S/--split-string)の保守的 deny は維持する。 +# env のオプション解析はコマンド名(最初の非オプション・非代入トークン)で終わるという +# GNU env の実引数規則を静的に再現し、コマンド名以降の -C/-S は誤認対象から外す。 +# - 他案不採用理由: env 形を全て未解決に倒す従来動作の維持は、日常の env prefix commit を +# 恒常的に止め摩擦が大きい。env の後続を完全 tokenize する案は本 hook の軽量 grep 設計に反する。 +# [2026-08-02][fix] 引数を取る env オプション(-u/--unset/シグナル系)は引数ごと消費する +# (PR #1354 codex-review Critical: `env -u FOO -C
git commit` の -C が +# FOO でパターンが止まり chdir 検出から外れるバイパスを塞ぐ)。 +# 引数付きを先に列挙し、その後に汎用オプション(-i 等・引数なし)と assignment を置く。 +# 汎用側で引数を消費しないのは、`env -i git -C ...` の git を env の引数と +# 誤認して #1344 の誤 deny を再導入しないため。 +readonly ENV_OPT_ARG='(-u|--unset|--block-signal|--default-signal|--ignore-signal)[[:space:]]+[^[:space:];&|()]+' +readonly ENV_OWN_TOKENS='(('"${ENV_OPT_ARG}"'|-[^[:space:];&|()]+|[A-Za-z_][A-Za-z0-9_]*=[^[:space:];&|()]*)[[:space:]]+)*' +command_uses_env_chdir() { + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[;&|()])[[:space:]]*(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+)?env[[:space:]]+'"${ENV_OWN_TOKENS}"'(-C([[:space:]]+|[^[:space:];&|()]+)|--chdir(=|[[:space:]]+))' +} + +# env -S/--split-string は1引数内の文字列を再分割してコマンド化するため、通常のwrapper解析では +# 実行されるgitを復元できない。git commit/pushを含む場合だけfail-closedにする。 +command_uses_env_split_git_write() { + echo "$COMMAND" | grep -qE '(^|[;&|()])[[:space:]]*(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+)?env[[:space:]]+'"${ENV_OWN_TOKENS}"'(-S([[:space:]]+|[^[:space:];&|()]+)|--split-string(=|[[:space:]]+))' && + echo "$COMMAND" | grep -qE 'git.*[[:space:]](commit|push)([^A-Za-z0-9_-]|$)' +} + +# [2026-07-11][fix] jtt-apps 本番タグ push 事例(v2.4.37) +# 背景: +# 依頼意図: `git -C push origin v2.4.37` のような単発 -C push が、 +# コマンド中に `2>&1` 等のリダイレクトが含まれるだけで single_git_c_target_dir() の +# `[;&|()]` チェックに誤ヒットし解決不能(deny)になっていた。DEPLOY_CHECKLIST.md の +# 正規タグ push 手順は worktree 経由でしか実行できないため、この誤検知で本番デプロイの +# 唯一の正規経路が塞がれていた。 +# 守るべき業務ルール: main 直 push/commit の fail-closed 判定は維持する。リダイレクトは +# 単一コマンドの出力先を変えるだけで複合コマンドの合図ではないため、それだけで +# 解決不能に倒すのは過剰検知。一方 `&`(バックグラウンド実行)や `|`(パイプ)は真に +# 複合コマンドの合図なので、従来どおり解決不能のまま扱う。 +# 他案不採用理由: +# 1) `[;&|()]` チェック自体を緩める案: `&` 単体や `|` まで見逃すと後続コマンドの +# 存在を検知できなくなり fail-open になるため不採用。 +# 2) has_unsafe_push() のようなトークン単位パーサに全面書き換える案: 影響範囲が +# 広く、今回の誤検知箇所以外の挙動まで変えるリスクがあるため不採用。 +# 対応: quote scanner 自身が引用外のリダイレクトだけを識別し、引用済み本文を変更せずに +# shell 制御演算子を判定する。 + +# [2026-07-12][fix] +# 背景: +# 依頼意図: main checkout を cwd にした Codex から専用 feature worktree へ +# `git -C commit -m 'fix(auth): ...'` を実行すると、引用符内の `()` を +# shell 制御演算子と誤認し、正規の branch + PR フローを deny していた。 +# 守るべき業務ルール: 引用済みメッセージは git の引数データとして許可する一方、非引用の +# `; & | ( )`、引用内でも実行される command substitution、壊れた引用は fail-closed にする。 +# 他案不採用理由: +# 1) `()` の検査を削る案は subshell を見逃して main 操作を早期許可しうるため不採用。 +# 2) conventional commit の括弧だけ正規表現で消す案は、任意の正当な引用済み本文に拡張できず +# セミコロン等で同じ誤検知が再発するため不採用。 +# 対応: 最小の shell quote scanner で、制御演算子が引用の外にある場合だけ真を返す。 +# 引用外の `>file` / `&1` は単一コマンドのリダイレクトとして読み飛ばすが、 +# その後のファイル名や制御演算子は走査を続ける。 +has_unquoted_shell_control() { + local scanner_rc + if COMMAND_TEXT="$1" python3 - <<'PY' +import os +import sys + +text = os.environ.get("COMMAND_TEXT", "") +quote = None +escaped = False +i = 0 +while i < len(text): + ch = text[i] + if escaped: + escaped = False + i += 1 + continue + if ch == "\\" and quote != "'": + escaped = True + i += 1 + continue + if quote == "'": + if ch == "'": + quote = None + i += 1 + continue + if quote == '"': + if ch == '"': + quote = None + elif ch == '`' or (ch == '$' and i + 1 < len(text) and text[i + 1] == '('): + raise SystemExit(0) + i += 1 + continue + if ch in ("'", '"'): + quote = ch + elif ch in "<>": + # Redirection itself does not compose another command. Skip only its + # operator/fd-copy portion; keep scanning the target and anything after it. + direction = ch + while i + 1 < len(text) and text[i + 1] == direction: + i += 1 + if i + 1 < len(text) and text[i + 1] == '&': + i += 1 + while i + 1 < len(text) and (text[i + 1].isdigit() or text[i + 1] == '-'): + i += 1 + elif ch in ";&|()" or ch == '`': + raise SystemExit(0) + i += 1 + +# Unterminated quoting is ambiguous and therefore unsafe. +raise SystemExit(0 if quote is not None or escaped else 1) +PY + then + return 0 + else + scanner_rc=$? + # [2026-07-12][fix] + # 背景: + # - 依頼意図: quote scanner の Python 起動不能や異常終了を「安全」と誤認し、main 保護が + # fail-open になる経路を閉じたい。 + # - 守るべき業務ルール: scanner が明示する rc=1 だけを安全とし、未導入・クラッシュ・ + # 想定外終了はすべて曖昧な入力として拒否する。 + # - 他案不採用理由: テスト用の interpreter override を本番環境変数として公開する案は、 + # exit 1 を返す任意プログラムで保護を迂回できるため不採用。 + # 対応: python3 は固定し、rc=1 以外を unsafe に正規化する。 + # rc=1 is the scanner's only explicit "safe" result. Missing Python, + # interpreter crashes, and every other unexpected status stay fail-closed. + [ "$scanner_rc" -eq 1 ] && return 1 + return 0 + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +# emit_deny(hook-io.sh) を呼び出す前に telemetry へ deny を記録する薄いラッパ。 +# 既存の deny メッセージ・exit 挙動は一切変えない(記録の追加のみ)。 +_emit_deny_with_telemetry() { + agent_hub_telemetry_log hook_deny block-main-commit deny 2>/dev/null || true + emit_deny "$1" +} + +DENY_MSG='[hook:block-main-commit] mainブランチへの直接コミット/プッシュはブロックされました。\n\n対応手順:\n1. git checkout -b feature/xxx でブランチを作成\n2. ブランチ上でコミット\n3. gh pr create でPRを作成\n\n理由: mainマージ = 本番DB自動適用 + 本番デプロイが即座に発動するため、レビューなしの変更は禁止です。' + +read_stdin +COMMAND=$(extract_field command) + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# CWD取得(push refspec検知より前に必要) +CWD=$(extract_field cwd) +if [ -z "$CWD" ]; then + CWD="." +fi + +# [2026-08-02][fix] #1313 / #1256: commit message・PR/Issue本文をgit実行列から除外する。 +# 背景: +# - 依頼意図: `git commit -m '説明; git push origin main'` や +# `gh pr create --body 'git reset --hard'` の本文を、実行されたgit writeとして +# 誤検知しない。ガード自身の修正記録・PR本文が書けない摩擦を解消する。 +# - 守るべき業務ルール: 引用外の `; git ...`、実際の command substitution、shell wrapper は +# 従来どおり安全側で扱う。除外するのは `-m/--message/--body/--body-file` の引数データだけ。 +# - 他案不採用理由: コマンド全体の `git` 文字列を無視する案は、引用外のmain pushを見逃す。 +# 正規表現へ例外を足し続ける案は引用境界を扱えず、同じ誤検知を再発させる。 +# 対応: shellの引用境界を小さく走査し、本文系オプションの次の1 tokenだけを空白化した +# 判定用コピーを作る。実行用の COMMAND は変更せず、quote scanner / -C path 解決は従来どおり +# raw input を参照する。展開を含む本文は空白化せず、保守的に検出・拒否する。 +sanitize_git_data_args() { + COMMAND_TEXT="$1" python3 - <<'PY' 2>/dev/null || printf '%s' "$1" +import os +import shlex + +text = os.environ.get("COMMAND_TEXT", "") +mask = [False] * len(text) +data_options = {"-m", "--message", "--body", "--body-file"} + +def spans(value): + result = [] + index = 0 + length = len(value) + while index < length: + if value[index].isspace(): + index += 1 + continue + if value[index] in ";|&()": + result.append((index, index + 1, value[index])) + index += 1 + continue + start = index + quote = None + escaped = False + while index < length: + char = value[index] + if escaped: + escaped = False + index += 1 + continue + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if quote == '"': + if char == '"': + quote = None + elif char == "\\": + escaped = True + index += 1 + continue + if char in ("'", '"'): + quote = char + index += 1 + continue + if char == "\\": + escaped = True + index += 1 + continue + if char.isspace() or char in ";|&()": + break + index += 1 + result.append((start, index, value[start:index])) + return result + +def decoded(raw): + try: + values = shlex.split(raw, posix=True) + except ValueError: + return raw + return values[0] if len(values) == 1 else raw + +def has_executable_expansion(raw): + quote = None + escaped = False + index = 0 + while index < len(raw): + char = raw[index] + if escaped: + escaped = False + index += 1 + continue + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if quote == '"': + if char == '"': + quote = None + elif char == "\\": + escaped = True + elif char == "$" and index + 1 < len(raw) and raw[index + 1] == "(": + return True + elif char == "`": + return True + index += 1 + continue + if char in ("'", '"'): + quote = char + elif char == "\\": + escaped = True + elif char == "$" and index + 1 < len(raw) and raw[index + 1] == "(": + return True + elif char == "`": + return True + index += 1 + return False + +tokens = spans(text) +expect_data = False +for start, end, raw in tokens: + if raw in ";|&()": + expect_data = False + continue + value = decoded(raw) + if expect_data: + # A command substitution/backtick is executable text, not static data. + # Keep it visible so the existing fail-closed patterns can reject it. + if not has_executable_expansion(raw): + for position in range(start, end): + mask[position] = True + expect_data = False + continue + if value in data_options: + expect_data = True + continue + if any(value.startswith(option + "=") for option in ("--message", "--body", "--body-file")): + for position in range(start, end): + mask[position] = True + continue + # `-mtext` is a valid git short option form. The whole token is message data. + if value.startswith("-m") and len(value) > 2 and not value.startswith("--"): + for position in range(start, end): + mask[position] = True + +print("".join(" " if mask[position] else char for position, char in enumerate(text)), end="") +PY +} + +# All regex-only git write searches below use this copy. Raw COMMAND remains the source for +# quote-aware shell-control and effective path checks. +COMMAND_FOR_GIT_MATCH="$(sanitize_git_data_args "$COMMAND")" + +is_allowed_main_direct_path() { + # 2026-07-01: AI hook 経由の main direct allowlist は廃止。 + # 互換テスト用に関数名は残すが、どの path も許可しない。 + return 1 +} + +# [2026-05-30][fix] issue #210 / cafe48 codex review follow-up +# 背景: +# ユーザー依頼意図: `git -C <別repo> push origin main` のように実効ディレクトリを変える +# グローバルオプション付き push/commit を、hook 実行 cwd ($CWD) の branch/差分で判定すると、 +# 「$CWD が main かつ軽量変更」のとき別 repo の main 直 push を軽量バイパスで許可してしまう +# fail-open が残っていた(#213 で git_command_query=実効 cwd 解決を削除した際の取りこぼし)。 +# 守るべき業務ルール: main 直 push/commit のブロックは確実(fail-closed)であること。 +# 他案不採用理由: +# 1) -C を抽出し実効 cwd を完全復元する案: 複数 -C の相対累積や --git-dir/--work-tree の +# 組合せまで正確に追うのは複雑で、#213 が regex 方式へ寄せた設計に逆行する。 +# 2) 何もしない案: 別 repo の main 直 push を $CWD=main・軽量時に通すため main 保護目的を満たさない。 +# 3) -C/--git-dir/--work-tree のみ検知(PR #229 初版): PR #229 codex レビューで指摘の通り +# `GIT_DIR=` / `GIT_WORK_TREE=` env 経由と `cd /other && git push` の複合コマンドが +# 残存 fail-open になるため不採用(v3.5.7 で同時対応)。 +# 対応: 実効ディレクトリを変える経路(-C / --git-dir / --work-tree / GIT_DIR= / GIT_WORK_TREE= / +# cd && git ...)が push/commit に付く場合は $CWD ベースの軽量バイパスを信頼せず、 +# main 向けは安全側で deny する(fail-closed)。-C なしの通常 cwd 上の Markdown 軽量直 push は +# 従来どおり許可され、false positive を広げない。 +command_targets_other_dir() { + # -C / --git-dir / --work-tree + # ただし `-C .` / `-C ./` は no-op(current dir)のため除外する。 + # path 部分を抽出して `.` または `./` でないことを確認する。 + local c_paths c_path + c_paths=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' || true) + if [ -n "$c_paths" ]; then + while IFS= read -r match; do + [ -z "$match" ] && continue + # 最後のフィールド = path(先頭の空白と -C を除去) + c_path=$(echo "$match" | awk '{print $NF}') + case "$c_path" in + "."|"./") ;; # no-op + *) return 0 ;; + esac + done <<< "$c_paths" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+))'; then + return 0 + fi + # GIT_DIR= / GIT_WORK_TREE= / GIT_NAMESPACE= 環境変数 prefix + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)='; then + return 0 + fi + # cd && git ... / cd ; git ... (複合コマンドで実効 cwd を変える) + # `cd .` / `cd ./` は no-op のため除外する。 + local cd_paths cd_path + cd_paths=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^[:space:];&|]+' || true) + if [ -n "$cd_paths" ]; then + while IFS= read -r match; do + [ -z "$match" ] && continue + cd_path=$(echo "$match" | awk '{print $NF}') + case "$cd_path" in + "."|"./") ;; # no-op + *) + # cd の後に && または ; があり git が続くことを確認 + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "cd[[:space:]]+$(printf '%s' "$cd_path" | sed 's/[[\.*^$/]/\\&/g')[[:space:]]*[;&]"; then + return 0 + fi + ;; + esac + done <<< "$cd_paths" + fi + return 1 +} + +# [2026-06-14][feat] 実効ターゲットディレクトリ(先頭の単一 cd 先)のブランチを解決する。-C は不採用=deny。 +# 背景: +# 依頼意図: Claude Code 等のハーネスは Bash の cwd を毎回プロジェクト直下(main)に戻すため、 +# worktree への操作は `cd && git commit/push` の形になる。$CWD(main) の枝で判定すると +# worktree(feature) への正当なコミット・PR push まで fail-closed で弾かれ、worktree 開発が成立しない。 +# 守るべき業務ルール: 解決対象は「コマンド先頭の単一 cd && ...」だけ(cd は後続コマンドの cwd に +# 効くため commit/push の実効ディレクトリになる)。GIT_DIR/GIT_WORK_TREE env・--git-dir/--work-tree/ +# --namespace・-C・複数 cd・先頭以外の cd が含まれる場合は解決不能(空)を返し、従来どおり fail-closed にする。 +# 他案不採用理由: +# 1) -C を解決に使う案: -C はその git 1 回にしか効かず、`git -C status && git commit` のように +# 後続 commit が main で動く形を誤許可するため不採用(-C は解決根拠にしない=従来 deny のまま)。 +# 2) 複数 cd の相対累積・env トリックまで追う案: 複雑で誤許可リスクが高い。安全に解決できる +# 「先頭単一 cd」だけを許可し、それ以外は安全側(空)に倒す。 +# 実効ターゲットディレクトリ(先頭の単一 cd 先)を解決して絶対パスを stdout に返す。解決不能なら空。 +# [2026-06-15][fix] dir 解決を effective_target_branch から切り出して関数化(bare push の宛先判定で +# has_unsafe_push が同じ dir を再利用するため)。ガード条件は従来と同一(変更なし)。 +effective_target_dir() { + # 実体を差し替える env / オプション / -C が含まれるものは解決不能(fail-closed 用に空を返す)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)=' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir|--work-tree|--namespace)([=[:space:]])' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])-C([[:space:]]|$)' && return 0 + # eval / exec / ` -c` は cd の効果範囲が静的に読めない → 解決不能(fail-closed)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(eval|exec)([[:space:]]|$)' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(sh|bash|zsh|dash|ksh)[[:space:]]+-[A-Za-z]*c([[:space:]]|$)' && return 0 + # コマンド位置(^ / ; & | 直後・サブシェル ( 直後)の cd を数える。複数あれば実効 cwd が曖昧 → 解決不能。 + # サブシェル `( cd /main && git commit )` の隠れた cd も ( を境界に含めることで検出する。 + local cds dir + cds=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[;&|(])[[:space:]]*cd[[:space:]]+[^[:space:];&|()]+' || true) + [ "$(printf '%s\n' "$cds" | grep -c .)" -ne 1 ] && return 0 + # その単一 cd が「先頭」かつ「&& / ; で後続に効く」形であること(背景 & / パイプ | は対象外)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '^[[:space:]]*cd[[:space:]]+[^[:space:];&|()]+[[:space:]]*(&&|;)' || return 0 + # [2026-06-16][fix] COMMAND が複数行(heredoc / 改行入りコミットメッセージ等)のとき、 + # sed が行単位で処理し非マッチ行(2 行目以降のメッセージ本文)を素通しするため dir がゴミ文字列化し、 + # git -C "$dir" が失敗 → 正当な worktree commit/push が誤 deny されていた。cd は先頭行(上の L427 で + # 先頭 + &&/; を保証済)にあるため、1 行目だけから抽出する(複数行は安全に L1 のみを見る)。 + dir=$(printf '%s' "$COMMAND" | sed -nE '1s/^[[:space:]]*cd[[:space:]]+([^[:space:];&|()]+).*/\1/p') + # ~ 展開 / 相対パスは $CWD(JSON の cwd) 基準で正規化(git -C が hook プロセスの cwd で解決するのを防ぐ)。 + case "$dir" in + ""|"."|"./") return 0 ;; + "~") dir="$HOME" ;; + "~/"*) dir="${HOME}/${dir#\~/}" ;; + /*) ;; + *) dir="$CWD/$dir" ;; + esac + printf '%s' "$dir" +} + +effective_target_branch() { + local dir + dir="$(effective_target_dir)" + [ -z "$dir" ] && return 0 + git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true +} + +# [2026-07-09][fix] +# 背景: +# 依頼意図: AGENT-HUB の専用 worktree 上で正当な `git -C commit` が +# block-main-commit に誤ブロックされ、正規の branch + PR フローを閉じられなかった。 +# 守るべき業務ルール: main 直 commit / push は引き続き fail-closed で止める。一方で、実効対象が +# 非 main branch だと確認できる単発 `git -C commit/push` は本番 main に影響しないため許可する。 +# 他案不採用理由: +# 1) `-C` を全面許可する案は、`git -C status && git commit` の後続 commit が main で動く形を +# 誤許可するため不採用。 +# 2) 複合 shell 構文まで静的解析する案は誤許可リスクが高いため不採用。 +# 3) 従来どおり全部 deny する案は、AGENT-HUB の標準 worktree 運用を阻害するため不採用。 +# 対応: shell 制御演算子を含まない単発 git コマンドだけ `-C` の対象 dir を解決し、非 main branch かつ +# unsafe push でない場合だけ早期許可する。env / git-dir / namespace trick は従来どおり fail-closed。 +# [2026-08-02][fix] Wave B / #1258: 引用内の `-C` を git global option と数えない。 +# 背景: +# - ユーザー依頼意図: `git -C commit -m '... -C ...'` のようにメッセージへ `-C` と +# 書いただけで単発 feature commit が deny され、文書・回帰テストが書けない。 +# - 守るべき業務ルール: 引用外の複数 `-C` は従来どおり解決不能。引用済み本文の `-C` はデータ。 +# - 他案不採用理由: メッセージから `-C` 文字を禁止する案は説明文を歪める。複合への -C 対称化はしない。 +# 対応: quote-aware に引用外の `-C ` をちょうど1つだけ抽出し、それを target dir にする。 +single_unquoted_git_c_path() { + COMMAND_TEXT="$1" python3 - <<'PY' 2>/dev/null || true +import os + +text = os.environ.get("COMMAND_TEXT", "") +quote = None +escaped = False +paths = [] +i = 0 +while i < len(text): + ch = text[i] + if escaped: + escaped = False + i += 1 + continue + if ch == "\\" and quote != "'": + escaped = True + i += 1 + continue + if quote == "'": + if ch == "'": + quote = None + i += 1 + continue + if quote == '"': + if ch == '"': + quote = None + i += 1 + continue + if ch in ("'", '"'): + quote = ch + i += 1 + continue + if ch == "-" and i + 1 < len(text) and text[i + 1] == "C": + prev = text[i - 1] if i > 0 else " " + if prev.isspace() or i == 0: + j = i + 2 + while j < len(text) and text[j] in " \t": + j += 1 + if j < len(text) and text[j] not in " \t\n;'\"|&()": + start = j + while j < len(text) and text[j] not in " \t\n;'\"|&()": + j += 1 + paths.append(text[start:j]) + i = j + continue + i += 1 + +if quote is not None or escaped or len(paths) != 1: + raise SystemExit(0) +print(paths[0], end="") +PY +} + +single_git_c_target_dir() { + # 単発 `git -C commit/push` だけを解決する。 + # `git -C status && git commit` のような後続 git へ -C が効かない形は従来どおり解決しない。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)=' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir|--work-tree|--namespace)([=[:space:]])' && return 0 + has_unquoted_shell_control "$COMMAND" && return 0 + # shell controlを除外済みの単発コマンドは、git本体とsubcommandだけを軽量に確認する。 + # ここで巨大な GIT_CMD 正規表現を再利用すると、引用本文を空白化した長い -C pathで + # EREのバックトラックが不安定になり、正当なfeature commit/pushを誤denyするため分離する。 + # [2026-08-02][fix] env / VAR=value prefix 付きの単発 git -C を解決対象に含める(issue #1344)。 + # 背景: + # - ユーザー依頼意図: `env FOO=bar git -C commit` / `FOO=bar git -C commit` + # が本軽量正規表現に一致せず未解決 → fail-closed で正当な feature commit/push まで + # 誤 deny されていた(PR #1343 codex-review が検出・再現ドライバで実測)。 + # - 守るべき業務ルール: GIT_DIR / GIT_WORK_TREE / GIT_NAMESPACE の assignment は本関数 + # 冒頭のガードが先に未解決へ倒す(実効 dir を -C 以外で動かす形は従来どおり保守的)。 + # 値に空白・引用を含む assignment は本パターンに一致せず未解決のまま(安全側)。 + # - 他案不採用理由: GIT_CMD 全体の再利用は上記バックトラック不安定のため不採用(既存判断)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '^[[:space:]]*(env[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*(env[[:space:]]+)?(command[[:space:]]+)?git([[:space:]]+[^[:space:]]+)*[[:space:]]+(commit|push)([[:space:]]|$)' || return 0 + + local dir + dir="$(single_unquoted_git_c_path "$COMMAND")" + case "$dir" in + ""|"."|"./") return 0 ;; + "~") dir="$HOME" ;; + "~/"*) dir="${HOME}/${dir#\~/}" ;; + /*) ;; + *) dir="$CWD/$dir" ;; + esac + printf '%s' "$dir" +} + +# [2026-06-14][feat] / [2026-06-15][fix] 早期許可してはならない push が含まれるか(main 保護の fail-closed 判定)。 +# 引数 $1: 実効ターゲットディレクトリ(effective_target_dir の解決結果)。bare/remote-only push の宛先を +# この dir の push.default + upstream で判定するために使う。空なら bare push は解決不能=unsafe に倒す。 +# 早期許可(worktree feature への exit 0)を通してよいのは: +# 1) 明示的非 main push: git push [安全フラグ]* <非main・非wildcard・非colon の単一ブランチ> +# 2) [2026-06-15][fix] refspec 省略の bare push(git push / git push / git push --force-with-lease)で、 +# 実効 dir のカレントブランチ(=呼び出し側が非 main を保証済み)が push.default 上 main に波及しないもの。 +# `cd && git push --force-with-lease` 形(refspec 省略の常用フロー)を許可するための拡張。 +# それ以外(複数 ref / 値を取るオプション(-o 等) / --all/--mirror / wildcard / main 宛て / +# push.default=matching / upstream が main)は main を押しうるため unsafe=true を返す。 +# トークン単位で解析し、未知オプション(値を取りうる)が残れば unsafe に倒す(保守的)。 +has_unsafe_push() { + local eff_dir="${1:-}" + local segs seg + segs=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE "${GIT_CMD}[[:space:]]+push[^;&|]*" || true) + [ -z "$segs" ] && return 1 # push なし(commit only)→ 安全 + while IFS= read -r seg; do + [ -z "$seg" ] && continue + local args remote="" ref="" extra=0 + args=$(printf '%s' "$seg" | sed -E 's/^.*[[:space:]]push([[:space:]]|$)/ /') + # glob 展開を抑止して push 引数をトークン化(refspec 内の * がファイル展開されないように)。 + set -f + # shellcheck disable=SC2086 + set -- $args + set +f + while [ "$#" -gt 0 ]; do + case "$1" in + # [2026-06-16][fix] リダイレクトトークン(2>&1 / 2> / >file / 1>&2 / &>file 等)を無視する。 + # segment 抽出 [^;&|]* は `2>&1` の `&` で切れ `2>` が残るため、従来はこれを余分な refspec + # と誤認し extra=1 → unsafe → 正当な worktree push が誤 deny されていた。git の refname は + # `<` `>` を含めないため(refname 規則)、これらを含むトークンは refspec ではない=安全に無視できる。 + *'>'*|*'<'*) ;; + # 値を取らない安全フラグのみ消費。 + -u|--set-upstream|-f|--force|--force-with-lease|-q|--quiet|-v|--verbose|-n|--dry-run|--no-verify|--porcelain|--progress|--atomic|--tags|--follow-tags) ;; + -*) return 0 ;; # 未知/値を取るオプション(--all/--mirror/-o 等) → 解析不能 → unsafe + *) + if [ -z "$remote" ]; then remote="$1" + elif [ -z "$ref" ]; then ref="$1" + else extra=1; fi ;; + esac + shift + done + [ "$extra" = 1 ] && return 0 # ref が 2 個以上 → 曖昧 → unsafe + if [ -z "$ref" ]; then + # refspec 省略(git push / git push )→ カレントブランチを push.default に従って push する。 + # 呼び出し側で「実効 dir のカレントブランチ != main」を保証済み。main に波及する設定のみ unsafe。 + [ -z "$eff_dir" ] && return 0 # dir 未解決 → 宛先を検証できない → unsafe(fail-closed) + local pd up + pd=$(git -C "$eff_dir" config --get push.default 2>/dev/null || true) + case "$pd" in + matching) + return 0 ;; # 全 matching ブランチ(main 含む)を push しうる → unsafe + upstream|tracking) + # 設定上の upstream を push。main(またはそれを指す upstream)なら unsafe、解決不能も unsafe。 + up=$(git -C "$eff_dir" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || true) + { [ -z "$up" ] || echo "$up" | grep -qE '(^|/)main$'; } && return 0 ;; + *) + : ;; # simple(既定)/current/nothing/未設定 → カレント(非main)ブランチのみ push → 安全 + esac + continue + fi + echo "$ref" | grep -qE '^[A-Za-z0-9._/-]+$' || return 0 # : や * を含む → unsafe + [ "$ref" = "main" ] && return 0 + echo "$ref" | grep -qE '(^|/)main$' && return 0 # refs/heads/main 等 → unsafe + done <<< "$segs" + return 1 +} + +BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || true) + +# 複合コマンド: checkout/switch main && commit/push を検知 +if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(switch|checkout)([[:space:]]+-[^[:space:]]+)*[[:space:]]+main([[:space:]]|$).*${GIT_CMD}[[:space:]]+(commit|push)([[:space:]]|$)"; then + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+push"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi +fi + +# push コマンドからmain向けrefspecを検知 +PUSH_SEGMENTS=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE "${GIT_CMD}[[:space:]]+push[^;&|]*" || true) +if [ -n "$PUSH_SEGMENTS" ]; then + while IFS= read -r push_segment; do + if echo "$push_segment" | grep -qE '(^|[[:space:]])\+?(refs/heads/)?main([[:space:]]|$)'; then + if [ "$BRANCH" != "main" ]; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + continue + fi + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$push_segment" | grep -qE '(^|[[:space:]])\+?[^[:space:]]*:(refs/heads/)?main([[:space:]]|$)'; then + if [ "$BRANCH" != "main" ]; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + continue + fi + _emit_deny_with_telemetry "$DENY_MSG" + fi + done <<< "$PUSH_SEGMENTS" +fi + +# [2026-07-18][fix] env split-string内のgit writeはGIT_CMDへ展開できないため、先に拒否する。 +if command_uses_env_split_git_write; then + _emit_deny_with_telemetry "$DENY_MSG" +fi + +# git commit / git push を含まない場合は許可 +if ! echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(commit|push)"; then + exit 0 +fi + +# env の chdir は hook JSON の cwd と異なる実効branchへ切り替わる。完全解決せずfail-closed。 +if command_uses_env_chdir; then + _emit_deny_with_telemetry "$DENY_MSG" +fi + +if [ -z "$BRANCH" ]; then + exit 0 +fi + +# mainブランチの場合 — AI hook 経由では軽量変更でも commit / push を許可しない +if [ "$BRANCH" = "main" ]; then + # [2026-06-14][fix] worktree(別ディレクトリ・feature ブランチ)への commit / 非 main push を許可。 + # 背景: + # 依頼意図: ハーネスが Bash cwd を毎回 main 直下に戻すため、worktree 運用は + # `cd && git commit/push` になる。従来は $CWD(main) の枝で fail-closed deny し、 + # worktree(feature) への正当なコミット・PR push まで弾けて worktree 並行開発が成立しなかった。 + # 守るべき業務ルール: 実効ターゲット(先頭の単一 cd 先)のブランチが main 以外で、かつ main への push を + # 含まないなら、本番デプロイ(=main push/merge)に一切影響しないため許可する。 + # 他案不採用理由: + # 1) 何もしない案: worktree 並行開発(ユーザーの主要フロー)が不能のままで不便。 + # 2) commit/push を全面許可する案: main push の fail-open を生むため不可。実効ブランチ判定 + + # has_unsafe_push ガードで main 保護を厳密に保つ(曖昧/全ref/wildcard/main 宛て push は早期許可しない)。 + # 3) 実効 cwd を完全復元する案: 複数 cd・env トリック・サブシェル・-c まで追うのは複雑で誤許可リスク。 + # 先頭の単一 cd のみ解決し(-C は git 1 回しか効かないため不採用=deny)、env トリック/複数 cd/ + # サブシェル/eval/-c シェルは effective_target_branch が空を返す=従来 deny。 + # 注: 本フックは「権限ルールの実体(SSOT)」そのもの。別途の権限ドキュメント同期は不要(ここが正本)。 + if command_targets_other_dir; then + eff_dir="$(effective_target_dir)" + if [ -n "$eff_dir" ]; then + eff_branch="$(git -C "$eff_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ -n "$eff_branch" ] && [ "$eff_branch" != "main" ] && ! has_unsafe_push "$eff_dir"; then + exit 0 # 別 worktree/別リポの feature への commit / 安全な非 main push(refspec 省略含む)→ 許可 + fi + fi + eff_dir="$(single_git_c_target_dir)" + if [ -n "$eff_dir" ]; then + eff_branch="$(git -C "$eff_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ -n "$eff_branch" ] && [ "$eff_branch" != "main" ] && ! has_unsafe_push "$eff_dir"; then + exit 0 # 単発 `git -C commit/push` は -C が対象 git へだけ効くため許可 + fi + fi + fi + + # [2026-05-30][fix] PR #229 codex review NO-GO 追加修正 + # 背景: BRANCH==main かつ $CWD が軽量だけのとき、`git -C /other push`(refspec なし)等で + # 実効 cwd が /other に切り替わるコマンドが CWD の軽量差分で素通りしていた(line 309 残存fail-open)。 + # 対応: command_targets_other_dir なら CWD ベース判定を信頼せず、main 向けは fail-closed。 + # `git -C /other push origin feature` (CWD=main) など希少な workflow を deny する副作用は + # メイン保護のため許容(自然なワークフローは /other へ cd して実行)。 + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(commit|push)"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi +fi + +# main以外は許可 +exit 0 diff --git a/.gemini/hooks/scripts/block-main-commit.test.sh b/.gemini/hooks/scripts/block-main-commit.test.sh new file mode 100755 index 000000000..f7eb58096 --- /dev/null +++ b/.gemini/hooks/scripts/block-main-commit.test.sh @@ -0,0 +1,517 @@ +#!/usr/bin/env bash +set -euo pipefail + +# [2026-04-10][test] +# 背景: +# - 依頼意図: block-main-commit hook の docs-only 例外が再び main 直 push の穴にならないよう、 +# commit / push の軽量変更例外を回帰テストで固定する。 +# - 守るべき業務ルール: main 直コミット/プッシュの例外は Markdown 系ドキュメントと +# sync-state.json など明示 allowlist だけ。コード変更や HEAD:main は拒否する。 +# - 他案不採用理由: 手動確認だけに戻す案は、同じ制御フロー退行を次回レビューまで見逃すため不採用。 +# +# [2026-06-19][test] +# 背景: +# - PR422 / 配布先レビューで、先頭 `cd` を含む複数行コマンドや redirect 付き push の +# 作業ディレクトリ解決が誤 deny される一方、main 明示 push は拒否し続ける必要があると分かった。 +# - 守るべき業務ルール: feature worktree への安全な push は止めず、main 直 push / HEAD:main / +# 解決不能な `git -C` 経由 push は止める。 +# - 他案不採用理由: 実装コメントだけで済ませる案は、sed 抽出の微妙な退行を次の配布まで見逃すため不採用。 + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/block-main-commit.sh" +PASS=0 +FAIL=0 + +json_string() { + python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1" +} + +run_hook() { + local cwd="$1" + local command="$2" + printf '{"tool_name":"Bash","tool_input":{"cwd":%s,"command":%s}}\n' "$(json_string "$cwd")" "$(json_string "$command")" | bash "$SCRIPT" +} + +run_hook_raw() { + local payload="$1" + printf '%s' "$payload" | bash "$SCRIPT" +} + +run_hook_script() { + local cwd="$1" + local command="$2" + local script="$3" + printf '{"tool_name":"Bash","tool_input":{"cwd":%s,"command":%s}}\n' "$(json_string "$cwd")" "$(json_string "$command")" \ + | bash "$script" +} + +expect_allow() { + local name="$1" + local cwd="$2" + local command="$3" + local out + out="$(run_hook "$cwd" "$command" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision'; then + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + else + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + fi +} + +expect_block() { + local name="$1" + local cwd="$2" + local command="$3" + local out + out="$(run_hook "$cwd" "$command" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_raw() { + local name="$1" + local payload="$2" + local out + out="$(run_hook_raw "$payload" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_with_script() { + local name="$1" + local cwd="$2" + local command="$3" + local script="$4" + local out + out="$(run_hook_script "$cwd" "$command" "$script" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +main_repo="$tmp/main" +feature_repo="$tmp/feature" +mkdir -p "$main_repo" "$feature_repo" +git -C "$main_repo" init -q +git -C "$main_repo" checkout -q -b main +git -C "$main_repo" config user.email test@example.com +git -C "$main_repo" config user.name "Test User" +echo init > "$main_repo/README.md" +git -C "$main_repo" add README.md +git -C "$main_repo" commit -q -m init +git -C "$main_repo" update-ref refs/remotes/origin/main HEAD + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only commit は拒否" \ + "$main_repo" \ + "git commit -m docs" +expect_block \ + "main上の先頭空白付きcommit は拒否" \ + "$main_repo" \ + " git commit -m docs" +expect_block \ + "main上のenv経由commit は拒否" \ + "$main_repo" \ + "env git commit -m docs" +expect_block \ + "main上のenv -u経由commit は拒否" \ + "$main_repo" \ + "env -u UNUSED_FLAG git commit -m docs" +expect_block \ + "main上のcommand経由push は拒否" \ + "$main_repo" \ + "command git push origin main" +expect_block \ + "main上の変数代入 + env経由commit は拒否" \ + "$main_repo" \ + "FOO=1 env git commit -m docs" +expect_block \ + "main上のsingle quote空白値 + commit は拒否" \ + "$main_repo" \ + "FOO='a b' git commit -m docs" +expect_block \ + "main上のdouble quote空白値 + env経由commit は拒否" \ + "$main_repo" \ + 'FOO="a b" env git commit -m docs' +expect_block \ + "main上の変数代入 + command経由push は拒否" \ + "$main_repo" \ + "FOO=1 command git push origin main" +git -C "$main_repo" reset -q + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only push は拒否" \ + "$main_repo" \ + "git push origin main" +git -C "$main_repo" reset -q + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only commit && push は拒否" \ + "$main_repo" \ + "git commit -m docs && git push origin main" +git -C "$main_repo" reset -q + +mkdir -p "$main_repo/.cursor/rules" "$main_repo/.codex" +echo rule > "$main_repo/.cursor/rules/project.mdc" +git -C "$main_repo" add .cursor/rules/project.mdc +expect_block \ + "main上の.mdc commit は拒否" \ + "$main_repo" \ + "git commit -m rules" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.cursor" + +echo '{}' > "$main_repo/.codex/sync-state.json" +git -C "$main_repo" add .codex/sync-state.json +expect_block \ + "main上のsync-state.json commit は拒否" \ + "$main_repo" \ + "git commit -m sync" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.codex" + +mkdir -p "$main_repo/.claude/hooks" +echo v > "$main_repo/.claude/hooks/.hook-library-version" +git -C "$main_repo" add .claude/hooks/.hook-library-version +expect_block \ + "main上のhook library version commit は拒否" \ + "$main_repo" \ + "git commit -m hook-version" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.claude" + +mkdir -p "$main_repo/src" +echo "export const value = 1;" > "$main_repo/src/app.ts" +git -C "$main_repo" add src/app.ts +expect_block \ + "main上のコード変更 commit は拒否" \ + "$main_repo" \ + "git commit -m code" +git -C "$main_repo" reset -q +rm -rf "$main_repo/src" + +git -C "$feature_repo" init -q +git -C "$feature_repo" checkout -q -b feature/test +git -C "$feature_repo" config user.email test@example.com +git -C "$feature_repo" config user.name "Test User" +echo init > "$feature_repo/README.md" +git -C "$feature_repo" add README.md +git -C "$feature_repo" commit -q -m init +git -C "$feature_repo" update-ref refs/remotes/origin/main HEAD +git -C "$feature_repo" branch --set-upstream-to=origin/main feature/test >/dev/null 2>&1 || true + +expect_block \ + "feature cwdからenv -C main commitは拒否" \ + "$feature_repo" \ + "env -C $main_repo git commit -m unsafe" + +expect_block \ + "feature cwdからenv --chdir main pushは拒否" \ + "$feature_repo" \ + "env --chdir=$main_repo git push" + +expect_block \ + "feature cwdからenv -S内のmain commitは拒否" \ + "$feature_repo" \ + "env -S 'git -C $main_repo commit -m unsafe'" + +expect_block \ + "feature cwdからenv --split-string内のmain pushは拒否" \ + "$feature_repo" \ + "env --split-string='git -C $main_repo push' ignored" + +# [2026-07-12][test] +# 背景: Codex が main checkout を cwd にしたまま専用 worktree へ単発 `git -C` commit する際、 +# conventional commit の scope 括弧や本文のセミコロンを shell 制御演算子と誤認して deny していた。 +# main 保護は維持しつつ、引用済みコミットメッセージ内の文字は引数データとして扱う必要がある。 +# 他案不採用理由: conventional commit の括弧だけを例外化するテストでは、引用済みのセミコロンや +# リダイレクト文字で同じ誤検知が再発するため、引用境界そのものを正負両方向で固定する。 +expect_allow \ + "-C feature commit の引用済み scope 括弧を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" + +expect_allow \ + "-C feature commit の引用済みセミコロンを許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix: first; second'" + +expect_allow \ + "-C feature commit の引用済み > を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: use > output'" + +# [2026-08-02][test] env / VAR= prefix 付き単発 -C feature commit の許可回帰(issue #1344)。 +# 背景: +# - ユーザー依頼意図: 旧 command_uses_env_chdir の貪欲マッチが git 側の -C を env の +# chdir と誤認し、正当な feature commit を誤 deny していた回帰を固定する。 +# - 守るべき業務ルール: env 自身の -C/--chdir・GIT_DIR 系 assignment の保守的 deny は +# 維持する(許可回帰と deny 回帰を対で置く)。 +# - 他案不採用理由: 許可側だけのテストでは、将来 env 判定を戻した時に chdir バイパスの +# deny が消えても検知できない。 +expect_allow \ + "env prefix の -C feature commit を許可" \ + "$main_repo" \ + "env FOO=bar git -C $feature_repo commit -m docs" +expect_allow \ + "VAR= prefix の -C feature commit を許可" \ + "$main_repo" \ + "FOO=bar git -C $feature_repo commit -m docs" +expect_block \ + "env 自身の -C (chdir) は従来どおり拒否" \ + "$feature_repo" \ + "env -C $main_repo git commit -m docs" +expect_block \ + "env GIT_DIR assignment は従来どおり保守的拒否" \ + "$main_repo" \ + "env GIT_DIR=$main_repo/.git git -C $feature_repo commit -m docs" +# PR #1354 codex-review Critical: 引数付き env オプション越しの chdir バイパスを deny 固定 +expect_block \ + "env -u 引数付きの env -C (chdir) main も拒否" \ + "$feature_repo" \ + "env -u FOO -C $main_repo git commit -m docs" +expect_block \ + "env --unset 引数付きの --chdir main も拒否" \ + "$feature_repo" \ + "env --unset FOO --chdir $main_repo git push origin main" +# 注: `env -i git -C commit` は single_git_c_target_dir が env オプションを +# 解決対象にしないため従来どおり保守的 deny(バイパスではなく安全側・許可回帰は置かない)。 + +expect_allow \ + "-C feature commit の引用済み < を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: use < input'" + +expect_allow \ + "-C feature commit のdouble quote済みメッセージを許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix(auth): allow feature worktree\"" + +# [2026-08-02][test] #1313 / #1256 +# 背景: 引用済みの commit message / PR本文に現れる `git push` や `git reset` を +# 実行コマンドと誤認すると、feature worktreeのcommitやガード修正PRを作れない。 +# 守るべき業務ルール: 本文系オプションの引数はデータとして扱い、引用外の実コマンドは拒否する。 +# 他案不採用理由: message 側の文字列を正規表現の例外へ追加する案は、例外列挙が際限なく増え +# 引用境界の正確な認識という根本対処を先送りするため不採用(PR #1343 codex-review 指摘の補完)。 +expect_allow \ + "-C feature commit message内のmain push文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs; git push origin main'" + +expect_allow \ + "-C feature commit message内のreset文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit --message='docs: git reset --hard は本文'" + +expect_allow \ + "single quote内のliteral command substitution文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: literal \$(git push origin main)'" + +expect_allow \ + "PR本文内のmain push文字列を許可" \ + "$main_repo" \ + "gh pr create --body 'release note; git push origin main'" + +expect_allow \ + "Issue本文のreset文字列を許可" \ + "$main_repo" \ + "gh issue comment 1 --body='docs: git reset --hard は実行しない'" + +expect_block \ + "本文の外にあるmain pushは引き続き拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs' ; git push origin main" + +expect_block \ + "-C feature commit 後の非引用セミコロン複合コマンドは拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m fix; git commit -m unsafe" + +expect_block \ + "-C feature commit の command substitution は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix: \$(git status)\"" + +expect_block \ + "-C feature commit の backtick command substitution は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix: \`git status\`\"" + +scanner_fixture="$tmp/scanner-fixture" +mkdir -p "$scanner_fixture/scripts" "$scanner_fixture/lib" +cp "$SCRIPT" "$scanner_fixture/scripts/block-main-commit.sh" +cp "$(dirname "$SCRIPT")/../lib/hook-io.sh" "$scanner_fixture/lib/hook-io.sh" +sed -i.bak 's/COMMAND_TEXT="$1" python3/COMMAND_TEXT="$1" missing-python3/' "$scanner_fixture/scripts/block-main-commit.sh" +rm -f "$scanner_fixture/scripts/block-main-commit.sh.bak" +expect_block_with_script \ + "quote scanner の起動不能は fail-closed" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" \ + "$scanner_fixture/scripts/block-main-commit.sh" + +crash_scanner="$scanner_fixture/scanner-exit-2" +printf '#!/usr/bin/env bash\nexit 2\n' > "$crash_scanner" +chmod +x "$crash_scanner" +sed -i.bak "s|COMMAND_TEXT=\"\$1\" missing-python3|COMMAND_TEXT=\"\$1\" $crash_scanner|" "$scanner_fixture/scripts/block-main-commit.sh" +rm -f "$scanner_fixture/scripts/block-main-commit.sh.bak" +expect_block_with_script \ + "quote scanner の異常終了(rc=2)は fail-closed" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" \ + "$scanner_fixture/scripts/block-main-commit.sh" + +expect_block \ + "-C feature commit の閉じていない single quote は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'broken" + +expect_block \ + "-C feature commit の閉じていない double quote は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"broken" + +expect_allow \ + "先頭 cd + multiline の feature push を許可" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease +commit body with spaces" + +expect_allow \ + "先頭 cd + redirect 付き feature push を許可" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease 2>&1" + +expect_block \ + "先頭 cd でも main 明示 push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push origin main 2>&1" + +expect_block \ + "HEAD:main は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push origin HEAD:main" + +# [2026-07-18][test] +# 全CLI配布物へ同じ回帰テストを展開する際、Claude/Cursor/Geminiのhook-ioはKimi固有payloadを +# 入力契約に持たない。別CLIのI/O契約まで要求せず、Kimi/Codex/正本でだけKimi payloadを検証する。 +case "$SCRIPT" in + */.claude/*|*/.cursor/*|*/.gemini/*) + printf '[SKIP] Kimi Shell toolInput の HEAD:main は対象外ランタイム\n' + ;; + *) + expect_block_raw \ + "Kimi Shell toolInput の HEAD:main は拒否" \ + "{\"toolName\":\"Shell\",\"toolInput\":{\"cwd\":$(json_string "$main_repo"),\"command\":$(json_string "cd $feature_repo && git push origin HEAD:main")}}" + ;; +esac + +git -C "$feature_repo" config push.default matching +expect_block \ + "push.default=matching の bare push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push" + +git -C "$feature_repo" config push.default upstream +expect_block \ + "upstream が main の bare push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease" + +git -C "$feature_repo" config push.default current +expect_block \ + "複数 cd は解決不能として拒否" \ + "$main_repo" \ + "cd $feature_repo && cd .. && git push" + +# [2026-07-11][test] jtt-apps 本番タグ push 事例(v2.4.37) +# 背景: single_git_c_target_dir()(PR #820)は単発 `git -C commit/push` のうち +# 実効ブランチが非main・かつ安全な push だけを許可する設計に変わっているが、本テストが +# 旧仕様(-C は常に解決不能=拒否)のまま残っていて、この設計変更を検出できずにいた。 +# 合わせて、コマンドに `2>&1` 等のリダイレクトが含まれるだけで誤って解決不能扱いになる +# 問題(DEPLOY_CHECKLIST.md のタグ push 手順が worktree 経由でも実行できなくなる不具合) +# も本ファイル修正で解消したため、そのケースも固定する。 +expect_allow \ + "-C 経由でも非mainブランチへの安全な push は許可" \ + "$main_repo" \ + "git -C $feature_repo push origin feature/test" + +expect_allow \ + "-C 経由 + redirect(2>&1) 付きの安全な push も許可" \ + "$main_repo" \ + "git -C $feature_repo push origin feature/test 2>&1" + +expect_block \ + "-C 経由でも main 宛て push は拒否" \ + "$main_repo" \ + "git -C $feature_repo push origin main" + +expect_block \ + "複数-Cは解決不能として拒否" \ + "$main_repo" \ + "git -C $tmp -C feature commit -m unsafe" + +# [2026-08-02][test] Wave B / #1258 / #1090 H1 +# 背景: +# - ユーザー依頼意図: main cwd から別リポ feature worktree へ commit/push する経路が +# 「無い」ように見える摩擦を、正本の実挙動(既に allow)で固定したい。 +# - 守るべき業務ルール: 先頭単一 `cd && git commit/push` と単発 +# `git -C commit/push` は non-main なら許可。複合への -C 対称化はしない。 +# - 他案不採用理由: helper 再発明や -C の複合対称化は後続 main 書き込みの誤許可を招く。 +# 注: 本 fixture の main_repo と feature_repo は別 git init(クロスリポ相当)。 +expect_allow \ + "クロスリポ相当: 先頭 cd + feature commit を許可" \ + "$main_repo" \ + "cd $feature_repo && git commit --allow-empty -m 'chore: cross-repo feature commit'" + +expect_allow \ + "クロスリポ相当: 単発 -C feature commit を許可" \ + "$main_repo" \ + "git -C $feature_repo commit --allow-empty -m 'chore: cross-repo -C commit'" + +expect_block \ + "-C feature の後続 commit へ対称化しない(複合は拒否)" \ + "$main_repo" \ + "git -C $feature_repo status && git commit --allow-empty -m unsafe" + +expect_block \ + "先頭 cd でも対象が main なら commit 拒否" \ + "$main_repo" \ + "cd $main_repo && git commit --allow-empty -m 'docs: still main'" + +expect_allow \ + "読み取り検索内の git push 文字列は許可" \ + "$main_repo" \ + 'rg -n "git push|post-merge-gate|workflow" hook-library scripts' + +TOTAL=$((PASS + FAIL)) +printf '\n=== block-main-commit.test.sh: %d/%d PASS ===\n' "$PASS" "$TOTAL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.gemini/hooks/scripts/block-skill-reverse-edit.sh b/.gemini/hooks/scripts/block-skill-reverse-edit.sh new file mode 100755 index 000000000..e1c4845e4 --- /dev/null +++ b/.gemini/hooks/scripts/block-skill-reverse-edit.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +# [2026-06-05][feat] Phase E: スキル参照一元化の逆流(SSOT汚染)ブロック +# 背景: +# - ユーザー依頼意図: スキルは AGENT-HUB を唯一の正本(SSOT)とし、各PJは +# .claude/skills/ の相対symlinkで参照する「参照一元化」へ移行済み +# (skill-reference-unification / AGENT-HUB PR #281・#282)。この構成では、PJ で +# 作業中に symlink経由でスキルファイル(.claude/skills//SKILL.md 等)を +# Write/Edit すると、symlink先の実体(AGENT-HUB/skills//...)がレビューなしで +# 直接書き換わり、参照中の全PJへ波及する(逆流)。ルール文だけでは AI が破る +# (遵守は確率的)ため、機械的にブロックして HUB のPR運用へ誘導したい。 +# - 守るべき業務ルール: スキル実体の編集は AGENT-HUB でブランチを切り +# PR→レビュー→マージ→各PJへ反映、の一方向に統一する。PJ側からの逆流編集は禁止。 +# - 他案不採用理由: +# 1) 警告のみ(非ブロック)案: AI は警告を無視して編集を続けるため SSOT 汚染を +# 防げず不採用。完全ブロックにする(ユーザー判断 2026-06-05「完全ブロック」)。 +# 2) パス文字列(.claude/skills/)だけで判定する案: PJ_LOCAL_EXCEPTION の実体コピー +# スキルや AGENT-HUB worktree 内の直接編集まで誤ブロックするため不採用。 +# realpath(symlink解決)で「実体が /skills/ か」「論理パスが hub の内か外か」 +# を見て、逆流(hub外の論理パス→hub内の実体)だけを deny する。 +# 3) hub パスをハードコードする案: worktree や別クローンで破綻するため、realpath を +# 遡って DISTRIBUTION.yaml を持つ skills 親を動的に hub root とみなす。 +# 4) CODEX_SCRIPT_MAP へ追加する案: PreToolUse(Write|Edit) は Codex のツール名体系と +# 異なり非対応(block-unauthorized-docs-file と同型)のため Claude 専用にする。 +# 対応: PreToolUse(Write|Edit|MultiEdit) で編集先 file_path を realpath 解決。実体が +# /skills//... (skills の親に DISTRIBUTION.yaml) かつ 論理パスが hub root の +# 外(=PJ の .claude/skills/ symlink経由)のときだけ deny。AGENT-HUB(worktree含む)内の +# 直接編集・PJの実体コピースキル・PJソースコードは素通り(fail-open: 逆流見逃しは +# 本番破壊ではないため、判定異常時は許可してAIの作業を止めない)。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny を記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +DENY_MSG='[hook:block-skill-reverse-edit] このスキルの正本(SSOT)は AGENT-HUB です。PJ の .claude/skills/(symlink)経由で実体を直接編集すると、レビューなしで参照中の全PJへ波及します(逆流)。\n\n対応手順:\n1. cd ~/business/AGENT-HUB\n2. git checkout -b feat/-update でブランチ作成\n3. skills// を編集\n4. gh pr create -> レビュー -> マージ(各PJへ自動反映)\n\n理由: スキルは1実体をHUBに一元管理(参照一元化)。PJ側からの編集はSSOT汚染になるためHUBのPR運用に統一します。' + +# emit_deny は hook-io.sh にもあるが reason を heredoc へ直接展開し JSON エスケープしない。 +# 将来 DENY_MSG に二重引用符等を含めても壊れないよう json.dumps でエスケープして deny を出す +# (block-unauthorized-docs-file.sh の emit_deny_safe と同型)。argv でなく env 経由で渡し安全化。 +emit_deny_safe() { + # telemetry(harness-checkup): deny を記録(記録失敗は無視・fail-open)。 + agent_hub_telemetry_log hook_deny block-skill-reverse-edit deny 2>/dev/null || true + HOOK_REASON="$1" python3 -c ' +import json, os +print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": os.environ.get("HOOK_REASON", "")}})) +' || true + exit 0 +} + +read_stdin +FILE_PATH=$(extract_file_path) + +# file_path を持たないツール入力は対象外 +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +# bash 前段フィルタ: スキル実体は必ず /skills/ 配下にある。パスに skills/ を含まない +# 大多数の編集は確実に対象外なので、python3 を起動せず即許可して発火コストを避ける。 +case "$FILE_PATH" in + */skills/*) : ;; # skills/ を含む → 詳細判定へ進む + *) exit 0 ;; # 含まない → 対象外(allow) +esac + +# 逆流判定は realpath 解決を伴うため python3 で行う(bash の realpath は未存在末端で +# 揺れるため)。verdict は "deny"(逆流) / "allow"(対象外 or HUB内直接編集)。 +verdict=$(HOOK_FILE_PATH="$FILE_PATH" python3 - <<'PY' 2>/dev/null || true +import os + +fp = os.environ.get("HOOK_FILE_PATH", "") +if not fp: + print("allow") + raise SystemExit(0) + +# 実体パス(symlink解決後)。os.path.realpath は末端が未存在でも経路上の symlink を +# 解決する(Write 新規作成に対応)。macOS の /var -> /private/var 等の上位 symlink も +# 正規化されるため、比較する hub もすべて realpath で揃える(prefix ずれ回避)。 +real = os.path.realpath(fp) + + +def find_hub_skill_root(real_path): + """real_path が /skills//... の形なら、DISTRIBUTION.yaml を持つ + skills 親(hub root, realpath)を返す。スキル実体でなければ None。""" + parts = real_path.split(os.sep) + for i, seg in enumerate(parts): + if seg == "skills" and i > 0: + hub_root = os.sep.join(parts[:i]) + if hub_root and os.path.isfile(os.path.join(hub_root, "DISTRIBUTION.yaml")): + return os.path.realpath(hub_root) + return None + + +def find_enclosing_hub(path): + """path(論理)を文字列的に上へ辿り、DISTRIBUTION.yaml を持つ最も近い祖先(realpath)を + 返す。symlink は辿らない(file_path が物理的にどの hub の中に在るかを見る)。 + 前提: bootstrap-skills.py は per-skill symlink(.claude/skills/)のみ生成し + .claude/skills/ ディレクトリ自体は実ディレクトリ。仮に .claude/skills/ 全体を hub への + symlink にする非標準構成では os.path.isfile が辿って誤許可しうるが、実環境では + bootstrap が生成しないため発生しない(fail-open 受容)。""" + cur = os.path.abspath(path) + while True: + if os.path.isfile(os.path.join(cur, "DISTRIBUTION.yaml")): + return os.path.realpath(cur) + parent = os.path.dirname(cur) + if parent == cur: + return None + cur = parent + + +real_hub = find_hub_skill_root(real) +if real_hub is None: + # スキル実体への書き込みではない(PJソース/実体コピースキル/通常ファイル) -> 対象外 + print("allow") + raise SystemExit(0) + +enclosing_hub = find_enclosing_hub(fp) +if enclosing_hub is not None and enclosing_hub == real_hub: + # file_path が物理的に属する hub と実体の hub が同一 -> HUB(worktree含む)内の直接編集 + print("allow") +else: + # file_path は hub の外(PJ)に在り、実体だけ hub 内 -> .claude/skills/ symlink 逆流 + print("deny") +PY +) + +if [ "$verdict" = "deny" ]; then + emit_deny_safe "$DENY_MSG" +fi + +exit 0 diff --git a/.gemini/hooks/scripts/block-skill-reverse-edit.test.sh b/.gemini/hooks/scripts/block-skill-reverse-edit.test.sh new file mode 100755 index 000000000..4cef02e2d --- /dev/null +++ b/.gemini/hooks/scripts/block-skill-reverse-edit.test.sh @@ -0,0 +1,137 @@ +#!/bin/bash + +# block-skill-reverse-edit.sh の回帰テスト。 +# 模擬 HUB(DISTRIBUTION.yaml + skills/ 実体)と模擬 PJ(.claude/skills/ が +# HUB 実体への相対symlink)を作り、逆流 deny / 各種許可ケースを検証する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK_PATH="$SCRIPT_DIR/block-skill-reverse-edit.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# --- 模擬 HUB(AGENT-HUB クローン相当) --- +HUB="$TMP/hub" +mkdir -p "$HUB/skills/demo-skill/references" "$HUB/skills/skills-manager" +: >"$HUB/DISTRIBUTION.yaml" +echo "demo" >"$HUB/skills/demo-skill/SKILL.md" +echo "mgr" >"$HUB/skills/skills-manager/SKILL.md" +# HUB 自身の .claude/skills/(skills 実体への相対symlink。bootstrap-skills.py 相当) +mkdir -p "$HUB/.claude/skills" +ln -s ../../skills/demo-skill "$HUB/.claude/skills/demo-skill" + +# --- 模擬 PJ(別ルートのプロジェクト) --- +PJ="$TMP/pj" +mkdir -p "$PJ/.claude/skills" "$PJ/src" "$PJ/.agents/skills/ext-skill" +# PJ の .claude/skills/ -> HUB の実体への相対symlink(参照一元化) +ln -s ../../../hub/skills/demo-skill "$PJ/.claude/skills/demo-skill" +# スキル名に 'skills' を含むケース(skills-manager) +ln -s ../../../hub/skills/skills-manager "$PJ/.claude/skills/skills-manager" +# PJ_LOCAL_EXCEPTION: 実体コピーのローカルスキル(symlink でない) +mkdir -p "$PJ/.claude/skills/local-skill" +echo "local" >"$PJ/.claude/skills/local-skill/SKILL.md" +# .agents/skills/ 外部管理スキル(DISTRIBUTION.yaml を持たない領域)への symlink +echo "ext" >"$PJ/.agents/skills/ext-skill/SKILL.md" +ln -s ../../.agents/skills/ext-skill "$PJ/.claude/skills/ext-skill" + +# --- PJ 自体が DISTRIBUTION.yaml を持つ(別 hub クローン)。HUB の skill を symlink --- +PJCLONE="$TMP/pj-clone" +mkdir -p "$PJCLONE/.claude/skills" +: >"$PJCLONE/DISTRIBUTION.yaml" +ln -s ../../../hub/skills/demo-skill "$PJCLONE/.claude/skills/demo-skill" + +# --- 別 hub(hub2, DISTRIBUTION.yaml あり)の skill を指す PJ2 --- +HUB2="$TMP/hub2" +mkdir -p "$HUB2/skills/demo-skill" +: >"$HUB2/DISTRIBUTION.yaml" +echo "demo2" >"$HUB2/skills/demo-skill/SKILL.md" +PJ2="$TMP/pj2" +mkdir -p "$PJ2/.claude/skills" +ln -s ../../../hub2/skills/demo-skill "$PJ2/.claude/skills/demo-skill" + +run_hook() { + # $1=file_path / $2=tool_name(既定 Write) / $3=tool_input のキー(既定 file_path) + local file_path="$1" + local tool_name="${2:-Write}" + local key="${3:-file_path}" + printf '{"tool_name":"%s","tool_input":{"%s":"%s"}}' "$tool_name" "$key" "$file_path" \ + | bash "$HOOK_PATH" +} + +run_hook_no_path() { + printf '{"tool_name":"Read","tool_input":{}}' | bash "$HOOK_PATH" +} + +assert_denied() { + local output="$1" label="$2" + # emit_deny_safe は json.dumps(セパレータにスペース)で出力するため空白0/1を許容。 + if ! printf '%s' "$output" | grep -qE '"permissionDecision": ?"deny"'; then + printf '[FAIL] %s : deny を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi + if ! printf '%s' "$output" | grep -qE '"permissionDecisionReason": ?'; then + printf '[FAIL] %s : permissionDecisionReason を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi + if printf '%s' "$output" | grep -qE '"reason": ?'; then + printf '[FAIL] %s : 旧 reason キーが残っている:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +assert_allowed() { + local output="$1" label="$2" + if [ -n "$output" ]; then + printf '[FAIL] %s : allow(無出力) を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +echo "1/15 PJ symlink 経由で SKILL.md 編集 -> deny(逆流)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md")" "pj symlink SKILL.md" + +echo "2/15 PJ symlink 経由で新規ファイル作成(未存在) -> deny(逆流)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/references/new.md")" "pj symlink new file" + +echo "3/15 HUB 内で skills/ を直接編集 -> allow(PR運用の本拠地)" +assert_allowed "$(run_hook "$HUB/skills/demo-skill/SKILL.md")" "hub direct skills edit" + +echo "4/15 HUB 内で .claude/skills/(自身のsymlink)経由 -> allow(worktree/HUB内)" +assert_allowed "$(run_hook "$HUB/.claude/skills/demo-skill/SKILL.md")" "hub .claude/skills symlink" + +echo "5/15 PJ の実体コピースキル(PJ_LOCAL_EXCEPTION) -> allow" +assert_allowed "$(run_hook "$PJ/.claude/skills/local-skill/SKILL.md")" "pj local real skill" + +echo "6/15 PJ のソースコード(.claude/skills 外) -> allow" +assert_allowed "$(run_hook "$PJ/src/foo.ts")" "pj source file" + +echo "7/15 file_path を持たないツール入力 -> allow(対象外)" +assert_allowed "$(run_hook_no_path)" "no file_path" + +echo "8/15 PJ symlink 経由で MultiEdit(file_path キー)の SKILL.md -> deny" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md" MultiEdit)" "pj symlink MultiEdit file_path" + +echo "9/15 PJ symlink 経由で Edit 単体 -> deny(matcher Edit カバレッジ)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md" Edit)" "pj symlink Edit" + +echo "10/15 PJ symlink 経由で MultiEdit(path キー) -> deny(実ペイロード形式)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md" MultiEdit path)" "pj symlink MultiEdit path-key" + +echo "11/15 PJ symlink 経由で多段ネスト(references/api/v2/schema.md) -> deny" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/references/api/v2/schema.md")" "pj symlink deep nest" + +echo "12/15 PJ 自体が DISTRIBUTION.yaml を持つ(別hubクローン)が HUB の skill を symlink -> deny" +assert_denied "$(run_hook "$PJCLONE/.claude/skills/demo-skill/SKILL.md")" "pj-clone with own DISTRIBUTION.yaml" + +echo "13/15 別 hub(hub2)の skill を指す PJ2 symlink -> deny(multi-hub)" +assert_denied "$(run_hook "$PJ2/.claude/skills/demo-skill/SKILL.md")" "pj2 -> hub2 symlink" + +echo "14/15 .agents/skills/ 外部管理スキル(DISTRIBUTION.yaml なし) -> allow" +assert_allowed "$(run_hook "$PJ/.claude/skills/ext-skill/SKILL.md")" "pj .agents external skill" + +echo "15/15 スキル名に 'skills' を含む(skills-manager) symlink 経由 -> deny" +assert_denied "$(run_hook "$PJ/.claude/skills/skills-manager/SKILL.md")" "pj symlink skills-manager" + +echo "block-skill-reverse-edit hook tests passed (15 cases)" diff --git a/.gemini/hooks/scripts/block-unauthorized-docs-file.sh b/.gemini/hooks/scripts/block-unauthorized-docs-file.sh new file mode 100755 index 000000000..7e270a0ea --- /dev/null +++ b/.gemini/hooks/scripts/block-unauthorized-docs-file.sh @@ -0,0 +1,551 @@ +#!/bin/bash +# @description Blocks unauthorized new docs/ SSOT files from file-edit and shell commands. +# @module hook-library/block-unauthorized-docs-file +# @status stable + +# [2026-05-26][feat] +# 背景: +# - ユーザー依頼意図: dev-guardrails 適用 PJ で、AI が docs/prd/ 等の SSOT ディレクトリに +# 推測でファイル名を決めて勝手に新規ファイル(next-action.md 等)を作る事故を止めたい。 +# AI は一度作ったファイルを自分から消さないため、無断生成物が溜まり続ける。ルール文だけでは +# AI が破る(指示の遵守は確率的)ため、機械的にブロックする hook を併設する。 +# - 守るべき業務ルール: docs-structure-rules.md(dev-guardrails)。prd/ は固定3ファイル+archives、 +# その他 SSOT ディレクトリ(architecture/business/api/database/operation/benchmark/testing)と +# docs/ 直下は baseline 許可ファイルのみ。新規 SSOT は伸太郎殿の承認(図解で必要性を説明)後に +# docs/.ssot-allowlist へ登録してから作る。 +# - 他案不採用理由: +# 1) docs/ 配下を全面ブロックする案: design/ release-notes/ 等の作業用ディレクトリへの +# 正当な新規作成(「デザイン案を作って」等)まで止めるため不採用。構造化 SSOT +# ディレクトリと docs/直下に限定する。 +# 2) prompt 型 hook で LLM 判定する案: 非決定的でチャットにプロンプトが漏れる。確定的な +# command hook に統一する(hooks-structure-rule.md)。 +# 3) 既存ファイルもブロックする案: 更新(Edit/上書き)は自由であるべき。ディスク上に存在する +# ファイルは grandfather して素通りさせ、純粋な新規作成のみをブロックする。 +# 対応: PreToolUse(Write|Edit|MultiEdit|Bash) で docs/ 配下の新規ファイルを検査。構造化 SSOT ディレクトリ + +# docs/直下 + 未承認の新規 docs/ サブディレクトリを deny し、図解で承認を取るよう AI に指示する。 +# docs/.ssot-allowlist 自体は AI の抜け道になるため手動更新扱いにし、既存ファイルは素通り。 +# +# [2026-05-27][fix] +# 背景: +# - ユーザー依頼意図: docs/plan/ は「廃止」する。プランの本流は ~/.claude/plans/(Claude)や +# ~/.cursor/plans/ などグローバルへ移っており、各PJの docs/plan/ は古いファイルの堆積(jtt-cms 100件が +# 5/7 から放置等)になっていた。今後 docs/plan/ には新規ファイルを作らせたくない。 +# - 守るべき業務ルール: docs/plan/ は WORK_DIRS(素通り)から外し、完全禁止にする。思考用プランは +# docs/ の外(~/.claude/plans/)に出るため本 hook は発火しない。docs/plan/ への新規作成だけをブロックする。 +# - 他案不採用理由: docs/plan/ を allowlist で個別解禁する案は、廃止方針と矛盾し再堆積を招くため不採用。 +# design/ release-notes/ は現状の用途が明確でないため WORK_DIRS に残し、plan/ のみ完全禁止にする。 +# 対応: WORK_DIRS から plan を除外(design release-notes のみ)。docs/plan/ 新規には「~/.claude/plans/ へ」 +# という専用メッセージで deny する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# 構造化 SSOT ディレクトリ(固定ファイルセットを持つ=新規ファイルを承認制にする) +# design/ release-notes/ archives/ など作業用ディレクトリは含めない(素通りさせる)。 +# plan/ は廃止(プランは ~/.claude/plans/ 等のグローバルへ)。WORK_DIRS から外し完全禁止扱いにする。 +GATED_DIRS="prd architecture business api database operation benchmark testing" +WORK_DIRS="design release-notes" + +# baseline 許可(docs-structure-rules.md と一致。構造定義で既に承認済みの正本ファイル)。 +is_baseline_allowed() { + local rel="$1" # docs/ より後ろの相対パス。例: prd/prd-active.md + case "$rel" in + # docs/ 直下 SSOT + FEATURE_FLAGS.md | PERMISSIONS.md) return 0 ;; + # prd/(固定3ファイル + archives スナップショット) + prd/prd-active.md | prd/prd-upcoming.md | prd/prd-future.md) return 0 ;; + prd/archives/*) return 0 ;; + # architecture/(設計意図 + 条件付き SSOT) + architecture/database-design.md | architecture/api-design.md) return 0 ;; + architecture/infrastructure-design.md) return 0 ;; + architecture/WEBSOCKET_CHANNELS.md) return 0 ;; + # business/ + business/BUSINESS_RULES.md | business/business-design.md | business/ROLE_DEFINITIONS.md) return 0 ;; + # api/ + api/API_SSOT.md) return 0 ;; + # database/ + database/DB_SCHEMA.md | database/DB_SCHEMA_UPDATE_GUIDE.md | database/SCHEMA_RELATIONS.md) return 0 ;; + # operation/ + operation/PROD_OPERATION.md | operation/STAGING_OPERATION.md | operation/LOCAL_OPERATION.md) return 0 ;; + operation/NOTIFICATION.md | operation/DEPLOY_LOG.md) return 0 ;; + operation/DEPLOY_CHECKLIST.md | operation/ENV_VARIABLES.md) return 0 ;; + esac + return 1 +} + +# [2026-06-26][feat] +# 背景: +# - ユーザー依頼意図: dev-guardrails の per-app SSOT 命名統一(-.md・4カテゴリ化) +# に追随し、docs SSOT 承認制 hook の許可パターンを更新する。直前の統一で per-app docs +# は -business-rules.md / -operations.md / --design.md へ +# 命名変更されたが、hook は旧名固定のため新命名ファイルが誤ってブロックされていた。 +# - 守るべき業務ルール: per-app docs は apps//docs/ 配下に限定し、ファイル名プレフィックス +# が app 名と一致することを backreference(\1) で機械的に保証する。旧命名ファイルは移行期中の +# 安全のため grandfather として残す。root の docs/architecture/ は per-app から分離し、 +# root 専用扱いを維持する。 +# - 他案不採用理由: +# 1) root docs 判定ロジックに per-app 判定を混ぜる案: root 専用 architecture/ 等との +# 優先順位・エラーメッセージが複雑化し、root ロジックを変更したくない本件の制約に反するため不採用。 +# 2) 旧命名 BUSINESS_RULES.md / OPERATIONS_SSOT.md を即座に削除する案: 移行期中に旧ファイル +# が存在し得るため、誤って既存ファイルの更新をブロックする恐れがあり不採用。 +# 3) ワイルドカードで apps//docs/* を広く許可する案: app 名不一致の推測ファイルや +# 任意名 SSOT を通してしまい、承認制の意味が薄れるため不採用。 +# 対応: apps//docs/ 配下を新たに検査対象に加え、is_per_app_baseline_allowed() で +# regex backreference 付きの許可パターンを判定する。新命名 + 旧命名 + prd パターンを許可し、 +# それ以外は未承認 SSOT としてブロックする。 +is_per_app_baseline_allowed() { + local rel="$1" + python3 - "$rel" <<'PY' +import re +import sys +rel = sys.argv[1] +patterns = [ + # 新 naming(dev-guardrails per-app SSOT 命名統一: -.md) + r'^apps/([-_a-zA-Z0-9]+)/docs/business/\1-business-rules\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/operation/\1-operations\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/architecture/\1-[-_a-zA-Z0-9]+-design\.md$', + # 既存 prd pattern + r'^apps/([-_a-zA-Z0-9]+)/docs/prd/\1-prd-(active|upcoming|future)\.md$', + # 旧 business/operation(移行期 grandfather) + r'^apps/([-_a-zA-Z0-9]+)/docs/business/BUSINESS_RULES\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/operation/OPERATIONS_SSOT\.md$', +] +for pat in patterns: + if re.match(pat, rel): + sys.exit(0) +sys.exit(1) +PY +} + +# [2026-06-19][fix] +# 背景: +# - jtt-apps レビューで、`auth-design.md` / `PASSWORD_GATES.md` / +# `PERFORMANCE_BASELINE.md` が存在しない PJ でも baseline 扱いとなり、 +# AI が無承認で新規 SSOT を作れる抜け道になると判明した。 +# - 守るべき業務ルール: 既存ファイルの更新は grandfather で許可するが、 +# PJ に存在しない条件付き SSOT の新規作成は docs/.ssot-allowlist 承認後に限る。 +# - 他案不採用理由: 全PJ共通 baseline に残す案は、存在しない SSOT を正本として +# 既成事実化できるため不採用。 + +# docs/.ssot-allowlist の glob パターンに一致するか(伸太郎殿が承認して追記したエントリ)。 +matches_allowlist_file() { + local rel="$1" + local allowlist="$2" + [ -f "$allowlist" ] || return 1 + local line trimmed + while IFS= read -r line || [ -n "$line" ]; do + trimmed="${line%%#*}" # 行コメント除去 + trimmed="$(printf '%s' "$trimmed" | tr -d '[:space:]')" # 空白除去 + [ -z "$trimmed" ] && continue + # case パターンとして glob 展開させるため $trimmed は unquoted + case "$rel" in + $trimmed) return 0 ;; + esac + done <"$allowlist" + return 1 +} + +# 安全な deny 出力(理由に改行・引用符を含められるよう python で JSON エスケープ)。 +emit_deny_safe() { + python3 - "$1" <<'PY' +import json +import sys +reason = sys.argv[1] +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } +})) +PY + exit 0 +} + +read_stdin + +# [2026-07-16][fix] +# 背景: +# - 依頼意図: Codex の apply_patch でも docs SSOT 承認制と docs/.ssot-allowlist 自己承認禁止を効かせる。 +# - 守るべき業務ルール: Codex の公式 hook 契約では Edit|Write matcher が apply_patch にも一致する。 +# matcher だけ配線して script 側で apply_patch を素通りさせてはならない。 +# - 他案不採用理由: changed_files を完了後だけ検査する案は、自己承認済み成果を worker に作らせた後で +# 止めるため不採用。PreToolUse で patch 対象を決定的に検査する。 +# 対応: apply_patch の patch/input から Add/Update/Delete/Move 対象を抽出し、既存の path gate へ渡す。 +# tool_name はトップレベルと bridge 環境変数から取得。matcher 設定がずれても fail-open を避けるため空は通す。 +TOOL_NAME=$(printf '%s' "$HOOK_INPUT" | python3 -c "import json,os,sys; d=json.load(sys.stdin); print(d.get('tool_name') or d.get('toolName') or d.get('name') or os.environ.get('CLAUDE_TOOL_NAME',''))" 2>/dev/null || true) +if [ -n "$TOOL_NAME" ] && [ "$TOOL_NAME" != "apply_patch" ] && [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "MultiEdit" ] && [ "$TOOL_NAME" != "WriteFile" ] && [ "$TOOL_NAME" != "StrReplaceFile" ] && [ "$TOOL_NAME" != "Bash" ] && [ "$TOOL_NAME" != "Shell" ]; then + exit 0 +fi + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" + +# docs/ 配下なら絶対パス + docs/ からの相対パスを返す。配下でなければ空。 +normalize_docs_path() { + FP="$1" ROOT="$PROJECT_DIR" python3 - <<'PY' 2>/dev/null || true +import os +# [2026-06-01][fix] codex PR#65 指摘②: abspath は symlink を解決しないため、 +# docs/ 自体や中間ディレクトリが symlink の場合に承認制を回避できた。realpath で +# symlink と相対(..)を実体パスに正規化してから docs/ 配下判定を行う。比較対象の +# docs も realpath で揃え、新規ファイル(末端未存在)は既存接頭辞だけ解決される。 +fp = os.environ.get("FP", "") +root = os.path.realpath(os.environ.get("ROOT", ".")) +if not fp: + print("") +else: + target = fp if os.path.isabs(fp) else os.path.join(root, fp) + ap = os.path.realpath(target) + docs = os.path.realpath(os.path.join(root, "docs")) + if ap == docs or ap.startswith(docs + os.sep): + print(ap + "\t" + os.path.relpath(ap, docs)) + else: + print("") +PY +} + +is_gated_rel() { + local rel="$1" + local top="$2" + if [ "$rel" = ".ssot-allowlist" ]; then + return 0 + fi + if [ -z "$top" ]; then + return 0 + fi + local d + for d in $GATED_DIRS; do + [ "$top" = "$d" ] && return 0 + done + for d in $WORK_DIRS; do + [ "$top" = "$d" ] && return 1 + done + # 未登録 docs// は docs-structure-rules の「追加ディレクトリ禁止」に合わせて承認制。 + return 0 +} + +check_docs_path() { + local file_path="$1" + local normalized target_path rel top deny_msg + normalized="$(normalize_docs_path "$file_path")" + [ -z "$normalized" ] && return 0 + target_path="${normalized%% *}" + rel="${normalized#* }" + + if [ "$rel" = ".ssot-allowlist" ]; then + deny_msg="[hook:block-unauthorized-docs] docs/.ssot-allowlist の AI 編集をブロックしました。 + +docs/.ssot-allowlist は未承認 SSOT 作成を許可する台帳なので、AI が自分で追記すると承認制の抜け道になります。 +伸太郎殿に図解で必要性を説明し、承認後は伸太郎殿の手動更新として扱ってください。" + emit_deny_safe "$deny_msg" + fi + + # 既存ファイルの更新・上書きは自由(新規作成のみ承認制) + [ -e "$target_path" ] && return 0 + + # 第1階層ディレクトリを判定(docs/直下ファイルは TOP="" 扱い) + case "$rel" in + */*) top="${rel%%/*}" ;; + *) top="" ;; + esac + + is_gated_rel "$rel" "$top" || return 0 + + # docs/plan/ は廃止。プランはグローバル(~/.claude/plans/ 等)へ作る。専用メッセージで deny。 + if [ "$top" = "plan" ]; then + deny_msg="[hook:block-unauthorized-docs] docs/plan/ への新規ファイル作成をブロックしました: docs/${rel} + +docs/plan/ は廃止されました。プランファイルは docs/ ではなくグローバルに作成してください: + - Claude Code のプラン → ~/.claude/plans/(プランモードが自動で書き出す) + - 各PJの docs/plan/ には新規プランを置かない(古いファイルの堆積を防ぐため) + +docs/.ssot-allowlist に plan/... を追加しても docs/plan/ の新規作成は許可されません。" + emit_deny_safe "$deny_msg" + fi + + # baseline / allowlist のいずれかに該当すれば許可 + is_baseline_allowed "$rel" && return 0 + matches_allowlist_file "$rel" "$PROJECT_DIR/docs/.ssot-allowlist" && return 0 + + # 未承認の新規 SSOT → ブロック + deny_msg="[hook:block-unauthorized-docs] docs/ 配下への未承認の新規 SSOT ファイル作成をブロックしました: docs/${rel} + +docs/ 配下の SSOT は承認制です(推測でファイル名を決めて勝手に作らない)。次の手順を踏んでください: + 1. 図解(ASCII)で「なぜこのファイルが必要か」「なぜ既存の構成(prd-active.md 等)では不足か」を伸太郎殿に説明する + 2. 伸太郎殿の承認を得る + 3. 承認後、docs/.ssot-allowlist を伸太郎殿の手動更新として1行追記してから再作成する + +ブロックされないもの: 既存ファイルの更新・編集 / baseline 固定 SSOT(prd-active.md 等)/ design/ release-notes/ 等の作業用ディレクトリ。 +詳細: .claude/skills/dev-guardrails/references/docs-structure-rules.md §7" + + emit_deny_safe "$deny_msg" +} + +# apps//docs/ 配下の正規化。root docs/ とは別の階層なので独立した検査を行う。 +# apps//docs/ 配下でなければ空を返す。 +normalize_per_app_docs_path() { + FP="$1" ROOT="$PROJECT_DIR" python3 - <<'PY' 2>/dev/null || true +import os +fp = os.environ.get("FP", "") +root = os.path.realpath(os.environ.get("ROOT", ".")) +if not fp: + print("") +else: + target = fp if os.path.isabs(fp) else os.path.join(root, fp) + ap = os.path.realpath(target) + apps_dir = os.path.realpath(os.path.join(root, "apps")) + if ap == apps_dir or not ap.startswith(apps_dir + os.sep): + print("") + else: + rel = os.path.relpath(ap, root) + parts = rel.split(os.sep) + # apps//docs/... のみ対象 + if len(parts) >= 4 and parts[2] == "docs": + print(ap + "\t" + rel) + else: + print("") +PY +} + +# apps//docs/ 配下の新規 SSOT 検査。root docs/ ロジックとは独立して動作する。 +check_per_app_docs_path() { + local file_path="$1" + local normalized target_path rel deny_msg + normalized="$(normalize_per_app_docs_path "$file_path")" + [ -z "$normalized" ] && return 0 + target_path="${normalized%% *}" + rel="${normalized#* }" + + # 既存ファイルの更新・上書きは自由(新規作成のみ承認制) + [ -e "$target_path" ] && return 0 + + # baseline のいずれかに該当すれば許可 + is_per_app_baseline_allowed "$rel" && return 0 + + # 未承認の新規 SSOT → ブロック + deny_msg="[hook:block-unauthorized-docs] apps//docs/ 配下への未承認の新規 SSOT ファイル作成をブロックしました: ${rel} + +apps//docs/ 配下の SSOT は承認制です(推測でファイル名を決めて勝手に作らない)。次の手順を踏んでください: + 1. 図解(ASCII)で「なぜこのファイルが必要か」「なぜ既存の構成(-prd-active.md 等)では不足か」を伸太郎殿に説明する + 2. 伸太郎殿の承認を得る + 3. 承認後、docs/.ssot-allowlist を伸太郎殿の手動更新として1行追記してから再作成する + +ブロックされないもの: 既存ファイルの更新・編集 / baseline 固定 SSOT(-business-rules.md 等)。 +詳細: .claude/skills/dev-guardrails/references/docs-structure-rules.md §11" + + emit_deny_safe "$deny_msg" +} + +if [ "$TOOL_NAME" = "apply_patch" ]; then + PATCH_TEXT=$(extract_field patch) + [ -n "$PATCH_TEXT" ] || PATCH_TEXT=$(extract_field input) + [ -n "$PATCH_TEXT" ] || emit_deny_safe "[hook:block-unauthorized-docs] apply_patch の対象パスを検査できないため、安全側でブロックしました。" + PATCH_PATHS=$(printf '%s\n' "$PATCH_TEXT" | python3 -c ' +import re, sys +paths = [] +for line in sys.stdin.read().splitlines(): + match = re.match(r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", line) + if not match: + match = re.match(r"^\*\*\* Move to: (.+)$", line) + if match: + paths.append(match.group(1).strip()) +for path in dict.fromkeys(paths): + print(path) +') + [ -n "$PATCH_PATHS" ] || emit_deny_safe "[hook:block-unauthorized-docs] apply_patch の対象パスを解釈できないため、安全側でブロックしました。" + while IFS= read -r candidate; do + [ -z "$candidate" ] && continue + check_docs_path "$candidate" + check_per_app_docs_path "$candidate" + done <<< "$PATCH_PATHS" + exit 0 +fi + +if [ "$TOOL_NAME" = "Bash" ] || [ "$TOOL_NAME" = "Shell" ]; then + COMMAND=$(extract_field command) + CWD=$(extract_field cwd) + [ -z "$CWD" ] && CWD="$PROJECT_DIR" + [ -z "$COMMAND" ] && exit 0 + printf '%s' "$COMMAND" | grep -Eq '(^|[[:space:];|&])(:>|[0-9]*>{1,2}|&>{1,2}|touch|cat[[:space:]].*([0-9]*>{1,2}|&>{1,2})|cp|mv|install|mkdir|tee|sed[[:space:]].*-i|perl[[:space:]].*-pi)' || exit 0 + CANDIDATES=$( + COMMAND_TEXT="$COMMAND" CWD_TEXT="$CWD" PROJECT_DIR="$PROJECT_DIR" python3 - <<'PY' +import os +import re +import shlex + +cmd = os.environ.get("COMMAND_TEXT", "") +root = os.path.abspath(os.environ.get("PROJECT_DIR", ".")) +current_cwd = os.path.abspath(os.environ.get("CWD_TEXT") or root) +metachars = {";", "|", "&", "<", ">", ">>", "&>", "&>>", "&&", "||"} +paths = [] + + +def resolve_path(token, cwd): + if not token or token in metachars or token.startswith("-") or token.startswith("$"): + return "" + if os.path.isabs(token): + return os.path.normpath(token) + return os.path.normpath(os.path.join(cwd, token)) + + +def add_path(token, cwd=None): + path = resolve_path(token, cwd or current_cwd) + if path: + paths.append(path) + + +def add_copy_like_paths(segment): + if not segment: + return + destination = resolve_path(segment[-1], current_cwd) + if not destination: + return + if os.path.isdir(destination) and len(segment) > 1: + # [2026-06-19][fix] + # 背景: + # - `cp foo.md docs/prd/` のように宛先が既存ディレクトリの場合、 + # `docs/prd/` 自体は既存なので grandfather 判定で許可されていた。 + # - 守るべき業務ルール: 実際に作られる `docs/prd/foo.md` を検査し、 + # 未承認 SSOT の新規作成は同じく止める。 + # - 他案不採用理由: docs ディレクトリ宛てを全面 deny すると、 + # allowlist 済みファイルのコピーまで止まり運用が粗くなるため不採用。 + for source in segment[:-1]: + name = os.path.basename(source.rstrip("/")) + if name and name not in {".", ".."}: + paths.append(os.path.join(destination, name)) + return + paths.append(destination) + + +def copy_like_operands(command, raw_tokens): + option_args = { + "cp": {"-S", "-t", "--suffix", "--target-directory"}, + "mv": {"-S", "-t", "--suffix", "--target-directory"}, + "install": {"-g", "-m", "-o", "-S", "-t", "--group", "--mode", "--owner", "--suffix", "--target-directory"}, + } + target_directory = None + operands = [] + index = 0 + while index < len(raw_tokens): + token = raw_tokens[index] + if token == "--": + operands.extend(raw_tokens[index + 1 :]) + break + if token.startswith("--target-directory="): + target_directory = token.split("=", 1)[1] + index += 1 + continue + if token.startswith("--") and token != "--": + option = token.split("=", 1)[0] + if "=" not in token and option in option_args.get(command, set()): + if option == "--target-directory" and index + 1 < len(raw_tokens): + target_directory = raw_tokens[index + 1] + index += 2 + continue + index += 1 + continue + if token.startswith("-") and token != "-": + short = token[:2] + if token == short and short in option_args.get(command, set()): + if short == "-t" and index + 1 < len(raw_tokens): + target_directory = raw_tokens[index + 1] + index += 2 + continue + if token.startswith("-t") and len(token) > 2: + target_directory = token[2:] + index += 1 + continue + index += 1 + continue + operands.append(token) + index += 1 + if target_directory: + operands.append(target_directory) + return operands + + +try: + lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + tokens = list(lexer) +except Exception: + tokens = [] + +i = 0 +while i < len(tokens): + tok = tokens[i] + if tok == "cd" and i + 1 < len(tokens): + target = tokens[i + 1] + if target not in metachars and not target.startswith("$"): + next_cwd = resolve_path(target, current_cwd) + if next_cwd: + current_cwd = next_cwd + i += 2 + continue + if (tok in {">", ">>", "&>", "&>>"} or re.match(r"^(?:(?:\d*)>{1,2}|&>{1,2})$", tok)) and i + 1 < len(tokens): + add_path(tokens[i + 1]) + i += 2 + continue + if tok in {"touch", "tee", "mkdir"}: + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + add_path(candidate) + if tok in {"cp", "mv", "install"}: + raw_segment = [] + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + raw_segment.append(candidate) + segment = copy_like_operands(tok, raw_segment) + if segment: + add_copy_like_paths(segment) + # [2026-05-27][fix] R2 follow-up: sed/perl の in-place 編集ターゲットも検査対象にする。 + # 背景: 前段 grep は sed -i / perl -pi を作成・編集系として検知するが、ここで対象ファイルを + # paths に追加していなかったため docs/.ssot-allowlist の AI 編集が素通りしていた。 + # 守るべき業務ルール: docs/.ssot-allowlist は既存ファイルでも AI 編集を必ず deny する。 + # 他案不採用理由: fallback を常時 docs/ パス抽出に戻す案は、PR本文や commit message の + # docs/ 言及を再び作成ターゲットと誤認するため不採用。 + if tok in {"sed", "perl"}: + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + if candidate.startswith("-"): + continue + add_path(candidate) + i += 1 + +# [2026-05-27][fix] R2 誤検知: punctuation_chars lexer が 1 トークンも取れなかった +# (引用が壊れた・極端な複合コマンド) 場合に限り、最終手段として docs/ 明示パスを拾う。 +# 正常にトークン化できたコマンド (gh pr create --body "...docs/plan/..." / echo / git commit -m +# 等、説明テキストに docs/ を含むだけ) では作動させない。常時 fallback すると、PR 本文や +# コミットメッセージ中の docs/ 言及を作成ターゲットと誤認して deny してしまう (R2)。 +# 作成系のターゲットは上の operation-aware パス (リダイレクト/touch/tee/mkdir/cp/mv/install) が +# 既に網羅しており、トークン化が成功している限り fallback の追加カバレッジはノイズのみ。 +if not tokens: + try: + fallback_tokens = shlex.split(cmd, posix=True) + except Exception: + fallback_tokens = [] + for token in fallback_tokens: + if token.startswith("./docs/") or token.startswith("docs/"): + paths.append(token[2:] if token.startswith("./") else token) + for match in re.findall(r"(?:^|[\s\"'=<>])(\./docs/[^\s\"'`$;|&<>]+|docs/[^\s\"'`$;|&<>]+)", cmd): + paths.append(match[2:] if match.startswith("./") else match) +for path in dict.fromkeys(paths): + print(path) +PY + ) + while IFS= read -r candidate; do + [ -z "$candidate" ] && continue + check_docs_path "$candidate" + check_per_app_docs_path "$candidate" + done <<< "$CANDIDATES" + exit 0 +fi + +FILE_PATH=$(extract_file_path) +[ -z "$FILE_PATH" ] && exit 0 +check_docs_path "$FILE_PATH" +check_per_app_docs_path "$FILE_PATH" diff --git a/.gemini/hooks/scripts/block-unauthorized-docs-file.test.sh b/.gemini/hooks/scripts/block-unauthorized-docs-file.test.sh new file mode 100755 index 000000000..4edd38787 --- /dev/null +++ b/.gemini/hooks/scripts/block-unauthorized-docs-file.test.sh @@ -0,0 +1,329 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK_PATH="$SCRIPT_DIR/block-unauthorized-docs-file.sh" + +# 一時プロジェクトを作成(docs/ 構造・既存ファイル・allowlist を用意) +TMP_PROJECT="$(mktemp -d)" +trap 'rm -rf "$TMP_PROJECT"' EXIT +mkdir -p "$TMP_PROJECT/docs/prd/archives" \ + "$TMP_PROJECT/docs/architecture" \ + "$TMP_PROJECT/docs/operation" \ + "$TMP_PROJECT/docs/benchmark" \ + "$TMP_PROJECT/docs/plan" \ + "$TMP_PROJECT/docs/database" \ + "$TMP_PROJECT/src" \ + "$TMP_PROJECT/apps/koban-neko/docs/business" \ + "$TMP_PROJECT/apps/hyoka-wanko/docs/operation" \ + "$TMP_PROJECT/apps/chie-fukuro/docs/architecture" \ + "$TMP_PROJECT/apps/foo/docs/business" +# 既存ファイル(grandfather 対象) +: >"$TMP_PROJECT/docs/prd/prd-active.md" +: >"$TMP_PROJECT/docs/database/LEGACY_NOTES.md" # baseline 外だが既存 → 更新は許可される想定 +# allowlist 台帳(承認済みエントリ) +cat >"$TMP_PROJECT/docs/.ssot-allowlist" <<'EOF' +# 伸太郎殿承認済みの追加 SSOT +operation/INCIDENT_LOG.md +architecture/realtime-*.md +EOF + +run_hook() { + local tool_name="$1" + local file_path="$2" # 絶対パス推奨 + local payload + payload=$(python3 - "$tool_name" "$file_path" "$TMP_PROJECT" <<'PY' +import json +import sys +tool_name = sys.argv[1] +file_path = sys.argv[2] +tool_input = {} +if tool_name in {"Bash", "Shell"}: + tool_input["command"] = file_path + tool_input["cwd"] = sys.argv[3] if len(sys.argv) > 3 else "" +elif file_path: + tool_input["file_path"] = file_path +print(json.dumps({"tool_name": tool_name, "tool_input": tool_input}), end="") +PY +) + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +run_hook_raw() { + local payload="$1" + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +run_apply_patch_hook() { + local patch_text="$1" + local payload + payload=$(python3 - "$patch_text" <<'PY' +import json +import sys +print(json.dumps({"tool_name": "apply_patch", "tool_input": {"patch": sys.argv[1]}}), end="") +PY +) + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +assert_denied() { + local output="$1" + local label="$2" + if ! OUT="$output" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if not payload.get("permissionDecisionReason"): + sys.exit(1) +if "reason" in payload: + sys.exit(1) +PY + then + printf '[FAIL] %s : deny を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +assert_allowed() { + local output="$1" + local label="$2" + if [ -n "$output" ]; then + printf '[FAIL] %s : allow(無出力) を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +D="$TMP_PROJECT/docs" + +echo "1/21 prd/ への推測ファイル(next-action.md 新規)-> deny" +assert_denied "$(run_hook Write "$D/prd/next-action.md")" "prd/next-action.md" + +echo "2/21 prd/ baseline 固定ファイル(prd-future.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/prd/prd-future.md")" "prd/prd-future.md" + +echo "3/21 既存ファイル(prd-active.md 上書き)-> allow" +assert_allowed "$(run_hook Write "$D/prd/prd-active.md")" "prd/prd-active.md(existing)" + +echo "4/21 廃止された plan/ への新規 -> deny(docs/plan/ は廃止・~/.claude/plans へ)" +assert_denied "$(run_hook Write "$D/plan/2026-05-26-next-plan.md")" "plan/next-plan.md" + +echo "5/21 prd/archives/ スナップショット新規 -> allow" +assert_allowed "$(run_hook Write "$D/prd/archives/prd-active-2026-05.md")" "prd/archives/snapshot" + +echo "6/21 architecture/ baseline 外の新規(new-thing.md)-> deny" +assert_denied "$(run_hook Write "$D/architecture/new-thing.md")" "architecture/new-thing.md" + +echo "6a/21 database/ 未承認 root SSOT(NEW_RANDOM.md)-> deny" +assert_denied "$(run_hook Write "$D/database/NEW_RANDOM.md")" "database/NEW_RANDOM.md" + +echo "7/21 architecture/ baseline(database-design.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/database-design.md")" "architecture/database-design.md" + +echo "B1-1 architecture/auth-design.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/architecture/auth-design.md")" "architecture/auth-design.md" + +echo "B1-2 operation/PASSWORD_GATES.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/operation/PASSWORD_GATES.md")" "operation/PASSWORD_GATES.md" + +echo "B1-3 benchmark/PERFORMANCE_BASELINE.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/benchmark/PERFORMANCE_BASELINE.md")" "benchmark/PERFORMANCE_BASELINE.md" + +mkdir -p "$D/benchmark" +: >"$D/architecture/auth-design.md" +: >"$D/operation/PASSWORD_GATES.md" +: >"$D/benchmark/PERFORMANCE_BASELINE.md" + +echo "B1-4 architecture/auth-design.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/auth-design.md")" "architecture/auth-design.md(existing)" + +echo "B1-5 operation/PASSWORD_GATES.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Edit "$D/operation/PASSWORD_GATES.md")" "operation/PASSWORD_GATES.md(existing)" + +echo "B1-6 benchmark/PERFORMANCE_BASELINE.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Write "$D/benchmark/PERFORMANCE_BASELINE.md")" "benchmark/PERFORMANCE_BASELINE.md(existing)" + +echo "8/21 docs/ 直下の新規 SSOT(ROADMAP.md)-> deny" +assert_denied "$(run_hook Write "$D/ROADMAP.md")" "docs/ROADMAP.md" + +echo "9/21 docs/ 直下 baseline(FEATURE_FLAGS.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/FEATURE_FLAGS.md")" "docs/FEATURE_FLAGS.md" + +echo "9a/21 operation/PERMISSIONS.md(条件付き権限SSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/operation/PERMISSIONS.md")" "operation/PERMISSIONS.md" + +: >"$D/operation/PERMISSIONS.md" +echo "9b/21 operation/PERMISSIONS.md(条件付き権限SSOT・既存)-> allow" +assert_allowed "$(run_hook Edit "$D/operation/PERMISSIONS.md")" "operation/PERMISSIONS.md(existing)" + +echo "10/21 docs/ 外(src/foo.ts 新規)-> allow" +assert_allowed "$(run_hook Write "$D/../src/foo.ts")" "src/foo.ts" + +echo "11/21 allowlist 完全一致(operation/INCIDENT_LOG.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/operation/INCIDENT_LOG.md")" "operation/INCIDENT_LOG.md" + +echo "12/21 allowlist glob 一致(architecture/realtime-channels.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/realtime-channels.md")" "architecture/realtime-channels.md" + +echo "13/21 allowlist 台帳の新規/更新 -> deny" +assert_denied "$(run_hook Write "$D/.ssot-allowlist")" "docs/.ssot-allowlist" + +echo "13a/21 Kimi MultiEdit で allowlist 編集 -> deny" +assert_denied "$(run_hook MultiEdit "$D/.ssot-allowlist")" "MultiEdit docs/.ssot-allowlist" + +echo "13b/21 Kimi 旧 WriteFile で allowlist 編集 -> deny" +assert_denied "$(run_hook WriteFile "$D/.ssot-allowlist")" "WriteFile docs/.ssot-allowlist" + +echo "13c/21 Kimi 旧 StrReplaceFile で allowlist 編集 -> deny" +assert_denied "$(run_hook StrReplaceFile "$D/.ssot-allowlist")" "StrReplaceFile docs/.ssot-allowlist" + +echo "14/21 未登録 docs/ サブディレクトリへの新規 -> deny" +assert_denied "$(run_hook Write "$D/random/ROADMAP.md")" "docs/random/ROADMAP.md" + +echo "15/21 相対パスの既存ファイル更新(hook cwd がPJ外)-> allow" +(cd /tmp && assert_allowed "$(run_hook Write "docs/prd/prd-active.md")" "relative existing path") + +echo "16/21 Bash touch で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Bash "touch docs/prd/bash-next.md")" "bash touch docs/prd/bash-next.md" + +echo "17/21 Bash echo で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "echo architecture/foo.md >> docs/.ssot-allowlist")" "bash allowlist edit" + +echo "17a/21 Bash sed -i で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "sed -i.bak 's/foo/bar/' docs/.ssot-allowlist")" "bash sed allowlist edit" + +echo "17b/21 Bash perl -pi で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "perl -pi -e 's/foo/bar/' docs/.ssot-allowlist")" "bash perl allowlist edit" + +echo "17c/21 Bash cp で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp tmp-note.md docs/prd/")" "bash cp to docs/prd directory" + +echo "17d/21 Bash mv で既存 docs/architecture/ ディレクトリへ未承認SSOT移動 -> deny" +assert_denied "$(run_hook Bash "mv tmp-note.md docs/architecture/")" "bash mv to docs/architecture directory" + +echo "17e/21 Bash install で既存 docs/operation/ ディレクトリへ未承認SSOT配置 -> deny" +assert_denied "$(run_hook Bash "install tmp-note.md docs/operation/")" "bash install to docs/operation directory" + +mkdir -p "$TMP_PROJECT/tmp" +: >"$TMP_PROJECT/tmp/INCIDENT_LOG.md" +echo "17f/21 Bash install -m 644 で allowlist 済みSSOT配置 -> allow" +assert_allowed "$(run_hook Bash "install -m 644 tmp/INCIDENT_LOG.md docs/operation/")" "bash install mode allowlisted file" + +echo "17g/21 Bash install -m 644 で未承認SSOT配置 -> deny" +install_mode_output="$(run_hook Bash "install -m 644 tmp-note.md docs/operation/")" +assert_denied "$install_mode_output" "bash install mode to docs/operation directory" +if printf '%s' "$install_mode_output" | grep -q 'docs/operation/644'; then + printf '[FAIL] bash install mode option was treated as filename:\n%s\n' "$install_mode_output" >&2 + exit 1 +fi + +echo "17h/21 Bash cp -t で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp -t docs/prd tmp-note.md")" "bash cp -t docs/prd" + +echo "17i/21 Bash cp --target-directory= で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp --target-directory=docs/prd tmp-note.md")" "bash cp --target-directory docs/prd" + +echo "18/21 tool_name=Read(対象外)-> allow" +assert_allowed "$(run_hook Read "$D/prd/next-action.md")" "Read tool" + +echo "19/21 作業用ディレクトリ design/ への新規 -> allow(WORK_DIRS は維持)" +assert_allowed "$(run_hook Write "$D/design/new-mockup.md")" "design/new-mockup.md" + +echo "20/21 Bash cd 後の prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Bash "cd docs/prd && touch cd-next.md")" "bash cd docs/prd touch" + +echo "21/21 Kimi Shell で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Shell "touch docs/prd/shell-next.md")" "shell touch docs/prd/shell-next.md" + +echo "21a/21 Kimi toolInput camelCase で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook_raw '{"toolName":"Shell","toolInput":{"command":"touch docs/prd/kimi-toolinput-next.md","cwd":"'"$TMP_PROJECT"'"}}')" "kimi toolInput shell docs/prd" + +# --- R2 誤検知回帰テスト(2026-05-27): 説明テキスト中の docs/ 言及を作成ターゲットと誤認しない --- +echo "R2-1 gh pr create の --body に docs/plan/ 言及(touch 含む)-> allow" +assert_allowed "$(run_hook Bash 'gh pr create --title x --body "removes docs/plan/ legacy; touch up wording"')" "R2 gh pr create body docs mention" + +echo "R2-2 git commit -m に docs/prd/ 言及(> 含む)-> allow" +assert_allowed "$(run_hook Bash 'git commit -m "drop docs/prd/cleanup-notes.md > archive"')" "R2 git commit msg docs mention" + +echo "R2-3 実リダイレクトでの docs/ 新規作成は引き続き deny(保護が残っていること)" +assert_denied "$(run_hook Bash "printf hi > docs/architecture/brand-new.md")" "R2 real redirect still denied" + +echo "R2-4 数値付きリダイレクトでの docs/ 新規作成 -> deny" +assert_denied "$(run_hook Bash "printf hi 2> docs/architecture/fd-new.md")" "R2 numeric redirect denied" + +echo "R2-5 stdout/stderr リダイレクトでの docs/ 新規作成 -> deny" +assert_denied "$(run_hook Bash "printf hi &> docs/architecture/amp-new.md")" "R2 amp redirect denied" + +# --- per-app baseline 新命名テスト(2026-06-26) --- +echo "PA-1/7 per-app 新命名 business 許可: apps/koban-neko/docs/business/koban-neko-business-rules.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/koban-neko/docs/business/koban-neko-business-rules.md")" "per-app business new naming" + +echo "PA-2/7 per-app 新命名 operation 許可: apps/hyoka-wanko/docs/operation/hyoka-wanko-operations.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/hyoka-wanko/docs/operation/hyoka-wanko-operations.md")" "per-app operation new naming" + +echo "PA-3/7 per-app 新命名 architecture 許可: apps/chie-fukuro/docs/architecture/chie-fukuro-rag-design.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/chie-fukuro/docs/architecture/chie-fukuro-rag-design.md")" "per-app architecture new naming" + +echo "PA-4/7 per-app prd 既存パターン許可: apps/foo/docs/prd/foo-prd-active.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/prd/foo-prd-active.md")" "per-app prd pattern" + +echo "PA-5/7 per-app 旧 business 命名 grandfather 許可: apps/foo/docs/business/BUSINESS_RULES.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/business/BUSINESS_RULES.md")" "per-app old business naming grandfather" + +echo "PA-6/7 per-app 任意名 docs ファイルはブロック維持: apps/foo/docs/business/random-notes.md" +assert_denied "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/business/random-notes.md")" "per-app arbitrary name blocked" + +echo "PA-7/7 per-app app 名不一致はブロック: apps/koban-neko/docs/business/hyoka-wanko-business-rules.md" +assert_denied "$(run_hook Write "$TMP_PROJECT/apps/koban-neko/docs/business/hyoka-wanko-business-rules.md")" "per-app app name mismatch blocked" + +# --- Codex apply_patch hook 配線(2026-07-16) --- +echo "CX-1/5 Codex apply_patch で未承認 docs/prd 新規 -> deny" +assert_denied "$(run_apply_patch_hook $'*** Begin Patch\n*** Add File: docs/prd/codex-next.md\n+new\n*** End Patch')" "Codex apply_patch unauthorized docs" + +echo "CX-2/5 Codex apply_patch で docs/.ssot-allowlist 更新 -> deny" +assert_denied "$(run_apply_patch_hook $'*** Begin Patch\n*** Update File: docs/.ssot-allowlist\n@@\n+prd/codex-next.md\n*** End Patch')" "Codex apply_patch allowlist self-approval" + +echo "CX-3/5 Codex apply_patch で既存 docs/prd 更新 -> allow" +assert_allowed "$(run_apply_patch_hook $'*** Begin Patch\n*** Update File: docs/prd/prd-active.md\n@@\n+updated\n*** End Patch')" "Codex apply_patch existing docs" + +echo "CX-4/5 Codex apply_patch で src 新規 -> allow" +assert_allowed "$(run_apply_patch_hook $'*** Begin Patch\n*** Add File: src/codex.ts\n+export {};\n*** End Patch')" "Codex apply_patch non-docs" + +echo "CX-5/5 Codex apply_patch の対象欠損 -> deny" +assert_denied "$(run_hook_raw '{"tool_name":"apply_patch","tool_input":{}}')" "Codex apply_patch missing target" + +CODEX_HOOKS_JSON="$(cd "$SCRIPT_DIR/../.." && pwd)/hooks.json" +if [ -f "$CODEX_HOOKS_JSON" ]; then + echo "CX-REG Codex hooks.json で cross-runtime matcher 配線済み -> pass" + python3 - "$CODEX_HOOKS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + hooks = json.load(handle).get("hooks", {}).get("PreToolUse", []) +expected_tools = {"Bash", "Edit", "MultiEdit", "Shell", "StrReplaceFile", "Write", "WriteFile"} +registered = any( + expected_tools.issubset(set(entry.get("matcher", "").split("|"))) + and any( + "block-unauthorized-docs-file.sh" in hook.get("command", "") + for hook in entry.get("hooks", []) + ) + for entry in hooks +) +if not registered: + raise SystemExit("Codex hooks.json lacks the cross-runtime docs guard matcher set") +PY +fi + +echo "block-unauthorized-docs-file hook tests passed" diff --git a/.gemini/hooks/scripts/freshness-gate.sh b/.gemini/hooks/scripts/freshness-gate.sh new file mode 100755 index 000000000..7359fb42a --- /dev/null +++ b/.gemini/hooks/scripts/freshness-gate.sh @@ -0,0 +1,266 @@ +#!/bin/bash + +# [2026-03-03][feat] +# 背景: 77スキル中2つだけ日付マーカーあり。サブエージェントはコピー時スナップショット。 +# 手動チェックは現実的に不可能なため、SessionStart hookで毎セッション自動検出が必要。 +# staleness_check.sh(skill-organizer)は手動実行のみだった。skill-audit は 2026-07-13 に +# 正式スキル化(skills/skill-audit/)し、単一スキルの契約遵守を三値判定する。 +# 対応: SessionStart hookで軽量鮮度チェックを実行。 +# (1) hookバージョン差分 (2) スキル鮮度 (3) 依存バージョン乖離を検出。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「鮮度チェックが安全に動作し、監査時に迂回経路を残さないこと」。 +# 業務ルールとして、フック内で外部入力(ファイルパス)をコード文字列に直埋めしてはならない。 +# 代替案としてPythonワンライナーへパスを直接埋め込む実装を維持すると、 +# 特殊文字を含むパスで任意コード実行に繋がるため不採用。 +# 対応: Python呼び出しを引数渡しへ変更し、文字列埋め込みを廃止。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOKS_DIR="$SCRIPT_DIR/.." + +extract_last_verified() { + local skill_md="$1" + python3 - "$skill_md" <<'PY' 2>/dev/null || true +import re +import sys +from pathlib import Path + +skill_path = Path(sys.argv[1]) +try: + text = skill_path.read_text(encoding='utf-8') +except Exception: + print('') + raise SystemExit(0) + +m = re.search(r'last_verified:\s*(\d{4}-\d{2}-\d{2})', text) +print(m.group(1) if m else '') +PY +} + +extract_interval_days() { + local skill_md="$1" + python3 - "$skill_md" <<'PY' 2>/dev/null || echo "60" +import re +import sys +from pathlib import Path + +skill_path = Path(sys.argv[1]) +try: + text = skill_path.read_text(encoding='utf-8') +except Exception: + print('60') + raise SystemExit(0) + +m = re.search(r'interval_days:\s*(\d+)', text) +print(m.group(1) if m else '60') +PY +} + +# --- hookバージョンチェック --- +check_hook_version() { + local version_file="$HOOKS_DIR/.hook-library-version" + local agent_hub_version_file + + # AGENT-HUBのパスを環境変数またはデフォルトから取得 + local agent_hub_path="${AGENT_HUB_PATH:-$HOME/business/AGENT-HUB}" + agent_hub_version_file="$agent_hub_path/hook-library/VERSION" + + if [ ! -f "$version_file" ]; then + echo " - hook-library: バージョン情報なし(未デプロイ or 旧形式)" >&2 + return + fi + + local deployed_version + deployed_version="$(head -1 "$version_file" | sed 's/^v//' | cut -d' ' -f1)" + + if [ -f "$agent_hub_version_file" ]; then + local latest_version + latest_version="$(cat "$agent_hub_version_file" | tr -d '[:space:]')" + + if [ "$deployed_version" != "$latest_version" ]; then + echo " - hook-library: v${latest_version} が利用可能です(現在 v${deployed_version})" >&2 + fi + fi +} + +# --- スキル鮮度チェック --- +check_skill_freshness() { + local skills_dir + + # CLAUDE_PROJECT_DIR が設定されていればそのプロジェクトのスキルをチェック + if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then + skills_dir="$CLAUDE_PROJECT_DIR/.claude/skills" + else + skills_dir="$(pwd)/.claude/skills" + fi + + if [ ! -d "$skills_dir" ]; then + return + fi + + local today_epoch + today_epoch=$(date +%s) + local stale_skills="" + + # 各スキルのSKILL.mdからlast_verifiedを抽出 + for skill_dir in "$skills_dir"/*/; do + [ -d "$skill_dir" ] || continue + local skill_md="$skill_dir/SKILL.md" + [ -f "$skill_md" ] || continue + + local skill_name + skill_name="$(basename "$skill_dir")" + + local last_verified + last_verified="$(extract_last_verified "$skill_md")" + + if [ -z "$last_verified" ]; then + continue # last_verified未設定のスキルはスキップ(Phase 2で順次追加) + fi + + # 経過日数を計算 + local verified_epoch + verified_epoch=$(date -j -f "%Y-%m-%d" "$last_verified" +%s 2>/dev/null || date -d "$last_verified" +%s 2>/dev/null || echo "0") + + if [ "$verified_epoch" = "0" ]; then + continue + fi + + local days_ago=$(( (today_epoch - verified_epoch) / 86400 )) + + # freshness_check.interval_days を取得(デフォルト60日) + local interval + interval="$(extract_interval_days "$skill_md")" + + if [ "$days_ago" -gt "$interval" ]; then + stale_skills="$stale_skills\n - ${skill_name}: ${days_ago}日前(閾値: ${interval}日)" + fi + done + + if [ -n "$stale_skills" ]; then + echo -e " スキル鮮度:$stale_skills" >&2 + fi +} + +# [2026-05-21][feat] / [2026-05-25][refactor] +# 背景: +# - ユーザー依頼意図: 大原則 A「PWAを消して再登録は絶対にしない」と大原則 B「ネイティブアプリ模倣」の +# SSOT 文書が欠落している場合に、SessionStart 時に警告して AI セッションへ必読を促す。 +# 2026-05-25: jtt-apps ローカル限定だった本チェックを hook-library 正本へ upstream +# (/insights deep-check の --diff で「full deploy 時に jtt-apps から消える」ローカル限定実装と判明したため)。 +# - 守るべき業務ルール: SessionStart hook は常に exit 0(ブロックしない、情報提供のみ)。 +# hook-library は複数 PJ で共有されるため、PWA プロジェクト(public/sw.js または public/manifest.json を持つ) +# でのみ発火し、非 PWA PJ(jtt-cms 等)では誤警告させない。 +# - 他案不採用理由: +# 1) Stop hook で AI 最終出力を grep する案は false positive リスクが高すぎる +# (正当な「キャッシュクリア」言及まで誤ブロック)ため不採用。 +# 2) jtt-apps 限定の無条件チェックのまま据え置く案は、full deploy で hook-library 版に巻き戻り +# check_pwa_principles が消えるため不採用(2026-05-25 の deploy --diff で検出)。 +# 3) 無条件で全 PJ に配布する案は、PWA を持たない PJ で毎セッション誤警告を出すため不採用。 +# PWA 検出ゲートで発火対象を PWA PJ に限定する。 +# 対応: PWA 検出(public/sw.js または public/manifest.json)でゲートし、検出時のみ +# PWA_OPERATION_PRINCIPLE.md / PWA_NATIVE_APP_PARITY_RULE.md の存在を確認。メッセージは PJ 非依存化。 +# --- PWA 大原則 SSOT 存在確認(PWA プロジェクトのみ) --- +check_pwa_principles() { + local project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" + + # PWA プロジェクト判定: service worker または manifest を持つ場合のみ発火(非 PWA PJ では誤爆させない) + if [ ! -f "$project_dir/public/sw.js" ] && [ ! -f "$project_dir/public/manifest.json" ]; then + return + fi + + local pwa_op_principle="$project_dir/.claude/rules/general/PWA_OPERATION_PRINCIPLE.md" + local pwa_parity_rule="$project_dir/.claude/rules/general/PWA_NATIVE_APP_PARITY_RULE.md" + + if [ ! -f "$pwa_op_principle" ]; then + echo " ⚠️ PWA_OPERATION_PRINCIPLE.md (.claude/rules/general/) が存在しません。PWA 運用大原則 A (「PWAを消して再登録は絶対にしない」) の SSOT が欠落しています。" >&2 + fi + + if [ ! -f "$pwa_parity_rule" ]; then + echo " ⚠️ PWA_NATIVE_APP_PARITY_RULE.md (.claude/rules/general/) が存在しません。PWA 大原則 B (「ネイティブアプリ模倣」) の SSOT が欠落しています。" >&2 + fi +} + +# [2026-08-02][feat] ローカル main の behind をセッション開始時に警告する(issue #1327)。 +# 背景: +# - ユーザー依頼意図: セッション開始時のシステムプロンプトにはローカルの git log が載るため、 +# AI が「最新」と誤認して古いベースにコミットを積み、push 拒否 → worktree 作り直し → +# 幽霊 hook 誤爆(#1230 と重複)の手戻り連鎖が実測された(2026-08-02 jtt-cafe-pj)。 +# `git fetch` を1回打っていれば全て回避できたため、SessionStart で機械化する。 +# - 守るべき業務ルール: 警告のみで block しない(SessionStart は情報提供・常に exit 0)。 +# オフライン・認証不能・遅延時は fail-open(既存チェックと同じ精神)。 +# macOS 標準に GNU timeout が無いため bg + poll + kill で上限を実装し、 +# GIT_TERMINAL_PROMPT=0 / ssh BatchMode で認証プロンプトの hang を封じる。 +# - 他案不採用理由: PreToolUse(add/commit 時)検知の案 B は、警告が作業途中に割り込み +# ベース選択の時点(worktree 作成)に間に合わない。システムプロンプト側への ahead/behind +# 併記(案 C)は Claude Code 本体の変更で当方から変更不能。 +check_main_behind() { + local repo_root behind fetch_pid waited + repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 + git -C "$repo_root" rev-parse --verify -q refs/heads/main >/dev/null 2>&1 || return 0 + git -C "$repo_root" remote get-url origin >/dev/null 2>&1 || return 0 + ( + export GIT_TERMINAL_PROMPT=0 + export GIT_SSH_COMMAND="ssh -oBatchMode=yes -oConnectTimeout=3" + exec git -C "$repo_root" fetch -q origin "+refs/heads/main:refs/remotes/origin/main" + ) >/dev/null 2>&1 & + fetch_pid=$! + waited=0 + while kill -0 "$fetch_pid" 2>/dev/null; do + if [ "$waited" -ge 50 ]; then + # 5秒(0.1s x 50)で fetch を打ち切り fail-open(オフライン・低速回線) + kill "$fetch_pid" 2>/dev/null || true + wait "$fetch_pid" 2>/dev/null || true + return 0 + fi + sleep 0.1 + waited=$((waited + 1)) + done + wait "$fetch_pid" 2>/dev/null || return 0 + behind="$(git -C "$repo_root" rev-list --count main..origin/main 2>/dev/null)" || return 0 + case "$behind" in ''|*[!0-9]*) return 0 ;; esac + if [ "$behind" -gt 0 ]; then + echo " ⚠ ローカル main が origin/main より ${behind} コミット遅れています(fetch 実行済み)。" >&2 + echo " 冒頭の Recent commits はローカル基準です。古いベースへのコミットを避けるため、" >&2 + echo " worktree / branch は origin/main から作成してください。" >&2 + fi + return 0 +} + +# --- メイン実行 --- +main() { + local warnings="" + + # 一時ファイルで警告を収集 + local tmp_file + tmp_file=$(mktemp) + trap "rm -f '$tmp_file'" EXIT + + check_hook_version 2>"$tmp_file" + warnings="$(cat "$tmp_file")" + + check_skill_freshness 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + check_pwa_principles 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + check_main_behind 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + if [ -n "$warnings" ]; then + echo "" >&2 + echo "🔍 [freshness-gate] 鮮度チェック結果:" >&2 + echo "$warnings" >&2 + echo "" >&2 + echo " 詳細: skills/skill-audit の audit_skill.py または staleness_check.sh で確認してください" >&2 + echo "" >&2 + fi + + # SessionStart hookは常にexit 0(ブロックしない、情報提供のみ) + exit 0 +} + +main diff --git a/.gemini/hooks/scripts/gemini-hook-bridge.py b/.gemini/hooks/scripts/gemini-hook-bridge.py new file mode 100755 index 000000000..5d19aa28c --- /dev/null +++ b/.gemini/hooks/scripts/gemini-hook-bridge.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +import json +import os +import subprocess +import sys +from pathlib import Path + +RUNTIME_SPEC = { 'before': { 'generic_hooks': [ 'block-destructive-git.sh', + 'block-main-commit.sh', + 'post-merge-gate.sh', + 'storage-url-pr-gate.sh'], + 'pre_pr_check_on_git_push': False}, + 'after': { 'write_edit': { 'auto_catalog': False, + 'check_frontmatter': False, + 'validate_skills': False, + 'validate_ssot': False, + 'validate_prompt_ssot': False}}, + 'agent': {'pre_implementation_check': True}} +PROJECT_ROOT = Path(os.environ.get("GEMINI_PROJECT_DIR", ".")).resolve() + + +def read_payload(): + raw = sys.stdin.read() + if not raw.strip(): + return {} + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def write_json(payload): + sys.stdout.write(json.dumps(payload, ensure_ascii=False)) + + +def run_process(command, args, *, stdin_text="", env=None, cwd=None): + merged_env = os.environ.copy() + if env: + merged_env.update(env) + return subprocess.run( + [command, *args], + input=stdin_text, + text=True, + capture_output=True, + cwd=str(cwd or PROJECT_ROOT), + env=merged_env, + ) + + +def combined_output(result): + return "\n".join(part.strip() for part in [result.stdout, result.stderr] if part.strip()).strip() + + +def parse_claude_deny(output_text): + if not output_text.strip(): + return None + try: + payload = json.loads(output_text) + except json.JSONDecodeError: + return None + hook_output = payload.get("hookSpecificOutput", {}) + if not isinstance(hook_output, dict): + return None + if hook_output.get("permissionDecision") == "deny": + return str(hook_output.get("permissionDecisionReason") or hook_output.get("reason") or "blocked by Claude-compatible hook") + return None + + +def extract_path(tool_input): + if not isinstance(tool_input, dict): + return "" + for key in ("file_path", "path", "filePath", "absolute_path", "absolutePath"): + value = tool_input.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def run_generate_catalog(target_path): + if not RUNTIME_SPEC["after"]["write_edit"]["auto_catalog"]: + return + normalized = str(target_path or "") + if normalized == "CLAUDE.md" or normalized.startswith(".claude/rules/"): + result = run_process("bash", [str(PROJECT_ROOT / "scripts" / "generate-agents-md.sh")]) + if result.returncode != 0: + raise RuntimeError(combined_output(result) or "generate-agents-md.sh failed") + elif normalized.startswith("skills/") and normalized.endswith("/SKILL.md"): + result = run_process("bash", [str(PROJECT_ROOT / "scripts" / "generate-catalog.sh")]) + if result.returncode != 0: + raise RuntimeError(combined_output(result) or "generate-catalog.sh failed") + + +def validate_path(target_path): + normalized = str(target_path or "") + if not normalized: + return + + run_generate_catalog(normalized) + + shared_env = { + "CLAUDE_FILE_PATH": normalized, + "GEMINI_FILE_PATH": normalized, + "CLAUDE_PROJECT_DIR": str(PROJECT_ROOT), + "GEMINI_PROJECT_DIR": str(PROJECT_ROOT), + } + + if RUNTIME_SPEC["after"]["write_edit"]["check_frontmatter"] and re_matches("skills/.*/SKILL\\.md$", normalized): + run_or_raise("python3", [str(PROJECT_ROOT / "scripts" / "check_frontmatter.py"), "--warn", normalized], shared_env) + if RUNTIME_SPEC["after"]["write_edit"]["validate_skills"] and re_matches("skills/.*/SKILL\\.md$", normalized): + run_or_raise("bash", [str(PROJECT_ROOT / "scripts" / "validate-skills.sh"), "--warn", normalized], shared_env) + if RUNTIME_SPEC["after"]["write_edit"]["validate_ssot"] and re_matches("(DISTRIBUTION\\.yaml|project-registry\\.yaml|hook-registry\\.yaml|agents\\.yaml)$", normalized): + run_or_raise("bash", [str(PROJECT_ROOT / "scripts" / "validate-ssot-consistency.sh")], shared_env) + if RUNTIME_SPEC["after"]["write_edit"]["validate_prompt_ssot"] and re_matches("snippet-prompts/.*\\.md$", normalized): + run_or_raise("bash", [str(PROJECT_ROOT / "scripts" / "validate-prompt-ssot-consistency.sh"), "--warn", normalized], shared_env) + + +def run_or_raise(command, args, env): + result = run_process(command, args, env=env) + if result.returncode != 0: + raise RuntimeError(combined_output(result) or f"{command} failed") + + +def re_matches(pattern, value): + import re + return re.search(pattern, value) is not None + + +def get_tool_input(payload): + # [2026-06-28][fix] + # 背景: Antigravity/Kimi は camelCase toolInput を送ることがあり、snake_case だけだと + # command が空になって main 保護 hook が fail-open する。逆に snake_case がある場合に + # camelCase が上書きして危険なコマンドを消せると fail-closed 要件を満たせない。 + # 他案不採用理由: + # - tool_input と toolInput の単純な camelCase 優先 / last-writer-wins 方針は、片側の + # 安全な値で危険コマンドを隠せるため不採用(fail-closed 破壊)。 + # 守るべき業務ルール: main 直書き系操作はどの CLI 派生でも fail-closed。 + # 対応: tool_input/toolInput を正規化しつつ、command は両値を失わず結合して返す。 + value = payload.get("tool_input", {}) + camel = payload.get("toolInput", {}) + if not isinstance(value, dict): + value = {} + if not isinstance(camel, dict): + camel = {} + merged = {} + merged.update(value) + merged.update(camel) + + snake_command = value.get("command") + camel_command = camel.get("command") + commands = [] + for command in (snake_command, camel_command): + if isinstance(command, str): + command = command.strip() + if command and command not in commands: + commands.append(command) + if commands and (snake_command is not None or camel_command is not None): + merged["command"] = " && ".join(commands) + + if ( + isinstance(payload, dict) + and "tool_input" not in payload + and "toolInput" not in payload + ): + for key in ("file_path", "path", "filePath", "absolute_path", "absolutePath"): + value = payload.get(key) + if isinstance(value, str) and value: + merged[key] = value + + return merged + + +def get_shell_tool_inputs(payload): + # [2026-07-02][fix] + # 背景: tool_input と toolInput を merge した 1 入力だけで検査すると、command は結合される一方で + # cwd は last-writer-wins になり、main 上の commit が別 worktree の cwd として判定される。 + # 守るべき業務ルール: command/cwd の組み合わせを失わず、曖昧な混在 payload は fail-closed。 + # 対応: snake/camel の個別入力に加え、存在する command と cwd の組み合わせを検査候補にする。 + candidates = [] + if not isinstance(payload, dict): + return [{"command": "", "cwd": str(PROJECT_ROOT)}] + + wrappers = [] + for key in ("tool_input", "toolInput"): + value = payload.get(key) + if isinstance(value, dict): + wrappers.append(value) + + if not wrappers: + tool_input = get_tool_input(payload) + return [tool_input] if isinstance(tool_input, dict) else [{"command": "", "cwd": str(PROJECT_ROOT)}] + + commands = [] + cwds = [] + for wrapper in wrappers: + command = wrapper.get("command") + if isinstance(command, str): + command = command.strip() + if command and command not in commands: + commands.append(command) + cwd = wrapper.get("cwd") + if isinstance(cwd, str): + cwd = cwd.strip() + if cwd and cwd not in cwds: + cwds.append(cwd) + candidates.append(dict(wrapper)) + + for command in commands: + for cwd in cwds or [str(PROJECT_ROOT)]: + candidates.append({"command": command, "cwd": cwd}) + + deduped = [] + seen = set() + for candidate in candidates: + marker = json.dumps(candidate, sort_keys=True, ensure_ascii=False) + if marker in seen: + continue + seen.add(marker) + deduped.append(candidate) + return deduped or [{"command": "", "cwd": str(PROJECT_ROOT)}] + + +def get_tool_inputs_for_path_validation(payload): + candidates = [] + if not isinstance(payload, dict): + return candidates + + for key in ("tool_input", "toolInput"): + value = payload.get(key) + if isinstance(value, dict): + candidates.append(value) + if not candidates: + tool_input = get_tool_input(payload) + if isinstance(tool_input, dict): + return [tool_input] + return candidates + + +def resolve_hook_script(name): + candidates = [ + Path(__file__).resolve().parent / name, + PROJECT_ROOT / ".claude" / "hooks" / "scripts" / name, + PROJECT_ROOT / "hook-library" / "scripts" / name, + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +def handle_before_tool_run_shell(): + payload = read_payload() + for tool_input in get_shell_tool_inputs(payload): + command_text = str(tool_input.get("command", "")) + cwd = Path(str(tool_input.get("cwd", PROJECT_ROOT))).resolve() + claude_payload = json.dumps({"tool_input": {"command": command_text, "cwd": str(cwd)}}, ensure_ascii=False) + + for hook_name in RUNTIME_SPEC["before"]["generic_hooks"]: + result = run_process( + "bash", + [str(resolve_hook_script(hook_name))], + stdin_text=claude_payload, + env={ + "CLAUDE_PROJECT_DIR": str(PROJECT_ROOT), + "GEMINI_PROJECT_DIR": str(PROJECT_ROOT), + }, + cwd=cwd, + ) + deny_reason = parse_claude_deny(result.stdout) + if deny_reason: + write_json({"decision": "deny", "reason": deny_reason}) + return + if result.returncode != 0: + write_json({"decision": "deny", "reason": combined_output(result) or f"{hook_name} failed"}) + return + + if RUNTIME_SPEC["before"]["pre_pr_check_on_git_push"] and "git push" in command_text: + result = run_process( + "bash", + [str(PROJECT_ROOT / "scripts" / "pre-pr-check.sh")], + env={ + "CLAUDE_PROJECT_DIR": str(PROJECT_ROOT), + "GEMINI_PROJECT_DIR": str(PROJECT_ROOT), + }, + cwd=cwd, + ) + if result.returncode != 0: + write_json({"decision": "deny", "reason": combined_output(result) or "pre-pr-check failed"}) + return + + write_json({}) + + +def handle_after_tool_write_edit(): + payload = read_payload() + for tool_input in get_tool_inputs_for_path_validation(payload): + validate_path(extract_path(tool_input)) + write_json({}) + + +def handle_before_agent_prompt(): + if not RUNTIME_SPEC["agent"]["pre_implementation_check"]: + write_json({}) + return + + result = run_process( + "bash", + [str(resolve_hook_script("pre-implementation-check.sh"))], + env={ + "CLAUDE_PROJECT_DIR": str(PROJECT_ROOT), + "GEMINI_PROJECT_DIR": str(PROJECT_ROOT), + }, + ) + if result.returncode != 0: + write_json({"decision": "deny", "reason": combined_output(result) or "pre-implementation-check failed"}) + return + + additional_context = result.stdout.strip() + if additional_context: + write_json({"hookSpecificOutput": {"additionalContext": additional_context}}) + return + + write_json({}) + + +def main(): + mode = sys.argv[1] if len(sys.argv) > 1 else "" + try: + if mode == "before_tool_run_shell": + handle_before_tool_run_shell() + elif mode == "after_tool_write_edit": + handle_after_tool_write_edit() + elif mode == "before_agent_prompt": + handle_before_agent_prompt() + else: + write_json({}) + except Exception as exc: # noqa: BLE001 + write_json({"decision": "deny", "reason": str(exc)}) + + +if __name__ == "__main__": + main() diff --git a/.gemini/hooks/scripts/handover-preflight.sh b/.gemini/hooks/scripts/handover-preflight.sh new file mode 100755 index 000000000..df4a9af71 --- /dev/null +++ b/.gemini/hooks/scripts/handover-preflight.sh @@ -0,0 +1,353 @@ +#!/bin/bash +# UserPromptSubmit hook for Handover hints. +# Quiet by default. Prints only when the prompt asks for +# "続き", "引き継ぎ書つくって", "引き継ぎ", "作業終了", "終了整理", "Closeout整理", +# "ふり返り", "振り返り", "ふりかえり", +# "handover", compatibility "takeover", or when HANDOVER_PREFLIGHT_FORCE=1 is set. +# +# [2026-06-30][refactor] +# 背景: +# - ユーザー依頼意図: ユーザー向けの引き継ぎ名を Takeover から Handover へ寄せ、 +# plan / Typinator / hook の入口名を揃えたい。 +# - 守るべき業務ルール: 旧 `takeover` / `continuation` 発話、旧 env、旧 +# `~/.agent-hub/takeovers` の保存済みデータは壊さず、互換入口として残す。 +# - 他案不採用理由: 旧 hook を即削除する案は既存 settings の command を壊す。 +# 新旧を同格にする案は正本名が再び揺れるため不採用。 +# 対応: `handover-preflight` を正本にし、旧 `takeover-preflight` は wrapper から本ファイルを呼ぶ。 + +set -euo pipefail + +RAW_INPUT="$(cat || true)" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +HOOK_INPUT="$RAW_INPUT" command python3 - "$PROJECT_DIR" <<'PY' +import json +import os +import re +import sys +from pathlib import Path + +project_dir = Path(sys.argv[1]).resolve() +raw = os.environ.get("HOOK_INPUT", "") + + +def prompt_from_payload(text: str) -> str: + if not text.strip(): + return "" + try: + payload = json.loads(text) + except Exception: + return text + if not isinstance(payload, dict): + return "" + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str): + return value + nested = payload.get("tool_input") + if isinstance(nested, dict): + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = nested.get(key) + if isinstance(value, str): + return value + return "" + + +def unquote_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def candidate_alias_paths() -> list[Path]: + paths: list[Path] = [] + env_path = os.environ.get("HANDOVER_ALIASES_PATH", "").strip() + if env_path: + paths.append(Path(env_path).expanduser()) + compat_env = os.environ.get("TAKEOVER_ALIASES_PATH", "").strip() + if compat_env: + paths.append(Path(compat_env).expanduser()) + legacy_env = os.environ.get("AGENT_MEMORY_ALIASES_PATH", "").strip() + if legacy_env: + paths.append(Path(legacy_env).expanduser()) + paths.append(project_dir / "agent-memory" / "aliases.yaml") + paths.append(Path("/Users/shintaro/business/AGENT-HUB/agent-memory/aliases.yaml")) + return paths + + +def load_apps(aliases_path: Path) -> list[dict[str, object]]: + apps: list[dict[str, object]] = [] + current_project: str | None = None + current: dict[str, object] | None = None + in_aliases = False + + for raw_line in aliases_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].rstrip() + if not line.strip(): + continue + + project_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if project_match: + current_project = project_match.group(1) + current = None + in_aliases = False + continue + + app_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if app_match and current_project: + current = { + "project": current_project, + "canonical_name": app_match.group(1), + "aliases": [], + } + apps.append(current) + in_aliases = False + continue + + if current is None: + continue + + kv_match = re.match(r"^ ([A-Za-z0-9_]+):\s*(.*)$", line) + if kv_match: + key = kv_match.group(1) + value = unquote_scalar(kv_match.group(2)) + in_aliases = key == "aliases" + if key != "aliases": + current[key] = value + continue + + alias_match = re.match(r"^ -\s*(.+?)\s*$", line) + if in_aliases and alias_match: + aliases = current.setdefault("aliases", []) + if isinstance(aliases, list): + aliases.append(unquote_scalar(alias_match.group(1))) + + return apps + + +def find_aliases_path() -> Path | None: + for path in candidate_alias_paths(): + if path.is_file(): + return path + return None + + +TRIGGER_RE = re.compile( + r"(続き|終了整理|Closeout整理|ふり返り|振り返り|ふりかえり|引継ぎ書つくって|引き継ぎ|作業終了|handover|takeover|continuation|continuation-closeout)", + re.IGNORECASE, +) +NEGATED_CONTINUATION_RE = re.compile( + r"続き\s*(?:ではなくて|ではなく|ではない|でなく|でない|じゃなくて|じゃなく|じゃない|" + r"はなく|はない|は不要|不要|はいらない|いらない|なく|ない)" +) +NEGATED_CLOSEOUT_KEYWORDS = ("終了整理", "Closeout整理", "ふり返り", "振り返り", "ふりかえり", "作業終了") +NEGATED_CLOSEOUT_SUFFIXES = ( + "ではない", + "ではないです", + "ではなく", + "ではなくて", + "でない", + "でないです", + "じゃない", + "じゃないです", + "はない", + "はいらない", + "は不要", + "必要ない", + "不要", + "要らない", + "いらない", + "ない", +) +NEGATED_PUNCTUATION = re.compile(r"[\s、。.!?!?ー−‐\\-]") +MAX_ALIAS_TRIGGER_DISTANCE = 32 + + +def normalize_for_negation(value: str) -> str: + return NEGATED_PUNCTUATION.sub("", value.casefold()) + + +def is_negated_closeout_trigger(prompt: str, trigger: str) -> bool: + folded = normalize_for_negation(prompt) + normalized_trigger = normalize_for_negation(trigger) + index = 0 + while True: + index = folded.find(normalized_trigger, index) + if index < 0: + return False + tail = folded[index + len(normalized_trigger):] + for suffix in NEGATED_CLOSEOUT_SUFFIXES: + if tail.startswith(normalize_for_negation(suffix)): + return True + index += len(normalized_trigger) + + +def positive_trigger_spans(prompt: str) -> list[tuple[int, int]]: + spans: list[tuple[int, int]] = [] + for match in TRIGGER_RE.finditer(prompt): + tail = prompt[match.start() : match.end() + 12] + if match.group(1) == "続き" and NEGATED_CONTINUATION_RE.match(tail): + continue + if match.group(1) in NEGATED_CLOSEOUT_KEYWORDS and is_negated_closeout_trigger(prompt, match.group(1)): + continue + spans.append(match.span()) + return spans + + +def alias_near_trigger(prompt: str, name: str, trigger_spans: list[tuple[int, int]]) -> bool: + if not name: + return False + for name_match in re.finditer(re.escape(name), prompt, flags=re.IGNORECASE): + for trigger_start, trigger_end in trigger_spans: + if name_match.end() <= trigger_start: + distance = trigger_start - name_match.end() + else: + distance = name_match.start() - trigger_end + if 0 <= distance <= MAX_ALIAS_TRIGGER_DISTANCE: + return True + return False + + +def matched_app(prompt: str, apps: list[dict[str, object]]) -> dict[str, object] | None: + folded_prompt = prompt.casefold() + trigger_spans = positive_trigger_spans(prompt) + for app in apps: + names: list[str] = [] + for key in ("canonical_name", "display_name"): + value = app.get(key) + if isinstance(value, str): + names.append(value) + aliases = app.get("aliases") + if isinstance(aliases, list): + names.extend(str(alias) for alias in aliases) + + for name in names: + if trigger_spans and alias_near_trigger(prompt, name, trigger_spans): + return app + if name and name.casefold() in folded_prompt: + return app + return None + + +def project_from_cwd(path: Path) -> str: + if (path / "DISTRIBUTION.yaml").is_file() and (path / "hook-registry.yaml").is_file(): + return "AGENT-HUB" + text = str(path) + checks = [ + ("AGENT-HUB", "/AGENT-HUB"), + ("jtt-system", "/jtt-system"), + ("jtt-apps", "/jtt-apps"), + ("jtt-cms", "/jtt-cms"), + ("jtt-cafe-pj", "/jtt-cafe-pj"), + ("hermes", "/mac-mini-server/hermes"), + ] + for project, marker in checks: + if marker in text: + return project + if (path / "pnpm-workspace.yaml").is_file() and (path / "apps").is_dir(): + return "jtt-system" + return "non-pj" + + +def scope_from_cwd(project: str, path: Path) -> str: + parts = path.parts + if project == "jtt-system" and "apps" in parts: + idx = parts.index("apps") + if idx + 1 < len(parts): + return parts[idx + 1] + if project == "AGENT-HUB": + for marker in ("skills", "hook-library", "snippet-prompts", "agent-memory"): + if marker in parts: + idx = parts.index(marker) + if idx + 1 < len(parts): + return parts[idx + 1] + return marker + return "root" + + +def handover_path(project: str, scope: str) -> str: + return str(Path.home() / ".agent-hub" / "handovers" / project / scope / "current.md") + + +def legacy_path(project: str, scope: str) -> str: + return str(Path.home() / ".agent-hub" / "takeovers" / project / scope / "current.md") + + +PROJECT_CLAUDE_MEMORY_PATHS = { + "AGENT-HUB": "-Users-shintaro-business-AGENT-HUB", + "bank-payment-automator": "-Users-shintaro-business-bank-payment-automator", + "hermes": "-Users-shintaro-mac-mini-server-hermes", + "jtt-apps": "-Users-shintaro-Herd-jtt-apps", + "jtt-cafe-pj": "-Users-shintaro-business-jtt-cafe-pj", + "jtt-cms": "-Users-shintaro-LLM-Dev-jtt-cms", + "jtt-system": "-Users-shintaro-jtt-system", +} + + +def claude_memory_path(project: str, app: dict[str, object] | None) -> str: + if app is not None: + configured = app.get("claude_memory_path") + if isinstance(configured, str) and configured: + return configured + encoded = PROJECT_CLAUDE_MEMORY_PATHS.get(project) + if not encoded: + return "未登録" + return str(Path.home() / ".claude" / "projects" / encoded / "memory" / "MEMORY.md") + + +def print_hint(app: dict[str, object] | None, forced: bool) -> None: + manual_path = "skills/handover-manual/references/handover.md" + reflection_path = "agent-memory/registry/reflection-policy.md" + placement_path = "agent-memory/registry/placement-policy.md" + + if app is not None: + project = str(app.get("project") or project_from_cwd(project_dir)) + scope = str(app.get("canonical_name") or scope_from_cwd(project, project_dir)) + display = app.get("display_name") or scope + else: + project = project_from_cwd(project_dir) + scope = scope_from_cwd(project, project_dir) + display = scope + + print("handover preflight:") + print(f"- scope: {project}/{scope}") + print(f"- handover_path: {handover_path(project, scope)}") + print(f"- legacy_path: {legacy_path(project, scope)}") + print(f"- claude_memory: {claude_memory_path(project, app)}") + print(f"- manual: {manual_path}") + print(f"- reflection-policy: {reflection_path}") + print(f"- placement-policy: {placement_path}") + # [2026-07-18][fix] + # 背景: closeoutでPJ固有の短期状態までGBrain候補に混ざり、人間の判断原則と技術台帳の境界が曖昧だった。 + # 守るべき業務ルール: GBrain候補はユーザーしか判断できない原則へ抽象化し、技術/PJ情報はTech GBrainかSSOTへ置く。 + # 他案不採用理由: 候補を全件GBrainへ送る案は確認負荷と重複を増やすため不採用。 + print("- closeout: 未完了 / 次回やること / Tech G-Brain候補 / GBrain候補 / SSOT昇格候補を分ける") + print("- gbrain: 技術名・PJ固有名・短期状態は候補にせず、人間の判断原則へ抽象化") + print("- handover_update: 未完了がある時だけ current.md を更新") + if forced and app is None: + print("- alias: 未検出。cwdから推定") + elif app is not None: + print(f"- app: {display}") + + +prompt = prompt_from_payload(raw) +forced = ( + os.environ.get("HANDOVER_PREFLIGHT_FORCE", "0") == "1" + or os.environ.get("TAKEOVER_PREFLIGHT_FORCE", "0") == "1" + or os.environ.get("AGENT_MEMORY_PREFLIGHT_FORCE", "0") == "1" +) + +if not forced and not positive_trigger_spans(prompt): + raise SystemExit(0) + +aliases_path = find_aliases_path() +app = None +if aliases_path is not None: + try: + app = matched_app(prompt, load_apps(aliases_path)) + except Exception: + app = None + +print_hint(app, forced) +PY diff --git a/.gemini/hooks/scripts/handover-preflight.test.sh b/.gemini/hooks/scripts/handover-preflight.test.sh new file mode 100755 index 000000000..0dd3f95e1 --- /dev/null +++ b/.gemini/hooks/scripts/handover-preflight.test.sh @@ -0,0 +1,156 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +fi +HOOK="$SCRIPT_DIR/handover-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +extract_field() { + printf "%s\n" "$1" | sed -n "s/^-[[:space:]]*$2: //p" +} + +is_agent_hub_source_repo() { + [ -f "$REPO_ROOT/DISTRIBUTION.yaml" ] && [ -f "$REPO_ROOT/hook-registry.yaml" ] +} + +assert_exact_scope() { + local output="$1" + local expected="$2" + local scope + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + [ "$scope" = "$expected" ] || fail "scope が期待値と一致しない: $output" +} + +assert_scoped_path() { + local output="$1" + local category="$2" + local scope + local expected + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + expected="$HOME/.agent-hub/$category/$scope/current.md" + printf "%s\n" "$output" | grep -Fq "$expected" \ + || fail "$category が scope と一致しない: $output" +} + +assert_claude_memory_path() { + local output="$1" + local marker="$2" + local memory_path + + memory_path="$(extract_field "$output" "claude_memory")" + [ -n "$memory_path" ] || fail "claude_memory が取得できない: $output" + case "$memory_path" in + *"/.claude/projects/"*"/memory/MEMORY.md") + : + ;; + *) + fail "claude_memory の形式が想定外: $memory_path" + ;; + esac + case "$memory_path" in + *"$marker"*) + : + ;; + *) + fail "claude_memory が期待するPJを示していない: $memory_path" + ;; + esac +} + +run_hook() { + local prompt="$1" + local project_dir="${2:-$REPO_ROOT}" + printf '{"user_prompt": "%s"}' "$prompt" | CLAUDE_PROJECT_DIR="$project_dir" bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +negative_output="$(run_hook "評価わんこについて。続きではなく概要を教えて")" +[ -z "$negative_output" ] || fail "否定文は無音であるべき: $negative_output" + +negative_reflection_output="$(run_hook "ふり返りは不要です")" +[ -z "$negative_reflection_output" ] || fail "否定文は無音であるべき: $negative_reflection_output" + +negative_hiragana_reflection_output="$(run_hook "ふりかえりはいらない")" +[ -z "$negative_hiragana_reflection_output" ] || fail "ひらがな否定文は無音であるべき: $negative_hiragana_reflection_output" + +hyoka_output="$(run_hook "評価わんこの続き")" +echo "$hyoka_output" | grep -q "handover preflight:" \ + || fail "handover preflight が出ない: $hyoka_output" +assert_scoped_path "$hyoka_output" "handovers" +assert_scoped_path "$hyoka_output" "takeovers" +echo "$hyoka_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "handover manual が出ない: $hyoka_output" + +admin_output="$(run_hook "引継ぎ書つくって")" +assert_scoped_path "$admin_output" "handovers" + +closeout_output="$(run_hook "作業終了。今回の内容を Handover に整理して")" +echo "$closeout_output" | grep -q "placement-policy" \ + || fail "作業終了で placement-policy が出ない: $closeout_output" +echo "$closeout_output" | grep -q "reflection-policy" \ + || fail "作業終了で reflection-policy が出ない: $closeout_output" +echo "$closeout_output" | grep -q "未完了 / 次回やること / Tech G-Brain候補 / GBrain候補 / SSOT昇格候補" \ + || fail "分類分離の案内が出ない: $closeout_output" +echo "$closeout_output" | grep -q "未完了がある時だけ current.md を更新" \ + || fail "handover更新条件の案内が出ない: $closeout_output" + +jtt_apps_reflection_output="$(run_hook "jtt-appsにふり返りを依頼")" +echo "$jtt_apps_reflection_output" | grep -q "handover preflight:" \ + || fail "jtt-appsのふり返りで preflight が出ない: $jtt_apps_reflection_output" +echo "$jtt_apps_reflection_output" | grep -q "scope: jtt-apps/root" \ + || fail "jtt-appsのscopeが出ない: $jtt_apps_reflection_output" +assert_claude_memory_path "$jtt_apps_reflection_output" "Herd-jtt-apps" + +jtt_apps_hiragana_reflection_output="$(run_hook "jtt-appsのふりかえりをお願い")" +echo "$jtt_apps_hiragana_reflection_output" | grep -q "scope: jtt-apps/root" \ + || fail "jtt-appsのひらがなふりかえりでscopeが出ない: $jtt_apps_hiragana_reflection_output" + +jtt_cms_reflection_output="$(run_hook "ふり返りをお願い" "/Users/shintaro/LLM-Dev/jtt-cms")" +echo "$jtt_cms_reflection_output" | grep -q "handover preflight:" \ + || fail "jtt-cmsのふり返りで preflight が出ない: $jtt_cms_reflection_output" +echo "$jtt_cms_reflection_output" | grep -q "scope: jtt-cms/root" \ + || fail "jtt-cmsのscopeが出ない: $jtt_cms_reflection_output" +assert_scoped_path "$jtt_cms_reflection_output" "handovers" +assert_claude_memory_path "$jtt_cms_reflection_output" "LLM-Dev-jtt-cms" + +jtt_system_reflection_output="$(run_hook "ふり返りをお願い" "/Users/shintaro/jtt-system")" +echo "$jtt_system_reflection_output" | grep -q "scope: jtt-system/root" \ + || fail "jtt-systemのscopeが出ない: $jtt_system_reflection_output" + +if is_agent_hub_source_repo; then + agent_hub_reflection_output="$(run_hook "ふり返りをお願い" "$REPO_ROOT")" + assert_exact_scope "$agent_hub_reflection_output" "AGENT-HUB/root" + assert_scoped_path "$agent_hub_reflection_output" "handovers" +fi + +compat_output="$(run_hook "continuation-closeout")" +echo "$compat_output" | grep -q "handover preflight:" \ + || fail "continuation-closeout 互換 trigger が出ない: $compat_output" + +handover_output="$(run_hook "handover")" +echo "$handover_output" | grep -q "handover preflight:" \ + || fail "handover trigger が出ない: $handover_output" + +force_output="$(printf '{"user_prompt": "ただの相談"}' | HANDOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$force_output" | grep -q "handover preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "alias: 未検出" || fail "FORCE時に alias 推定が出ない: $force_output" + +compat_force_output="$(printf '{"user_prompt": "ただの相談"}' | TAKEOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$compat_force_output" | grep -q "handover preflight:" || fail "旧TAKEOVER_PREFLIGHT_FORCE時の preflight が出ない: $compat_force_output" + +echo "PASS: handover-preflight" diff --git a/.gemini/hooks/scripts/post-merge-gate.sh b/.gemini/hooks/scripts/post-merge-gate.sh new file mode 100755 index 000000000..c10c54ba7 --- /dev/null +++ b/.gemini/hooks/scripts/post-merge-gate.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) post-merge gate. +# `gh pr merge` の直接実行を止め、マージ担当者が ccprmerd 正本を読む wrapper へ誘導する。 +# [2026-06-20][feat] +# 背景: +# - ユーザー依頼意図: マージ担当者がマージ作業の中で必ず `;ccprmerd` 相当の +# Typinator 正本を読み、マージ後確認まで含めて進める運用にしたい。 +# - 守るべき業務ルール: マージ処理は「PRレビュー → マージ時チェックリスト読み込み → +# マージ → 同じチェックリストで反映確認」までを一連の作業として扱う。 +# - 他案不採用理由: SKILL.md に手順だけ書く案は、AI が直接 `gh pr merge` を叩く経路を残し、 +# ccprmerd 読み込み漏れを機械的に防げないため不採用。 +# 対応: PreToolUse(Bash) で直接 `gh pr merge` を deny し、`merge-pr.py` 経由へ誘導する。 +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: dirty cleanup のレビューで、`xargs gh pr merge` が直接マージ禁止を迂回できると判明した。 +# - 守るべき業務ルール: 実行ラッパーを挟んでも `gh pr merge` は merge-pr.py 経由へ統一する。 +# - 他案不採用理由: 単純な文字列検索は説明文を誤検知し、`xargs` 全面禁止は無関係な利用まで止めるため不採用。 +# 対応: xargs のオプションを除いた実行コマンドを既存の gh サブコマンド解析へ渡す。 +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: web2context のレビューで、`nohup gh pr merge` が直接マージ禁止を迂回できると判明した。 +# - 守るべき業務ルール: 実行方法を変える標準ラッパーを挟んでも merge-pr.py 経由を強制する。 +# - 他案不採用理由: `nohup` だけを個別検知する案は `setsid` / `nice` で同じ抜け道を残すため不採用。 +# 対応: 副作用のない実行ラッパー3種と各オプションを prefix parser で正規化する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +read_stdin + +COMMAND="$(extract_field command)" + +if [ -z "$COMMAND" ]; then + printf '{"continue":true}\n' + exit 0 +fi + +if [ "${AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE:-0}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass post-merge-gate allow '{"env":"AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE"}' 2>/dev/null || true + printf '{"continue":true}\n' + exit 0 +fi + +if COMMAND_TEXT="$COMMAND" python3 - <<'PY' +from __future__ import annotations + +import os +import re +import shlex +import sys + +command = os.environ.get("COMMAND_TEXT", "") +RESERVED_PREFIXES = {"if", "while", "until"} + +def normalize_newline_separators(text: str) -> str: + """Turn unquoted newlines into command separators before tokenization.""" + result: list[str] = [] + quote = None + escaped = False + for char in text: + if escaped: + result.append(char) + escaped = False + continue + if char == "\\" and quote != "'": + result.append(char) + escaped = True + continue + if quote is not None: + result.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + result.append(char) + continue + result.append(";" if char in "\r\n" else char) + return "".join(result) + +def split_segments(text: str) -> list[list[str]]: + try: + lexer = shlex.shlex(normalize_newline_separators(text), posix=True, punctuation_chars=";&|(){}") + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return [] + segments: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + if current: + segments.append(current) + current = [] + else: + current.append(token) + if current: + segments.append(current) + return segments + +def split_segments_with_dynamic_commands(text: str) -> list[list[str]]: + """Keep the normal parse and add a view where command expansions are one token.""" + masked = re.sub(r"\$\([^()\r\n]*\)", "$DYNAMIC_COMMAND", text) + masked = re.sub(r"\$\{[^{}\r\n]+\}", "$DYNAMIC_COMMAND", masked) + segments = split_segments(text) + if masked != text: + segments.extend(split_segments(masked)) + return segments + +def iter_backticks(text: str) -> list[str]: + chunks: list[str] = [] + start = None + escaped = False + quote = None + for index, char in enumerate(text): + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if quote == "'": + if char == "'": + quote = None + continue + if start is None and char in {"'", '"'}: + # 二重引用符内の ' は literal(single-quote モードに入れない)。 + # これを怠ると `echo "'`...`'"` で backtick command-sub を見逃す。 + if char == "'" and quote == '"': + continue + quote = None if quote == char else char + continue + if char != "`": + continue + if start is None: + start = index + 1 + else: + chunks.append(text[start:index]) + start = None + return chunks + +def iter_dollar_subshells(text: str) -> list[str]: + chunks: list[str] = [] + index = 0 + quote = None + escaped = False + while index < len(text): + char = text[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\": + escaped = True + index += 1 + continue + if char == "'" and quote != '"': + quote = None if quote == "'" else "'" + index += 1 + continue + if char == '"' and quote != "'": + quote = None if quote == '"' else '"' + index += 1 + continue + if quote == "'" or not text.startswith("$(", index): + index += 1 + continue + start = index + depth = 1 + cursor = start + 2 + inner_quote = None + inner_escaped = False + while cursor < len(text): + char = text[cursor] + if inner_escaped: + inner_escaped = False + elif char == "\\": + inner_escaped = True + elif inner_quote: + if char == inner_quote: + inner_quote = None + elif char in {"'", '"'}: + inner_quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + chunks.append(text[start + 2:cursor]) + break + cursor += 1 + index = cursor + 1 + return chunks + +def strip_prefix(tokens: list[str]) -> list[str]: + index = 0 + while index < len(tokens): + token = os.path.basename(tokens[index]) + if "=" in tokens[index] and tokens[index].split("=", 1)[0].replace("_", "A").isalnum(): + index += 1 + continue + if token in {"command", "builtin", "exec"}: + index += 1 + if index < len(tokens) and tokens[index] == "-p": + index += 1 + continue + if token == "time": + index += 1 + if index < len(tokens) and tokens[index] == "-p": + index += 1 + continue + if token == "sudo": + index += 1 + while index < len(tokens) and tokens[index].startswith("-"): + opt = tokens[index] + index += 1 + if opt in {"-u", "-g", "-h", "-p", "-C", "-T"} and index < len(tokens): + index += 1 + continue + if token == "env": + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt in {"-u", "--unset", "-C", "--chdir"} and index + 1 < len(tokens): + index += 2 + continue + if opt.startswith("-"): + index += 1 + continue + if "=" in opt and opt.split("=", 1)[0].replace("_", "A").isalnum(): + index += 1 + continue + break + continue + if token in {"nohup", "setsid"}: + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt == "--": + index += 1 + break + if not opt.startswith("-"): + break + index += 1 + continue + if token == "nice": + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt == "--": + index += 1 + break + if opt in {"-n", "--adjustment"} and index + 1 < len(tokens): + index += 2 + continue + if opt.startswith("--adjustment=") or (opt.startswith("-") and opt[1:].lstrip("+").isdigit()): + index += 1 + continue + break + continue + break + return tokens[index:] + +def gh_subcommand(tokens: list[str]) -> list[str]: + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "gh": + return [] + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "--": + index += 1 + break + if token in {"-R", "--repo", "--hostname", "--config"}: + index += 2 + continue + if token.startswith("-R") and len(token) > 2: + index += 1 + continue + if token.startswith("--repo=") or token.startswith("--hostname=") or token.startswith("--config="): + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def indirect_subcommand(tokens: list[str]) -> list[str]: + """Fail closed when a dynamic command position could expand to gh.""" + tokens = strip_prefix(tokens) + if not tokens: + return [] + command = tokens[0] + is_simple_var = command.startswith("$") and command[1:].replace("_", "A").isalnum() + is_dynamic_expansion = ( + (command.startswith("${") and command.endswith("}")) + or command.startswith("$(") + or command.startswith("`") + ) + if not (is_simple_var or is_dynamic_expansion): + return [] + # [2026-07-18][fix] + # 背景: + # - PR1018再レビューで `${GH:-gh}` / `${GH?err}` / `$(printf gh)` のような + # command-position expansionが単純変数判定を外れ、直接mergeを実行できると判明した。 + # - 守るべき業務ルール: 実行ファイルを静的に確定できない `pr merge` はfail-closedにする。 + # - 他案不採用理由: shell parameter expansionを評価してghか判定する案は、default/error演算子や + # command substitutionの実行環境を再実装することになり、別形式で再びfail-openするため不採用。 + # 対応: 動的command tokenの後ろからpr subcommand境界を探し、展開形式を限定せず拒否する。 + for index, token in enumerate(tokens[1:], start=1): + if token == "pr": + return tokens[index:] + return [] + +def strip_pr_options(tokens: list[str]) -> list[str]: + index = 0 + while index < len(tokens): + token = tokens[index] + if token in {"-R", "--repo", "--hostname", "--config"}: + index += 2 + continue + if token.startswith("-R") and len(token) > 2: + index += 1 + continue + if token.startswith("--repo=") or token.startswith("--hostname=") or token.startswith("--config="): + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def xargs_command(tokens: list[str]) -> list[str]: + """Return the command executed by xargs, or an empty list.""" + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "xargs": + return [] + options_with_value = { + "-a", "--arg-file", "-d", "--delimiter", "-E", "--eof", "-I", "--replace", "-J", + "-L", "--max-lines", "-n", "--max-args", "-P", "--max-procs", + "-R", "-S", "-s", "--max-chars", + } + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "--": + return tokens[index + 1:] + if token in options_with_value: + index += 2 + continue + if token.startswith("--") and "=" in token: + index += 1 + continue + if token.startswith(("-d", "-E", "-I", "-J", "-L", "-n", "-P", "-R", "-S", "-s")) and len(token) > 2: + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def find_exec_command(tokens: list[str]) -> list[str]: + """Return the command passed to find -exec/-execdir, or an empty list.""" + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "find": + return [] + for index, token in enumerate(tokens): + if token in {"-exec", "-execdir"}: + return tokens[index + 1:] + return [] + +def candidate_commands(segment: list[str]) -> list[list[str]]: + candidates = [segment] + for index, token in enumerate(segment[:-1]): + if token in RESERVED_PREFIXES: + candidates.append(segment[index + 1:]) + return candidates + +def contains_generated_shell_command(text: str, depth: int) -> bool: + """Detect a direct merge emitted by printf/echo inside command substitution.""" + for chunk in iter_dollar_subshells(text) + iter_backticks(text): + for segment in split_segments(chunk): + stripped = strip_prefix(segment) + if not stripped or os.path.basename(stripped[0]) not in {"echo", "printf"}: + continue + for token in stripped[1:]: + if contains_direct_merge(token, depth + 1): + return True + return False + +def contains_direct_merge(text: str, depth: int = 0) -> bool: + if depth > 3: + return False + for chunk in iter_backticks(text): + if contains_direct_merge(chunk, depth + 1): + return True + for chunk in iter_dollar_subshells(text): + if contains_direct_merge(chunk, depth + 1): + return True + for segment in split_segments_with_dynamic_commands(text): + for candidate in candidate_commands(segment): + commands = [candidate] + wrapped = xargs_command(candidate) + if wrapped: + commands.append(wrapped) + find_wrapped = find_exec_command(candidate) + if find_wrapped: + commands.append(find_wrapped) + for nested_tokens in (wrapped, find_wrapped): + if nested_tokens: + nested_text = " ".join(shlex.quote(token) for token in nested_tokens) + if contains_direct_merge(nested_text, depth + 1): + return True + for command_tokens in commands: + sub = gh_subcommand(command_tokens) + if not sub: + sub = indirect_subcommand(command_tokens) + if sub and sub[0] == "pr": + pr_sub = strip_pr_options(sub[1:]) + if pr_sub and pr_sub[0] == "merge": + return True + stripped = strip_prefix(segment) + if stripped and os.path.basename(stripped[0]) == "eval": + for token in stripped[1:]: + if contains_direct_merge(token, depth + 1): + return True + if stripped and os.path.basename(stripped[0]) in {"bash", "sh", "zsh"}: + for i, token in enumerate(stripped[1:], start=1): + if token in {"-c", "-lc"} and i + 1 < len(stripped): + payload = stripped[i + 1] + if contains_generated_shell_command(payload, depth) or contains_direct_merge(payload, depth + 1): + return True + return False + +sys.exit(0 if contains_direct_merge(command) else 1) +PY +then + # telemetry(harness-checkup): deny を記録(fail-open)。 + agent_hub_telemetry_log hook_deny post-merge-gate deny 2>/dev/null || true + # [2026-07-31][docs] Issue #1105: 回避策を deny メッセージに明示する + # 背景: + # - 報告は「PR 本文(--body)に説明目的でコマンド例を書いただけでブロックされる」だったが、実測すると + # ブロックされるのは **二重引用符内に backtick / $() で書いた場合だけ**で、これは bash が実際に + # コマンド置換として実行する形=真陽性だった(単一引用符・素のテキスト・--body-file は通る)。 + # - よって Issue の第一案「判定対象を実行される先頭コマンドに限定する」は採らない。採ると + # `--body "$(...)"` のような本物の実行経路を見逃し、正しい安全検査を弱めるため。 + # - 実際に不足していたのは「なぜ止まったか・どう書けば通るか」の案内なので、Issue の第二案 + # (メッセージへ回避策を明示)だけを実施する。 + emit_deny "[hook:post-merge-gate] 直接の gh pr merge は禁止です。マージ担当者が ccprmerd 正本を読むため、python3 ~/business/AGENT-HUB/skills/post-merge/scripts/merge-pr.py を使ってください。 +説明文・PR 本文にコマンド例を書いただけで止まった場合: 二重引用符の中の backtick や \$() は bash が実際に実行するため検知対象です。単一引用符で囲むか --body-file を使ってください。 +リリース昇格 / forward-merge(head が main 等の長寿命ブランチ)の PR は、既定の --squash だと履歴が乖離します。--method merge --no-delete-branch --no-cleanup を明示してください。 +緊急時のみ AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE=1 を明示できます。" +fi + +printf '{"continue":true}\n' diff --git a/.gemini/hooks/scripts/post-merge-gate.test.sh b/.gemini/hooks/scripts/post-merge-gate.test.sh new file mode 100755 index 000000000..b45a94b46 --- /dev/null +++ b/.gemini/hooks/scripts/post-merge-gate.test.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/post-merge-gate.sh" +PASS=0 +FAIL=0 + +run_hook() { + local command="$1" + printf '{"tool_name":"Bash","tool_input":{"command":%s}}\n' "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$command")" | bash "$SCRIPT" +} + +run_shell_hook() { + local command="$1" + printf '{"tool_name":"Shell","tool_input":{"command":%s}}\n' "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$command")" | bash "$SCRIPT" +} + +expect_block() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if "[hook:post-merge-gate]" not in payload.get("permissionDecisionReason", ""): + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue":true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_shell_block() { + local name="$1" + local command="$2" + local out + out="$(run_shell_hook "$command" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +data = json.loads(os.environ["OUT"]) +payload = data.get("hookSpecificOutput", {}) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block "direct gh pr merge" "gh pr merge 123 --squash --delete-branch" +expect_block "repo option gh pr merge" "gh --repo owner/repo pr merge 123 --squash" +expect_block "short repo option gh pr merge" "gh -Rowner/repo pr merge 123" +expect_block "pr-level repo option gh pr merge" "gh pr --repo owner/repo merge 123" +expect_block "pr-level short repo option gh pr merge" "gh pr -Rowner/repo merge 123" +expect_block "command prefix gh pr merge" "command gh pr merge 123" +expect_block "shell nested gh pr merge" "bash -lc 'gh pr merge 123 --squash'" +expect_block "wrapper mention does not bypass direct merge" "echo merge-pr.py && gh pr merge 123 --squash" +expect_block "if statement gh pr merge" "if gh pr merge 123 --squash; then echo ok; fi" +expect_block "while statement gh pr merge" "while gh pr merge 123; do break; done" +expect_block "eval gh pr merge" "eval \"gh pr merge 123 --squash\"" +expect_block "backtick gh pr merge" "echo \`gh pr merge 123\`" +expect_block "quoted dollar subshell gh pr merge" "echo \"\$(gh pr merge 123)\"" +expect_block "double-quoted single-quote dollar subshell bypass" "echo \"'\$(gh pr merge 123)'\"" +expect_block "double-quoted single-quote backtick bypass" "echo \"'\`gh pr merge 123\`'\"" +expect_block "pipe through xargs gh pr merge" "printf '123\\n' | xargs gh pr merge" +expect_block "xargs with options gh pr merge" "xargs -n1 gh pr merge <<<123" +expect_block "macOS xargs replacement gh pr merge" "xargs -J % gh pr merge % <<<123" +expect_block "macOS xargs size gh pr merge" "xargs -S 255 gh pr merge <<<123" +expect_block "macOS xargs replacements gh pr merge" "xargs -R 1 gh pr merge <<<123" +expect_block "GNU xargs delimiter gh pr merge" "printf '123\\n' | xargs -d '\\n' gh pr merge" +expect_block "newline separated gh pr merge" $'printf ok\ngh pr merge 123' +expect_block "shell generated gh pr merge" "bash -c \"\$(printf 'gh pr merge 123')\"" +expect_block "find exec gh pr merge" "find . -exec gh pr merge 123 {} \\;" +expect_block "xargs shell nested gh pr merge" "printf '123\\n' | xargs sh -c 'gh pr merge \"\$0\"'" +expect_block "find shell nested gh pr merge" "find . -exec sh -c 'gh pr merge 123' \\;" +expect_block "variable command gh pr merge" "GH=gh; \"\$GH\" pr merge 123" +expect_block "default parameter expansion gh pr merge" 'GH=gh; "${GH:-gh}" pr merge 123' +expect_block "error parameter expansion gh pr merge" 'GH=gh; "${GH?err}" pr merge 123' +expect_block "command substitution gh pr merge" '$(printf gh) pr merge 123' +expect_block "nohup gh pr merge" "nohup gh pr merge 123" +expect_block "setsid gh pr merge" "setsid -f gh pr merge 123" +expect_block "nice gh pr merge" "nice -n 5 gh pr merge 123" +expect_shell_block "Shell tool gh pr merge" "gh pr merge 123" + +expect_allow "pr view allowed" "gh pr view 123" +expect_allow "wrapper allowed" "python3 ~/business/AGENT-HUB/skills/post-merge/scripts/merge-pr.py 123 --confirm-read" +expect_allow "text mention allowed" "echo 'gh pr merge 123 should use wrapper'" +expect_allow "single quoted dollar subshell text allowed" "echo '\$(gh pr merge 123)'" +expect_allow "single quoted backtick text allowed" "echo '\`gh pr merge 123\`'" + +# [2026-07-31][test] Issue #1105: deny メッセージが回避策を案内することを固定する。 +# 実測の結果、ブロックされるのは二重引用符内の backtick / $()(bash が実際に実行する形=真陽性)だけで、 +# 単一引用符・素のテキスト・--body-file は上の expect_allow 群のとおり通る。よって判定ロジックは変えず、 +# 「なぜ止まったか・どう書けば通るか」を案内するメッセージだけを追加した。その回帰を固定する。 +expect_deny_message_contains() { + local name="$1" + local command="$2" + local needle="$3" + local out + out="$(run_hook "$command" 2>&1)" + if OUT="$out" NEEDLE="$needle" python3 - <<'PYCHECK' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +reason = data.get("hookSpecificOutput", {}).get("permissionDecisionReason", "") +sys.exit(0 if os.environ["NEEDLE"] in reason else 1) +PYCHECK + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_deny_message_contains "deny message points at --body-file workaround" "gh pr merge 123" "--body-file" +expect_deny_message_contains "deny message explains single quotes" "gh pr merge 123" "単一引用符" +expect_deny_message_contains "deny message still points at the wrapper" "gh pr merge 123" "merge-pr.py" + +printf 'post-merge-gate tests: %s passed, %s failed\n' "$PASS" "$FAIL" +test "$FAIL" -eq 0 diff --git a/.gemini/hooks/scripts/pre-implementation-check.sh b/.gemini/hooks/scripts/pre-implementation-check.sh new file mode 100755 index 000000000..4dc2732fd --- /dev/null +++ b/.gemini/hooks/scripts/pre-implementation-check.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# UserPromptSubmit フック — 軽量リマインダー(重い処理はしない) +# docs/ の構成を検出し、3層読み込み戦略のリマインダーを出力 +# +# 設置先: .claude/hooks/scripts/pre-implementation-check.sh +# トリガー: UserPromptSubmit +# タイムアウト: 5秒 +# +# [2026-03-21][fix] +# 背景: +# - ユーザー依頼意図: PR30レビューで、実装前リマインダーを Claude が次の行動判断に使える状態へ直したい。 +# - 守るべき業務ルール: UserPromptSubmit の非ブロッキング hook は、モデルへ渡したい文言を stdout に出す必要がある。 +# - 他案不採用理由: stderr へ出す方式のままでは、警告文が人間向けログに留まり、実装前コンテキストとして機能しない。 +# 対応: 非ブロッキング成功のまま stdout 出力へ統一し、プロジェクト構成に応じたリマインダーを Claude に渡す。 +# +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: AGENT-HUB の UserPromptSubmit hook が毎回大きなリマインダーを表示し、 +# hook失敗のように見えて作業体験を悪化させているため静かにしたい。 +# - 守るべき業務ルール: CaD確認自体はAGENT-HUB運用で必須。ただし通常プロンプトごとに可視出力して +# 失敗表示と混同させてはいけない。 +# - 他案不採用理由: +# 1) stderrへ戻す案はモデル文脈に渡らず、PR30で不採用済みのため不採用。 +# 2) settingsだけ残して実体を削除する案は hook 実行時の参照切れを再発させるため不採用。 +# 3) CaDリマインダーを完全削除する案は必須運用を失うため不採用。 +# 対応: 通常は無音成功にし、明示的に `AGENT_HUB_SHOW_PRE_IMPL_REMINDER=1` を指定した場合だけ stdout に出す。 + +# プロジェクトルートを検出 +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" + +if [ "${AGENT_HUB_SHOW_PRE_IMPL_REMINDER:-0}" != "1" ]; then + exit 0 +fi + +if [ -d "$PROJECT_DIR/docs/business" ]; then + # docs/business/ が存在する場合: 3層読み込み戦略リマインダー + cat <<'REMINDER' +⚠️ SSOT 3層読み込み戦略を実行せよ: +Layer 1: CLAUDE.md + rules + prd-active Context Summary +Layer 2: business-design.md / BUSINESS_RULES.md の目次→関係セクション特定 +Layer 3: 変更スコープに応じたSSOTの該当セクションだけ全文読み ++ CaD不採用パターンをブロックリスト化 → サブエージェントに引き渡し ++ PM Agent の直接実装禁止 → サブエージェントに委譲 +REMINDER +else + # docs/business/ が存在しない場合: CaD確認リマインダー + cat <<'REMINDER' +⚠️ CaD確認必須: 変更対象の不採用理由をブロックリスト化 → サブエージェントに引き渡し +REMINDER +fi diff --git a/.gemini/hooks/scripts/stop-quality-check.sh b/.gemini/hooks/scripts/stop-quality-check.sh new file mode 100755 index 000000000..05085b2c2 --- /dev/null +++ b/.gemini/hooks/scripts/stop-quality-check.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。薄いラッパーでlib/の共通ロジックを呼び出す。 +# 対応: Stop → lib/quality-check-common.sh の run_quality_check_hook を呼出。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/quality-check-common.sh" + +run_quality_check_hook \ + "stop-quality-check" \ + "$SCRIPT_DIR/.." \ + "No file changes detected - research/planning task, skipping quality check." diff --git a/.gemini/hooks/scripts/storage-url-pr-gate.sh b/.gemini/hooks/scripts/storage-url-pr-gate.sh new file mode 100755 index 000000000..8e8452887 --- /dev/null +++ b/.gemini/hooks/scripts/storage-url-pr-gate.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。PreToolUseでPR作成前にStorage URL全件検証。 +# 対応: jtt-cms storage-url-pr-gate.sh をポート。lib/hook-io.sh + lib/storage-url-common.py を使用。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「PR作成前ゲートが環境差で無効化されず、常に同じ判定になること」。 +# 業務ルールとして、セキュリティ/品質ゲートは fail-open(失敗時素通り)を禁止する。 +# 代替案として `origin/main` 固定 + `|| true` を維持すると、 +# ブランチ構成差やremote未設定時に検査がスキップされるため不採用。 +# 対応: ベースブランチ解決を動的化し、diff取得や検査失敗時は明示denyに変更。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +resolve_base_ref() { + local cwd="$1" + + # 1) origin/HEAD を優先 + local remote_head + remote_head="$(git -C "$cwd" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || true)" + if [ -n "$remote_head" ]; then + echo "${remote_head#refs/remotes/}" + return 0 + fi + + # 2) origin/main または origin/master + if git -C "$cwd" rev-parse --verify origin/main >/dev/null 2>&1; then + echo "origin/main" + return 0 + fi + if git -C "$cwd" rev-parse --verify origin/master >/dev/null 2>&1; then + echo "origin/master" + return 0 + fi + + # 3) 最後のフォールバック: ローカル main/master + if git -C "$cwd" rev-parse --verify main >/dev/null 2>&1; then + echo "main" + return 0 + fi + if git -C "$cwd" rev-parse --verify master >/dev/null 2>&1; then + echo "master" + return 0 + fi + + return 1 +} + +read_stdin +COMMAND=$(extract_field command) + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `gh pr create`(複数空白)や `gh --repo owner/repo pr create` のように +# gh のグローバルオプション付き呼び出しが固定文字列 `gh pr create` に一致せず +# fail-open(ゲートをスルー)する脆弱性を修正したい。 +# 守るべき業務ルール: セキュリティ/品質ゲートは fail-open 禁止(2026-03-04 CaD と同型)。 +# 他案不採用理由: +# 1) `grep -qF "gh pr create"` を維持しつつ空白を `[[:space:]]*` に変えるだけ → 長形式オプション +# (--repo, --base 等) を見逃すため不採用。 +# 2) コマンド全体を解析する案 → shlex が必要で bash のみより複雑。正規表現の方が保守しやすい。 +# 対応: grep -qE で gh のグローバルオプション(短形式 -R / 長形式 --repo 等)と複数空白を許容する正規表現に変更。 +# [2026-05-27][fix] review follow-up: +# --repo / -R のように値を別トークンで取るグローバルオプションも消費する。値なしオプションだけを +# 許容する旧パターンでは `gh --repo owner/repo pr create` が early exit して fail-open するため不採用。 +# [2026-05-28][fix] issue #210 / v3.5.6 regression fix: +# gh が許容する連結形式 `-Rowner/repo`(値を別トークンにせず短縮形へ glue)も消費する。 +# #201/#213 hardening で `-R[^[:space:]]+` 分岐が脱落し、`gh -Rowner/repo pr create` が +# GH_GLOBAL_OPTS にマッチせず early exit → storage URL gate を fail-open する退化が入っていた。 +# `-[A-Za-z]+` 分岐は `-Rowner/repo` の `/` で止まるため連結 repo 値を消費できない。実機検証で +# gh は `-Rowner/repo` を受理するため(git の連結 `-C/path` は逆に弾かれる)、本分岐の復活が必須。 +readonly GH_GLOBAL_OPTS='([[:space:]]+((-R|--repo|--hostname)[[:space:]]+[^[:space:]]+|-R[^[:space:]]+|--repo=[^[:space:]]+|--hostname=[^[:space:]]+|-[A-Za-z]+|--[A-Za-z0-9_-]+))*' +if ! echo "$COMMAND" | grep -qE "gh${GH_GLOBAL_OPTS}[[:space:]]+pr[[:space:]]+create"; then + exit 0 +fi + +CWD=$(extract_field cwd) +if [ -z "$CWD" ]; then + CWD="." +fi + +BASE_REF="" +if ! BASE_REF="$(resolve_base_ref "$CWD")"; then + emit_deny "[hook:storage-url-pr-gate] 比較対象ブランチ(origin/HEAD, main, master)を解決できません。ベースブランチを取得してから再実行してください。" +fi + +set +e +CHANGED_FILES=$(git -C "$CWD" diff --name-only --diff-filter=ACMR "$BASE_REF"...HEAD 2>/dev/null) +DIFF_STATUS=$? +set -e + +if [ "$DIFF_STATUS" -ne 0 ]; then + emit_deny "[hook:storage-url-pr-gate] 変更ファイル差分の取得に失敗しました(base: $BASE_REF)。リポジトリ状態を確認してください。" +fi + +if [ -z "$CHANGED_FILES" ]; then + exit 0 +fi + +MIGRATION_FILES=$(echo "$CHANGED_FILES" | grep -E '^supabase/migrations/.*\.sql$' || true) +if [ -z "$MIGRATION_FILES" ]; then + exit 0 +fi + +FILE_ARGS=() +while IFS= read -r mf; do + FILE_ARGS+=("$CWD/$mf") +done <<< "$MIGRATION_FILES" + +set +e +DENY_REASON=$(python3 "$SCRIPT_DIR/../lib/storage-url-common.py" gate "${FILE_ARGS[@]}" 2>/dev/null) +GATE_STATUS=$? +set -e + +if [ "$GATE_STATUS" -eq 0 ]; then + exit 0 +fi + +if [ "$GATE_STATUS" -eq 1 ] && [ -n "$DENY_REASON" ]; then + emit_deny "$DENY_REASON" +fi + +emit_deny "[hook:storage-url-pr-gate] Storage URL検証処理でエラーが発生しました。ログを確認して再実行してください。" diff --git a/.gemini/hooks/scripts/subagent-quality-check.sh b/.gemini/hooks/scripts/subagent-quality-check.sh new file mode 100755 index 000000000..795fe76bf --- /dev/null +++ b/.gemini/hooks/scripts/subagent-quality-check.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。薄いラッパーでlib/の共通ロジックを呼び出す。 +# 対応: SubagentStop → lib/quality-check-common.sh の run_quality_check_hook を呼出。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/quality-check-common.sh" + +run_quality_check_hook \ + "subagent-quality-check" \ + "$SCRIPT_DIR/.." \ + "No file changes detected - research/planning agent, skipping quality check." \ + "false" diff --git a/.gemini/hooks/scripts/takeover-preflight.sh b/.gemini/hooks/scripts/takeover-preflight.sh new file mode 100755 index 000000000..0a3cc6160 --- /dev/null +++ b/.gemini/hooks/scripts/takeover-preflight.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Compatibility wrapper. Handover is the canonical preflight name. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec bash "$SCRIPT_DIR/handover-preflight.sh" diff --git a/.gemini/hooks/scripts/takeover-preflight.test.sh b/.gemini/hooks/scripts/takeover-preflight.test.sh new file mode 100755 index 000000000..b388fa494 --- /dev/null +++ b/.gemini/hooks/scripts/takeover-preflight.test.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +fi +HOOK="$SCRIPT_DIR/takeover-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +extract_field() { + printf "%s\n" "$1" | sed -n "s/^-[[:space:]]*$2: //p" +} + +is_agent_hub_source_repo() { + [ -f "$REPO_ROOT/DISTRIBUTION.yaml" ] && [ -f "$REPO_ROOT/hook-registry.yaml" ] +} + +assert_exact_scope() { + local output="$1" + local expected="$2" + local scope + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + [ "$scope" = "$expected" ] || fail "scope が期待値と一致しない: $output" +} + +assert_scoped_path() { + local output="$1" + local category="$2" + local scope + local expected + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + expected="$HOME/.agent-hub/$category/$scope/current.md" + printf "%s\n" "$output" | grep -Fq "$expected" \ + || fail "$category が scope と一致しない: $output" +} + +run_hook() { + local prompt="$1" + printf '{"user_prompt": "%s"}' "$prompt" | CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +negative_output="$(run_hook "評価わんこについて。続きではなく概要を教えて")" +[ -z "$negative_output" ] || fail "否定文は無音であるべき: $negative_output" + +negative_finish_output="$(run_hook "終了整理は不要です")" +[ -z "$negative_finish_output" ] || fail "否定文は無音であるべき: $negative_finish_output" + +negative_closeout_output="$(run_hook "Closeout整理はいらない")" +[ -z "$negative_closeout_output" ] || fail "否定文は無音であるべき: $negative_closeout_output" + +negative_work_output="$(run_hook "作業終了ではないです")" +[ -z "$negative_work_output" ] || fail "否定文は無音であるべき: $negative_work_output" + +representative_prompt="作業終了。今回の内容を終了整理して。GBrain候補は僕の確認待ち、SSOTとTech G-Brainは自動判定で。未完了がある時だけTakeoverも更新して。" +representative_output="$(run_hook "$representative_prompt")" +echo "$representative_output" | grep -q "handover preflight:" \ + || fail "代表入力文で preflight が出ない: $representative_output" +echo "$representative_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "代表入力文で handover manual が出ない: $representative_output" + +closeout_word_output="$(run_hook "終了整理")" +echo "$closeout_word_output" | grep -q "handover preflight:" \ + || fail "終了整理単独で preflight が出ない: $closeout_word_output" + +closeout_compat_output="$(run_hook "Closeout整理")" +echo "$closeout_compat_output" | grep -q "handover preflight:" \ + || fail "Closeout整理で preflight が出ない: $closeout_compat_output" + +hyoka_output="$(run_hook "評価わんこの続き")" +echo "$hyoka_output" | grep -q "handover preflight:" \ + || fail "handover preflight が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q ".agent-hub/handovers/jtt-system/hyoka-wanko/current.md" \ + || fail "評価わんこの handover_path が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q ".agent-hub/takeovers/jtt-system/hyoka-wanko/current.md" \ + || fail "評価わんこの legacy_path が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "handover manual が出ない: $hyoka_output" + +admin_output="$(run_hook "引継ぎ書つくって")" +assert_scoped_path "$admin_output" "handovers" + +compat_output="$(run_hook "continuation-closeout")" +echo "$compat_output" | grep -q "handover preflight:" \ + || fail "continuation-closeout 互換 trigger が出ない: $compat_output" + +force_output="$(printf '{"user_prompt": "ただの相談"}' | TAKEOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$force_output" | grep -q "handover preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "alias: 未検出" || fail "FORCE時に alias 推定が出ない: $force_output" + +compat_force_output="$(printf '{"user_prompt": "ただの相談"}' | AGENT_MEMORY_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$compat_force_output" | grep -q "handover preflight:" || fail "旧AGENT_MEMORY_PREFLIGHT_FORCE 時の preflight が出ない: $compat_force_output" + +if is_agent_hub_source_repo; then + agent_hub_reflection_output="$(run_hook "ふり返りをお願い")" + assert_exact_scope "$agent_hub_reflection_output" "AGENT-HUB/root" + assert_scoped_path "$agent_hub_reflection_output" "handovers" +fi + +echo "PASS: takeover-preflight" diff --git a/.gemini/hooks/scripts/telemetry-lib.sh b/.gemini/hooks/scripts/telemetry-lib.sh new file mode 100755 index 000000000..163432ccf --- /dev/null +++ b/.gemini/hooks/scripts/telemetry-lib.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# telemetry-lib.sh — shared harness telemetry function. +# +# Provides: agent_hub_telemetry_log [meta_json] +# +# 絶対方針: fail-open。 +# - いかなるエラーでも exit 0・ブロックしない・stdout に出力しない。 +# - git / date / python3 / mkdir のいずれかが欠損・失敗しても黙って return 0。 +# - AGENT_HUB_TELEMETRY_DISABLE=1 で完全無効化(何もしない)。 +# - 外部ネットワーク不使用。ローカル JSONL 追記のみ。 +# +# 他 hook からの読み込み(配布先で lib が無くても壊さない no-op fallback): +# . "$(dirname "$0")/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } +# +# 出力先: ${AGENT_HUB_TELEMETRY_DIR:-$HOME/.agent-hub/telemetry}/YYYY-MM-DD.jsonl +# レコード: {"ts","tool","pj","event_type","name","outcome","meta"} + +# 注意: 本ファイルは他 hook から `source` されるため set -e を使わない。 +# 呼び出し元(block-main-commit.sh 等)が set -euo pipefail を設定済みの場合、 +# ここでの未定義変数や失敗コマンドは親の set -e で source 全体を中断しうる。 +# そのため全ての変数参照は ${VAR:-} 形式とし、外部コマンドは || true で包む。 + +agent_hub_telemetry_log() { + # fail-open: 無効化フック + [ "${AGENT_HUB_TELEMETRY_DISABLE:-0}" = "1" ] && return 0 + + local event_type="${1:-}" + local name="${2:-}" + local outcome="${3:-}" + local meta_json="${4:-}" + + # 引数不足でも黙って返す(ブロックしない) + [ -z "$event_type" ] && return 0 + + # 出力ディレクトリ解決(環境変数で上書き可。テスト用) + local base_dir="${AGENT_HUB_TELEMETRY_DIR:-${HOME:-}/.agent-hub/telemetry}" + local date_str + date_str="$(date +%Y-%m-%d 2>/dev/null || echo unknown)" + [ -z "$date_str" ] && date_str="unknown" + local out_file="$base_dir/$date_str.jsonl" + + # ディレクトリ作成(失敗は無視 → 後段の追記も失敗して return 0 に至る) + [ -d "$base_dir" ] || mkdir -p "$base_dir" 2>/dev/null || true + + # pj 解決(優先順: 環境変数 > CLAUDE_PROJECT_DIR > git root basename > PWD basename) + local pj="${AGENT_HUB_TELEMETRY_PJ:-}" + if [ -z "$pj" ]; then + if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then + pj="${CLAUDE_PROJECT_DIR##*/}" + else + local git_root="" + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ]; then + pj="${git_root##*/}" + else + pj="${PWD##*/}" + fi + fi + fi + [ -z "$pj" ] && pj="unknown" + + # ISO8601 UTC タイムスタンプ + local ts + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" + + # [2026-07-07][feat] harness phaseB: telemetry tool名を T_TOOL 由来で上書き可にする + local tool="${T_TOOL:-claude-code}" + + # JSON 1 行を組み立てて追記(python3 で値を escape-safe に)。 + # python3 が無い環境では純 bash で最小エスケープして追記する(fail-open)。 + if command -v python3 >/dev/null 2>&1; then + T_EVENT="$event_type" \ + T_NAME="$name" \ + T_OUTCOME="$outcome" \ + T_PJ="$pj" \ + T_TOOL="$tool" \ + T_TS="$ts" \ + T_META="$meta_json" \ + T_OUT="$out_file" \ + python3 - <<'PY' 2>/dev/null || true +import json +import os + + +def as_str(value: str) -> str: + return value if isinstance(value, str) else "" + + +meta_raw = os.environ.get("T_META", "") +meta_value = {} +if meta_raw: + try: + decoded = json.loads(meta_raw) + if isinstance(decoded, dict): + meta_value = decoded + else: + meta_value = {"value": decoded} + except Exception: + # JSON でなければ文字列として保持(破損させない) + meta_value = {"raw": meta_raw} + +record = { + "ts": as_str(os.environ.get("T_TS", "")), + "tool": as_str(os.environ.get("T_TOOL", "claude-code")), + "pj": as_str(os.environ.get("T_PJ", "")), + "event_type": as_str(os.environ.get("T_EVENT", "")), + "name": as_str(os.environ.get("T_NAME", "")), + "outcome": as_str(os.environ.get("T_OUTCOME", "")), + "meta": meta_value, +} + +out_path = os.environ.get("T_OUT", "") +if not out_path: + raise SystemExit(0) + +try: + with open(out_path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") +except Exception: + pass +PY + else + # python3 無し: JSON パーサ/シリアライザが無いため meta_json を構造化オブジェクトとして + # 安全に組み込めない。 + # [2026-07-04][fix] Codexレビュー対応(PR #670 🟡1): + # 背景: 旧実装は meta_json(呼び出し元が渡す生JSON片、例 {"label":"foo"})に対して + # 文字列用の _telemetry_escape をそのまま適用したうえで "meta":%s (無クォート)へ + # 埋め込んでいた。meta_json 内にダブルクォート/バックスラッシュが含まれると + # エスケープと JSON 構造が二重に競合し、不正な JSONL 行になり得た。 + # 守るべき業務ルール: telemetry は fail-open かつ JSONL を絶対に壊さない。 + # 他案不採用理由: meta_json を素朴な文字列置換で「JSON オブジェクトとして」再構築する案は、 + # ネスト・エスケープの全パターンを網羅できずシェルだけでの安全な JSON 生成は非現実的なため不採用。 + # 対応: python3 無し環境では meta は常に空オブジェクト{}に固定し、元データは + # meta_raw に「文字列値」として安全にエスケープして退避する(構造は壊さず、情報も欠落させない)。 + _telemetry_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/ }" + s="${s//$'\r'/ }" + s="${s//$'\t'/ }" + printf '%s' "$s" + } + if [ -n "$meta_json" ]; then + printf '{"ts":"%s","tool":"%s","pj":"%s","event_type":"%s","name":"%s","outcome":"%s","meta":{},"meta_raw":"%s"}\n' \ + "$(_telemetry_escape "$ts")" \ + "$(_telemetry_escape "$tool")" \ + "$(_telemetry_escape "$pj")" \ + "$(_telemetry_escape "$event_type")" \ + "$(_telemetry_escape "$name")" \ + "$(_telemetry_escape "$outcome")" \ + "$(_telemetry_escape "$meta_json")" \ + >> "$out_file" 2>/dev/null || true + else + printf '{"ts":"%s","tool":"%s","pj":"%s","event_type":"%s","name":"%s","outcome":"%s","meta":{}}\n' \ + "$(_telemetry_escape "$ts")" \ + "$(_telemetry_escape "$tool")" \ + "$(_telemetry_escape "$pj")" \ + "$(_telemetry_escape "$event_type")" \ + "$(_telemetry_escape "$name")" \ + "$(_telemetry_escape "$outcome")" \ + >> "$out_file" 2>/dev/null || true + fi + fi + + return 0 +} diff --git a/.gemini/hooks/scripts/telemetry-log.sh b/.gemini/hooks/scripts/telemetry-log.sh new file mode 100755 index 000000000..f154df33a --- /dev/null +++ b/.gemini/hooks/scripts/telemetry-log.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# telemetry-log.sh — Claude Code hook entry for harness telemetry. +# +# Claude Code の PreToolUse / PostToolUse / SessionStart / Stop / SubagentStop で呼ばれる。 +# stdin の hook JSON を読み、ツール名/イベントから event_type を判定して 1 行 JSON を追記する。 +# +# 絶対方針: fail-open。 +# - いかなる入力・エラーでも exit 0・ブロックしない・stdout には何も出さない +# (PreToolUse で空 stdout = 許可継続。テレメトリが原因でツールを止めない)。 +# - AGENT_HUB_TELEMETRY_DISABLE=1 で完全無効化。 +# - 外部ネットワーク不使用。 +# +# event_type マッピング: +# tool_name=Skill → skill_fire, name=スキル名 +# tool_name=Task/Agent → subagent_start, name=subagent_type +# hook_event=SessionStart → session_start +# hook_event=Stop → session_stop +# hook_event=SubagentStop → subagent_stop +# その他の tool_name 付きツール → tool_use, name=tool_name +# (event/tool ともに取れない場合は記録しない) + +# set -e を使わない(fail-open 優先)。 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# 共有関数を読み込み。lib が無い配布先でも壊れない no-op fallback。 +. "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } + +# stdin を1回だけ読む(hook JSON)。 +input="$(cat 2>/dev/null || true)" + +# 無効化フックはここでも早抜け(呼び出しコストを避ける)。 +if [ "${AGENT_HUB_TELEMETRY_DISABLE:-0}" = "1" ]; then + exit 0 +fi + +# hook JSON を解析して event_type / name / outcome を決定し、lib 関数へ渡す。 +# python3 が読めれば解析、なければ何もしない(fail-open)。 +parsed="$(HOOK_INPUT="$input" python3 - <<'PY' 2>/dev/null || true +import json +import os +import sys + + +def get_string(data, *keys): + for key in keys: + value = data.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def get_tool_input(data): + ti = data.get("tool_input") + if isinstance(ti, dict): + return ti + ti = data.get("toolInput") + if isinstance(ti, dict): + return ti + return {} + + +raw = os.environ.get("HOOK_INPUT", "") +try: + data = json.loads(raw) if raw else {} +except Exception: + data = {} + +if not isinstance(data, dict): + data = {} + +hook_event = get_string(data, "hook_event_name", "hookEventName") +tool_name = get_string(data, "tool_name", "toolName") +agent_type = get_string(data, "agent_type", "agentType") +ti = get_tool_input(data) + +event_type = "" +name = "" + +if hook_event == "SessionStart": + event_type = "session_start" + name = "session" +elif hook_event == "Stop": + event_type = "session_stop" + name = "session" +elif hook_event == "SubagentStop": + # [2026-07-04][fix] Codexレビュー対応(PR #670 🟡2): + # 背景: ファイル冒頭コメントは SubagentStop でも呼ばれる前提だったが、 + # tool_name を伴わない SubagentStop はどの分岐にも一致せず event_type が + # 空のまま記録漏れ(サブエージェント終了が観測されない)になっていた。 + # 対応: SubagentStop を明示的に subagent_stop として記録する。 + event_type = "subagent_stop" + name = "subagent" +elif tool_name == "Skill": + event_type = "skill_fire" + # [2026-07-23][fix] + # 背景: 現行runtimeが tool_input.skill へ変わり、旧nameだけでは空観測になった。 + # 守る契約: skillを正本として読み、旧name/skill_nameは互換入力として維持する。 + # 他案不採用: 旧キー専用へ戻すと現行payloadを再び欠損させるため採らない。 + name = get_string(ti, "skill", "name", "skill_name") +elif tool_name in ("Task", "Agent"): + event_type = "subagent_start" + name = get_string(ti, "subagent_type", "subtype") or agent_type +elif tool_name: + event_type = "tool_use" + name = tool_name + +if not event_type: + # 記録対象が無い → 何も出力しない + sys.exit(0) + +# タブ区切りで shell へ返す(name にタブが含まれる可能性は低いが、念のため除去) +name = name.replace("\t", " ").replace("\n", " ") +print("\t".join([event_type, name, "ok"])) +PY +)" + +# python3 が何も返さなければテレメトリ追記しない(fail-open)。 +if [ -n "$parsed" ]; then + event_type="${parsed%%$'\t'*}" + rest="${parsed#*$'\t'}" + name="${rest%%$'\t'*}" + outcome="${rest#*$'\t'}" + agent_hub_telemetry_log "$event_type" "$name" "$outcome" 2>/dev/null || true +fi + +exit 0 diff --git a/.gemini/hooks/scripts/telemetry-log.test.sh b/.gemini/hooks/scripts/telemetry-log.test.sh new file mode 100755 index 000000000..e1afcb38e --- /dev/null +++ b/.gemini/hooks/scripts/telemetry-log.test.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# telemetry-log.test.sh — telemetry-log.sh のフックエントリ専用回帰テスト。 +# +# 背景(jtt-apps PR #964 の Codex レビュー起点): +# telemetry-log.sh / telemetry-lib.sh 自体の網羅テストは scripts/test-telemetry-hook.sh +# (AGENT-HUB 自身の CI・.github/workflows/ci.yml「Hook integration tests」で実行)が担う。 +# 一方、hook-library/scripts/*.test.sh は「配布先 PJ に script_map 経由で同梱し、配布後の +# hook 単体を再検証できる」サイドカーの規約(block-main-commit.test.sh 等と同型)。 +# telemetry-log だけこのサイドカーが無く、配布先で telemetry-log.sh 単体の動作を +# 再確認する手段が欠けていたため新設する。 +# +# 検証内容(3点。scripts/test-telemetry-hook.sh の該当項目のサブセット): +# 1. Skill ツールの hook JSON を stdin に与えると JSONL が1行増える +# 2. AGENT_HUB_TELEMETRY_DISABLE=1 で何も書かず exit 0 +# 3. 壊れた JSON 入力でも exit 0(fail-open) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/telemetry-log.sh" + +PASS=0 +FAIL=0 + +pass() { printf '[PASS] %s\n' "$1"; PASS=$((PASS + 1)); } +fail() { printf '[FAIL] %s: %s\n' "$1" "$2" >&2; FAIL=$((FAIL + 1)); } + +# telemetry-lib.sh の出力先は AGENT_HUB_TELEMETRY_DIR で上書き可能(テスト用)。 +# 本物の ~/.agent-hub/telemetry/ を汚さないよう一時ディレクトリへ差し替える。 +TEST_TMP="$(mktemp -d)" +trap 'rm -rf "$TEST_TMP"' EXIT +export AGENT_HUB_TELEMETRY_DIR="$TEST_TMP/telemetry" +unset AGENT_HUB_TELEMETRY_DISABLE || true +unset AGENT_HUB_TELEMETRY_PJ || true + +count_lines() { + local files + files="$(ls -1 "$AGENT_HUB_TELEMETRY_DIR"/*.jsonl 2>/dev/null || true)" + if [ -z "$files" ]; then + echo 0 + return + fi + cat $files 2>/dev/null | wc -l | tr -d '[:space:]' +} + +last_line() { + local files + files="$(ls -1 "$AGENT_HUB_TELEMETRY_DIR"/*.jsonl 2>/dev/null || true)" + if [ -z "$files" ]; then + echo "" + return + fi + cat $files 2>/dev/null | tail -n1 +} + +# ── 1/3: Skill ツールの hook JSON → JSONL が1行増える ──────────────── +BEFORE=$(count_lines) +printf '{"hook_event_name":"PreToolUse","tool_name":"Skill","tool_input":{"name":"plan-approval"}}' \ + | bash "$HOOK" 2>/dev/null +AFTER=$(count_lines) +if [ "$AFTER" -gt "$BEFORE" ]; then + LAST_LINE="$(last_line)" + if printf '%s' "$LAST_LINE" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["event_type"]=="skill_fire" and d["name"]=="plan-approval"' 2>/dev/null; then + pass "Skill発火のhook JSONでJSONLが1行増える" + else + fail "Skill発火のJSONL内容" "想定外の内容: $LAST_LINE" + fi +else + fail "Skill発火でJSONLが増える" "行数が増えなかった(before=$BEFORE after=$AFTER)" +fi + +# ── 2/3: AGENT_HUB_TELEMETRY_DISABLE=1 で何も書かず exit 0 ─────────── +BEFORE=$(count_lines) +DISABLE_OUT="$(printf '{"hook_event_name":"PreToolUse","tool_name":"Skill","tool_input":{"name":"nope"}}' \ + | AGENT_HUB_TELEMETRY_DISABLE=1 bash "$HOOK" 2>/dev/null; echo "rc=$?")" +AFTER=$(count_lines) +if [ "$BEFORE" = "$AFTER" ] && printf '%s' "$DISABLE_OUT" | grep -q 'rc=0'; then + pass "AGENT_HUB_TELEMETRY_DISABLE=1で何も書かずexit 0" +else + fail "AGENT_HUB_TELEMETRY_DISABLE=1" "行数変化(before=$BEFORE after=$AFTER) または非0終了: $DISABLE_OUT" +fi + +# ── 3/3: 壊れた JSON でも exit 0(fail-open) ─────────────────────────── +BROKEN_OUT="$(printf 'not json at all {{{' | bash "$HOOK" 2>/dev/null; echo "rc=$?")" +if printf '%s' "$BROKEN_OUT" | grep -q 'rc=0'; then + pass "壊れたJSON入力でもexit 0(fail-open)" +else + fail "壊れたJSON入力" "exit 0 にならなかった: $BROKEN_OUT" +fi + +# 空 stdin も fail-open で exit 0 であることも併せて確認(壊れたJSON系の代表的な派生形)。 +EMPTY_OUT="$(printf '' | bash "$HOOK" 2>/dev/null; echo "rc=$?")" +if printf '%s' "$EMPTY_OUT" | grep -q 'rc=0'; then + pass "空stdinでもexit 0(fail-open)" +else + fail "空stdin" "exit 0 にならなかった: $EMPTY_OUT" +fi + +echo "" +echo "=== telemetry-log.test.sh: $PASS passed, $FAIL failed ===" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.gemini/sync-state.json b/.gemini/sync-state.json new file mode 100644 index 000000000..df154fc1d --- /dev/null +++ b/.gemini/sync-state.json @@ -0,0 +1,113 @@ +{ + "tool": "antigravity-sync", + "generated_at": "2026-08-03T14:57:39.219071+00:00", + "source_commit": "e6a0c00", + "project_root": ".", + "generated_gemini_md": true, + "copied_agents": [ + "backend-architect.md", + "backend-developer.md", + "chatgpt-image-creator.md", + "document-writer.md", + "frontend-developer.md", + "implementation-auditor.md", + "qa-reviewer.md", + "quality-engineer.md", + "stitch-screen-creator.md", + "technical-writer.md", + "test-runner.md" + ], + "copied_skills": [], + "skill_surface_owner": "sync-runtime-skills:.agents/skills", + "legacy_skill_writer": "LEGACY_WRITER_DISABLED", + "copied_hook_scripts": [ + ".hook-library-version", + "lib/code-quality-check.md", + "lib/hook-io.sh", + "lib/quality-check-common.sh", + "lib/storage-url-common.py", + "scripts/block-destructive-git.sh", + "scripts/block-destructive-git.test.sh", + "scripts/block-main-commit.sh", + "scripts/block-main-commit.test.sh", + "scripts/block-skill-reverse-edit.sh", + "scripts/block-skill-reverse-edit.test.sh", + "scripts/block-unauthorized-docs-file.sh", + "scripts/block-unauthorized-docs-file.test.sh", + "scripts/freshness-gate.sh", + "scripts/handover-preflight.sh", + "scripts/handover-preflight.test.sh", + "scripts/post-merge-gate.sh", + "scripts/post-merge-gate.test.sh", + "scripts/pre-implementation-check.sh", + "scripts/stop-quality-check.sh", + "scripts/storage-url-pr-gate.sh", + "scripts/subagent-quality-check.sh", + "scripts/takeover-preflight.sh", + "scripts/takeover-preflight.test.sh", + "scripts/telemetry-lib.sh", + "scripts/telemetry-log.sh", + "scripts/telemetry-log.test.sh", + "scripts/gemini-hook-bridge.py" + ], + "copied_agent_rules": [ + "ai-model-selection.md", + "branch-rule.md", + "constructive-dissent.md", + "hooks-structure-rule.md", + "latest-stack-context7.md", + "mandate-registry.md", + "mcp-key-management.md", + "memory-lookups.md", + "plan-approval-gate.md", + "plan-commitment-tracking.md", + "reference-over-hardcode.md", + "response-style.md", + "responsive-both-viewports.md", + "settings-protection-coexistence.md", + "sub-agent-scope-contract.md", + "ui-stitch-mandatory.md", + "visual-progress-map.md", + "worktree-rule.md" + ], + "hook_events": [ + "BeforeAgent", + "BeforeTool" + ], + "mcp_server_count": 7, + "warnings": [ + "backend-developer.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", + "backend-developer.md: agent frontmatter field `memory` は Gemini 互換でないため除去", + "backend-developer.md: agent frontmatter field `skills` は Gemini 互換でないため除去", + "chatgpt-image-creator.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", + "document-writer.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", + "document-writer.md: agent frontmatter field `memory` は Gemini 互換でないため除去", + "document-writer.md: agent frontmatter field `skills` は Gemini 互換でないため除去", + "frontend-developer.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", + "frontend-developer.md: agent frontmatter field `memory` は Gemini 互換でないため除去", + "frontend-developer.md: agent frontmatter field `skills` は Gemini 互換でないため除去", + "implementation-auditor.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", + "implementation-auditor.md: agent frontmatter field `memory` は Gemini 互換でないため除去", + "implementation-auditor.md: agent frontmatter field `disallowedTools` は Gemini 互換でないため `tools` へ反映", + "implementation-auditor.md: agent frontmatter field `skills` は Gemini 互換でないため除去", + "implementation-auditor.md: disallowedTools のみ指定されていたため、Gemini 互換の既定ツール集合から禁止ツールを除外して tools を生成", + "qa-reviewer.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", + "qa-reviewer.md: agent frontmatter field `memory` は Gemini 互換でないため除去", + "qa-reviewer.md: agent frontmatter field `disallowedTools` は Gemini 互換でないため `tools` へ反映", + "qa-reviewer.md: agent frontmatter field `skills` は Gemini 互換でないため除去", + "qa-reviewer.md: disallowedTools のみ指定されていたため、Gemini 互換の既定ツール集合から禁止ツールを除外して tools を生成", + "stitch-screen-creator.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", + "test-runner.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", + "test-runner.md: agent frontmatter field `memory` は Gemini 互換でないため除去", + "test-runner.md: agent frontmatter field `permissionMode` は Gemini 互換でないため除去", + "test-runner.md: agent frontmatter field `skills` は Gemini 互換でないため除去", + "PreToolUse matcher=Write|Edit|MultiEdit は Gemini の run_shell_command へ未変換のためスキップ", + "PreToolUse matcher=Bash|Edit|MultiEdit|Shell|StrReplaceFile|Write|WriteFile は Gemini の run_shell_command へ未変換のためスキップ", + "PreToolUse matcher=Skill|Task|Agent は Gemini の run_shell_command へ未変換のためスキップ", + "SessionStart→SessionStart は現状の runtime bridge 未対応のためスキップ", + "UserPromptSubmit command 未対応のため未変換: bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/scripts/handover-preflight.sh\"", + "SubagentStop→AfterAgent は現状の runtime bridge 未対応のためスキップ", + "Stop→AfterAgent は現状の runtime bridge 未対応のためスキップ", + "PostToolUse matcher=Skill|Task|Agent は Gemini の write_file|replace へ未変換のためスキップ" + ] +} diff --git a/.gitignore b/.gitignore index ba6af995b..14a4ffe3a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,15 @@ dist/ *.log .DS_Store -.claude/ +# Keep the canonical project rules under version control; other Claude state +# remains machine-local and ignored. +.claude/* +!.claude/rules/ +!.claude/rules/general/ +!.claude/rules/general/* + +# MCP接続設定は各マシンのローカル設定(sync-agents が生成) +.mcp.json plugin/scripts/*.map plugin/scripts/*.d.mts @@ -29,3 +37,18 @@ integrations/hermes/__pycache__/ eval/reports/ # LongMemEval download is 278MB; fetched on demand eval/data/longmemeval/ + +# AGENT-HUB MANAGED: harness-generated-surfaces START +.agent/workflows/* +.agents/skills/* +.claude/agents/* +.claude/commands/* +.claude/skills/* +.codex/agents/* +.cursor/agents/* +.cursor/skills/* +.gemini/agents/* +.gemini/settings.json +.kimi-code/skills/* +.opencode/agents/* +# AGENT-HUB MANAGED: harness-generated-surfaces END diff --git a/.kimi-code/AGENTS.md b/.kimi-code/AGENTS.md new file mode 100644 index 000000000..e794e615d --- /dev/null +++ b/.kimi-code/AGENTS.md @@ -0,0 +1,31 @@ +# agentmemory Kimi Code CLI Runtime + +このファイルは `sync-kimi-from-cc.py` が生成する Kimi Code CLI 用の補助 instruction です。 +手編集せず、Claude Code 側の正本を更新してから再同期してください。 + +## 正本 + +- ルールの正本はルートの `AGENTS.md` / `CLAUDE.md` / `.claude/rules/` です。 +- Kimi はプロジェクトの `AGENTS.md` も読むため、ここでは Kimi 固有の差分だけを補足します。 +- 旧 `.kimi/agent.yaml` / `.kimi/agents/` は使いません。 + +## 起動 + +- 通常起動: `kimi` +- 自動承認: `kimi --yolo` +- 計画モード: `kimi --plan` +- 直近セッション再開: `kimi --continue` +- セッション選択: `kimi --session` + +`default_thinking = true` を `~/.kimi-code/config.toml` で管理します。Thinking 用 CLI flag は現在の +Kimi Code CLI help に出ていないため、起動案内には使いません。 +`--continue` / `--session` は `--plan` / `--yolo` と併用しません。 + +## Hook と Sub-Agent + +- block 可能な hook は `PreToolUse` / `UserPromptSubmit` / `Stop` だけです。 +- 観測系 event では重い処理を走らせません。 +- 旧 `PostToolUse` 系の整形・表示・同期ガードは Kimi では未強制です。 + 未変換 hook 名は `.kimi-code/sync-state.json` の `warnings` に列挙されます。 +- Sub-agent は Kimi 内蔵の `coder` / `explore` / `plan` を使います。 +- Claude の `.claude/agents/*.md` は Kimi 独自 YAML へ変換しません。必要な知識は Skill か本 instruction へ寄せます。 diff --git a/.kimi-code/hooks/.hook-library-version b/.kimi-code/hooks/.hook-library-version new file mode 100644 index 000000000..8d5bf551a --- /dev/null +++ b/.kimi-code/hooks/.hook-library-version @@ -0,0 +1 @@ +v3.6.37 | profile: agentmemory diff --git a/.kimi-code/hooks/kimi-hook-bridge.py b/.kimi-code/hooks/kimi-hook-bridge.py new file mode 100755 index 000000000..acac069d5 --- /dev/null +++ b/.kimi-code/hooks/kimi-hook-bridge.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +Kimi hook stdin JSON を Claude hook 互換 env に変換して既存 hook command を実行する。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a Claude-style hook command from Kimi hook input.") + parser.add_argument("command", help="Shell command generated from .claude/settings.json") + return parser.parse_args() + + +def load_payload() -> dict: + raw = sys.stdin.read() + if not raw.strip(): + return {} + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return {"raw_stdin": raw} + return payload if isinstance(payload, dict) else {"payload": payload} + + +def first_string(*values: object) -> str: + for value in values: + if isinstance(value, str) and value: + return value + return "" + + +def main() -> int: + args = parse_args() + payload = load_payload() + tool_input = payload.get("tool_input") + if not isinstance(tool_input, dict): + tool_input = payload.get("toolInput") + if not isinstance(tool_input, dict): + tool_input = {} + + env = os.environ.copy() + env["CLAUDE_PROJECT_DIR"] = first_string(payload.get("cwd"), payload.get("project_dir"), env.get("PWD"), os.getcwd()) + env["CLAUDE_FILE_PATH"] = first_string( + tool_input.get("file_path"), + tool_input.get("path"), + tool_input.get("notebook_path"), + payload.get("file_path"), + payload.get("path"), + ) + tool_name = first_string(payload.get("tool_name"), payload.get("toolName"), payload.get("name")) + env["CLAUDE_TOOL_INPUT"] = json.dumps(tool_input or payload, ensure_ascii=False) + env.setdefault("CLAUDE_TOOL_NAME", tool_name) + + # Inner Claude-style hooks still read stdin via hook-io.sh. Pass a normalized + # payload through so hooks do not silently lose file_path, cwd, or tool_name. + normalized_tool_input = dict(tool_input or payload) + project_dir = first_string(payload.get("cwd"), payload.get("project_dir")) + if project_dir and "cwd" not in normalized_tool_input: + normalized_tool_input["cwd"] = project_dir + stdin_payload_obj = {"tool_input": normalized_tool_input} + if tool_name: + stdin_payload_obj["tool_name"] = tool_name + if project_dir: + stdin_payload_obj["cwd"] = project_dir + stdin_payload = json.dumps(stdin_payload_obj, ensure_ascii=False) + result = subprocess.run(args.command, shell=True, executable="/bin/bash", env=env, input=stdin_payload, text=True) + return result.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.kimi-code/hooks/lib/code-quality-check.md b/.kimi-code/hooks/lib/code-quality-check.md new file mode 100644 index 000000000..384cad9b1 --- /dev/null +++ b/.kimi-code/hooks/lib/code-quality-check.md @@ -0,0 +1,205 @@ +# Code Quality Checklist(SubagentStop / Stop hook用) + + + +サブエージェントの作業完了時に、以下の観点で品質チェックを実施する。 +対象: 直前のサブエージェントが**新規作成・変更した**ファイルのみ。 + +--- + + +## コメント品質(Code as ドキュメント) + +### 必須コメント + +| 対象 | ルール | +|------|--------| +| 関数・メソッド | JSDoc / PHPDoc で @param, @returns を記載 | +| 複雑なロジック | 条件分岐3つ以上、正規表現 → 「なぜ」のコメント | +| マジックナンバー | 定数化 or コメントで意味を説明 | +| TODO / FIXME | 理由と期限を記載(// TODO(2025-03): ○○対応後に削除) | + +### 変更コメントの必須フォーマット + +既存コードに意味のある変更を加えた場合、以下のフォーマットでコメントを残すこと。 +**目的:** 次にAIがこの領域を修正する際に同じ過ちを繰り返さないための判断基準を残す。 + +``` +// [YYYY-MM-DD][fix|feat|refactor] +// 背景: ユーザーがその修正を依頼した理由・意図 +// 守るべき業務ルール・ブランド基準 +// 他の実装方法ではダメな理由の判断根拠 +// 対応: 実施した変更内容 +``` + +**背景に含めるべき3要素:** +1. ユーザーがその修正を依頼した理由・意図 +2. その領域で守るべき業務ルール・ブランド基準 +3. なぜ他の実装方法ではダメなのかの判断根拠 + +- [ ] 変更箇所に `[YYYY-MM-DD][fix|feat|refactor]` コメントがあるか +- [ ] 背景に「ユーザー意図」「業務ルール」「不採用理由」が含まれるか +- [ ] 次のAIが同じ判断ミスをしない情報が残っているか + + +--- + + +## 重複機能の禁止(DRY原則) + +| チェック項目 | 基準 | +|-------------|------| +| 既存検索義務 | 新コンポーネント・関数作成前に既存コードベースを検索したか | +| 適用範囲 | ロジック・スタイル定義・色・文言すべてに適用 | +| 類似機能の扱い | 新規作成ではなく既存を拡張・共通化すること | +| パラメータ化 | 同目的のコンポーネントは1つに統合しprops/パラメータで切替 | + +- [ ] 新規関数・コンポーネント作成前にGrep検索で既存を確認したか +- [ ] 同様のロジック・スタイル・文言が既に存在しないか +- [ ] 類似機能がある場合、新規作成ではなく既存を拡張したか + + +--- + + +## ハードコード防止 + +DB由来データ(店舗名、ロール、ステータス等)がコード内にリテラルで直書きされていないかチェックする。 + +### データ管理の優先順位 + +| 優先度 | 方法 | 対象 | +|--------|------|------| +| 1(最優先) | DBから取得 | 変更頻度があるもの: 店舗名、ブランドカラー、設定値、営業時間等 | +| 2 | 定数ファイルに定義 | 環境に依存しない固定値: ステータスEnum、カテゴリ種別等 | +| 3(最終手段) | ハードコード | ①②が不可能な場合のみ。理由をコメントに明記すること | + +**追加ルール:** 同じ値が2箇所以上に出現する場合、必ず①または②で一元管理すること。 + +### チェック項目 + +| 対象 | ルール | +|------|--------| +| ビジネスデータ直書き | 店舗名・ロール名・ステータス等がリテラル文字列で記述されていないか | +| 既存定数の未使用 | プロジェクトにModel定数・Enum・ValueObjectがあるのに文字列比較していないか | +| フロントのマスタデータ | コンポーネント内に選択肢リストがハードコードされていないか(propsまたはAPI経由にする) | +| 固有名詞の条件分岐 | `name.includes('固有名詞')` のような分岐がないか(IDまたはフラグで判定する) | +| TODO_DB / PLACEHOLDER | DB由来データを暫定的に書く場合、`// TODO_DB(YYYY-MM\|ISSUE-123): テーブル名.カラム名` または `// PLACEHOLDER(YYYY-MM\|ISSUE-123): 理由` が付いているか | + +### 許可パターン(チェック対象外) + +- Model / Enum / ValueObject 内の定数定義 +- テストファイル・Seeder・Factory +- config/ 配下の設定ファイル +- 定数ファイル(constants.ts 等) + +- [ ] 定数・設定値がDB or 定数ファイルから取得されているか +- [ ] 同じ値が2箇所以上にハードコードされていないか +- [ ] やむを得ないハードコードに理由コメントがあるか + + +--- + + +## 破壊的変更の事前確認 + +| チェック項目 | 基準 | +|-------------|------| +| 参照洗い出し | 関数・コンポーネント・スタイルの変更/削除前にgrep等で全参照箇所を特定 | +| 整合性修正 | 参照箇所が見つかった場合、全箇所を整合性を保って修正 | +| 報告義務 | 変更した全ファイルと箇所のサマリーをユーザーに報告 | + +- [ ] 変更・削除した関数の全参照箇所をGrepで確認したか +- [ ] 参照箇所を整合性を保って全て修正したか +- [ ] 変更ファイルと箇所のサマリーを報告したか + + +--- + + +## メタ情報コメント(Serena MCP検索対応) + +新規作成ファイルの冒頭に、検索可能なメタ情報コメントがあるか確認する。 +**既存ファイルへの軽微な修正(1-2行の変更)は対象外。** + +### TypeScript / JavaScript / React / React Native / Next.js + +```ts +/** + * @module モジュール名(PascalCase) + * @description 日本語で1行の概要。Serenaのsearch_for_patternで引っかかるキーワードを含める + * @related 関連モジュール名をカンマ区切り + * @stack react-native | react | nextjs ← プロジェクトのスタックを明記 + */ +``` + +### PHP / Laravel + +```php +/** + * @module モジュール名 + * @description 日本語で1行の概要 + * @related 関連クラス・モデル名 + * @stack laravel + */ +``` + +### 対象外(メタ情報コメント不要) +- 設定ファイル(.env, tailwind.config.*, tsconfig.json, composer.json等) +- テストファイル(テスト名が十分なドキュメント) +- 自動生成ファイル(migration以外のartisan generate等) +- package.json, Gemfile, requirements.txt等の依存定義 + +### 命名・配置 + +| チェック項目 | 基準 | +|-------------|------| +| シンボル命名 | 検索しやすい名前か(略語を避ける。ResCtrl → ReservationController) | +| ファイル配置 | プロジェクトの標準ディレクトリに配置されているか | + + +--- + + +## 型チェック(Code as Documentの土台) + +| スタック | ツール | 基準 | +|---------|--------|------| +| TypeScript | `tsc --noEmit` | strict mode必須。any禁止 | +| Laravel | PHPStan | Level 8以上(目標: Level 10) | +| React Native | `tsc --noEmit` | strict mode必須 | + +### チェック項目 + +| 対象 | ルール | +|------|--------| +| 関数の引数・戻り値 | 型アノテーション必須(any / mixed 禁止) | +| API レスポンス | Zod / FormRequest で型を定義 | +| Props | TypeScript interface / PHPDoc @param で明示 | +| 状態管理 | useState / typed Collection で型付け | + +**型が曖昧なコード = ドキュメントとして読めないコード**。AIが推測に頼る原因になるため、型は厳格に。 + + +--- + + +--- + +## 判定基準 + +- 全項目OK → {"decision": "approve", "reason": "品質基準を満たしています"} +- 1つでもNG → {"decision": "block", "reason": "【具体的な指摘と修正指示をここに書く】"} +- stop_hook_activeがtrueの場合 → 無限ループ防止のため必ずapprove + + +- Codex Stop hook の全項目OK / stop_hook_active=true → {"continue": true} diff --git a/.kimi-code/hooks/lib/hook-io.sh b/.kimi-code/hooks/lib/hook-io.sh new file mode 100755 index 000000000..219c77bb5 --- /dev/null +++ b/.kimi-code/hooks/lib/hook-io.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: jtt-cms Gen 3 のhook-io.shをAGENT-HUBのhook-libraryにポート。 +# PreToolUse/PostToolUse共通のJSON解析・出力関数を一元管理。 +# 3PJで同一ロジックが重複しており、修正時の漏れを防止するためコンポーネント化。 +# 対応: jtt-cms hook-io.sh をそのままポート。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「フック判定が環境差(Node有無)で揺れず、同じ入力なら同じ結果になること」。 +# 業務ルールとして、JSON抽出はエスケープ文字や改行を含む実データでも破綻してはならない。 +# 代替案として sed ベースの簡易抽出を維持すると、文字列中の引用符で誤抽出が起きるため不採用。 +# 対応: Node未導入時は Python JSON パースを使う安全フォールバックへ変更。 + +# --- stdin読み込み --- +# stdinからJSON入力を読み込み、HOOK_INPUT変数に格納する。 +# 各フックのエントリポイントで最初に呼ぶこと。 +read_stdin() { + HOOK_INPUT="$(cat)" +} + +# --- JSON フィールド抽出 (PreToolUse用) --- +# tool_input内の文字列フィールドを抽出する。Node.js優先、sed fallback。 +# 使用例: COMMAND=$(extract_field command) +extract_field() { + local field="$1" + if command -v node >/dev/null 2>&1; then + printf '%s' "$HOOK_INPUT" | node -e ' + const fs = require("fs"); + const field = process.argv[1]; + const raw = fs.readFileSync(0, "utf8"); + let value = ""; + try { + const parsed = JSON.parse(raw); + const source = + parsed && typeof parsed.tool_input === "object" && parsed.tool_input !== null + ? parsed.tool_input + : parsed && typeof parsed.toolInput === "object" && parsed.toolInput !== null + ? parsed.toolInput + : parsed; + if (source && typeof source[field] === "string") { + value = source[field]; + } + } catch {} + process.stdout.write(value); + ' "$field" 2>/dev/null || true + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + PY_FIELD="$field" HOOK_JSON="$HOOK_INPUT" python3 - <<'PY' 2>/dev/null || true +import json +import os + +field = os.environ.get("PY_FIELD", "") +raw = os.environ.get("HOOK_JSON", "") +value = "" +try: + parsed = json.loads(raw) + source = ( + parsed.get("tool_input") + or parsed.get("toolInput") + or parsed + if isinstance(parsed, dict) + else {} + ) + candidate = source.get(field, "") if isinstance(source, dict) else "" + if isinstance(candidate, str): + value = candidate +except Exception: + pass +print(value, end="") +PY + return 0 + fi + + # Node / Python が未導入の場合のみ簡易フォールバック(誤抽出リスクあり) + echo "$HOOK_INPUT" | sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -1 || true +} + +# --- file_path抽出 (PostToolUse用) --- +# tool_inputからfile_path(またはpath)を抽出する。Python3使用。 +# 使用例: filepath=$(extract_file_path) +extract_file_path() { + printf '%s' "$HOOK_INPUT" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + ti = data.get('tool_input') or data.get('toolInput') or {} + print(ti.get('file_path', ti.get('path', ''))) +except Exception: + print('') +" 2>/dev/null || echo "" +} + +# --- Supabase migration SQL 判定 --- +is_supabase_migration_sql_path() { + local path="${1:-}" + [[ "$path" =~ (^|/)supabase/migrations/[^/]+\.sql$ ]] +} + +# --- deny JSON出力 (PreToolUse用) --- +# hookSpecificOutput形式のdeny JSONを出力し、exit 0で終了する。 +# 使用例: emit_deny "ブロック理由メッセージ" +# [2026-06-19][fix] +# 背景: +# - Claude/Codex/Kimi の hook deny 出力で旧 `reason` キーが混在すると、 +# 新しい権限UIで理由が表示されない環境がある。 +# - 守るべき業務ルール: deny 理由は `permissionDecisionReason` に統一し、 +# JSON 文字列は Python で escape して壊れた hook 出力を防ぐ。 +# - 他案不採用理由: 各 hook で個別に printf する案は schema 差分と escape 漏れが再発するため不採用。 +emit_deny() { + local reason="$1" + HOOK_REASON="$reason" python3 - <<'PY' +import json +import os + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": os.environ.get("HOOK_REASON", ""), + } +}, ensure_ascii=False, separators=(",", ":"))) +PY + exit 0 +} diff --git a/.kimi-code/hooks/lib/quality-check-common.sh b/.kimi-code/hooks/lib/quality-check-common.sh new file mode 100755 index 000000000..72a0486c3 --- /dev/null +++ b/.kimi-code/hooks/lib/quality-check-common.sh @@ -0,0 +1,1007 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: jtt-cms Gen 3 (403行) をAGENT-HUBのhook-libraryにポート。 +# 3PJで独立進化したhookを統一するため、最先端のGen 3をSSOTとして抽出。 +# 各PJ個別実装だと変更が伝播せず重複が増え続けるため、コンポーネント化して +# deploy-hooks.pyで全PJに配布する設計。 +# 対応: jtt-cms quality-check-common.sh をhook-library/lib/にポート。 +# パス解決をscripts/サブディレクトリ構成に対応させ、 +# チェックリストパスをproject_dir起点に変更。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「transcript解析がPython 3.8環境でも失敗せず動くこと」。 +# 業務ルールとして、品質ゲート共通ライブラリはPJ間で同一挙動を保つ必要がある。 +# 代替案として `set[str]` 型注釈を維持すると、3.8で構文エラーになり判定が抜けるため不採用。 +# 対応: 埋め込みPythonの型注釈を `typing.Set` ベースへ変更。 + +set -euo pipefail + +# telemetry(harness-checkup): quality-gate 系(stop/subagent)の deny を記録。 +# 本 lib は hook-library/lib/ に在り、telemetry-lib.sh は hook-library/scripts/ にある。 +# 配布先でも同じ相対構成(.claude/hooks/lib/ と .claude/hooks/scripts/)のため ../scripts/ で解決できる。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも quality-gate 本体を絶対に壊さない。 +_quality_common_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$_quality_common_dir/../scripts/telemetry-lib.sh" ]; then + . "$_quality_common_dir/../scripts/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: jtt-apps の /brainstorm 質問のみセッションで Stop hook が誤発火する事故 (B1) を、git diff fallback がバックグラウンド同期で書き換わった untracked 派生物 (.opencode/sync-state.json 等) を「変更ファイル」と誤認することで起きる問題として根治したい。 +# - 守るべき業務ルール: 配布先 PJ 側で sync スクリプトが書き換える派生物 (.opencode/, .cursor/, .gemini/, .augment/, .codex/hooks/, .agent/, sync-state.json) は AI のツール呼び出し由来ではないため品質ゲートの対象外にする。 +# - 他案不採用理由: +# 1) .gitignore に追加して回避する案 → 検出ロジックの欠陥は残ったまま、新しい派生物ディレクトリが増えるたびに各 PJ で .gitignore を直す必要があり SSOT 原則違反。 +# 2) git diff fallback を完全廃止する案 → Bash 経由 (sed -i / cat > / tee 等) の書き換えを救う最後の砦が消える。 +# 対応: DOC_SKIP_PATTERNS に sync 派生物パターンを追加し多重防御。主防御は run_quality_check_hook の transcript 判定変更で行う。 +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: business profile の PJ (jtt-cafe-pj / non-pj) は議事録・PRD・戦略などの .md/docs が +# 成果物そのもの。従来は全 PJ 共通で .md/docs を skip していたため、business PJ がローカルで DOC_SKIP を +# 書き換える drift が発生していた (hook-library v3.4.11 配布で露見・Codex 指摘)。SSOT で一元解決したい。 +# - 守るべき業務ルール: 同期派生物 (.opencode/ 等・ツール生成物) は全 profile で skip。文書 (.md/docs 等) は +# code profile では skip、business profile では品質チェック対象にする。配布時に deploy-hooks.py が +# business profile のみ DOC_TYPE_SKIP を外す。配布物のローカル編集 (drift) は禁止のため SSOT 側で分岐させる。 +# - 他案不採用理由: (1) 各 business PJ で DOC_SKIP をローカル編集 → 配布物改変禁止に反し再 drift。 +# (2) runtime で checklist md の文言から profile 推定 → 文言変更で静かに壊れる。 +# 対応: パターンを DOC_TYPE_SKIP (文書) と SYNC_DERIVATIVE_SKIP (同期派生物) に分割。deploy-hooks.py は +# business profile 配布時に下の結合行を `readonly DOC_SKIP_PATTERNS="${SYNC_DERIVATIVE_SKIP}"` へ置換する。 +# [2026-07-09][fix] +# 背景: +# - ユーザー依頼意図: `.brv/` と Kimi 系生成物が同期派生物なのに品質チェック対象へ入り、実作業の本質と +# 無関係な検出ノイズになるのを防ぎたい。 +# - 守るべき業務ルール: AI ツール CLI 派生物は SSOT から再生成・同期されるため、quality check の本文対象ではなく +# SYNC_DERIVATIVE_SKIP に集約する。文書本文の品質チェック分岐は既存の DOC_TYPE_SKIP と分けたまま維持する。 +# - 他案不採用理由: 各 PJ の `.gitignore` へ個別追加する案は配布先ごとの drift を増やすため不採用。 +# DOC_TYPE_SKIP 側へ混ぜる案は business profile の文書チェック分岐を壊すため不採用。 +# 対応: SYNC_DERIVATIVE_SKIP に `.brv/`、`.kimi-code/`、`.kimi/` を追加する。 +# [2026-07-30][fix] +# 背景: +# - ユーザー依頼意図: jtt-apps の実装セッション(シフト確定 v2.5.2)で、Stop hook が +# `.claude/hooks/.hook-library-version` を「変更されたコードファイル」として毎回検知し、 +# 品質チェック済みでも完了報告のたびに block を繰り返した。セッション由来でない配布物で止めたくない。 +# - 守るべき業務ルール: `.claude/hooks/**` は deploy-hooks.py が hook-library 正本から生成する配布物であり、 +# 配布先での直接編集は禁止(settings-protection-coexistence)。よって配布先 PJ で品質チェックの +# 対象にする意味がなく、正本側(AGENT-HUB `hook-library/`)でチェックすべき対象である。 +# 既に `^\.codex/hooks/` は除外済みで、Claude 側だけが抜けていた非対称性が原因。 +# - 他案不採用理由: +# 1) git diff fallback で追跡変更を拾うのを止める案 → Bash 経由(sed -i / cat >)の実コード変更を +# 見逃し品質ゲートが弱くなるため不採用(2026-05-26 の判断を維持)。 +# 2) `.hook-library-version` だけをファイル名で除外する案 → 同じ配布物である `lib/*.sh` や +# `scripts/*.sh` のドリフトで再発するため対症療法。ディレクトリ単位で `.codex/hooks/` と揃える。 +# 3) 配布先 PJ の drift をその都度コミットして消す案 → 配布のたびに日付スタンプで再発するため恒久解にならない。 +# 対応: SYNC_DERIVATIVE_SKIP に `^\.claude/hooks/|/\.claude/hooks/` を追加し、`.codex/hooks/` と対称にする。 +readonly DOC_TYPE_SKIP='\.md$|\.prd$|\.txt$|^docs/|/docs/|\.template$|CLAUDE\.md|README|CHANGELOG' +readonly SYNC_DERIVATIVE_SKIP='^\.opencode/|/\.opencode/|^\.cursor/|/\.cursor/|^\.gemini/|/\.gemini/|^\.augment/|/\.augment/|^\.claude/hooks/|/\.claude/hooks/|^\.codex/hooks/|/\.codex/hooks/|^\.agent/|/\.agent/|^\.brv/|/\.brv/|^\.kimi-code/|/\.kimi-code/|^\.kimi/|/\.kimi/|sync-state\.json$' +# DEPLOY-MARKER(business): deploy-hooks.py は business profile でこの行を SYNC_DERIVATIVE_SKIP のみへ置換する。 +readonly DOC_SKIP_PATTERNS="${DOC_TYPE_SKIP}|${SYNC_DERIVATIVE_SKIP}" +# [2026-03-17][refactor] +# 背景: +# - ユーザー依頼意図: hookのblock reasonにチェックリスト全文(395行)が毎回チャットに出力され、 +# 視認性が悪くコンテキストウィンドウを圧迫するため、最小限の出力に変更したい。 +# - 守るべき業務ルール: dev-guardrails SKILL.md Section 9「発火フロー」に記載の +# 「ファイルパス参照指示を block reason に記載 → AIが Read ツールで code-quality-check.md を +# 読み込み品質チェック実施」方式をランタイムで実現すること。 +# - 他案不採用理由: (1) チェックリスト全文のインライン注入は視認性を壊す(現状の問題そのもの)。 +# (2) 要約版を別ファイルで管理する案はDRY違反で同期漏れを再発させるため不採用。 +# (3) block reasonを完全に空にする案はAIが何をすべきか分からなくなるため不採用。 +# 対応: block reasonにはファイルパス+変更ファイル一覧のみ出力し、 +# AIにReadツールでチェックリストを読ませる方式に変更。 +readonly BLOCK_PREFIX='作業完了前に品質チェックを実施してください。指定されたチェックリストファイルを Read ツールで読み込み、各項目を確認してください。問題があれば修正してから再度完了を報告してください。' +readonly CODE_FILE_PATTERNS='\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|sql|php|py|sh|yaml|yml|toml|ini|mdx?)$' + +emit_json() { + local decision="$1" + local reason="$2" + + PY_DECISION="$decision" PY_REASON="$reason" python3 - <<'PY' +import json +import os + +print( + json.dumps( + {"decision": os.environ["PY_DECISION"], "reason": os.environ["PY_REASON"]}, + ensure_ascii=False, + ) +) +PY +} + +is_codex_hook_root() { + local hook_root="$1" + local normalized_hook_root + + normalized_hook_root="$(cd "$hook_root" 2>/dev/null && pwd || printf '%s\n' "$hook_root")" + + case "$normalized_hook_root" in + */.codex/hooks|*/.codex/hooks/) return 0 ;; + *) return 1 ;; + esac +} + +is_kimi_hook_root() { + local hook_root="$1" + local normalized_hook_root + + normalized_hook_root="$(cd "$hook_root" 2>/dev/null && pwd || printf '%s\n' "$hook_root")" + + case "$normalized_hook_root" in + */.kimi-code/hooks|*/.kimi-code/hooks/|*/.kimi/hooks|*/.kimi/hooks/) return 0 ;; + *) return 1 ;; + esac +} + +emit_block_json() { + local hook_root="$1" + local reason="$2" + + if is_kimi_hook_root "$hook_root"; then + PY_REASON="$reason" python3 - <<'PY' +import json +import os + +print(json.dumps({ + "hookSpecificOutput": { + "permissionDecision": "deny", + "permissionDecisionReason": os.environ["PY_REASON"], + } +}, ensure_ascii=False)) +PY + return 0 + fi + + emit_json "block" "$reason" +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: Codex Stop hook が2回目停止時に +# "hook returned invalid stop hook JSON output" で失敗する問題を、配布元の正本で直したい。 +# - 守るべき業務ルール: hook-library は Claude Code / Codex CLI の共通正本なので、 +# Codex だけに必要な出力差分は配布先 hook_root で分岐し、Claude 側の既存応答を維持する。 +# - 他案不採用理由: 共通ライブラリ全体を `decision: approve` のままにする案は Codex Stop で再発する。 +# 逆に全環境を `continue: true` に変える案は Claude Code 側の既存運用に不要な互換リスクを持ち込むため不採用。 +# 対応: `.codex/hooks` 配下で動く approve 相当分岐だけ `{"continue": true}` を返す。 +emit_approval_json() { + local hook_root="$1" + local reason="$2" + + if is_codex_hook_root "$hook_root"; then + python3 - <<'PY' +import json + +print(json.dumps({"continue": True})) +PY + return 0 + fi + + emit_json "approve" "$reason" +} + +resolve_project_dir() { + local hook_root="$1" + local inferred_dir git_root + + if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}" ]; then + printf '%s\n' "$CLAUDE_PROJECT_DIR" + return + fi + + # hook_root is .claude/hooks/ → go up 2 levels to project root + inferred_dir="$(cd "$hook_root/../.." && pwd)" + if git -C "$inferred_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '%s\n' "$inferred_dir" + return + fi + + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ]; then + printf '%s\n' "$git_root" + return + fi + + printf '%s\n' "$inferred_dir" +} + +extract_stop_hook_active() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + print(str(data.get('stop_hook_active', False)).lower()) +except Exception: + print('error') +" <<<"$input" 2>/dev/null || echo "error" +} + +extract_transcript_path() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + value = data.get('transcript_path', '') + print(value if isinstance(value, str) else '') +except Exception: + print('') +" <<<"$input" 2>/dev/null || true +} + +extract_agent_type() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + print(data.get('agent_type', '')) +except Exception: + print('') +" <<<"$input" 2>/dev/null || echo "" +} + +extract_agent_transcript_path() { + local input="$1" + + python3 -c " +import json +import sys + +try: + data = json.load(sys.stdin) + value = data.get('agent_transcript_path', '') + print(value if isinstance(value, str) else '') +except Exception: + print('') +" <<<"$input" 2>/dev/null || true +} + +# [2026-03-17][fix] +# 背景: +# - ユーザー依頼意図: PR429レビューで、hook が変更ファイル一覧を誤判定せず、 +# 品質チェックの block/approve 判定を安定して行える状態にしたい。 +# - 守るべき業務ルール: Git 管理下の合法パス(前後空白や改行を含む名前を含む)でも +# 品質ゲートが誤検知・見逃しを起こさないこと。品質ゲートの誤作動は +# 「本来 block すべき変更を素通しする」「関係ない変更で block する」の両面で運用事故になる。 +# - 他案不採用理由: (1) 改行区切りのまま扱う案は改行入りパスで分裂する。 +# (2) strip で前後空白を落とす案は合法パスを別名に変えてしまう。 +# (3) 特殊ケースを無視する案は次回AIが同じバグを再発させるため不採用。 +# 対応: 変更ファイル一覧は JSON 配列で受け渡しし、表示時だけ安全に整形する。 +extract_changed_files_from_input() { + local input="$1" + + python3 -c " +import json +import sys + +PATH_KEYS = {'file_path', 'path', 'new_path', 'old_path', 'target_path'} + +def walk(node, out): + if isinstance(node, dict): + for key, value in node.items(): + if key.lower() in PATH_KEYS and isinstance(value, str) and value != '': + out.add(value) + walk(value, out) + return + if isinstance(node, list): + for item in node: + walk(item, out) + +paths = set() +try: + payload = json.load(sys.stdin) + walk(payload, paths) +except Exception: + pass + +print(json.dumps(sorted(paths), ensure_ascii=False)) +" <<<"$input" 2>/dev/null || echo "[]" +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: /brainstorm のような質問のみセッション (AI が Write/Edit を一切呼ばない) で Stop hook が誤発火する問題 (B1) の主防御。 +# - 守るべき業務ルール: transcript が読み取れた状態で Write 系ツールが 0 件なら、コード変更は本会話由来ではないと判定し git diff fallback を呼ばずに approve する。 +# - 他案不採用理由: +# 1) extract_changed_files_from_transcript の戻り値だけで判定する案 → "[]" が「読めて 0件」と「読めなかった」を区別できず、Bash 経由書き換え時に fallback が呼ばれなくなる。 +# 2) 戻り値に sentinel 文字列を混ぜる案 → 呼び出し側のパース処理が複雑化し、JSON との混在で誤判定リスク。 +# 対応: transcript_path の読み取り可否を別関数で boolean 返却し、呼び出し側で 3 状態 (paths あり / 読めて 0件 / 読めなかった) に分岐する。 +transcript_was_readable() { + local transcript_path="$1" + if [ -n "$transcript_path" ] && [ -f "$transcript_path" ]; then + echo "true" + else + echo "false" + fi +} + +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: PR87レビューで、transcript が読める状態の Bash 書き込み +# (`cat > file`, `tee`, `sed -i` 等) が Write/Edit 0件扱いで品質ゲートを素通りする問題を直したい。 +# - 守るべき業務ルール: /brainstorm の質問のみセッションでは誤発火させない一方で、git diff fallback は +# Bash 経由書き換えを救う最後の砦として残す必要がある。 +# - 他案不採用理由: +# 1) git diff fallback を完全廃止する案 → Bash 経由書き換え検出が失われるため不採用。 +# 2) transcript 内に Bash があるだけで fallback する案 → `git status` だけの質問セッションで再発火しやすいため不採用。 +# 対応: transcript の Bash command から書き込み系パターンだけを検出し、その時だけ fallback に進める。 +# +# [2026-04-28][fix] +# 背景: +# - ユーザー依頼意図: 読取専用セッション(gh / git 系コマンドのみ)で Stop hook が連続誤発火し、 +# タイポディレクトリ `.claire/` 配下の untracked ファイルを「変更コード」と誤検出する事故が再発した。 +# - 守るべき業務ルール: シェルリダイレクト `2>&1` / `1>&2` はファイル書き込みではない。 +# `&>/dev/null` / `&>>/dev/null` も破棄目的の診断出力であり、WRITE 判定に含めると +# 診断目的の `gh ... 2>&1` 連発で git diff fallback が誤起動し、 +# 別 worktree や typo ディレクトリの差分まで拾ってしまう。 +# - 他案不採用理由: +# 1) WRITE_COMMAND_RE から `>` を完全削除する案 → 真の `cmd > file` 書き込みを見逃すため不採用。 +# 2) DOC_SKIP_PATTERNS に `.claire` を足す案 → 対症療法。次の typo に対応できないため不採用。 +# 3) 実行時に shlex で AST パースする案 → bash heredoc / 複合コマンドで誤動作しやすく過剰実装。 +# 対応: FD複製 (`2>&1`) と `/dev/null` 破棄だけを除外し、`1>file` / `2>file` / `&>file` は +# 真のファイル書き込みとして検出する。 +transcript_has_bash_write_command() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "false" + return 0 + fi + + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "false" +import json +import re +import sys +from typing import Any + +# `>` / `>>` はFD複製 (`2>&1`) と `/dev/null` 破棄だけを除外する。 +# これにより `1>file`, `2>file`, `&>file` は検出し、`2>&1`, `1>&2`, `&>/dev/null` は除外する。 +WRITE_COMMAND_RE = re.compile( + r"((?:^|[\s;|])(?:\d*)?>>?(?!&)(?!\s*/dev/null\b)\s*|(?:^|[\s;|])&>{1,2}(?!>)(?!\s*/dev/null\b)\s*|\btee\b|\bsed\s+-i\b|\bperl\s+-pi\b|\bcp\b|\bmv\b|\brm\b|\btouch\b|\bmkdir\b|\bcat\s+<<)" +) + + +def tool_name(node: Any) -> str: + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + value = node.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def command_text(node: Any) -> str: + if not isinstance(node, dict): + return "" + if isinstance(node.get("command"), str): + return node["command"] + nested = node.get("input") + if isinstance(nested, dict) and isinstance(nested.get("command"), str): + return nested["command"] + tool_input = node.get("tool_input") + if isinstance(tool_input, dict) and isinstance(tool_input.get("command"), str): + return tool_input["command"] + return "" + + +def has_bash_write(node: Any) -> bool: + if isinstance(node, dict): + name = tool_name(node) + if name in {"Bash", "Shell"} and WRITE_COMMAND_RE.search(command_text(node)): + return True + return any(has_bash_write(value) for value in node.values()) + if isinstance(node, list): + return any(has_bash_write(item) for item in node) + return False + + +try: + content = open(sys.argv[1], encoding="utf-8", errors="ignore").read() +except Exception: + print("false") + raise SystemExit(0) + +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + if has_bash_write(json.loads(line)): + print("true") + raise SystemExit(0) + except SystemExit: + raise + except Exception: + pass + +try: + result = has_bash_write(json.loads(content)) +except Exception: + result = False + +print("true" if result else "false") +PY +} + +extract_changed_files_from_transcript() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "[]" + return 0 + fi + + # Layer 3: 書き込みツール(Write/Edit/NotebookEdit/MultiEdit)のfile_pathのみ収集。 + # Read/Grep/Globなどの読み取り専用ツールのfile_pathを「変更」と誤認しない。 + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "[]" +import json +import sys +from typing import Any, Set + +WRITE_TOOLS = frozenset({"Write", "Edit", "NotebookEdit", "MultiEdit", "WriteFile", "StrReplaceFile"}) +PATH_KEYS = frozenset({"file_path", "path", "new_path", "old_path", "target_path"}) + +transcript_path = sys.argv[1] +paths: Set[str] = set() + + +def collect_paths(node: Any) -> None: + """Collect file paths from a node known to belong to a write tool.""" + if isinstance(node, dict): + for key, value in node.items(): + if key.lower() in PATH_KEYS and isinstance(value, str) and value != "": + paths.add(value) + collect_paths(value) + return + if isinstance(node, list): + for item in node: + collect_paths(item) + + +def find_tool_name(node: Any) -> str: + """Extract tool name from a dict node.""" + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + val = node.get(key, "") + if isinstance(val, str) and val: + return val + return "" + + +def process_entry(entry: Any) -> None: + """Walk an entry and only collect paths from write tool invocations.""" + if not isinstance(entry, dict): + return + tool_name = find_tool_name(entry) + if tool_name in WRITE_TOOLS: + collect_paths(entry) + # Recurse into nested structures (content, messages, etc.) + for key in ("content", "messages", "tool_use", "input"): + child = entry.get(key) + if isinstance(child, list): + for item in child: + process_entry(item) + elif isinstance(child, dict): + process_entry(child) + + +try: + with open(transcript_path, encoding="utf-8", errors="ignore") as f: + content = f.read() +except Exception: + print("[]") + sys.exit(0) + +# JSON Lines format +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + process_entry(json.loads(line)) + except Exception: + pass + +# Single JSON object format +try: + process_entry(json.loads(content)) +except Exception: + pass + +print(json.dumps(sorted(paths), ensure_ascii=False)) +PY +} + +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: Bash の作成系コマンド (`cat > f` / `tee f` / `touch f` / `> f`) の +# ターゲットパスを transcript から抽出し、git diff フォールバックで「セッションが作成した +# 未追跡ファイルだけ」を拾えるようにする。 +# - 守るべき業務ルール: 移動・削除系 (mv / cp / rm) は新規コード作成の判定に使わない。 +# `mv tmp dest` のような plumbing を作成扱いすると、他セッション WIP の誤検知 (R1) を再発させる。 +# - 他案不採用理由: +# 1) WRITE_COMMAND_RE の boolean 判定を流用する案は、ターゲットパスが取れず未追跡の絞り込みができない。 +# 2) 正規表現だけでパスを分割する案は、`cat > "src/space file.ts"` のような引用符付きパスを見逃す。 +# 対応: shlex で Bash コマンドの引用符を解釈し、作成系リダイレクト/コマンドのターゲットだけを抽出する。 +extract_bash_created_paths_from_transcript() { + local transcript_path="$1" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + echo "[]" + return 0 + fi + + python3 - "$transcript_path" <<'PY' 2>/dev/null || echo "[]" +import json +import os +import re +import shlex +import sys +from typing import Any + +REDIRECT_TOKEN_RE = re.compile(r"^(?:(?:\d*)>{1,2}|&>{1,2})$") +METACHARS = {";", "|", "&", "<", ">", ">>", "&>", "&>>", "&&", "||"} + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `cd scripts && cat > foo.py` のように Bash の cwd が変わった後の +# ファイル作成を transcript から抽出するとき、cwd を無視して相対パスのまま返すため +# `git ls-files --others` の `scripts/foo.py` と一致せず未追跡ファイルを見逃す問題を修正したい。 +# 守るべき業務ルール: 移動・削除系 (mv / cp / rm) は作成扱いしない(R1 誤検知防止)。 +# 変数展開を含む `cd "$VAR"` は追跡不能で、従来どおり相対のまま許容する。 +# cwd 正規化は `detect_changed_files()` 内の created_rel 変換と対称に行う。 +# 他案不採用理由: +# 1) cwd を環境変数で渡す案 → Bash ノード間で状態が引き継がれず `cd && cmd` のケースを処理できない。 +# 2) shlex の AST パース案 → bash heredoc / 複合コマンドで誤動作しやすく過剰実装。 +# 対応: `cwd_from_node()` を追加してノードの cwd フィールドを取得。 +# `harvest()` に cwd 引数を追加し `cd ` を検出したら current_cwd を更新。 +# `add_target()` に cwd 引数を追加して絶対パス正規化を行う。 + +targets = set() + + +def cwd_from_node(node): + """Bash ノードの cwd フィールドを取得する。複数のキー名に対応。""" + if not isinstance(node, dict): + return "" + # 直接フィールド + v = node.get("cwd") + if isinstance(v, str) and v: + return v + # tool_input.cwd + ti = node.get("tool_input") + if isinstance(ti, dict): + v = ti.get("cwd") + if isinstance(v, str) and v: + return v + # input.cwd + inp = node.get("input") + if isinstance(inp, dict): + v = inp.get("cwd") + if isinstance(v, str) and v: + return v + return "" + + +def add_target(tok, cwd=""): + tok = tok.strip() + # フラグ (-a 等)・FD複製 (&1)・破棄先 (/dev/null) は作成ターゲットではない。 + if not tok or tok.startswith(("-", "&")) or tok == "/dev/null" or tok.endswith("/dev/null"): + return + if os.path.isabs(tok): + targets.add(tok) + elif cwd: + targets.add(os.path.normpath(os.path.join(cwd, tok))) + else: + targets.add(tok) + + +def shell_tokens(cmd): + try: + lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + return list(lexer) + except Exception: + return [] + + +def harvest(cmd, cwd=""): + tokens = shell_tokens(cmd) + current_cwd = cwd + for i, tok in enumerate(tokens): + # `cd ` を検出して current_cwd を更新 + if tok == "cd" and i + 1 < len(tokens): + new_dir = tokens[i + 1] + # 変数展開 ($VAR 等) は追跡不能なのでスキップ + if not new_dir.startswith("$") and new_dir not in METACHARS: + if os.path.isabs(new_dir): + current_cwd = new_dir + elif current_cwd: + current_cwd = os.path.normpath(os.path.join(current_cwd, new_dir)) + else: + current_cwd = new_dir + continue + # 作成系リダイレクト `> f` / `1> f`。`2>&1` や `/dev/null` は add_target 側で除外。 + if (tok in {">", ">>", "&>", "&>>"} or REDIRECT_TOKEN_RE.match(tok)) and i + 1 < len(tokens): + add_target(tokens[i + 1], current_cwd) + continue + if tok in {"tee", "touch"}: + for candidate in tokens[i + 1 :]: + if candidate in METACHARS: + break + add_target(candidate, current_cwd) + + +def tool_name(node): + if isinstance(node, dict): + for key in ("name", "tool_name", "toolName"): + v = node.get(key) + if isinstance(v, str) and v: + return v + return "" + + +def command_text(node): + if not isinstance(node, dict): + return "" + if isinstance(node.get("command"), str): + return node["command"] + for key in ("input", "tool_input"): + nested = node.get(key) + if isinstance(nested, dict) and isinstance(nested.get("command"), str): + return nested["command"] + return "" + + +def walk(node: Any) -> None: + if isinstance(node, dict): + if tool_name(node) in {"Bash", "Shell"}: + node_cwd = cwd_from_node(node) + harvest(command_text(node), node_cwd) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for item in node: + walk(item) + + +try: + content = open(sys.argv[1], encoding="utf-8", errors="ignore").read() +except Exception: + print("[]") + raise SystemExit(0) + +for line in content.splitlines(): + line = line.strip() + if not line: + continue + try: + walk(json.loads(line)) + except Exception: + pass + +try: + walk(json.loads(content)) +except Exception: + pass + +print(json.dumps(sorted(targets), ensure_ascii=False)) +PY +} + +# [2026-05-26][fix] +# 背景: +# - ユーザー依頼意図: jtt-cafe-pj の /insights リフレッシュ作業終了時、Stop hook が +# 別セッションの未追跡 WIP (.claude/skills/dev-guardrails/** 等) を「変更コードファイル」 +# として誤検知し block する事象が実発火した (R1)。クリーンに直したい。 +# - 守るべき業務ルール: git diff フォールバックは transcript 検出 (Write/Edit の file_path) が +# 失敗した時の最終手段。未追跡ファイルは git 履歴がなくセッション帰属を判定できないため、 +# 無条件に拾うと他セッションの WIP・スクラッチ・他ツール生成物を誤検知する。 +# - 他案不採用理由: +# 1) 未追跡検出を完全除去する案 → Bash で新規作成したコードファイル (`cat > scripts/foo.py`) を +# フォールバックで見逃し品質ゲートが弱くなる (Codex レビュー指摘) ため不採用。 +# 2) DOC_SKIP_PATTERNS にディレクトリを足し続ける案 → 「次の untracked に対応できない対症療法」のため不採用。 +# 対応: 追跡変更 (git diff / --cached) は常に対象。未追跡ファイルは +# 「このセッションが Bash 作成系で書いたターゲット」(created_paths) に一致するものだけ対象にする。 +# created_paths が空 (transcript 読めない等) の場合は未追跡を一切拾わない (帰属不能なため安全側)。 +detect_changed_files() { + local project_dir="$1" + local created_paths_json="${2:-[]}" + + python3 - "$project_dir" "$CODE_FILE_PATTERNS" "$created_paths_json" <<'PY' 2>/dev/null || echo "[]" +import json +import os +import re +import subprocess +import sys + +project_dir = sys.argv[1] +code_file_pattern = re.compile(sys.argv[2], re.IGNORECASE) +try: + created = json.loads(sys.argv[3]) + if not isinstance(created, list): + created = [] +except Exception: + created = [] + +# セッションが Bash 作成系で書いたターゲットを project_dir 相対パスに正規化。 +# basename 一致は使わない(別ディレクトリの同名未追跡ファイルを誤検知するため。Codex レビュー指摘)。 +created_rel = set() +for t in created: + if not isinstance(t, str) or not t: + continue + norm = t + if os.path.isabs(t): + try: + norm = os.path.relpath(t, project_dir) + except Exception: + norm = t + if norm.startswith("./"): + norm = norm[2:] + created_rel.add(norm) + +paths = set() + +# 追跡ファイルの変更は常に対象。 +for command in ( + ["git", "-C", project_dir, "diff", "--name-only", "-z", "--diff-filter=ACMR"], + ["git", "-C", project_dir, "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"], +): + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False) + for raw_path in result.stdout.split(b"\0"): + if raw_path: + paths.add(raw_path.decode("utf-8", errors="surrogateescape")) + +# 未追跡は「このセッションが作成したターゲット」に相対パス完全一致するコードファイルだけ対象にする。 +if created_rel: + result = subprocess.run( + ["git", "-C", project_dir, "ls-files", "--others", "--exclude-standard", "-z"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, + ) + for raw_path in result.stdout.split(b"\0"): + if not raw_path: + continue + path = raw_path.decode("utf-8", errors="surrogateescape") + if not code_file_pattern.search(path): + continue + if path in created_rel: + paths.add(path) + +print(json.dumps(sorted(paths), ensure_ascii=False)) +PY +} + +json_file_list_is_empty() { + local files_json="$1" + + python3 -c " +import json +import sys + +try: + print('true' if not json.load(sys.stdin) else 'false') +except Exception: + print('true') +" <<<"$files_json" 2>/dev/null || echo "true" +} + +filter_non_doc_files() { + local files_json="$1" + + python3 -c " +import json +import re +import sys + +pattern = re.compile(sys.argv[1], re.IGNORECASE) + +try: + files = json.loads(sys.argv[2]) +except Exception: + print('[]') + sys.exit(0) + +print(json.dumps([path for path in files if not pattern.search(path)], ensure_ascii=False)) +" "$DOC_SKIP_PATTERNS" "$files_json" 2>/dev/null || echo "[]" +} + +json_file_list_contains_sql() { + local files_json="$1" + + python3 -c " +import json +import re +import sys + +try: + files = json.loads(sys.argv[1]) +except Exception: + print('false') + sys.exit(0) + +print('true' if any(re.search(r'\\.sql$', path, re.IGNORECASE) for path in files) else 'false') +" "$files_json" 2>/dev/null || echo "false" +} + +format_file_list_for_display() { + local files_json="$1" + + python3 -c " +import json +import sys + +try: + files = json.loads(sys.argv[1]) +except Exception: + sys.exit(0) + +for path in files: + print(json.dumps(path, ensure_ascii=False)) +" "$files_json" 2>/dev/null || true +} + +# --- メインエントリーポイント --- +# 引数: +# $1: hook_name - ログ用の識別子 (例: "subagent-quality-check") +# $2: hook_root - hookルートディレクトリ (.claude/hooks/) +# $3: no_file_change_reason - ファイル変更なし時の理由メッセージ +# $4: use_git_diff_fallback - git diffフォールバック使用 (default: true) +run_quality_check_hook() { + local hook_name="$1" + local hook_root="$2" + local no_file_change_reason="$3" + local use_git_diff_fallback="${4:-true}" + local input project_dir checklist_path stop_hook_active input_changed_files transcript_path transcript_changed_files transcript_readable transcript_bash_write bash_created_paths changed_files non_doc_files formatted_non_doc_files block_reason sql_files security_checklist_path is_codex_hook + + is_codex_hook="false" + if is_codex_hook_root "$hook_root"; then + is_codex_hook="true" + fi + + # [2026-04-26][fix] + # 背景: + # - ユーザー依頼意図: Codex Stop hook の stdout/stderr 混在で JSON パース失敗を疑う状態をなくしたい。 + # - 守るべき業務ルール: Codex hook の通常出力は JSON だけに固定し、診断ログは明示的なデバッグ時だけ出す。 + # - 他案不採用理由: 常時 stderr にログを出す案は、Codex 側の厳密な Stop hook JSON 判定で + # invalid JSON 扱いの再発要因になり得るため不採用。 + # 対応: Codex 配布先では CODEX_HOOK_DEBUG=1 の時だけ stderr ログを出す。Claude 側は既存どおりログを出す。 + log() { + if [ "$is_codex_hook" != "true" ] || [ "${CODEX_HOOK_DEBUG:-}" = "1" ]; then + echo "[hook:${hook_name}] $*" >&2 + fi + } + + if ! command -v python3 >/dev/null 2>&1; then + log "python3 command is missing, approving as safe fallback" + if is_codex_hook_root "$hook_root"; then + echo '{"continue":true}' + else + echo '{"decision":"approve","reason":"python3 is required for quality hook. Approved as safe fallback."}' + fi + return 0 + fi + + input="$(cat)" + project_dir="$(resolve_project_dir "$hook_root")" + checklist_path="$hook_root/lib/code-quality-check.md" + + log "hook invoked for project: $project_dir" + + if [ ! -f "$checklist_path" ]; then + log "checklist not found at $checklist_path, approving" + emit_approval_json "$hook_root" "No quality checklist found, skipping." + return 0 + fi + + stop_hook_active="$(extract_stop_hook_active "$input")" + if [ "$stop_hook_active" = "true" ]; then + log "stop_hook_active=true, approving to prevent infinite loop" + emit_approval_json "$hook_root" "Already in quality check loop, approving to prevent infinite loop." + return 0 + fi + if [ "$stop_hook_active" = "error" ]; then + log "WARNING: failed to parse stop_hook_active from input JSON, approving as fallback" + emit_approval_json "$hook_root" "Could not parse hook input JSON, approving as safety fallback." + return 0 + fi + + # Layer 1: agent_type による読み取り専用エージェント即時判定 + # Explore/Plan等はWrite/Editツールを持たない(公式仕様で除外)ため、 + # コード変更は構造的に不可能。ファイル検出を一切行わずapproveする。 + local agent_type + agent_type="$(extract_agent_type "$input")" + case "$agent_type" in + Explore|Plan|feature-dev:code-reviewer|feature-dev:code-architect|feature-dev:code-explorer|claude-code-guide) + log "read-only agent type '$agent_type', approving without quality check" + emit_approval_json "$hook_root" "Read-only agent type ($agent_type), quality check not applicable." + return 0 + ;; + esac + + input_changed_files="$(extract_changed_files_from_input "$input")" + if [ "$(json_file_list_is_empty "$input_changed_files")" = "false" ]; then + changed_files="$input_changed_files" + log "detected changed files from hook input" + else + # Layer 2: agent_transcript_path を優先使用 + # SubagentStopでは agent_transcript_path(サブエージェント固有の履歴)を使い、 + # transcript_path(メインセッション全履歴)へのフォールバックで親の書き込みを誤検知しない。 + transcript_path="$(extract_agent_transcript_path "$input")" + if [ -z "$transcript_path" ]; then + transcript_path="$(extract_transcript_path "$input")" + fi + transcript_changed_files="$(extract_changed_files_from_transcript "$transcript_path")" + transcript_readable="$(transcript_was_readable "$transcript_path")" + transcript_bash_write="$(transcript_has_bash_write_command "$transcript_path")" + if [ "$(json_file_list_is_empty "$transcript_changed_files")" = "false" ]; then + changed_files="$transcript_changed_files" + log "detected changed files from transcript_path" + elif [ "$transcript_readable" = "true" ] && [ "$transcript_bash_write" = "true" ] && [ "$use_git_diff_fallback" = "true" ]; then + # 未追跡はセッションが Bash 作成系で書いたターゲットだけに絞る(他セッション WIP の誤検知 R1 防止) + bash_created_paths="$(extract_bash_created_paths_from_transcript "$transcript_path")" + changed_files="$(detect_changed_files "$project_dir" "$bash_created_paths")" + log "transcript has bash write command, falling back to git diff (untracked limited to session-created targets)" + elif [ "$transcript_readable" = "true" ]; then + # [2026-04-26][fix] + # transcript が読めて Write/Edit/MultiEdit/NotebookEdit が 0 件 → /brainstorm 等の質問のみセッション。 + # git diff fallback を呼ぶとバックグラウンド同期で書き換わった派生物 (.opencode/sync-state.json 等) を + # 「変更ファイル」と誤認するため、ここで approve に進む。 + changed_files="$transcript_changed_files" # = "[]" + log "transcript readable but no write tool invocations, approving (B1 fix)" + elif [ "$use_git_diff_fallback" = "true" ]; then + changed_files="$(detect_changed_files "$project_dir")" + log "transcript unreadable, falling back to git diff" + else + changed_files="" + log "git diff fallback disabled, no input/transcript file changes found" + fi + fi + + if [ "$(json_file_list_is_empty "$changed_files")" = "true" ]; then + log "no file changes detected, approving" + emit_approval_json "$hook_root" "$no_file_change_reason" + return 0 + fi + + non_doc_files="$(filter_non_doc_files "$changed_files")" + if [ "$(json_file_list_is_empty "$non_doc_files")" = "true" ]; then + log "only document files changed, approving" + emit_approval_json "$hook_root" "Document file change - skipping code quality check." + return 0 + fi + + formatted_non_doc_files="$(format_file_list_for_display "$non_doc_files")" + log "code files changed, blocking for quality check: $(echo "$formatted_non_doc_files" | tr '\n' ', ')" + + block_reason="${BLOCK_PREFIX}"$'\n\n'"チェックリスト: ${checklist_path}" + + # SQLファイル変更時はセキュリティレビューチェックリストのパスも追加 + sql_files="$(json_file_list_contains_sql "$non_doc_files")" + if [ "$sql_files" = "true" ]; then + security_checklist_path="$hook_root/lib/security-review-check.md" + if [ -f "$security_checklist_path" ]; then + block_reason="${block_reason}"$'\n'"セキュリティチェックリスト: ${security_checklist_path}" + log "SQL files detected, adding security review checklist path" + fi + fi + + block_reason="${block_reason}"$'\n\n'"変更されたコードファイル:"$'\n'"${formatted_non_doc_files}" + # telemetry(harness-checkup): quality-gate deny を記録(fail-open)。 + agent_hub_telemetry_log hook_deny "$hook_name" deny 2>/dev/null || true + emit_block_json "$hook_root" "$block_reason" + return 0 +} diff --git a/.kimi-code/hooks/lib/storage-url-common.py b/.kimi-code/hooks/lib/storage-url-common.py new file mode 100644 index 000000000..08305bb99 --- /dev/null +++ b/.kimi-code/hooks/lib/storage-url-common.py @@ -0,0 +1,190 @@ +# [2026-05-16][refactor] +# 背景: +# - ユーザー依頼意図: gmail-mcp へ配布された hook-library の Python ファイルも、配布先の CaD ルールに合う形へ揃えたい。 +# - 守るべき業務ルール: Python ファイル冒頭には shebang 直後または冒頭に # 形式の CaD ヘッダーを置く。 +# - 他案不採用理由: docstring 内の履歴だけに残す案は、配布先の CaD 検査で冒頭ヘッダーとして認識されないため不採用。 +# 対応: 既存 docstring 履歴を残したまま、冒頭に配布共通の CaD ヘッダーを追加。 +""" +Storage URL検証の共通ロジック。 +storage-url-check.sh (PostToolUse) と storage-url-pr-gate.sh (PreToolUse) から呼び出される。 + +[2026-03-03][refactor] +背景: jtt-cms Gen 3 のstorage-url-common.pyをAGENT-HUBのhook-libraryにポート。 + Supabase Storage URLの存在検証をPJ横断で共有するためコンポーネント化。 +対応: jtt-cms storage-url-common.py をそのままポート。 + +[2026-03-04][fix] +背景: ユーザー意図は「Python実行環境差でチェックが無効化されないこと」。 + 業務ルールとして、共通ライブラリは最低運用環境でも構文エラーなく動作する必要がある。 + 代替案として Python 3.9+ 専用型ヒントを維持すると、3.8系でゲートが素通りするため不採用。 +対応: 型ヒントを typing.List/Set/Tuple へ置換し、互換性を確保。 + +使い方: + python3 lib/storage-url-common.py [ ...] + mode: "check" (PostToolUse用) または "gate" (PreToolUse用) + +- check モード: 最大5URL検証、未アップロードがあれば stderr + exit 2 +- gate モード: 最大10URL検証(並列)、未アップロードがあれば deny理由を stdout + exit 1 +""" + +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import List, Set, Tuple + +# --- 定数 --- +CURL_TIMEOUT_SECONDS = 2 +MAX_URLS_CHECK_MODE = 5 +MAX_URLS_GATE_MODE = 10 + +STORAGE_URL_PATTERN = re.compile( + r"https://[a-z0-9]+\.supabase\.co/storage/v1/object/public/[^\x22\x27\s,)\]}\x60]+" +) + + +def remove_sql_comments(content: str) -> str: + """SQLコメントを除去する。コメント内のURLを誤検知しないため。""" + content = re.sub(r"--[^\n]*", "", content) + content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) + return content + + +def extract_storage_urls(filepaths: List[str]) -> List[str]: + """ファイル群からStorage URLを抽出し、重複排除・ソートして返す。""" + all_urls: Set[str] = set() + for fp in filepaths: + if not os.path.isfile(fp): + continue + try: + content = open(fp, encoding="utf-8").read() + except Exception: + continue + cleaned = remove_sql_comments(content) + all_urls.update(STORAGE_URL_PATTERN.findall(cleaned)) + return sorted(all_urls) + + +def check_url_head(url: str) -> Tuple[str, str]: + """curl HEAD でURLの存在を検証し、(url, HTTPステータス) を返す。""" + try: + result = subprocess.run( + [ + "curl", "-sI", + "--max-time", str(CURL_TIMEOUT_SECONDS), + "-o", "/dev/null", + "-w", "%{http_code}", + url, + ], + capture_output=True, + text=True, + timeout=CURL_TIMEOUT_SECONDS + 3, + ) + return (url, result.stdout.strip()) + except Exception: + return (url, "error") + + +def build_upload_hints(missing_urls: List[Tuple[str, str]]) -> List[str]: + """未アップロードURLからバケット名・パスを逆算し、アップロードコマンドを生成する。""" + hints: List[str] = [] + for url, _ in missing_urls: + m = re.search(r"https://[^/]+/storage/v1/object/public/([^/]+)/(.+)", url) + if m: + hints.append(f" pnpm upload:storage {m.group(1)} {m.group(2)}") + return hints + + +def run_check_mode(filepaths: List[str]) -> None: + """PostToolUse用: 逐次検証、未アップロードがあればstderr + exit 2。""" + urls = extract_storage_urls(filepaths) + if not urls: + sys.exit(0) + + check_urls = urls[:MAX_URLS_CHECK_MODE] + remaining = max(0, len(urls) - MAX_URLS_CHECK_MODE) + + missing: List[Tuple[str, str]] = [] + for url in check_urls: + url, status = check_url_head(url) + if status != "200": + missing.append((url, status)) + + if not missing: + sys.exit(0) + + msg = "\n[hook:storage-url-check] 未アップロードのStorage画像を検出しました:\n" + for url, status in missing: + msg += f" - {url} -> HTTP {status}\n" + if remaining > 0: + msg += f" (他に{remaining}件のURLが未検証です)\n" + + hints = build_upload_hints(missing) + msg += "\nアップロード方法:\n" + if hints: + msg += "\n".join(hints) + "\n" + else: + msg += " Supabase DashboardまたはMCP経由でStorage画像をアップロードしてください。\n" + msg += "\nアップロード完了後、再度ファイルを保存してください。\n" + + sys.stderr.write(msg) + sys.exit(2) + + +def run_gate_mode(filepaths: List[str]) -> None: + """PreToolUse用: 並列検証、未アップロードがあればdeny理由をstdout + exit 1。""" + urls = extract_storage_urls(filepaths) + if not urls: + sys.exit(0) + + check_urls = urls[:MAX_URLS_GATE_MODE] + remaining = max(0, len(urls) - MAX_URLS_GATE_MODE) + + missing: List[Tuple[str, str]] = [] + with ThreadPoolExecutor(max_workers=MAX_URLS_GATE_MODE) as executor: + futures = {executor.submit(check_url_head, url): url for url in check_urls} + for future in as_completed(futures): + url, status = future.result() + if status != "200": + missing.append((url, status)) + + if not missing: + sys.exit(0) + + parts = [ + "[hook:storage-url-pr-gate] 未アップロードのStorage画像があります。" + "PR作成前にアップロードしてください。\\n\\n未検証URL:" + ] + for url, status in sorted(missing): + parts.append(f" - {url} -> HTTP {status}") + + if remaining > 0: + parts.append(f" (他に{remaining}件のURLが未検証です)") + + hints = build_upload_hints(sorted(missing)) + parts.append("\\nアップロード方法:") + if hints: + parts.extend(hints) + else: + parts.append(" Supabase DashboardまたはMCP経由でStorage画像をアップロードしてください。") + + print("\\n".join(parts)) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} [file2 ...]", file=sys.stderr) + sys.exit(1) + + mode = sys.argv[1] + files = sys.argv[2:] + + if mode == "check": + run_check_mode(files) + elif mode == "gate": + run_gate_mode(files) + else: + print(f"Unknown mode: {mode}", file=sys.stderr) + sys.exit(1) diff --git a/.kimi-code/hooks/managed-hooks.json b/.kimi-code/hooks/managed-hooks.json new file mode 100644 index 000000000..21273bfdc --- /dev/null +++ b/.kimi-code/hooks/managed-hooks.json @@ -0,0 +1,69 @@ +{ + "version": 1, + "generated_by": "sync-kimi-from-cc.py", + "project_root": ".", + "hooks": [ + { + "event": "PreToolUse", + "command": "PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; bash \"$PROJECT_DIR/.kimi-code/hooks/scripts/block-destructive-git.sh\"", + "matcher": "Bash|Shell", + "timeout": 10 + }, + { + "event": "PreToolUse", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/block-main-commit.sh\"", + "matcher": "Bash|Shell", + "timeout": 10 + }, + { + "event": "PreToolUse", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/storage-url-pr-gate.sh\"", + "matcher": "Bash|Shell", + "timeout": 15 + }, + { + "event": "PreToolUse", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/block-skill-reverse-edit.sh\"", + "matcher": "Write|Edit|MultiEdit|WriteFile|StrReplaceFile", + "timeout": 10 + }, + { + "event": "PreToolUse", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/block-unauthorized-docs-file.sh\"", + "matcher": "Write|Edit|MultiEdit|WriteFile|StrReplaceFile|Bash|Shell", + "timeout": 10 + }, + { + "event": "PreToolUse", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/post-merge-gate.sh\"", + "matcher": "Bash|Shell", + "timeout": 10 + }, + { + "event": "PreToolUse", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/telemetry-log.sh\"", + "matcher": "Skill|Task|Agent", + "timeout": 5 + }, + { + "event": "UserPromptSubmit", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/handover-preflight.sh\"", + "timeout": 5 + }, + { + "event": "UserPromptSubmit", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/pre-implementation-check.sh\"", + "timeout": 5 + }, + { + "event": "Stop", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/stop-quality-check.sh\"", + "timeout": 30 + }, + { + "event": "Stop", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/telemetry-log.sh\"", + "timeout": 5 + } + ] +} diff --git a/.kimi-code/hooks/scripts/block-destructive-git.sh b/.kimi-code/hooks/scripts/block-destructive-git.sh new file mode 100755 index 000000000..ee13c308f --- /dev/null +++ b/.kimi-code/hooks/scripts/block-destructive-git.sh @@ -0,0 +1,1963 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) destructive git guard. +# AI/自動化が tracked local changes を暗黙に破棄する事故を止める。 +# [2026-06-14][feat] +# 背景: +# - ユーザー依頼意図: AI 横断作業中の `git reset --hard` / `git clean -f` / `git checkout --` による +# tracked local changes の暗黙破棄を止めたい。 +# - 守るべき業務ルール: ローカル変更の破棄は、差分確認後に明示許可した復旧作業だけに限定する。 +# - 他案不採用理由: 破壊的 git をルール文だけで禁止する案は、別セッション WIP の事故を機械的に止められないため不採用。 +# 対応: 安全な dry-run / unstage は許可し、作業ツリーを破棄する git 操作だけを PreToolUse でブロックする。 + +set -uo pipefail + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +. "$(dirname "$0")/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } + +input="$(cat)" + +command="$( + INPUT_JSON="${input}" python3 - <<'PY' 2>/dev/null || true +import json +import os + +try: + data = json.loads(os.environ.get("INPUT_JSON", "{}")) +except json.JSONDecodeError: + data = {} +tool_input = {} +if isinstance(data.get("tool_input"), dict): + tool_input = data["tool_input"] +elif isinstance(data.get("toolInput"), dict): + tool_input = data["toolInput"] +print(tool_input.get("command") or "") +PY +)" + +allow_json() { + printf '{"continue": true}\n' +} + +# [2026-08-03][fix] deny メッセージを「コマンド行の先頭に書けば通る」という誤った案内から、 +# 実際に効く手順(セッションの環境変数として設定)へ正す。 +# 背景: +# - ユーザー依頼意図: 2026-08-03 jtt-cms 作業中、旧メッセージの案内どおり +# `AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 git ...` をコマンド行の先頭に書いて再実行したが、 +# 再びブロックされた。66行目の bypass 判定はこの hook プロセス自身の環境変数だけを見ており、 +# Bash ツールは呼び出しごとに cwd がリセットされるため実際の再実行はほぼ必ず +# `cd && AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 git ...` の形になる。1745行目付近の +# inline bypass はコマンド全体の最初のトークンが裸の代入直後の `git` である場合だけしか +# 救済せず、`cd &&` 等が前に付くと機能しない(実測で再現・恒久的に効く手段ではない)。 +# - 守るべき業務ルール: AI エージェントは自己判断で破壊的 git を通せてはならない。bypass は +# 利用者がセッションの環境変数として明示設定した場合だけに限定する設計を維持する +# (bypass 判定ロジック自体は変更しない・本対応はメッセージ文言のみ)。 +# - 他案不採用理由: 「コマンド行の先頭に書けば常に効くようにする」案は、AI が自分の発行する +# コマンド文字列だけで bypass を成立させられてしまい、破壊的 git を自己判断で通す抜け道になる +# ため不採用。メッセージを正直にし、実際に効く手段(利用者へのセッション環境変数設定の依頼、 +# または hook にかからない代替コマンド)を案内する方針を採る。 +block_json() { + local label="$1" + # telemetry(harness-checkup): deny を記録。fail-open(記録失敗は無視)。 + agent_hub_telemetry_log hook_deny block-destructive-git deny "{\"label\":\"$label\"}" 2>/dev/null || true + HOOK_LABEL="$label" python3 - <<'PY' +import json +import os + +label = os.environ.get("HOOK_LABEL", "") +reason_lines = [ + f"[hook:block-destructive-git] destructive git command blocked: {label}。", + "ローカル変更を暗黙に破棄しないため停止しました。", + ( + "AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 は、このセッションの環境変数として設定されている" + "必要があります。コマンド行の先頭に書くだけでは効きません" + "(cd 等が前に付くと届かないため)。" + ), + ( + "AI はこの環境変数を自分で設定できません。復旧が必要な場合は、利用者に" + "「このセッションで AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 を設定してください」と依頼してください。" + ), + ( + "単一ファイルを HEAD の内容へ戻すだけなら、この hook にかからない " + "`git show HEAD: > ` で足りることが多いです。" + ), +] +reason = "\n".join(reason_lines) +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } +}, ensure_ascii=False)) +PY +} + +if [ -z "${command}" ]; then + allow_json + exit 0 +fi + +if [ "${AGENT_HUB_ALLOW_DESTRUCTIVE_GIT:-0}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass block-destructive-git allow '{"env":"AGENT_HUB_ALLOW_DESTRUCTIVE_GIT"}' 2>/dev/null || true + allow_json + exit 0 +fi + +# [2026-08-02][fix] path-qualified git executable を token 境界で裸の `git` に正規化する。 +# 背景: +# - ユーザー依頼意図: `/usr/bin/git` や空白を含む引用符付き path でも、`reset --hard` / +# `clean` 等を取り逃がさないようにする。 +# - 守るべき業務ルール: 実行 token の basename が `git` の場合だけ、裸の `git` と同じく +# fail-closed で止める。`/tmp/git tools/notgit` のような非 git executable は許可する。 +# - 他案不採用理由: Bash regex で path の slash・quote・空白を列挙する案は token 境界を失い、 +# 新しい path 表記や git を含む別 executable の誤検出を招く。PJ ごとの hook 手修正も不採用。 +# 対応: Python 標準 `shlex` で実行 token を解決し、`os.path.basename(token) == "git"` のときだけ +# その token を `git` に置換してから、後段の Bash 判定へ渡す。 +# [2026-08-02][fix] nice/nohup を安全に解析し、未知・解決不能な前置きを fail-closed にする。 +# 背景: +# - ユーザー依頼意図: 標準ラッパー経由の `nice git ...` / `nohup git ...` でも破壊的 Git を止めたい。 +# - 守るべき業務ルール: 既知の引数だけを消費し、曖昧な option や欠落した command は許可しない。 +# - 他案不採用理由: 任意の `-...` を無条件に読み飛ばす案は、未知 option の後ろの Git を取り逃がすため不採用。 +# 対応: nice の数値 option と nohup の `--` だけを明示的に消費し、未知・解決不能時は marker を出して停止する。 +readonly GIT_BIN='git' +readonly GIT_GLOBAL_OPT='(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--config-env[[:space:]]+[^[:space:]]+|--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--namespace(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--exec-path(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)?|--paginate|--no-pager|--no-replace-objects|--bare|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--help|--version|--html-path|--man-path|--info-path|-p)' +readonly GIT_GLOBAL_OPTS="([[:space:]]+${GIT_GLOBAL_OPT})*" +readonly SUDO_OPT='((-u|-g|-h|-p|-C|-T)[[:space:]]+[^[:space:]]+|-[^[:space:]]+)' +readonly ENV_OPT='((-u|--unset|-C|--chdir)[[:space:]]+[^[:space:]]+|-[^[:space:]]+)' +readonly GIT_PREFIX_TOKEN='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+|!|if|then|else|elif|do|while|until|command([[:space:]]+-p)?|builtin|exec|time([[:space:]]+-p)?|sudo([[:space:]]+'"${SUDO_OPT}"')*)' +readonly ENV_BIN='(/([^[:space:]/]+/)*env|env)' +readonly ENV_PREFIX="${ENV_BIN}"'([[:space:]]+'"${ENV_OPT}"')*([[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+)*' +readonly GIT_SEGMENT_START='^[[:space:]]*(('"${GIT_PREFIX_TOKEN}"'|'"${ENV_PREFIX}"')[[:space:]]+)*'"${GIT_BIN}" + +command_segments="$( + COMMAND_TEXT="$command" python3 - <<'PY' 2>/dev/null || true +import os +import re +import shlex + +cmd = os.environ.get("COMMAND_TEXT", "") + +CONTROL_WORDS = {"!", "if", "then", "else", "elif", "do", "while", "until"} +UNRESOLVED_WRAPPER = -1 + +def executable_basename(token: str, *, decoded: bool = False): + if decoded: + return os.path.basename(token) + try: + lexer = shlex.shlex(token, posix=True) + lexer.whitespace_split = True + words = list(lexer) + except ValueError: + return None + if len(words) != 1: + return None + return os.path.basename(words[0]) + +# [2026-08-02][fix] command wrapperと実行名は静的に確定できる場合だけ許可する。 +# 背景: +# - ユーザー依頼意図: env/time/exec/sudo/eval 等を挟んだ場合や、変数・command substitutionで +# 実行名を組み立てた場合も、破壊的Git操作を同じ基準で止める。 +# - 守るべき業務ルール: wrapper後の実行ファイルを静的に確定できない場合は許可しない。 +# posix lexerでdecode済みのtokenは再度shell parseせず、実ファイル名のquoteをliteralとして扱う。 +# - 他案不採用理由: 各OS・wrapperの全optionを推測して許可すると、引数をcommandとして +# 再解釈するoptionや将来追加されたoptionが新しい迂回経路になる。decode済みtokenの再shlexは +# quoteを含む有効なpathを構文エラーに変え、basename=gitの検出を失うため不採用。 +# 対応: 安全性を確認したoptionだけをwhitelistし、eval・未知option・再分割option・動的実行名は +# unresolved markerへ送る。decode済みtokenのbasenameは文字列から直接取得する。 +def command_executable_index(segment: list[str], *, decoded: bool = False): + sudo_short_options_with_arg = {"-u", "-g", "-h", "-p", "-C", "-T", "-D", "-R", "-r", "-t", "-U"} + sudo_short_options_no_arg = set("ABbEeHiKklnPSsVv") + sudo_long_options_with_arg = { + "--user", "--group", "--host", "--prompt", "--close-from", + "--chdir", "--chroot", "--command-timeout", "--other-user", + "--login-class", "--role", "--type", + } + sudo_long_options_no_arg = { + "--askpass", "--background", "--bell", "--edit", "--help", "--login", + "--list", "--non-interactive", "--preserve-env", "--remove-timestamp", + "--reset-timestamp", "--set-home", "--shell", "--stdin", "--validate", "--version", + } + index = 0 + while index < len(segment): + wrapper_start = index + while index < len(segment): + token = segment[index] + if token in CONTROL_WORDS or re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token): + index += 1 + continue + break + if index >= len(segment): + return None + + executable = executable_basename(segment[index], decoded=decoded) + if executable in {"command", "builtin"}: + index += 1 + while index < len(segment): + if segment[index] == "--": + index += 1 + break + if segment[index] == "-p": + index += 1 + continue + break + elif executable == "eval": + # eval reparses every remaining argument as shell source. + return UNRESOLVED_WRAPPER + elif executable == "exec": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option == "-a": + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("-a") and option != "-a": + index += 1 + continue + if re.fullmatch(r"-[cl]+", option): + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + elif executable == "time": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"-o", "-f", "--output", "--format"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("--output=") or option.startswith("--format="): + index += 1 + continue + if option in {"--append", "--verbose", "--portability", "--quiet"}: + index += 1 + continue + if re.fullmatch(r"-[ahlpv]+", option): + index += 1 + continue + if re.fullmatch(r"-(?:o|f).+", option): + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + # [2026-08-02][fix] timeout wrapper の後段 command を限定解析する。 + # 背景: + # - ユーザー依頼意図: 全PJへ配布する破壊的Git guardで、`timeout 5 git reset --hard` の + # ような標準wrapper経由の実行も直接実行と同じ基準で止める。 + # - 守るべき業務ルール: timeoutの既知optionと必須durationだけを消費し、その直後の + # commandを再帰的に検査する。未知option・値不足・command不足はfail-closedにする。 + # - 他案不採用理由: timeout以下を通常引数として許可する案は破壊操作を見逃し、全optionを + # 無条件に読み飛ばす案は将来の再解釈optionで同じ迂回を再発させるため不採用。 + # 対応: GNU timeoutの副作用を持たない既知optionだけを許可し、durationを1語消費して + # 後段commandへ解析を継続する。hook自身はtimeoutや対象commandを実行しない。 + elif executable == "timeout": + duration_pattern = r"(?:\d+(?:\.\d*)?|\.\d+)(?:s|m|h|d)?" + signal_pattern = r"(?:SIG)?[A-Za-z0-9]+" + + def static_timeout_value(token: str, pattern: str) -> bool: + if token_has_unresolved_executable_expansion(token): + return False + try: + value = token if decoded else decode_shell_command(token) + except (TypeError, ValueError): + return False + return re.fullmatch(pattern, value) is not None + + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"--preserve-status", "--foreground", "--verbose", "-v"}: + index += 1 + continue + if option in {"-k", "--kill-after", "-s", "--signal"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + value_pattern = duration_pattern if option in {"-k", "--kill-after"} else signal_pattern + if not static_timeout_value(segment[index + 1], value_pattern): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("-k") and option != "-k": + if not static_timeout_value(option[2:], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("-s") and option != "-s": + if not static_timeout_value(option[2:], signal_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("--kill-after="): + if not static_timeout_value(option.split("=", 1)[1], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("--signal="): + if not static_timeout_value(option.split("=", 1)[1], signal_pattern): + return UNRESOLVED_WRAPPER + index += 1 + continue + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + # timeout requires one duration token followed by a command. + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + if not static_timeout_value(segment[index], duration_pattern): + return UNRESOLVED_WRAPPER + index += 1 + elif executable == "nice": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option == "-n" or option == "--adjustment": + index += 1 + if index >= len(segment) or not re.fullmatch(r"[+-]?\d+", segment[index]): + return UNRESOLVED_WRAPPER + index += 1 + continue + if re.fullmatch(r"-n[+-]?\d+", option) or re.fullmatch(r"-\+?\d+", option): + index += 1 + continue + if re.fullmatch(r"--adjustment=[+-]?\d+", option): + index += 1 + continue + if option in {"--help", "--version"}: + return None + if option.startswith("-"): + return UNRESOLVED_WRAPPER + break + if index >= len(segment): + return UNRESOLVED_WRAPPER + elif executable == "nohup": + index += 1 + if index < len(segment) and segment[index] == "--": + index += 1 + elif index < len(segment) and segment[index].startswith("-"): + return UNRESOLVED_WRAPPER + if index >= len(segment): + return UNRESOLVED_WRAPPER + elif executable == "sudo": + index += 1 + terminated = False + while index < len(segment) and segment[index].startswith("-"): + option = segment[index] + if option == "--": + index += 1 + terminated = True + break + if option.startswith("--"): + if "=" in option: + option_name, _ = option.split("=", 1) + has_attached_value = True + else: + option_name = option + has_attached_value = False + if option_name not in sudo_long_options_with_arg and option_name not in sudo_long_options_no_arg: + return UNRESOLVED_WRAPPER + index += 1 + if ( + not has_attached_value + and option_name in sudo_long_options_with_arg + ): + if index >= len(segment): + return UNRESOLVED_WRAPPER + index += 1 + continue + option_name = option[:2] + if option_name in sudo_short_options_with_arg: + index += 1 + if len(option) == 2: + if index >= len(segment): + return UNRESOLVED_WRAPPER + index += 1 + continue + if all(char in sudo_short_options_no_arg for char in option[1:]): + index += 1 + continue + return UNRESOLVED_WRAPPER + if not terminated: + while index < len(segment) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", segment[index]): + index += 1 + # [2026-08-02][fix] xargs を wrapper として解析し、実行 command へ検査を継続する。 + # 背景: + # - ユーザー依頼意図: `printf 'HEAD' | xargs -n1 git reset --hard` のように xargs 経由で + # 破壊的 Git を起動すると、git が引数位置に見えて検査から漏れていた + # (jtt-cms PR #1542 の codex-review が検出した Critical)。 + # - 守るべき業務ルール: timeout / env と同じく、副作用と再解釈の無い既知 option だけを + # whitelist で消費し、直後の command を通常の検査へ流す。引数が任意個の option + # (GNU の bare -l / -i / -e、--replace 単独等)は静的に境界を確定できないため + # unresolved(fail-closed)に送る。command 無しの xargs は既定 echo のため安全。 + # - 他案不採用理由: xargs を一律 unresolved にする案は、`ls | xargs rm` 等の非 git 用途 + # まで全 deny し誤検知摩擦(#1313 で解消したクラス)を再発させる。全 option の + # 読み飛ばしは将来の再解釈 option で迂回を再発させる(timeout の CaD と同判断)。 + elif executable == "xargs": + xargs_long_with_arg = { + "--arg-file", "--delimiter", "--eof", "--max-args", "--max-chars", + "--max-lines", "--max-procs", "--process-slot-var", + } + xargs_no_arg = { + "-0", "--null", "-p", "--interactive", "-r", "--no-run-if-empty", + "-t", "--verbose", "-x", "--exit", "-o", "--open-tty", + } + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"--help", "--version"}: + return None + if option in {"-n", "-L", "-s", "-P", "-a", "-d", "-E", "-J", "-I"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option in xargs_long_with_arg: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if any(option.startswith(name + "=") for name in xargs_long_with_arg | {"--replace"}): + index += 1 + continue + if re.fullmatch(r"-[nLsPadEJIi].+", option): + # 値が密着した短形(-n1 / -I{} / -i{} / -d\n 等) + index += 1 + continue + if option in xargs_no_arg or re.fullmatch(r"-[0prtxo]+", option): + index += 1 + continue + if option.startswith("-"): + # bare -l / -i / -e / --replace 等の任意引数 option・未知 option + return UNRESOLVED_WRAPPER + break + elif executable == "env": + index += 1 + while index < len(segment): + option = segment[index] + if option == "--": + index += 1 + break + if option in {"-S", "--split-string"} or option.startswith("-S") or option.startswith("--split-string="): + # split-string reparses one token into a complete command. + return UNRESOLVED_WRAPPER + if option in {"-u", "--unset", "-C", "--chdir"}: + if index + 1 >= len(segment): + return UNRESOLVED_WRAPPER + index += 2 + continue + if option.startswith("--unset=") or option.startswith("--chdir="): + index += 1 + continue + if re.fullmatch(r"-(?:u|C).+", option): + index += 1 + continue + if option in {"-", "-i", "--ignore-environment", "-0", "--null", "--debug"}: + index += 1 + continue + if option in {"--help", "--version"}: + return None + if option.startswith("-"): + return UNRESOLVED_WRAPPER + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", option): + index += 1 + continue + break + else: + return index + + if index <= wrapper_start: + return None + return None + +def token_has_unresolved_executable_expansion(token: str) -> bool: + """Return whether an executable word requires shell expansion to resolve.""" + if token.startswith("="): + # zsh expands a leading equals command name to an absolute executable path. + return True + quote = None + index = 0 + while index < len(token): + char = token[index] + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + return True + if char == "$" and index + 1 < len(token): + next_char = token[index + 1] + if next_char in "{([?*!#@$-0123456789_" or next_char.isalpha(): + return True + if char in "<>" and index + 1 < len(token) and token[index + 1] == "(": + return True + if quote is None and char in "*?": + return True + if ( + quote is None + and char in "[{" + and index + 1 < len(token) + and not token[index + 1].isspace() + and token[index + 1] not in ";&|" + ): + return True + if quote is None and char in "@+!" and index + 1 < len(token) and token[index + 1] == "(": + return True + if ( + quote is None + and char == "(" + and index > 0 + and not token[index - 1].isspace() + and token[index - 1] not in ";&|(<" + ): + return True + index += 1 + return False + +def has_unresolved_command_start(text: str) -> bool: + """Inspect raw command-start words without evaluating shell syntax.""" + try: + # Keep parentheses inside words so `$(...)`, extglob, and zsh qualifiers + # remain visible. The regular parser separately handles grouping syntax. + lexer = shlex.shlex(text, posix=False, punctuation_chars=";&|") + # Real shell comments were removed by + # collapse_shell_line_continuations(). Keep `#` inside parameter + # expansions such as `${#name}` visible to the lexer. + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return True + + segment: list[str] = [] + for token in tokens + [";"]: + if token in {"(", ")", "{", "}"} or (token and all(char in ";&|" for char in token)): + if segment: + executable_index = command_executable_index(segment) + if executable_index == UNRESOLVED_WRAPPER: + return True + if ( + executable_index is not None + and executable_index >= 0 + and token_has_unresolved_executable_expansion(segment[executable_index]) + ): + return True + segment = [] + else: + segment.append(token) + return False + +def emit_segment_line(text: str) -> None: + if has_unresolved_command_start(text): + print("__UNRESOLVED_COMMAND_WRAPPER__") + try: + lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + for segment in re.split(r"[;&|(){}]+", text): + segment = segment.strip() + if segment: + print(segment) + return + + segment = [] + + def flush() -> None: + if segment: + # `segment` came from a posix=True lexer, so quoted arguments with + # spaces are already one token. Replace those spaces only for the + # executable parser; nested shell bodies keep their original quotes. + parser_segment = [re.sub(r"\s+", "__ARG_SPACE__", token) for token in segment] + executable_index = command_executable_index(parser_segment, decoded=True) + if executable_index == UNRESOLVED_WRAPPER: + print("__UNRESOLVED_COMMAND_WRAPPER__") + segment.clear() + return + # [2026-08-02][fix] grouping 構文の内側でも動的 executable を fail-closed にする。 + # 背景: + # - ユーザー依頼意図: `{ "$G" reset --hard; }`、subshell、function body のように + # command start が grouping token の後ろにある場合も、破壊的 Git を取り逃がさない。 + # - 守るべき業務ルール: 実行ファイル名を静的に `git` 以外と確定できない command segment は + # grouping の深さに関係なく unresolved marker へ送り、既存の fail-close 契約を保つ。 + # - 他案不採用理由: 外側の raw scanner だけで grouping 全体を一つの command とみなす案は、 + # brace/subshell/function の内側にある実際の executable 境界を失うため不採用。 + # 対応: decoded parser が抽出した各 segment の executable token も検査し、変数または + # command substitution を含む場合は unresolved marker を出す。 + if ( + executable_index is not None + and executable_index >= 0 + and ( + parser_segment[executable_index] == "$" + or token_has_unresolved_executable_expansion(parser_segment[executable_index]) + ) + ): + # The decoded parser also sees command starts inside brace/paren + # groups and function bodies that the outer raw segment begins + # with grouping syntax rather than the eventual executable. + print("__UNRESOLVED_COMMAND_WRAPPER__") + segment.clear() + return + if ( + executable_index is not None + and executable_index >= 0 + and executable_basename(parser_segment[executable_index], decoded=True) == "git" + ): + segment[:] = ["git"] + segment[executable_index + 1:] + # shlex は引用を外すため、空白入り `git -C "/tmp/a b"` をそのまま join すると + # 後段の正規表現が git global option の引数境界を誤る。判定に不要な内部空白だけ + # sentinel に寄せ、実コマンドの語順は保ったまま検査する。 + normalized = [re.sub(r"\s+", "__ARG_SPACE__", token) for token in segment] + print(" ".join(normalized).strip()) + segment.clear() + + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + flush() + else: + segment.append(token) + flush() + +# [2026-08-02][fix] dash / ksh も shell receiver として再帰検査する(issue #1344)。 +# 背景: +# - ユーザー依頼意図: dash / ksh へ here-doc(quoted 'EOF' 区切り)で流し込んだ破壊的 Git が +# receiver 集合の漏れで再帰検査されず素通りしていた(PR #1343 の codex-review が検出)。 +# ※このコメントに here-doc 演算子そのものを書かないこと: 本 Python は bash の $( ) 置換内の +# quoted heredoc に埋まっており、bash の置換パーサはコメント内でも演算子を解釈して壊れる。 +# - 守るべき業務ルール: shell として本文を実行する受け手は全て同じ fail-close 再帰へ送る。 +# - 他案不採用理由: 任意の実行ファイルを receiver 扱いする案は、非 shell の cat/tee まで +# 本文をコマンド検査して誤検知を増やすため不採用(shell 実体の列挙を維持し不足だけ足す)。 +shells = {"sh", "bash", "zsh", "dash", "ksh"} +MAX_SHELL_DEPTH = 4 +UNRESOLVED_COMMAND_MARKER = "__UNRESOLVED_COMMAND_WRAPPER__" + +# [2026-08-02][fix] 引用内改行で論理行を分断しない(issue #1313 誤検知ファミリー)。 +# 背景: +# - ユーザー依頼意図: `git commit -m "<複数行メッセージ>"` / `gh pr create --body "<複数行>"` が +# text.splitlines() の引用非対応分割で引用途中に千切れ、unresolved 判定→deny になっていた +# (1セッション3〜6回の実測摩擦。値は実行されないデータであり真陽性ではない)。 +# - 守るべき業務ルール: shell の行分割は引用外の改行だけがコマンド区切り。引用内・$( ) / +# backtick 内の改行はトークン/置換本文の一部として同じ論理行に留める。未終端の引用・置換は +# 従来どおり None を返し fail-closed(unresolved)へ倒す。$( ) / backtick の本文検査は +# shell_substitution_bodies 側が従来どおり再帰実施するため、検知力は変えない。 +# - 他案不採用理由: -m/--body 等の「データ引数の値」を走査対象から除外する案は、値の中の +# $( ) 置換(shell が実際に実行する)まで免除しかねず、緩和面が広い。引用対応の分割は +# 誤検知3ケースを同時に解消しつつ既存の置換再帰検査を一切変えない最小修正のため採用。 +def split_shell_logical_lines(text: str): + """Split on newlines that are outside quotes / $() / backticks. None if unterminated. + + Context stack model: 'sq' (single quote), 'dq' (double quote), 'sub' + ($() or bare paren inside a substitution), 'bt' (backtick). Newlines break + logical lines only when the stack is empty (= plain command position). + """ + BACKTICK = chr(96) # 字面のバッククォートは外側 bash の置換スキャナを壊すため chr で持つ + lines = [] + current = [] + stack: list[str] = [] + index = 0 + length = len(text) + while index < length: + char = text[index] + state = stack[-1] if stack else None + if state == "sq": + # 単一引用内の backslash+改行は「continuation に見える難読化」の既存保守契約を + # 維持するため unresolved(None)へ倒す(test: single quoted continuation)。 + if char == "\\" and index + 1 < length and text[index + 1] in "\r\n": + return None + current.append(char) + if char == "'": + stack.pop() + index += 1 + continue + if char == "\\": + # escape consumes next char in normal / dq / sub / bt contexts + current.append(char) + if index + 1 < length: + current.append(text[index + 1]) + index += 2 + else: + index += 1 + continue + if state == "dq": + if char == '"': + stack.pop() + elif char == "$" and index + 1 < length and text[index + 1] == "(": + # NOTE: dollar+開き括弧のリテラルを1トークンで書かない。外側 bash の + # 置換スキャナが引用内でも入れ子置換の開始と解釈して構文崩壊するため、 + # 2文字に分けて append する(本ファイル特有の制約)。 + current.append("$") + current.append("(") + stack.append("sub") + index += 2 + continue + elif char == BACKTICK: + stack.append("bt") + current.append(char) + index += 1 + continue + if state == "bt": + if char == BACKTICK: + stack.pop() + current.append(char) + index += 1 + continue + # state is None (top level) or 'sub' — both accept openers + if char == "'": + stack.append("sq") + current.append(char) + index += 1 + continue + if char == '"': + stack.append("dq") + current.append(char) + index += 1 + continue + if char == BACKTICK: + stack.append("bt") + current.append(char) + index += 1 + continue + if char == "$" and index + 1 < length and text[index + 1] == "(": + stack.append("sub") + # NOTE: dollar+開き括弧のリテラルは2文字に分けて append(上の分岐と同じ理由)。 + current.append("$") + current.append("(") + index += 2 + continue + if state == "sub": + if char == "(": + stack.append("sub") + elif char == ")": + stack.pop() + current.append(char) + index += 1 + continue + if char == "\n": + lines.append("".join(current)) + current = [] + index += 1 + continue + current.append(char) + index += 1 + if stack: + return None + lines.append("".join(current)) + return [line for line in lines if line.strip()] or [""] + + +# [2026-08-02][fix] git 無縁と静的に確定できる nested body だけ unresolved deny を免除する +# (issue #1313 案3の安全部分集合・下の呼び出し元 CaD と対)。 +# 背景: +# - ユーザー依頼意図: 変数や特殊パラメータを含むだけの非 git body(例: exit status 表示付きの +# script 実行)が unresolved 扱いで deny される摩擦を解消したい。 +# - 守るべき業務ルール: 既存の fail-closed 契約(変数 executable / glob・brace・class による +# git 難読化 / 引用継続の難読化は deny)を 1 件も後退させない。判定は「安全と証明できた +# 場合のみ許可」の片側条件とし、証明できない形は全て従来どおり deny に落とす。 +# - 他案不採用理由: body へ再帰降下する案は、glob executable(g?t 等)を非 git と誤読する。 +# git 文字列の有無だけで判定する案は、変数 executable(PAYLOAD 経由)を素通りさせる。 +def nested_body_safely_non_git(body: str) -> bool: + """True only when the body is provably inert w.r.t. destructive git. + + 条件(全て満たす時だけ許可・1つでも証明できなければ False = 従来の deny): + 1. body に "git" 文字列が無い(大文字小文字無視・部分一致で安全側) + 2. brace / backtick / 置換開始(dollar+開き括弧)が無い + 3. dollar 展開は単文字特殊パラメータ(? $ ! #)のみ($VAR / ${...} は + eval や interpreter の引数経由で任意コマンド化しうるため一律 deny) + 4. 各 command segment の実行子がプレーンリテラルで、shell でも + 実行 wrapper(eval / exec / env / sudo / xargs 等・引数を実行する類)でもない + """ + lowered = body.lower() + if "git" in lowered: + return False + if "{" in body or "}" in body: + # brace expansion は tokenizer が区切りとして分解し executable 難読化 + # (/usr/bin/g{it} 等)を見えなくするため、含む body は証明不能として deny 側 + return False + if chr(96) in body: + # backtick 置換は静的解決不能 + return False + # [2026-08-02][fix] PR #1354 codex-review Critical 対応: $VAR / ${...} を含む body は + # `eval $PAYLOAD` / `python3 -c $CODE` 等の引数経由で任意コマンド化するため許可しない。 + # 実行時に値が確定済みで不活性なのは単文字特殊パラメータだけ、という許可リストへ縮小する。 + position = body.find("$") + while position != -1: + follower = body[position + 1:position + 2] + if follower not in {"?", "$", "!", "#"}: + return False + position = body.find("$", position + 2) + segments = segment_tokens(body) + if segments is None: + return False + plain_executable = re.compile(r"[A-Za-z0-9_./-]+") + # 引数を新たなコマンドとして実行しうる wrapper。列挙は原理的に完全にならないため、 + # ここに無い未知 wrapper への防御は上の「$VAR 全面 deny」(引数が静的リテラルなら + # wrapper 経由でも body 内に "git" が現れ 1. で deny)と組み合わせて成立させる。 + exec_wrappers = { + "eval", "exec", "command", "builtin", "source", ".", + "env", "sudo", "doas", "su", "xargs", "nohup", "nice", + "time", "timeout", "setsid", "script", "watch", "caffeinate", + } + for segment in segments: + index = command_executable_index(segment) + if index == UNRESOLVED_WRAPPER: + return False + if index is None: + # 実行子なし(純 assignment 等)は破壊操作を持たない + continue + if index < 0 or index >= len(segment): + return False + token = segment[index] + if plain_executable.fullmatch(token) is None: + return False + basename = executable_basename(token) + if basename in shells or basename in exec_wrappers: + # nested-nested shell / 実行 wrapper は本関数で安全証明できないため deny 側 + return False + return True + + +def decode_shell_command(token: str) -> str: + lexer = shlex.shlex(token, posix=True) + lexer.whitespace_split = True + words = list(lexer) + if len(words) != 1: + raise ValueError("invalid shell command argument") + return words[0] + +def segment_tokens(text: str): + try: + # Keep the outer quote around `bash -c`/`sh -c` bodies so the nested + # command can be decoded once without losing its own quoted path tokens. + lexer = shlex.shlex(text, posix=False, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return None + segments: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + if current: + segments.append(current) + current = [] + else: + current.append(token) + if current: + segments.append(current) + return segments + +def shell_start_index(segment: list[str]): + index = command_executable_index(segment) + return index if index is not None and index >= 0 and executable_basename(segment[index]) in shells else None + + +# [2026-08-02][fix] nested shell の動的 body を fail-closed にする。 +# 背景: +# - ユーザー依頼意図: `PAYLOAD="git reset --hard"; bash -c "$PAYLOAD"` のように、 +# shell `-c` の body を変数・command substitution・process substitution で組み立てる +# 経路でも、破壊的 Git の静的検査を迂回させない。 +# - 守るべき業務ルール: hook が安全に確定できない nested body は許可せず、必ず deny する。 +# hook 自身が変数展開や command substitution を実行して body を得ることは禁止する。 +# - 他案不採用理由: body を実行して展開結果を得る案は hook の副作用・コマンドインジェクションを +# 招く。正規表現だけで全ての shell 展開を再現する案は quote/escape 境界を取り違えるため不採用。 +# 対応: shlex で decode 済みの body を小さな quote-aware scanner で確認し、未解決の `$` 展開、 +# backtick、`$()`、process substitution、pathname/brace展開を marker に変換する。 +# 静的 body の再帰検査は従来どおり行う。 +# [2026-08-02][fix] double quote 中の single quote で scanner state を切り替えない。 +# 背景: shell では double quote 内の `'` は literal だが、旧 scanner は single quote 開始と誤認し、 +# 後続の `$PAYLOAD` を「展開されない文字列」として見逃し得た。scanner 単体でも shell semantics と +# 一致させる必要がある。quote 全文を正規表現へ戻す案は既存の escape 境界を失うため不採用。 +# 対応: double quote state は `"` だけで終了し、その中の `'` は通常文字として扱う。 +def has_unresolved_shell_expansion(text: str) -> bool: + """Return whether a nested shell body contains expansion we must not evaluate.""" + quote = None + index = 0 + + def parameter_expansion_at(position: int) -> bool: + if position + 1 >= len(text): + return False + next_char = text[position + 1] + if next_char in "{([?*!#@$-0123456789_": + return True + return next_char.isalpha() + + while index < len(text): + char = text[index] + if quote == "'": + # Single-quoted shell text has no expansion semantics. + if char == "'": + quote = None + index += 1 + continue + + if char == "\\": + # In unquoted/double-quoted text, an escaped next character is literal. + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + return True + if char == "$" and parameter_expansion_at(index): + return True + if char in "<>" and index + 1 < len(text) and text[index + 1] == "(": + return True + if quote is None and char in "*?": + return True + if ( + quote is None + and char in "[{" + and index + 1 < len(text) + and not text[index + 1].isspace() + and text[index + 1] not in ";&|" + ): + return True + if ( + quote is None + and char in "@+!" + and index + 1 < len(text) + and text[index + 1] == "(" + ): + # Bash extglob such as @(git) can synthesize the executable name. + return True + if ( + quote is None + and char == "(" + and index > 0 + and not text[index - 1].isspace() + and text[index - 1] not in ";&|(<" + ): + # zsh glob qualifiers such as /usr/bin/git(.) are attached to a word. + return True + index += 1 + return False + + +# [2026-08-02][fix] 通常 command の引数内にある shell substitution も再帰検査する。 +# 背景: +# - ユーザー依頼意図: `printf '%s' "$(git reset --hard)"` のように、外側の executable が +# `git` でなくても実行される破壊的 Git を取り逃がさない。 +# - 守るべき業務ルール: command / process / backtick substitution の body は、引用位置に関係なく +# 実際に shell が実行する範囲だけを静的に抽出し、既存と同じ fail-close 判定へ渡す。 +# - 他案不採用理由: substitution を含む command を一律 deny すると `$(pwd)` 等の安全な開発操作まで +# 止める。shell 展開を実行して body を得る案は副作用と command injection を招くため不採用。 +# 対応: single quote と escape を尊重する小さな scanner で `$()` / `<()` / `>()` / backtick の +# body を抽出する。対応できない構文・不均衡・深すぎる再帰は unresolved marker へ送る。 +def shell_substitution_bodies(text: str): + """Return executable substitution bodies, or ``None`` when ambiguous.""" + + def backtick_end(start: int): + position = start + 1 + while position < len(text): + if text[position] == "\\": + position += 2 + continue + if text[position] == chr(96): + return position + position += 1 + return None + + def paren_end(open_index: int): + depth = 1 + quote = None + position = open_index + 1 + while position < len(text): + char = text[position] + if quote == "'": + if char == "'": + quote = None + position += 1 + continue + if char == "\\": + position += 2 + continue + if quote == '"': + if char == '"': + quote = None + position += 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "{": + # Parameter expansion patterns may legally contain `)` and + # make a hand-written parenthesis matcher terminate early. + return None + if char == "$" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == chr(96): + nested_end = backtick_end(position) + if nested_end is None: + return None + position = nested_end + 1 + continue + position += 1 + continue + if char in {"'", '"'}: + quote = char + position += 1 + continue + if ( + char == "#" + and ( + position == open_index + 1 + or text[position - 1].isspace() + or text[position - 1] in ";&|({}" + ) + ): + # An unquoted shell comment hides every `)` through the newline. + newline = text.find("\n", position + 1) + if newline < 0: + return None + position = newline + 1 + continue + if char == chr(96): + nested_end = backtick_end(position) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "$" and position + 1 < len(text) and text[position + 1] == "{": + return None + if char == "<" and position + 1 < len(text) and text[position + 1] == "<": + # Skip a here-doc inside `$()` so a later `)` / command remains visible. + # Example: `$(cat <" and position + 1 < len(text) and text[position + 1] == "(": + nested_end = paren_end(position + 1) + if nested_end is None: + return None + position = nested_end + 1 + continue + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return position + position += 1 + return None + + bodies = [] + quote = None + index = 0 + while index < len(text): + char = text[index] + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + index += 2 + continue + if quote == '"' and char == '"': + quote = None + index += 1 + continue + if quote is None and char in {"'", '"'}: + quote = char + index += 1 + continue + if char == chr(96): + end = backtick_end(index) + if end is None: + return None + body = text[index + 1:end] + # Inside legacy backticks, an escaped backtick opens/closes a nested + # command substitution. Until that grammar is decoded losslessly, + # preserve the documented fail-close boundary instead of treating it + # as a literal escape and dropping the nested executable. + if chr(92) + chr(96) in body: + return None + bodies.append(body) + index = end + 1 + continue + if char == "$" and index + 1 < len(text) and text[index + 1] == "(": + end = paren_end(index + 1) + if end is None: + return None + body = text[index + 2:end] + if body.startswith("("): + # Arithmetic expansion is not itself a command, but may contain one. + nested = shell_substitution_bodies(body) + if nested is None: + return None + bodies.extend(nested) + else: + # A case-pattern `)` is indistinguishable from the substitution + # terminator in this deliberately small scanner. Never infer + # safety from a later `esac` string: it may be pattern data before + # the prematurely matched `)` rather than the closing keyword. + case_start = r"(?:^|[;&|({\n]|\b(?:then|do|else)\b)\s*case\b" + if re.search(case_start, body): + return None + bodies.append(body) + index = end + 1 + continue + if quote is None and char in "<>" and index + 1 < len(text) and text[index + 1] == "(": + end = paren_end(index + 1) + if end is None: + return None + bodies.append(text[index + 2:end]) + index = end + 1 + continue + index += 1 + if quote is not None: + return None + return bodies + + +# [2026-08-02][fix] shell tokenizationより先にline continuationを論理行へ戻す。 +# 背景: +# - ユーザー依頼意図: `g\\\nit reset --hard` のように物理改行で executable を分割しても、 +# 実行時に `git` へ戻る破壊操作を取り逃がさない。 +# - 守るべき業務ルール: shell がtokenize前に行うbackslash-newline除去を静的に再現し、 +# command substitution内外で同じfail-close判定へ渡す。single quote内のliteralは変更しない。 +# - 他案不採用理由: shell自体を実行して展開結果を得る案は、副作用とcommand injectionを招く。 +# 物理行を別々に検査する旧方式は、改行をまたいだ実行tokenを原理的に復元できない。 +# 対応: quote-awareな標準Python処理でLF/CRLF continuationだけを除去し、その後に既存scannerを使う。 +def collapse_shell_line_continuations(text: str) -> str: + """Collapse continuations and remove real comments before ``shlex``.""" + result = [] + quote = None + in_comment = False + index = 0 + while index < len(text): + char = text[index] + if in_comment: + # Backslash-newline is literal comment text here; the physical newline + # still ends the comment before the next command. + if char == "\n": + result.append(char) + in_comment = False + index += 1 + continue + if quote == "'": + result.append(char) + if char == "'": + quote = None + index += 1 + continue + if char == "\\": + if index + 1 < len(text) and text[index + 1] == "\n": + index += 2 + continue + if index + 2 < len(text) and text[index + 1:index + 3] == "\r\n": + index += 3 + continue + result.append(char) + if index + 1 < len(text): + result.append(text[index + 1]) + index += 2 + else: + index += 1 + continue + if ( + quote is None + and char == "#" + and not (len(result) >= 2 and result[-2:] == ["$", "{"]) + and ( + not result + or result[-1].isspace() + or result[-1] in ";&|({}" + ) + ): + in_comment = True + index += 1 + continue + if quote == '"' and char == '"': + quote = None + elif quote is None and char in {"'", '"'}: + quote = char + result.append(char) + index += 1 + return "".join(result) + + + +# [2026-08-02][fix] here-doc 本文は受信コマンドのデータであり、行分割して再検査しない。 +# 背景: +# - ユーザー依頼意図: `git commit -F -` への here-doc や `gh ... --body "$(cat <= len(text) or text[lt_index:lt_index + 2] != "<<": + return None + pos = lt_index + 2 + strip_tabs = False + if pos < len(text) and text[pos] == "-": + strip_tabs = True + pos += 1 + while pos < len(text) and text[pos] in " \t": + pos += 1 + if pos >= len(text) or text[pos] == "\n": + return None + + quoted = False + if text[pos] == "\\": + quoted = True + pos += 1 + if pos >= len(text): + return None + start = pos + while pos < len(text) and (text[pos].isalnum() or text[pos] == "_"): + pos += 1 + delimiter = text[start:pos] + elif text[pos] in {"'", '"'}: + quoted = True + quote = text[pos] + pos += 1 + start = pos + while pos < len(text) and text[pos] != quote: + if text[pos] == "\\" and quote == '"': + pos += 2 + continue + pos += 1 + if pos >= len(text): + return None + delimiter = text[start:pos] + pos += 1 + else: + start = pos + while pos < len(text) and (text[pos].isalnum() or text[pos] in "_-"): + pos += 1 + delimiter = text[start:pos] + if not delimiter: + return None + + newline = text.find("\n", pos) + if newline < 0: + return None + body_pos = newline + 1 + while body_pos <= len(text): + next_nl = text.find("\n", body_pos) + line = text[body_pos:] if next_nl < 0 else text[body_pos:next_nl] + compare = line.lstrip("\t") if strip_tabs else line + if compare == delimiter: + end = len(text) if next_nl < 0 else next_nl + 1 + return end, quoted + if next_nl < 0: + return None + body_pos = next_nl + 1 + return None + + + +def extract_heredocs(text: str): + """Split here-doc bodies from command text. + + Returns ``(without_bodies, shell_bodies, unquoted_bodies)``. + + Here-docs are recognized outside single quotes. Double quotes are ignored as + a quoting barrier so ``"$(cat <= end: + without.append(text[index:end]) + index = end + continue + without.append(text[index : delim_line_end + 1]) + body = text[delim_line_end + 1 : end] + body_lines = body.splitlines(keepends=True) + body_content = "".join(body_lines[:-1]) if body_lines else "" + receiver_line = "".join(without[line_start:]) + text[index:delim_line_end] + try: + lexer = shlex.shlex(receiver_line, posix=True, punctuation_chars=";&|(){}") + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + tokens = [] + exec_index = command_executable_index(tokens, decoded=True) if tokens else None + is_shell = ( + exec_index is not None + and exec_index >= 0 + and executable_basename(tokens[exec_index], decoded=True) in shells + ) + if is_shell: + shell_bodies.append(body_content) + elif not quoted and body_content.strip(): + unquoted_bodies.append(body_content) + index = end + if index > 0 and text[index - 1] == "\n": + line_start = len(without) + continue + if char == "\n": + without.append(char) + index += 1 + line_start = len(without) + continue + without.append(char) + index += 1 + return "".join(without), shell_bodies, unquoted_bodies + + + + +def _strip_quoted_heredocs_completely(body: str): + """Remove quoted here-docs entirely from a ``$()`` body. + + Returns ``(remaining, True)`` when every here-doc used a quoted delimiter. + Returns ``(None, False)`` when an unquoted/incomplete here-doc is present + (caller must not collapse — trailing commands or expansions may remain). + """ + sq = chr(39) + remaining = [] + in_single = False + index = 0 + saw_heredoc = False + while index < len(body): + char = body[index] + if in_single: + remaining.append(char) + if char == sq: + in_single = False + index += 1 + continue + if char == "\\": + remaining.append(char) + if index + 1 < len(body): + remaining.append(body[index + 1]) + index += 2 + else: + index += 1 + continue + if char == sq: + in_single = True + remaining.append(char) + index += 1 + continue + if char == "<" and index + 1 < len(body) and body[index + 1] == "<": + end_info = heredoc_skip_end(body, index) + if end_info is None: + return None, False + end, quoted = end_info + if not quoted: + return None, False + saw_heredoc = True + index = end + continue + remaining.append(char) + index += 1 + if not saw_heredoc: + return None, False + return "".join(remaining), True + + +def collapse_data_substitutions(text: str): + """Collapse data-command substitutions that only feed quoted here-doc text. + + Only ``$(cat <<'EOF' ... EOF)`` style payloads collapse. Unquoted here-docs, + trailing ``; cmd``, pipelines, and nested substitutions are left intact so + later scanners still see real executable git. + """ + data_commands = {"cat", "printf", "echo", "head", "tail", "base64", "wc", "true"} + dq = chr(34) + sq = chr(39) + open_sub = "$" + "(" + token = dq + "__HOOK_STATIC_HEREDOC_DATA__" + dq + separators = {";", "&", "|", "||", "&&", "(", ")", "{", "}"} + out = [] + index = 0 + while index < len(text): + dollar = text.find(open_sub, index) + if dollar < 0: + out.append(text[index:]) + break + prefix = text[index:dollar] + in_single = False + p = 0 + while p < len(prefix): + ch = prefix[p] + if in_single: + if ch == sq: + in_single = False + p += 1 + continue + if ch == "\\": + p += 2 + continue + if ch == sq: + in_single = True + p += 1 + if in_single: + out.append(text[index:dollar + len(open_sub)]) + index = dollar + len(open_sub) + continue + depth = 1 + pos = dollar + len(open_sub) + replaced = False + while pos < len(text) and depth: + ch = text[pos] + if ch == "\\": + pos += 2 + continue + if ch == sq: + pos += 1 + while pos < len(text) and text[pos] != sq: + pos += 1 + pos += 1 + continue + if ch == dq: + pos += 1 + while pos < len(text) and text[pos] != dq: + if text[pos] == "\\": + pos += 2 + continue + pos += 1 + pos += 1 + continue + if ch == "<" and pos + 1 < len(text) and text[pos + 1] == "<": + skipped = heredoc_skip_end(text, pos) + if skipped is None: + break + pos = skipped[0] + continue + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + body = text[dollar + len(open_sub):pos] + remaining, ok = _strip_quoted_heredocs_completely(body) + if ok and remaining is not None: + nested = shell_substitution_bodies(remaining) + if nested == []: + try: + lexer = shlex.shlex( + remaining, posix=True, punctuation_chars=";&|(){}" + ) + lexer.commenters = "" + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + tokens = [] + if tokens and not any(tok in separators for tok in tokens): + exec_index = command_executable_index( + tokens, decoded=True + ) + if ( + exec_index is not None + and exec_index >= 0 + and executable_basename( + tokens[exec_index], decoded=True + ) + in data_commands + ): + start = dollar + endpos = pos + 1 + if ( + start > 0 + and endpos < len(text) + and text[start - 1] == dq + and text[endpos] == dq + ): + start -= 1 + endpos += 1 + out.append(text[index:start]) + out.append(token) + index = endpos + replaced = True + break + pos += 1 + if not replaced: + if pos >= len(text) and depth: + out.append(text[index:]) + break + out.append(text[index:dollar + len(open_sub)]) + index = dollar + len(open_sub) + return "".join(out) + + +def emit_segments(text: str, depth: int = 0) -> None: + """Emit a shell command and recursively inspect every ``*-c`` body. + + Shells can be nested arbitrarily (for example ``bash -c 'sh -c ...'``). + Bound the static expansion so an adversarially deep or malformed payload + becomes an unresolved marker instead of silently bypassing the hook. + """ + if depth > MAX_SHELL_DEPTH: + print(UNRESOLVED_COMMAND_MARKER) + return + + text = collapse_shell_line_continuations(text) + + # Collapse quoted data here-docs inside $() BEFORE stripping, otherwise the + # opener remains and collapse can no longer find the terminator. + text = collapse_data_substitutions(text) + + # Here-doc bodies are data for the receiving command. Do not line-split them + # into fake top-level commands. Shell receivers still re-inspect the body. + stripped, shell_heredocs, unquoted_heredocs = extract_heredocs(text) + if stripped is None: + print(UNRESOLVED_COMMAND_MARKER) + return + text = stripped + + # 引用/置換の内側の改行で論理行を千切らない(split_shell_logical_lines の CaD 参照)。 + # 未終端の引用・置換は None → 従来どおり unresolved で fail-closed。 + lines = split_shell_logical_lines(text) + if lines is None: + print(UNRESOLVED_COMMAND_MARKER) + return + if not lines: + emit_segment_line(text) + else: + for line in lines: + emit_segment_line(line) + + for heredoc_body in shell_heredocs: + if heredoc_body.strip(): + emit_segments(heredoc_body, depth + 1) + + # Unquoted here-doc bodies expand $()/backticks; inspect those only. + for heredoc_body in unquoted_heredocs: + expansion_bodies = shell_substitution_bodies(heredoc_body) + if expansion_bodies is None: + print(UNRESOLVED_COMMAND_MARKER) + return + for body in expansion_bodies: + emit_segments(body, depth + 1) + + substitution_bodies = shell_substitution_bodies(text) + if substitution_bodies is None: + print(UNRESOLVED_COMMAND_MARKER) + return + for body in substitution_bodies: + emit_segments(body, depth + 1) + + segments = segment_tokens(text) + if segments is None: + print(UNRESOLVED_COMMAND_MARKER) + return + if depth > 0 and not text.strip(): + print(UNRESOLVED_COMMAND_MARKER) + return + if depth > 0 and text.strip() and not segments: + print(UNRESOLVED_COMMAND_MARKER) + return + + for segment in segments: + index = shell_start_index(segment) + if index is None: + continue + lookahead = index + 1 + tokens = segment + while lookahead < len(tokens) and tokens[lookahead].startswith("-"): + option_token = tokens[lookahead] + option = option_token.lstrip("-") + if not option_token.startswith("--") and "c" in option: + command_index = lookahead + 1 + if command_index < len(tokens) and tokens[command_index] == "--": + command_index += 1 + if command_index >= len(tokens): + print(UNRESOLVED_COMMAND_MARKER) + break + try: + nested_command = decode_shell_command(tokens[command_index]) + except Exception: + # A malformed nested shell argument must fail closed. + print(UNRESOLVED_COMMAND_MARKER) + break + if has_unresolved_shell_expansion(nested_command): + # Do not evaluate shell variables/substitutions in the hook. The body may + # resolve to destructive Git after the hook returns, so static inspection is + # impossible without executing untrusted input. + # + # [2026-08-02][fix] git 無縁の nested body まで deny しない(issue #1313 案3の + # 安全部分集合)。 + # 背景: + # - ユーザー依頼意図: `bash -c '... echo "exit=$?" ...'` のような、git を + # 一切含まない body が $? / $VAR だけで unresolved 扱いされ deny される + # 摩擦を解消したい(実測: 1セッション3回)。 + # - 守るべき業務ルール: 本 hook の守備範囲は破壊的 Git のみ(冒頭 CaD)。 + # "git" が現れない body は展開後も git になり得る余地を静的に持たない + # (g${X}it 型の難読化は変数側に "git" が現れないが、その場合 body 内に + # substring "git" が無くても executable 難読化は既存の変数 executable + # fail-close が上流で拾う)。substring 判定(大文字小文字無視・単語境界 + # なし)を使い、"digital" 等を含む body も deny 側へ倒す(安全側の過剰)。 + # - 他案不採用理由: unresolved wrapper 全面緩和(案3全体)は影響範囲が + # 読めず不採用。データ引数の値の除外は $( ) 置換の免除リスクがあり不採用。 + # git 文字列の有無だけの判定は変数 executable を素通りさせるため不採用 + # (安全証明は nested_body_safely_non_git に集約)。 + if not nested_body_safely_non_git(nested_command): + print(UNRESOLVED_COMMAND_MARKER) + break + emit_segments(nested_command, depth + 1) + break + if option_token in {"-o", "-O", "--rcfile", "--init-file"} and lookahead + 1 < len(tokens): + lookahead += 2 + continue + lookahead += 1 + +emit_segments(cmd) +PY +)" + +if printf '%s\n' "${command_segments}" | grep -Fqx '__UNRESOLVED_COMMAND_WRAPPER__'; then + block_json "unresolved command wrapper" + exit 0 +fi + +# [2026-08-02][fix] inline override は実行対象の git に直結する assignment だけを許可する。 +# 背景: +# - ユーザー依頼意図: `printf` / `echo` の引数や別 segment に書かれた +# `AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1` を、破壊的 git 復旧の許可と誤認しないようにする。 +# - 守るべき業務ルール: 破壊的 Git 操作は fail-closed で止め、明示的な復旧時だけ +# inherited env または実行対象 `git` の直前 assignment による inline override を許可する。 +# - 他案不採用理由: +# 1) コマンド全体 grep の継続は、文字列・引数・別 segment の偽装を検出できず Critical を再発させる。 +# 2) 生成された PJ 側 hook の手修正は中央正本を迂回して再発する。 +# 3) `git clean` の設定値列挙やルール文だけの禁止は、迂回経路を原理的に閉じない。 +# 4) 新規外部依存や全面的な shell parser 導入は、配布対象を増やし保守境界を曖昧にする。 +# 対応: Python 標準 `shlex` で単一の shell segment を tokenize し、segment 全体が実コマンド先頭の +# assignment token 直後の裸の `git` の場合だけ inline bypass を許可する。separator・改行・引用符・ +# 通常引数・別 segment の文字列は許可せず、tokenize 失敗時は `0` を返して安全側に倒す。 +inline_bypass="$( + COMMAND_TEXT="${command}" python3 - <<'PY' 2>/dev/null || printf '0' +import os +import re +import shlex + +BYPASS = "AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1" +ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$") +PUNCTUATION = ";&|(){}" +text = os.environ.get("COMMAND_TEXT", "") + +def validate_punctuation(tokens): + expected = [] + pairs = {"(": ")", "{": "}"} + for token in tokens: + if not token or not all(char in PUNCTUATION for char in token): + continue + for char in token: + if char in pairs: + expected.append(pairs[char]) + elif char in {")", "}"} and (not expected or expected.pop() != char): + raise ValueError("unbalanced shell punctuation") + if expected: + raise ValueError("unbalanced shell punctuation") + +try: + # posix=True validates quoting/escaping; posix=False retains quote markers + # so a quoted assignment cannot become an override token. + validator = shlex.shlex(text, posix=True, punctuation_chars=PUNCTUATION) + validator.whitespace_split = True + list(validator) + lexer = shlex.shlex(text, posix=False, punctuation_chars=PUNCTUATION) + lexer.whitespace_split = True + tokens = list(lexer) + validate_punctuation(tokens) + if "\n" in text or any(token and all(char in PUNCTUATION for char in token) for token in tokens): + print("0") + raise SystemExit +except Exception: + print("0") + raise SystemExit + +index = 0 +while index < len(tokens) and ASSIGNMENT.fullmatch(tokens[index]): + index += 1 +if index > 0 and index < len(tokens): + if tokens[index] == "git" and tokens[index - 1] == BYPASS: + print("1") + raise SystemExit + +print("0") +PY +)" +if [ "${inline_bypass}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass block-destructive-git allow '{"env":"AGENT_HUB_ALLOW_DESTRUCTIVE_GIT"}' 2>/dev/null || true + allow_json + exit 0 +fi + +reset_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+reset([[:space:]][^;&|()]*)?[[:space:]]--hard([[:space:]]|$)" || true)" +if [ -n "${reset_segments}" ]; then + block_json "git reset --hard" + exit 0 +fi + +clean_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+clean([[:space:]]|$)" || true)" +if [ -n "${clean_segments}" ]; then + while IFS= read -r segment; do + [ -z "${segment}" ] && continue + clean_args="$(printf '%s' "${segment}" | sed -E "s#${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+clean([[:space:]]|$)##")" + if printf '%s' "${clean_args}" | grep -Eq '(^|[[:space:]])(--dry-run|-n|-n[a-zA-Z]*|-[a-zA-Z]*n[a-zA-Z]*)([[:space:]]|$)'; then + continue + fi + # [2026-08-01][fix] `-f` の有無で判定すると設定経由で迂回できる(codex-review Critical)。 + # 背景: + # - ユーザー依頼意図: 破壊的 git 操作ガードが「実際に消せるコマンド」を取り逃がさないようにする。 + # - 守るべき業務ルール: `git clean` は `clean.requireForce=false` を渡すと `-f` 無しで + # 未追跡ファイルを削除できる。`git -c clean.requireForce=false clean -dx` は `.env` 等の + # ローカル秘匿ファイルまで消すため、`-f` を探す実装では素通りする(実測で PASS を確認)。 + # - 他案不採用理由: + # 1) `-c clean.requireForce=false` を追加でパターン検出する案は、`GIT_CONFIG_*` 環境変数や + # `--config-env`、既存の repo/global 設定でも同じ状態を作れるため、列挙が原理的に閉じない。 + # 2) 実際の設定値を読んで判定する案は、hook が対象 repo を確定できない場面(複合コマンド・ + # `--git-dir` 指定)で誤判定するため不採用。 + # 対応: dry-run でない `git clean` は一律 deny する。dry-run は上の continue で通過済み。 + block_json "git clean(--dry-run / -n 以外)" + exit 0 + done <" + exit 0 +fi + +# [2026-08-02][fix] `--` なし checkout の曖昧な位置引数を fail-closed にする。 +# 背景: +# - ユーザー依頼意図: 全PJ共通の破壊的Git guardで、`git checkout README.md` や +# `git checkout .` によるtracked変更の暗黙破棄も確実に止める。 +# - 守るべき業務ルール: checkoutの単一位置引数はbranch名とpathspecを静的に完全判別できないため、 +# 読み取りだけでpathだと断定できない場合も安全側へ倒す。branch移動には`git switch`を使う。 +# - 他案不採用理由: 拡張子・`/`・実在pathだけを列挙する案は、拡張子のないfile、glob、 +# `git -C`先のpathを取り逃がす。hook内でGitのref/path解決を実行する案はrepo/cwd境界を誤る。 +# 対応: 明示的な新規branch作成(`-b` / `--orphan`)、detach、help/versionだけを許可し、 +# `-p` / `--ours` / `--theirs` / `-B` 等を含む残りのcheckoutは一律denyする。 +# 安全optionは最初の位置引数より前にある場合だけ許可し、pathspec後ろのoptionで +# branch作成/detachへ見せかける並び替えは許可しない。安全モード後も引数個数を固定する。 +checkout_ambiguous_segments="$(printf '%s\n' "${command_segments}" | grep -E "${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+checkout([[:space:]]|$)" || true)" +if [ -n "${checkout_ambiguous_segments}" ]; then + while IFS= read -r segment; do + [ -z "${segment}" ] && continue + checkout_args="$(printf '%s' "${segment}" | sed -E "s#${GIT_SEGMENT_START}${GIT_GLOBAL_OPTS}[[:space:]]+checkout([[:space:]]|$)##")" + checkout_tokens=() + if [ -n "${checkout_args}" ]; then + read -r -a checkout_tokens <<< "${checkout_args}" + fi + checkout_count="${#checkout_tokens[@]}" + checkout_index=0 + checkout_safe=0 + checkout_invalid=0 + while (( checkout_index < checkout_count )); do + checkout_token="${checkout_tokens[checkout_index]}" + case "${checkout_token}" in + -q|--quiet|-m|--merge) + checkout_index=$((checkout_index + 1)) + ;; + --help|--version) + if (( checkout_index + 1 == checkout_count )); then + checkout_safe=1 + else + checkout_invalid=1 + fi + break + ;; + -b|--orphan) + if (( checkout_index + 2 == checkout_count )); then + checkout_branch="${checkout_tokens[checkout_index + 1]}" + if [ -n "${checkout_branch}" ] && [[ "${checkout_branch}" != -* ]]; then + checkout_safe=1 + else + checkout_invalid=1 + fi + else + checkout_invalid=1 + fi + break + ;; + --detach) + if (( checkout_index + 1 == checkout_count )); then + checkout_safe=1 + elif (( checkout_index + 2 == checkout_count )); then + checkout_ref="${checkout_tokens[checkout_index + 1]}" + if [ -n "${checkout_ref}" ] && [[ "${checkout_ref}" != -* ]]; then + checkout_safe=1 + else + checkout_invalid=1 + fi + else + checkout_invalid=1 + fi + break + ;; + *) + checkout_invalid=1 + break + ;; + esac + done + if (( checkout_safe == 1 && checkout_invalid == 0 )); then + continue + fi + block_json "git checkout(pathspec ambiguity; use git switch for branches)" + exit 0 + done <" + exit 0 + fi + if printf '%s' "${restore_args}" | grep -Eq '(^|[[:space:]])(--staged|-S|-[A-Za-z]*S[A-Za-z]*)([[:space:]]|$)'; then + continue + fi + block_json "git restore " + exit 0 + done <&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) + +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +reason = payload.get("permissionDecisionReason", "") +if "[hook:block-destructive-git]" not in reason: + sys.exit(1) +if "reason" in payload: + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue": true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow_inherited_env() { + local name="$1" + local command="$2" + local out + out="$(AGENT_HUB_ALLOW_DESTRUCTIVE_GIT=1 run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue": true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_raw() { + local name="$1" + local payload="$2" + local out + out="$(run_hook_raw "$payload" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) + +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if "[hook:block-destructive-git]" not in payload.get("permissionDecisionReason", ""): + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_embedded_python_py39() { + local name="embedded Python blocks parse as Python 3.9" + local out + if out="$(HOOK_SCRIPT="$SCRIPT" python3 - <<'PY' 2>&1 +import ast +import os +import re + +source = open(os.environ["HOOK_SCRIPT"], encoding="utf-8").read() +blocks = re.findall(r"<<'PY'[^\n]*\n(.*?)\nPY(?:\n|$)", source, re.S) +if not blocks: + raise SystemExit("no embedded Python blocks found") +for index, block in enumerate(blocks, 1): + try: + tree = ast.parse(block, feature_version=(3, 9)) + except SyntaxError as exc: + raise SystemExit(f"block {index}: {exc}") + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + returns = node.returns + if isinstance(returns, ast.BinOp) and isinstance(returns.op, ast.BitOr): + raise SystemExit(f"block {index}: Python 3.10 union return annotation") +PY +)"; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_double_quote_single_quote_scanner() { + local name="double quote内single quote後のvariable expansionを検出" + if HOOK_SCRIPT="$SCRIPT" python3 - <<'PY' +import os +import re + +source = open(os.environ["HOOK_SCRIPT"], encoding="utf-8").read() +blocks = re.findall(r"<<'PY'[^\n]*\n(.*?)\nPY(?:\n|$)", source, re.S) +scanner_blocks = [block for block in blocks if "def has_unresolved_shell_expansion" in block] +if len(scanner_blocks) != 1: + raise SystemExit(f"expected one scanner block, got {len(scanner_blocks)}") +namespace = {} +exec(scanner_blocks[0], namespace) +scanner = namespace["has_unresolved_shell_expansion"] +if not scanner('echo "\'"; $PAYLOAD'): + raise SystemExit("variable expansion after a single quote inside double quotes was missed") +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s\n' "$name" + FAIL=$((FAIL + 1)) + fi +} + +expect_embedded_python_py39 +expect_double_quote_single_quote_scanner +expect_block "git reset --hard deny" "git reset --hard" +expect_block "/usr/bin/git reset --hard deny" "/usr/bin/git reset --hard" +expect_block "command /usr/bin/git clean -fd deny" "command /usr/bin/git clean -fd" +expect_block "quoted /usr/bin/git reset --hard deny" "\"/usr/bin/git\" reset --hard" +expect_block "quoted ./bin/git clean -fd deny" "'./bin/git' clean -fd" +expect_block "quoted path with spaces git reset --hard deny" "\"/tmp/git tools/git\" reset --hard" +expect_block "consecutive-slash /usr//bin/git reset --hard deny" "/usr//bin/git reset --hard" +expect_block "consecutive-slash ./bin//git clean -fd deny" "./bin//git clean -fd" +expect_block "git -C reset --hard deny" "git -C /tmp/repo reset --hard origin/main" +expect_block "git -C path with spaces reset --hard deny" "git -C '/tmp/repo with spaces' reset --hard origin/main" +expect_block "git --git-dir/--work-tree path with spaces clean deny" "git --git-dir='/tmp/repo with spaces/.git' --work-tree '/tmp/repo with spaces' clean -fd" +expect_block "command git reset --hard deny" "command git reset --hard" +expect_block "env git clean -fd deny" "env git clean -fd" +expect_block "/usr/bin/env git clean -fd deny" "/usr/bin/env git clean -fd" +expect_block "/usr/bin/env -u FOO git reset --hard deny" "/usr/bin/env -u FOO git reset --hard" +expect_block "env assignment git checkout -f deny" "env FOO=bar git checkout -f main" +expect_block "sudo git reset --hard deny" "sudo git reset --hard" +expect_block "exec git reset --hard deny" "exec git reset --hard" +expect_block "exec alternate argv0 still finds git" "exec -a harmless /usr/bin/git reset --hard" +expect_block "exec unknown option fail closed" "exec --future-option /usr/bin/git reset --hard" +expect_block "sudo git clean -fd deny" "sudo -n git clean -fd" +expect_block "sudo -u root git reset --hard deny" "sudo -u root git reset --hard" +expect_block "sudo --user root path git reset --hard deny" "sudo --user root /usr/bin/git reset --hard" +expect_block "sudo --user=root path git reset --hard deny" "sudo --user=root /usr/bin/git reset --hard" +expect_block "sudo -- terminator path git reset --hard deny" "sudo -- /usr/bin/git reset --hard" +expect_block "sudo short chdir option still finds git" "sudo -D /tmp /usr/bin/git reset --hard" +expect_block "sudo long chdir option still finds git" "sudo --chdir /tmp /usr/bin/git clean -fd" +expect_block "sudo unknown option fail closed" "sudo --future-option /usr/bin/git reset --hard" +expect_block "sudo env path git reset --hard deny" "sudo env /usr/bin/git reset --hard" +expect_block "command env path git clean -fd deny" "command env /usr/bin/git clean -fd" +expect_block "env -u FOO git clean -fd deny" "env -u FOO git clean -fd" +expect_block "env -S reset payload fail closed" "env -S '/usr/bin/git reset --hard'" +expect_block "env --split-string clean payload fail closed" "env --split-string='/usr/bin/git clean -fd'" +expect_block "env unknown option before git fail closed" "env --future-option /usr/bin/git reset --hard" +expect_block "env ignore-environment still finds git" "env -i /usr/bin/git reset --hard" +expect_block "env attached unset still finds git" "env --unset=FOO /usr/bin/git clean -fd" +expect_block "env option terminator still finds git" "env -- /usr/bin/git reset --hard" +expect_block "time macOS long report still finds git" "/usr/bin/time -l /usr/bin/git reset --hard" +expect_block "time output option still finds git" "/usr/bin/time -o /tmp/timing.txt /usr/bin/git clean -fd" +expect_block "time unknown option before git fail closed" "/usr/bin/time --future-option /usr/bin/git reset --hard" +expect_block "eval path git reset fail closed" "eval /usr/bin/git reset --hard" +expect_block "eval quoted git reset fail closed" "eval 'git reset --hard'" +expect_block "nested eval quoted git reset fail closed" "bash -c 'eval \"git reset --hard\"'" +expect_block "nested eval git clean fail closed" "bash -c 'eval git clean -fd'" +expect_block "variable executable path fail closed" 'G=/usr/bin/git; "$G" reset --hard' +expect_block "variable executable basename fail closed" 'GIT=git; $GIT clean -fd' +expect_block "command substitution executable fail closed" '$(printf /usr/bin/git) reset --hard' +expect_block "zsh equals executable reset fail closed" "=git reset --hard" +expect_block "zsh equals executable clean fail closed" "=git clean -fd" +expect_block "brace group variable executable fail closed" '{ "$G" reset --hard; }' +expect_block "brace group command substitution executable fail closed" '{ $(printf git) clean -fd; }' +expect_block "paren group variable executable fail closed" '( "$G" reset --hard )' +expect_block "function body variable executable fail closed" 'danger(){ "$G" reset --hard; }; danger' +expect_block "function body command substitution executable fail closed" 'danger(){ $(printf git) clean -fd; }; danger' +expect_block "quoted argument command substitution reset deny" 'printf '\''%s'\'' "$(git reset --hard)"' +expect_block "quoted argument command substitution clean deny" 'echo "$(git clean -fd)"' +expect_block "argument process substitution restore deny" 'cat <(git restore src/app.ts)' +expect_block "argument backtick checkout deny" 'printf '\''%s'\'' "`git checkout -f main`"' +nested_backtick_argument='echo "`echo \`git reset --hard\``"' +expect_block "nested legacy backtick reset fail closed" "$nested_backtick_argument" +expect_block "nested argument substitution reset deny" 'printf '\''%s'\'' "$(printf '\''%s'\'' "$(git reset --hard)")"' +expect_block "arithmetic nested substitution clean deny" 'printf '\''%s'\'' "$((1 + $(git clean -fd)))"' +expect_block "case pattern esac text cannot hide reset" 'printf '\''%s'\'' "$(case esac in *esac*) git reset --hard ;; esac)"' +comment_substitution="printf '%s' \"\$( # ) +git reset --hard)\"" +expect_block "comment close paren cannot hide reset" "$comment_substitution" +comment_continuation_substitution="printf '%s' \$(echo ok # ) +g\\ +it reset --hard)" +expect_block "comment and line continuation cannot hide reset" "$comment_continuation_substitution" +comment_line_continuation="printf x # foo\\ +git reset --hard" +expect_block "comment line continuation cannot swallow next reset" "$comment_line_continuation" +comment_after_separator="printf x; # foo\\ +git clean -fd" +expect_block "separator comment continuation cannot swallow next clean" "$comment_after_separator" +comment_crlf_continuation=$'printf x # foo\\\r\n\tgit reset --hard' +expect_block "CRLF comment continuation cannot swallow next reset" "$comment_crlf_continuation" +comment_multiple_continuation=$'printf x # foo\\\ng\\\ni\\\nt clean -fd' +expect_block "comment with multiple continuations cannot hide clean" "$comment_multiple_continuation" +parameter_length_continuation="x=value; : \${#x}; g\\ +it reset --hard" +expect_block "parameter length hash is not a comment" "$parameter_length_continuation" +expect_allow "real comment remains inert" 'printf x # git reset --hard' +continued_git="g\\ +it reset --hard" +expect_block "line continuation executable reset deny" "$continued_git" +continued_git_multiple="g\\ +i\\ +t clean -fd" +expect_block "multiple line continuations executable clean deny" "$continued_git_multiple" +continued_git_quoted="printf '%s' \"\$(g\\ +it reset --hard)\"" +expect_block "double quoted continuation executable reset deny" "$continued_git_quoted" +single_quoted_continuation="printf '%s' 'g\\ +it reset --hard'" +expect_block "single quoted continuation remains conservative deny" "$single_quoted_continuation" +expect_block "parameter pattern close paren cannot hide reset" 'printf '\''%s'\'' "$(x=x; : ${x%)}; git reset --hard)"' +heredoc_substitution="printf '%s' \"\$(cat < /tmp/a.txt 2>&1; echo "exit=$?"; tail -3 /tmp/a.txt'\''' +expect_allow "chained add and multiline commit" 'git add -A && git commit -m "one +two"' +expect_block "commit -m command substitution still denied" 'git commit -m "$(git reset --hard)"' +# push --force は本 hook の守備範囲外(ローカル変更破壊系のみ)のため、置換ペイロードは +# 守備範囲内の reset --hard で「データ引数内の置換も deny」を固定する +expect_block "gh body command substitution still denied" 'gh pr create --body "$(git reset --hard)"' +expect_block "bash -c variable body still denied after relaxation" 'bash -c "$BODY"' +# PR #1354 codex-review Critical: 実行 wrapper の引数経由で任意コマンド化する経路を deny 固定 +expect_block "bash -c eval variable payload denied" 'bash -c '\''eval $PAYLOAD'\''' +expect_block "bash -c env variable payload denied" 'bash -c '\''env $PAYLOAD'\''' +expect_block "bash -c interpreter variable code denied" 'bash -c '\''python3 -c $CODE'\''' +dash_heredoc='dash <<'\''EOF'\'' +git reset --hard +EOF' +expect_block "dash heredoc destructive body deny" "$dash_heredoc" +ksh_heredoc='ksh <<'\''EOF'\'' +git clean -fd +EOF' +expect_block "ksh heredoc destructive body deny" "$ksh_heredoc" +expect_block "dash -c destructive body deny" "dash -c 'git reset --hard'" + +# [2026-08-02][test] xargs wrapper 経由の破壊的 Git 検査(jtt-cms PR #1542 codex-review Critical)。 +# 背景: +# - ユーザー依頼意図: xargs 経由の破壊的 Git が引数位置に見えて素通りしていた迂回を deny 固定し、 +# 非 git 用途の xargs(rm 等)や安全 subcommand を巻き込まないことを対で固定する。 +# - 守るべき業務ルール: 任意引数 option(bare -l 等)は静的境界不能として fail-closed。 +# - 他案不採用理由: deny 側のみのテストは、whitelist 縮小で日常 xargs が全滅しても気づけない。 +expect_block "xargs -n1 destructive reset deny" "printf 'HEAD\n' | xargs -n1 git reset --hard" +expect_block "xargs -I replace destructive clean deny" "xargs -I{} git clean -fd" +expect_block "xargs bare optional-arg option fail closed" "xargs -l git reset --hard" +expect_allow "xargs non-git command stays allowed" "ls | xargs -n1 rm -f" +expect_allow "xargs safe git subcommand stays allowed" "printf 'x\n' | xargs git log --oneline" + +TOTAL=$((PASS + FAIL)) +printf '\n=== block-destructive-git.test.sh: %d/%d PASS ===\n' "$PASS" "$TOTAL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.kimi-code/hooks/scripts/block-main-commit.sh b/.kimi-code/hooks/scripts/block-main-commit.sh new file mode 100755 index 000000000..d4c01aac0 --- /dev/null +++ b/.kimi-code/hooks/scripts/block-main-commit.sh @@ -0,0 +1,956 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: +# 依頼意図: AIがmainに直接プッシュする事故の再発防止。 +# ルール記載(branch-rule.md)だけでは防げなかった実績があり(F2直接プッシュ事故)、 +# 技術的強制力を追加する必要があった。 +# 業務ルール: mainマージ = 本番DB即時適用 + 本番デプロイ発火のため、 +# レビューなし変更は業務リスクが高い。 +# 不採用理由: ルール記載のみでは実際の事故を防げなかった実績がある。 +# git hookよりもClaude Code PreToolUseの方が実行パスに近く確実にブロックできる。 +# 対応: jtt-cms block-main-commit.sh をポート。lib/hook-io.sh を使用。 + +# [2026-04-10][fix] +# 背景: +# 依頼意図: `git push origin main` が.mdファイルのみでもブロックされるバグの修正。 +# 守るべき業務ルール: .mdのみの変更はmainで直接コミット・プッシュ可能(branch-rule.md)。 +# 他案不採用理由: Path Aを削除する案はrefspec経由の非docs pushを見逃すため不採用。 +# 軽量変更判定をインライン展開する案はPath Bとの重複(DRY違反)のため不採用。 +# 対応: 軽量変更 push 判定を is_push_lightweight_only() に関数化し、Path A/B両方から呼び出し。 +# 撤回: 2026-07-01 に AI hook 経由の main 直接 commit/push 例外は全廃。上記は履歴のみ。 + +# [2026-04-18][fix] +# 背景: +# 依頼意図: エージェント環境で origin/main が未解決のとき Markdown のみの push まで拒否される。 +# Cursor/CLI の PreToolUse が同じスクリプトを通すため、比較基準 ref の解決を強化したい。 +# 守るべき業務ルール: main 直 push の例外は「Markdown 系ドキュメント + sync-state.json のみ」(CLAUDE.md / branch-rule.md)。 +# 他案不採用理由: 非 .md コードを許可する案は本番自動適用リスクのため不採用。 +# 対応: 比較 ref を origin/main → refs/remotes/origin/main → main@{upstream} の順で解決。 +# 許可拡張子に .mdc / .mdx を含める(Cursor ルール・MDX ドキュメント)。 +# AGENT-HUB: jtt-cms 正本と同一内容を hook-library に同期(docs/prd/prd-active.md 参照)。 +# 撤回: 2026-07-01 に Markdown / sync-state 等の main 直 push 例外は全廃。上記は履歴のみ。 +# +# [2026-04-27][fix] +# 背景: +# 依頼意図: .codex/sync-state.json のような同期状態ファイルだけで main 直コミットが止まるのは運用上のノイズ。 +# 守るべき業務ルール: sync-state.json はツール自動生成の状態ファイルとして Markdown 系ドキュメントと同じ軽量変更扱いにする。 +# 他案不採用理由: .json 全体を許可する案は package.json や設定 JSON までレビューなしで通すため不採用。 +# 対応: main 直コミット/プッシュの例外に sync-state.json だけを追加し、commit/push で共通判定を使う。 +# 撤回: 2026-07-01 に sync-state.json を含む軽量変更例外は全廃。上記は履歴のみ。 + +# [2026-05-05][fix] +# 背景: +# 依頼意図: Issue #123 で、PR #121 内で Revert された Issue #122 対応を安全に再導入したい。 +# 複合コマンド検知ブロックに +# 軽量変更バイパスが未適用のまま main に残っている。.md のみの変更でも `git switch main && git push` で deny される。 +# 守るべき業務ルール: main 直 push の例外は「Markdown 系ドキュメント + sync-state.json のみ」(branch-rule.md)。 +# 3つの検知パス(複合コマンド、push refspec、mainブランチ)は対称に保つ。 +# 他案不採用理由: +# 1) 複合コマンド検知ブロックを削除する案は、refspec 経由の非 docs push を見逃すため不採用(2026-04-10 と同型)。 +# 2) staged diff 判定をインライン展開する案は、mainブランチ検知ブロックとの重複(DRY違反)のため不採用。 +# 3) `scripts/` 配下のローカル hook を併用する案は、比較 ref と許可拡張子が分岐し SSOT が壊れるため不採用。 +# 対応: `is_commit_lightweight_only()` を新設し、mainブランチ検知から呼び出す。 +# 複合コマンド検知では switch 後の target ref (`main`) を比較対象にし、commit を含む場合は安全側で deny。 +# 撤回: 2026-07-01 に軽量変更バイパスは全廃。上記は履歴のみ。 + +# [2026-05-16][feat] +# 背景: +# 依頼意図: ccrec 運用で GitHub Actions / Claude Code クレジットを節約するため、 +# 人手レビュー価値の薄い運用設定ファイル(agents.yaml / typinator-sync.yaml / +# MCP 台帳等)も main 直接 push 可にする。 +# 守るべき業務ルール: ソースコード・hook 本体(*.sh)・CI 定義(.github/workflows)・ +# Web ビルド設定(package.json / tsconfig.json / composer.json)・hook 登録設定は引き続き PR 必須。 +# 許可は事前定義した allowlist のファイル名・パターンに限定する。 +# 他案不採用理由: +# 1) .json / .yaml 拡張子全体を許可: package.json / tsconfig.json / composer.json / +# src/**/*.json までレビューなしで通るため不採用(2026-04-27 と同型の理由)。 +# 2) 拡張子許可 + denylist: denylist 漏れが致命的になるため allowlist で明示する方が安全。 +# 3) AGENT-HUB 限定で CWD 分岐: 配布先 PJ の AI ツール設定もツール再同期で書き換わるため、 +# 全 PJ 一律許可が運用整合的(ユーザー判断 2026-05-16)。 +# 4) .github/workflows/*.yml を許可: CI 挙動を無レビューで変えるリスクのため不採用。 +# 5) *.sh を許可: hook スクリプト挙動を無レビューで変えるリスクのため不採用。 +# 6) 外部設定ファイル化(allowlist を YAML に切り出す): 比較 ref と許可判定の SSOT が +# 分岐するため不採用(2026-05-05 と同型)。 +# 7) hook 登録設定(.claude/settings.json / .codex/hooks.json 等)を許可: block-main-commit +# 自体をレビューなしで弱められるため不採用。 +# 対応: is_allowed_main_direct_path() に case 文 allowlist を追加し、hook 登録を含まない AI ツール設定 / +# AGENT-HUB ルート運用設定 / codex-mcp 台帳を許可する。 +# 撤回: 2026-07-01 に運用設定 allowlist も全廃。上記は履歴のみ。 + +# [2026-05-21][feat] +# 背景: +# 依頼意図: .codex/config.toml と .gemini/hooks/.hook-library-version は Kimi Code MCP 設定 / .cursor/mcp.json +# と同等の sync 完全自動生成ファイル(手動編集 0 行)だが、2026-05-16 拡張時に取りこぼされていた。 +# 対称性を回復して、sync 実行のたびに main 直 push が deny されて GitHub Actions / Claude Code クレジットを +# 消費する状況を解消したい。 +# 守るべき業務ルール: +# - .codex/config.toml は全 PJ で MANAGED CODEX MCP START/END block の完全自動生成のみ。 +# 将来 managed block 外の手動編集領域が追加された場合は branch-rule.md を再評価する。 +# - .gemini/settings.json は hook 登録設定(BeforeTool/AfterTool/BeforeAgent)と MCP を混在で持つため +# allowlist には載せない(hook 登録設定の許可は 2026-05-16 [feat] 不採用理由 7 と同型で禁止)。 +# なお全 PJ で .gemini/settings.json は gitignore のため commit 経路自体が無く、本 hook へ到達しない。 +# 他案不採用理由: +# 1) .gemini/settings.json も同時許可: hook 登録を含む混在ファイルのため、settings.json + bridge スクリプト +# の同時変更で block-main-commit を弱められる経路を作ってしまう(2026-05-16 不採用理由 7 と同型)。 +# 2) .gemini/hooks/{lib,scripts}/*.sh / *.py を許可: hook ロジック本体の無レビュー変更を許す +# (2026-05-16 不採用理由 5 と同型)。 +# 3) .toml 拡張子全体を許可: dotfiles/codex/config.toml.base(features.apps 保護対象)まで通る +# ため不採用(2026-05-16 不採用理由 1 と同型)。 +# 対応: is_allowed_main_direct_path() の case 文に .codex/config.toml と +# .gemini/hooks/.hook-library-version を対称順で追加する。 + +# [2026-06-05][feat] .codex/hooks.json を main 直接 allowlist に追加(ユーザー承認・過去判断の変更) +# 背景: +# - ユーザー依頼意図: Codex hook の user-level 移行(PR #284)で各PJの .codex/hooks.json を +# 縮小版へ再配布する。この派生物コミットを毎回 PR にするのは負荷が高く、伸太郎殿の +# 「AIエージェント設定ファイルだけの変更を毎回PRに出したくない」要望(2026-06-05)に応える。 +# - 守るべき業務ルール: .codex/hooks.json は deploy-hooks.py が hook-registry.yaml から生成する +# sync 自動生成の派生物(手編集禁止、codex-sync.md)。hook 挙動は AGENT-HUB 側 PR で既にレビュー済み。 +# - 他案不採用理由(過去の不採用判断を覆す根拠): +# 2026-05-21 [feat] 不採用理由1 / 2026-05-16 [feat] 不採用理由7 で「hook 登録設定 +# (.claude/settings.json / .codex/hooks.json 等)は block-main-commit 自体を無レビューで +# 弱められるため allowlist 禁止」としていた。今回 .codex/hooks.json のみ覆すのは、 +# (a) deploy-hooks 生成物に限定され手編集しない運用が確立、(b) block-main-commit は +# Claude(.claude/settings.json は allowlist 据え置き=PR必須)でも効くため Codex 側を弱めても +# main 保護の実効性が残る、(c) Codex は補助ツール、の3点でリスク限定的と伸太郎殿が判断したため。 +# .claude/settings.json(hook登録の中核)は引き続き allowlist に入れない(PR必須維持)。 +# 対応: is_allowed_main_direct_path() の case に .codex/hooks.json を追加。.claude/settings.json は据え置き。 + +# [2026-06-05][feat] deploy-hooks 配布物(各PJ .claude/hooks/ ・ .codex/hooks/ の scripts/lib)を allowlist 追加 +# 背景: +# - ユーザー依頼意図: Phase E(PR #283) + Codex 移行(PR #284) + allowlist(PR #285)を全PJへ実配布する際、 +# 各PJの hook 配布物(block-main-commit.sh / block-skill-reverse-edit.sh / lib 等)を毎回 PR にするのは +# 16PJ規模で非現実的。「設定・配布物の機械的更新を毎回PRにしたくない」要望(2026-06-05)に応える。 +# - 守るべき業務ルール: 各PJ .claude/hooks/ ・ .codex/hooks/ 配下の scripts/lib は deploy-hooks.py が +# hook-library(SSOT)から配布する派生物。hook 挙動の変更は hook-library 本体の AGENT-HUB PR でレビュー +# 済み。各PJで人が直接編集する運用はなく、drift は sync-reconcile.py が検出する。 +# - 他案不採用理由(覆した過去判断): +# 2026-05-16 #5 で「*.sh(hook ロジック本体)は allowlist 禁止」としていた。今回 .claude/hooks/scripts/ ・ +# .codex/hooks/scripts/ ・ lib/ 配下の配布物のみ覆すのは、(a) これらは hook-library からの機械配布物で +# SSOT 本体(hook-library/scripts/)は PR 必須のまま、(b) sync-reconcile で drift 検出可能、(c) 各PJ実配布の +# 運用負荷が許容外、の3点。settings.json(block-main-commit の matcher 登録を含む hook 登録の中核)は +# 許可しない(main 保護自体を無レビューで外せてしまうため。2026-05-16 #7 維持)。hook-library/scripts/ +# (SSOT 本体)も別パスのため PR 必須を維持。 +# 対応: is_allowed_main_direct_path() の case に .claude/hooks/{scripts,lib}/ ・ .codex/hooks/{scripts,lib}/ を +# 追加。settings.json と hook-library/scripts/ は据え置き。 + +# [2026-06-23][refactor] 配布差分放置防止のため 2026-06-05 の main 直接 allowlist を撤回 +# 背景: +# - ユーザー依頼意図: AGENT-HUB から hook / skill / rule / agent 派生物を各PJへ配布した後、 +# AI が「これは私の修正したファイルではない」として配布先差分を放置する事故を防ぐ。 +# 配布を実行した担当者が PR 作成・レビュー・マージ・cleanup・clean 確認まで責任を持つ。 +# - 守るべき業務ルール: 機械配布物でも、配布先 PJ の tracked 差分は作った担当者が閉じる。 +# .codex/hooks.json と .claude/.codex hooks scripts/lib は main 直接 push ではなく PR 経由に戻す。 +# - 他案不採用理由: +# 1) ルール文書だけの更新は hook allowlist が残り、main 直 push で closeout を迂回できるため不採用。 +# 2) --push を即削除する案は既存運用互換の破壊が大きいため、まず hook 側で main 直許可を撤回する。 +# 対応: is_allowed_main_direct_path() から .codex/hooks.json と .claude/.codex hooks scripts/lib を削除。 + +# [2026-06-15][fix] worktree/別リポへの refspec 省略 bare push を許可(PR #369 の取りこぼし修正) +# 背景: +# 依頼意図: `cd && git push --force-with-lease`(refspec 省略の bare push)が +# PR #369 後も deny される。ハーネスは Bash cwd を毎回 main 直下に戻すため worktree への push は +# refspec 省略の bare push になることが多く(upstream に任せる常用フロー)、worktree 並行開発が成立しない。 +# 守るべき業務ルール: main 直 push/commit の保護は厳密(fail-closed)に維持する。本番デプロイ=main push のため。 +# 根本原因: has_unsafe_push() が「remote/refspec 欠落の push」を宛先不明として無条件 unsafe にしていた。 +# しかし実効ターゲット(先頭の単一 cd 先)のカレントブランチは判明済み(非 main)で、bare push はその +# カレントブランチを push するだけ。一律 unsafe は過剰だった。 +# 他案不採用理由: +# 1) bare push を実効ブランチ非 main なら無条件許可: push.default=matching(全 matching ブランチ=main 波及) +# や push.default=upstream で upstream が main のとき main を押す経路が残るため不採用。 +# 2) 何もしない案: refspec 省略の worktree push(ユーザーの主要フロー)が不能のままで不便。 +# 対応: dir 解決を effective_target_dir() に関数化し、has_unsafe_push() に eff_dir を渡す。bare/remote-only +# push は eff_dir の push.default + @{upstream} を解決し、matching / upstream→main / 解決不能のみ unsafe、 +# simple(既定)/current 等は非 main カレントブランチのみ push として安全に許可する。明示的 main 宛て / +# --all/--mirror/wildcard/複数 ref は従来どおり deny。汎用設計のため worktree 以外の別リポにも同様に効く。 + +# [2026-07-01][refactor] AI hook 経由の main 直接 commit / push 例外を完全撤回 +# 背景: +# 依頼意図: 文書ルールだけでなく、PreToolUse hook 実体でも Markdown / sync-state.json / +# agents.yaml / typinator-sync.yaml 等の軽量変更 allowlist を閉じ、全ディレクトリ・全 AI で +# main checkout を掴まない運用を強制したい。 +# 守るべき業務ルール: AI の通常作業では main branch の commit / push は軽量変更でも deny。 +# 非 main branch / 専用 worktree の commit / push は従来どおり許可し、PR 作成フローを壊さない。 +# 他案不採用理由: +# 1) allowlist を文書上だけ廃止して hook に残す案は、AI が実際には main 直 commit / push できるため不採用。 +# 2) 環境変数 override を追加する案は、AI が自己判断で例外を使う経路になるため不採用。 +# 3) 初回 repo 作成や人間明示承認を hook が推測して許可する案は、安全側で判定できないため不採用。 +# 対応: is_allowed_main_direct_path は常に deny にし、main branch 検知・main refspec push 検知では +# 軽量差分判定を呼ばず即 deny する。worktree feature branch の早期許可は維持。 + +# [2026-07-18][fix] git標準ラッパーと先頭空白によるmain保護迂回を防止 +# 背景: +# - ユーザー依頼意図: dirty cleanup PRのレビューで `env git commit` / `command git push` / +# 先頭空白付きgitが検出から漏れ、main直操作を許可できることが判明した。 +# - 守るべき業務ルール: 標準ラッパーや整形上の空白でmain保護の強さを変えない。 +# - 他案不採用理由: `env` 後の任意トークンを許す正規表現は `env echo git ...` まで誤検知するため不採用。 +# 対応: command/envの標準形とenv代入だけをコマンド位置で消費し、その後のgitサブコマンドを既存判定へ渡す。 + +set -euo pipefail + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `git -C path push origin main` や `git -c k=v push origin main` のように +# グローバルオプション付きで git を呼び出すと、既存の正規表現 `git[[:space:]]+push` が +# マッチせず main 直 push/commit をスルーしてしまう脆弱性を修正したい。 +# 守るべき業務ルール: main 直 push/commit のブロックは確実でなければならない。 +# false positive(許可ケースを誤拒否)を増やさないこと。 +# 他案不採用理由: +# 1) オプション列を貪欲に `.*` で許可 → セミコロン区切りの複合コマンドで誤マッチしやすい。 +# `[^[:space:]]+` で空白終端を保証する設計の方が安全。 +# 2) `-C` / `-c` だけを許可する案 → `git --no-pager push` が fail-open し、 +# main 保護の目的を満たせないため不採用。 +# 対応: スクリプト先頭に共通定数 GIT_GLOBAL_OPTS を定義し、値あり/値なしの代表的な +# git グローバルオプションを消費してから push/commit/switch/checkout を検知する。 +# git グローバルオプションを 0個以上許容する共通パターン。 +readonly GIT_GLOBAL_OPT='(-C[[:space:]]+[^[:space:]]+|-c[[:space:]]+[^[:space:]]+|--config-env[[:space:]]+[^[:space:]]+|--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--namespace(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--exec-path(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)?|--super-prefix[[:space:]]+[^[:space:]]+|--paginate|--no-pager|--no-replace-objects|--bare|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--help|--version|--html-path|--man-path|--info-path|-p)' +readonly GIT_GLOBAL_OPTS="([[:space:]]+${GIT_GLOBAL_OPT})*" +readonly GIT_ENV_VALUE="([^[:space:];&|()'\"]+|'[^']*'|\"([^\"\\\\]|\\\\.)*\")+" +readonly GIT_ENV_ASSIGN="[A-Za-z_][A-Za-z0-9_]*=${GIT_ENV_VALUE}" +readonly GIT_ENV_PREFIX="(${GIT_ENV_ASSIGN}[[:space:]]+)*" +readonly ENV_OPT_WITH_VALUE='(-u|--unset|-C|--chdir|-P|--path|-S|--split-string)[[:space:]]+[^[:space:];&|()]+' +readonly GIT_COMMAND_WRAPPER="(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+|env([[:space:]]+((${ENV_OPT_WITH_VALUE})|-[^[:space:];&|()]+|${GIT_ENV_ASSIGN}))*[[:space:]]+)?" +readonly GIT_CMD="(^|[;&|()])[[:space:]]*${GIT_ENV_PREFIX}${GIT_COMMAND_WRAPPER}${GIT_ENV_PREFIX}git${GIT_GLOBAL_OPTS}" + +# [2026-07-18][fix] +# 背景: +# - PR1018再レビューで、環境変数代入をenv/command wrapperの前に置くとGIT_CMDがgit writeを見失った。 +# - 守るべき業務ルール: POSIXで有効なprefix順序の違いでmain保護の強さを変えない。 +# - 他案不採用理由: FOO=1だけを文字列denyする案は変数名ごとに再発するため不採用。 +# 対応: 環境変数prefixをwrapperの前後どちらにも許容し、その後のgit commit/pushを同じ判定へ渡す。 + +# [2026-07-18][fix] +# 背景: +# - PR1018最終レビューで、空白を含む引用済み環境変数値がGIT_ENV_PREFIXを分断し、 +# main上の `FOO='a b' git commit` をgit writeなしとして許可できると判明した。 +# - 守るべき業務ルール: shellで有効な引用・escapeを含む代入でもmain保護をfail-openにしない。 +# - 他案不採用理由: quoteを含む行を一律denyすると、説明文やfeature branchの通常操作まで誤拒否する。 +# 対応: 環境変数値をunquoted/single-quoted/double-quotedのshell wordとして認識し、wrapper内外で共通利用する。 + +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: PR1018再レビューで、feature cwdから `env -C
` を使うと +# hook入力のcwd側ブランチだけを見てmain commit/pushを許可し得る経路が見つかった。 +# - 守るべき業務ルール: 実効cwdを確実に解決できないcommit/pushはfail-closedにする。 +# - 他案不採用理由: env chdir先の完全解決は相対path・複数wrapper・複合commandで誤許可を生むため不採用。 +# 対応: env -C/--chdir(=形式を含む)とgit commit/pushが同じ入力にある場合は安全側で拒否する。 +# [2026-08-02][fix] env と -C/-S の間に許すトークンを env 自身のオプション/代入に限定する(issue #1344)。 +# 背景: +# - ユーザー依頼意図: 旧パターンの `env([[:space:]]+[^;&|()]*)?` は貪欲で、 +# `env FOO=bar git -C commit` の **git の -C** まで env の -C(chdir)と誤認し、 +# 正当な feature worktree commit/push を fail-closed で誤 deny していた +# (PR #1343 codex-review 検出・再現ドライバで実測)。 +# - 守るべき業務ルール: env 実行系(-C/--chdir/-S/--split-string)の保守的 deny は維持する。 +# env のオプション解析はコマンド名(最初の非オプション・非代入トークン)で終わるという +# GNU env の実引数規則を静的に再現し、コマンド名以降の -C/-S は誤認対象から外す。 +# - 他案不採用理由: env 形を全て未解決に倒す従来動作の維持は、日常の env prefix commit を +# 恒常的に止め摩擦が大きい。env の後続を完全 tokenize する案は本 hook の軽量 grep 設計に反する。 +# [2026-08-02][fix] 引数を取る env オプション(-u/--unset/シグナル系)は引数ごと消費する +# (PR #1354 codex-review Critical: `env -u FOO -C
git commit` の -C が +# FOO でパターンが止まり chdir 検出から外れるバイパスを塞ぐ)。 +# 引数付きを先に列挙し、その後に汎用オプション(-i 等・引数なし)と assignment を置く。 +# 汎用側で引数を消費しないのは、`env -i git -C ...` の git を env の引数と +# 誤認して #1344 の誤 deny を再導入しないため。 +readonly ENV_OPT_ARG='(-u|--unset|--block-signal|--default-signal|--ignore-signal)[[:space:]]+[^[:space:];&|()]+' +readonly ENV_OWN_TOKENS='(('"${ENV_OPT_ARG}"'|-[^[:space:];&|()]+|[A-Za-z_][A-Za-z0-9_]*=[^[:space:];&|()]*)[[:space:]]+)*' +command_uses_env_chdir() { + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[;&|()])[[:space:]]*(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+)?env[[:space:]]+'"${ENV_OWN_TOKENS}"'(-C([[:space:]]+|[^[:space:];&|()]+)|--chdir(=|[[:space:]]+))' +} + +# env -S/--split-string は1引数内の文字列を再分割してコマンド化するため、通常のwrapper解析では +# 実行されるgitを復元できない。git commit/pushを含む場合だけfail-closedにする。 +command_uses_env_split_git_write() { + echo "$COMMAND" | grep -qE '(^|[;&|()])[[:space:]]*(command([[:space:]]+-[^[:space:];&|()]+)*[[:space:]]+)?env[[:space:]]+'"${ENV_OWN_TOKENS}"'(-S([[:space:]]+|[^[:space:];&|()]+)|--split-string(=|[[:space:]]+))' && + echo "$COMMAND" | grep -qE 'git.*[[:space:]](commit|push)([^A-Za-z0-9_-]|$)' +} + +# [2026-07-11][fix] jtt-apps 本番タグ push 事例(v2.4.37) +# 背景: +# 依頼意図: `git -C push origin v2.4.37` のような単発 -C push が、 +# コマンド中に `2>&1` 等のリダイレクトが含まれるだけで single_git_c_target_dir() の +# `[;&|()]` チェックに誤ヒットし解決不能(deny)になっていた。DEPLOY_CHECKLIST.md の +# 正規タグ push 手順は worktree 経由でしか実行できないため、この誤検知で本番デプロイの +# 唯一の正規経路が塞がれていた。 +# 守るべき業務ルール: main 直 push/commit の fail-closed 判定は維持する。リダイレクトは +# 単一コマンドの出力先を変えるだけで複合コマンドの合図ではないため、それだけで +# 解決不能に倒すのは過剰検知。一方 `&`(バックグラウンド実行)や `|`(パイプ)は真に +# 複合コマンドの合図なので、従来どおり解決不能のまま扱う。 +# 他案不採用理由: +# 1) `[;&|()]` チェック自体を緩める案: `&` 単体や `|` まで見逃すと後続コマンドの +# 存在を検知できなくなり fail-open になるため不採用。 +# 2) has_unsafe_push() のようなトークン単位パーサに全面書き換える案: 影響範囲が +# 広く、今回の誤検知箇所以外の挙動まで変えるリスクがあるため不採用。 +# 対応: quote scanner 自身が引用外のリダイレクトだけを識別し、引用済み本文を変更せずに +# shell 制御演算子を判定する。 + +# [2026-07-12][fix] +# 背景: +# 依頼意図: main checkout を cwd にした Codex から専用 feature worktree へ +# `git -C commit -m 'fix(auth): ...'` を実行すると、引用符内の `()` を +# shell 制御演算子と誤認し、正規の branch + PR フローを deny していた。 +# 守るべき業務ルール: 引用済みメッセージは git の引数データとして許可する一方、非引用の +# `; & | ( )`、引用内でも実行される command substitution、壊れた引用は fail-closed にする。 +# 他案不採用理由: +# 1) `()` の検査を削る案は subshell を見逃して main 操作を早期許可しうるため不採用。 +# 2) conventional commit の括弧だけ正規表現で消す案は、任意の正当な引用済み本文に拡張できず +# セミコロン等で同じ誤検知が再発するため不採用。 +# 対応: 最小の shell quote scanner で、制御演算子が引用の外にある場合だけ真を返す。 +# 引用外の `>file` / `&1` は単一コマンドのリダイレクトとして読み飛ばすが、 +# その後のファイル名や制御演算子は走査を続ける。 +has_unquoted_shell_control() { + local scanner_rc + if COMMAND_TEXT="$1" python3 - <<'PY' +import os +import sys + +text = os.environ.get("COMMAND_TEXT", "") +quote = None +escaped = False +i = 0 +while i < len(text): + ch = text[i] + if escaped: + escaped = False + i += 1 + continue + if ch == "\\" and quote != "'": + escaped = True + i += 1 + continue + if quote == "'": + if ch == "'": + quote = None + i += 1 + continue + if quote == '"': + if ch == '"': + quote = None + elif ch == '`' or (ch == '$' and i + 1 < len(text) and text[i + 1] == '('): + raise SystemExit(0) + i += 1 + continue + if ch in ("'", '"'): + quote = ch + elif ch in "<>": + # Redirection itself does not compose another command. Skip only its + # operator/fd-copy portion; keep scanning the target and anything after it. + direction = ch + while i + 1 < len(text) and text[i + 1] == direction: + i += 1 + if i + 1 < len(text) and text[i + 1] == '&': + i += 1 + while i + 1 < len(text) and (text[i + 1].isdigit() or text[i + 1] == '-'): + i += 1 + elif ch in ";&|()" or ch == '`': + raise SystemExit(0) + i += 1 + +# Unterminated quoting is ambiguous and therefore unsafe. +raise SystemExit(0 if quote is not None or escaped else 1) +PY + then + return 0 + else + scanner_rc=$? + # [2026-07-12][fix] + # 背景: + # - 依頼意図: quote scanner の Python 起動不能や異常終了を「安全」と誤認し、main 保護が + # fail-open になる経路を閉じたい。 + # - 守るべき業務ルール: scanner が明示する rc=1 だけを安全とし、未導入・クラッシュ・ + # 想定外終了はすべて曖昧な入力として拒否する。 + # - 他案不採用理由: テスト用の interpreter override を本番環境変数として公開する案は、 + # exit 1 を返す任意プログラムで保護を迂回できるため不採用。 + # 対応: python3 は固定し、rc=1 以外を unsafe に正規化する。 + # rc=1 is the scanner's only explicit "safe" result. Missing Python, + # interpreter crashes, and every other unexpected status stay fail-closed. + [ "$scanner_rc" -eq 1 ] && return 1 + return 0 + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +# emit_deny(hook-io.sh) を呼び出す前に telemetry へ deny を記録する薄いラッパ。 +# 既存の deny メッセージ・exit 挙動は一切変えない(記録の追加のみ)。 +_emit_deny_with_telemetry() { + agent_hub_telemetry_log hook_deny block-main-commit deny 2>/dev/null || true + emit_deny "$1" +} + +DENY_MSG='[hook:block-main-commit] mainブランチへの直接コミット/プッシュはブロックされました。\n\n対応手順:\n1. git checkout -b feature/xxx でブランチを作成\n2. ブランチ上でコミット\n3. gh pr create でPRを作成\n\n理由: mainマージ = 本番DB自動適用 + 本番デプロイが即座に発動するため、レビューなしの変更は禁止です。' + +read_stdin +COMMAND=$(extract_field command) + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# CWD取得(push refspec検知より前に必要) +CWD=$(extract_field cwd) +if [ -z "$CWD" ]; then + CWD="." +fi + +# [2026-08-02][fix] #1313 / #1256: commit message・PR/Issue本文をgit実行列から除外する。 +# 背景: +# - 依頼意図: `git commit -m '説明; git push origin main'` や +# `gh pr create --body 'git reset --hard'` の本文を、実行されたgit writeとして +# 誤検知しない。ガード自身の修正記録・PR本文が書けない摩擦を解消する。 +# - 守るべき業務ルール: 引用外の `; git ...`、実際の command substitution、shell wrapper は +# 従来どおり安全側で扱う。除外するのは `-m/--message/--body/--body-file` の引数データだけ。 +# - 他案不採用理由: コマンド全体の `git` 文字列を無視する案は、引用外のmain pushを見逃す。 +# 正規表現へ例外を足し続ける案は引用境界を扱えず、同じ誤検知を再発させる。 +# 対応: shellの引用境界を小さく走査し、本文系オプションの次の1 tokenだけを空白化した +# 判定用コピーを作る。実行用の COMMAND は変更せず、quote scanner / -C path 解決は従来どおり +# raw input を参照する。展開を含む本文は空白化せず、保守的に検出・拒否する。 +sanitize_git_data_args() { + COMMAND_TEXT="$1" python3 - <<'PY' 2>/dev/null || printf '%s' "$1" +import os +import shlex + +text = os.environ.get("COMMAND_TEXT", "") +mask = [False] * len(text) +data_options = {"-m", "--message", "--body", "--body-file"} + +def spans(value): + result = [] + index = 0 + length = len(value) + while index < length: + if value[index].isspace(): + index += 1 + continue + if value[index] in ";|&()": + result.append((index, index + 1, value[index])) + index += 1 + continue + start = index + quote = None + escaped = False + while index < length: + char = value[index] + if escaped: + escaped = False + index += 1 + continue + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if quote == '"': + if char == '"': + quote = None + elif char == "\\": + escaped = True + index += 1 + continue + if char in ("'", '"'): + quote = char + index += 1 + continue + if char == "\\": + escaped = True + index += 1 + continue + if char.isspace() or char in ";|&()": + break + index += 1 + result.append((start, index, value[start:index])) + return result + +def decoded(raw): + try: + values = shlex.split(raw, posix=True) + except ValueError: + return raw + return values[0] if len(values) == 1 else raw + +def has_executable_expansion(raw): + quote = None + escaped = False + index = 0 + while index < len(raw): + char = raw[index] + if escaped: + escaped = False + index += 1 + continue + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if quote == '"': + if char == '"': + quote = None + elif char == "\\": + escaped = True + elif char == "$" and index + 1 < len(raw) and raw[index + 1] == "(": + return True + elif char == "`": + return True + index += 1 + continue + if char in ("'", '"'): + quote = char + elif char == "\\": + escaped = True + elif char == "$" and index + 1 < len(raw) and raw[index + 1] == "(": + return True + elif char == "`": + return True + index += 1 + return False + +tokens = spans(text) +expect_data = False +for start, end, raw in tokens: + if raw in ";|&()": + expect_data = False + continue + value = decoded(raw) + if expect_data: + # A command substitution/backtick is executable text, not static data. + # Keep it visible so the existing fail-closed patterns can reject it. + if not has_executable_expansion(raw): + for position in range(start, end): + mask[position] = True + expect_data = False + continue + if value in data_options: + expect_data = True + continue + if any(value.startswith(option + "=") for option in ("--message", "--body", "--body-file")): + for position in range(start, end): + mask[position] = True + continue + # `-mtext` is a valid git short option form. The whole token is message data. + if value.startswith("-m") and len(value) > 2 and not value.startswith("--"): + for position in range(start, end): + mask[position] = True + +print("".join(" " if mask[position] else char for position, char in enumerate(text)), end="") +PY +} + +# All regex-only git write searches below use this copy. Raw COMMAND remains the source for +# quote-aware shell-control and effective path checks. +COMMAND_FOR_GIT_MATCH="$(sanitize_git_data_args "$COMMAND")" + +is_allowed_main_direct_path() { + # 2026-07-01: AI hook 経由の main direct allowlist は廃止。 + # 互換テスト用に関数名は残すが、どの path も許可しない。 + return 1 +} + +# [2026-05-30][fix] issue #210 / cafe48 codex review follow-up +# 背景: +# ユーザー依頼意図: `git -C <別repo> push origin main` のように実効ディレクトリを変える +# グローバルオプション付き push/commit を、hook 実行 cwd ($CWD) の branch/差分で判定すると、 +# 「$CWD が main かつ軽量変更」のとき別 repo の main 直 push を軽量バイパスで許可してしまう +# fail-open が残っていた(#213 で git_command_query=実効 cwd 解決を削除した際の取りこぼし)。 +# 守るべき業務ルール: main 直 push/commit のブロックは確実(fail-closed)であること。 +# 他案不採用理由: +# 1) -C を抽出し実効 cwd を完全復元する案: 複数 -C の相対累積や --git-dir/--work-tree の +# 組合せまで正確に追うのは複雑で、#213 が regex 方式へ寄せた設計に逆行する。 +# 2) 何もしない案: 別 repo の main 直 push を $CWD=main・軽量時に通すため main 保護目的を満たさない。 +# 3) -C/--git-dir/--work-tree のみ検知(PR #229 初版): PR #229 codex レビューで指摘の通り +# `GIT_DIR=` / `GIT_WORK_TREE=` env 経由と `cd /other && git push` の複合コマンドが +# 残存 fail-open になるため不採用(v3.5.7 で同時対応)。 +# 対応: 実効ディレクトリを変える経路(-C / --git-dir / --work-tree / GIT_DIR= / GIT_WORK_TREE= / +# cd && git ...)が push/commit に付く場合は $CWD ベースの軽量バイパスを信頼せず、 +# main 向けは安全側で deny する(fail-closed)。-C なしの通常 cwd 上の Markdown 軽量直 push は +# 従来どおり許可され、false positive を広げない。 +command_targets_other_dir() { + # -C / --git-dir / --work-tree + # ただし `-C .` / `-C ./` は no-op(current dir)のため除外する。 + # path 部分を抽出して `.` または `./` でないことを確認する。 + local c_paths c_path + c_paths=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' || true) + if [ -n "$c_paths" ]; then + while IFS= read -r match; do + [ -z "$match" ] && continue + # 最後のフィールド = path(先頭の空白と -C を除去) + c_path=$(echo "$match" | awk '{print $NF}') + case "$c_path" in + "."|"./") ;; # no-op + *) return 0 ;; + esac + done <<< "$c_paths" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir(=[^[:space:]]+|[[:space:]]+[^[:space:]]+)|--work-tree(=[^[:space:]]+|[[:space:]]+[^[:space:]]+))'; then + return 0 + fi + # GIT_DIR= / GIT_WORK_TREE= / GIT_NAMESPACE= 環境変数 prefix + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)='; then + return 0 + fi + # cd && git ... / cd ; git ... (複合コマンドで実効 cwd を変える) + # `cd .` / `cd ./` は no-op のため除外する。 + local cd_paths cd_path + cd_paths=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^[:space:];&|]+' || true) + if [ -n "$cd_paths" ]; then + while IFS= read -r match; do + [ -z "$match" ] && continue + cd_path=$(echo "$match" | awk '{print $NF}') + case "$cd_path" in + "."|"./") ;; # no-op + *) + # cd の後に && または ; があり git が続くことを確認 + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "cd[[:space:]]+$(printf '%s' "$cd_path" | sed 's/[[\.*^$/]/\\&/g')[[:space:]]*[;&]"; then + return 0 + fi + ;; + esac + done <<< "$cd_paths" + fi + return 1 +} + +# [2026-06-14][feat] 実効ターゲットディレクトリ(先頭の単一 cd 先)のブランチを解決する。-C は不採用=deny。 +# 背景: +# 依頼意図: Claude Code 等のハーネスは Bash の cwd を毎回プロジェクト直下(main)に戻すため、 +# worktree への操作は `cd && git commit/push` の形になる。$CWD(main) の枝で判定すると +# worktree(feature) への正当なコミット・PR push まで fail-closed で弾かれ、worktree 開発が成立しない。 +# 守るべき業務ルール: 解決対象は「コマンド先頭の単一 cd && ...」だけ(cd は後続コマンドの cwd に +# 効くため commit/push の実効ディレクトリになる)。GIT_DIR/GIT_WORK_TREE env・--git-dir/--work-tree/ +# --namespace・-C・複数 cd・先頭以外の cd が含まれる場合は解決不能(空)を返し、従来どおり fail-closed にする。 +# 他案不採用理由: +# 1) -C を解決に使う案: -C はその git 1 回にしか効かず、`git -C status && git commit` のように +# 後続 commit が main で動く形を誤許可するため不採用(-C は解決根拠にしない=従来 deny のまま)。 +# 2) 複数 cd の相対累積・env トリックまで追う案: 複雑で誤許可リスクが高い。安全に解決できる +# 「先頭単一 cd」だけを許可し、それ以外は安全側(空)に倒す。 +# 実効ターゲットディレクトリ(先頭の単一 cd 先)を解決して絶対パスを stdout に返す。解決不能なら空。 +# [2026-06-15][fix] dir 解決を effective_target_branch から切り出して関数化(bare push の宛先判定で +# has_unsafe_push が同じ dir を再利用するため)。ガード条件は従来と同一(変更なし)。 +effective_target_dir() { + # 実体を差し替える env / オプション / -C が含まれるものは解決不能(fail-closed 用に空を返す)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)=' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir|--work-tree|--namespace)([=[:space:]])' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])-C([[:space:]]|$)' && return 0 + # eval / exec / ` -c` は cd の効果範囲が静的に読めない → 解決不能(fail-closed)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(eval|exec)([[:space:]]|$)' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(sh|bash|zsh|dash|ksh)[[:space:]]+-[A-Za-z]*c([[:space:]]|$)' && return 0 + # コマンド位置(^ / ; & | 直後・サブシェル ( 直後)の cd を数える。複数あれば実効 cwd が曖昧 → 解決不能。 + # サブシェル `( cd /main && git commit )` の隠れた cd も ( を境界に含めることで検出する。 + local cds dir + cds=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE '(^|[;&|(])[[:space:]]*cd[[:space:]]+[^[:space:];&|()]+' || true) + [ "$(printf '%s\n' "$cds" | grep -c .)" -ne 1 ] && return 0 + # その単一 cd が「先頭」かつ「&& / ; で後続に効く」形であること(背景 & / パイプ | は対象外)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '^[[:space:]]*cd[[:space:]]+[^[:space:];&|()]+[[:space:]]*(&&|;)' || return 0 + # [2026-06-16][fix] COMMAND が複数行(heredoc / 改行入りコミットメッセージ等)のとき、 + # sed が行単位で処理し非マッチ行(2 行目以降のメッセージ本文)を素通しするため dir がゴミ文字列化し、 + # git -C "$dir" が失敗 → 正当な worktree commit/push が誤 deny されていた。cd は先頭行(上の L427 で + # 先頭 + &&/; を保証済)にあるため、1 行目だけから抽出する(複数行は安全に L1 のみを見る)。 + dir=$(printf '%s' "$COMMAND" | sed -nE '1s/^[[:space:]]*cd[[:space:]]+([^[:space:];&|()]+).*/\1/p') + # ~ 展開 / 相対パスは $CWD(JSON の cwd) 基準で正規化(git -C が hook プロセスの cwd で解決するのを防ぐ)。 + case "$dir" in + ""|"."|"./") return 0 ;; + "~") dir="$HOME" ;; + "~/"*) dir="${HOME}/${dir#\~/}" ;; + /*) ;; + *) dir="$CWD/$dir" ;; + esac + printf '%s' "$dir" +} + +effective_target_branch() { + local dir + dir="$(effective_target_dir)" + [ -z "$dir" ] && return 0 + git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true +} + +# [2026-07-09][fix] +# 背景: +# 依頼意図: AGENT-HUB の専用 worktree 上で正当な `git -C commit` が +# block-main-commit に誤ブロックされ、正規の branch + PR フローを閉じられなかった。 +# 守るべき業務ルール: main 直 commit / push は引き続き fail-closed で止める。一方で、実効対象が +# 非 main branch だと確認できる単発 `git -C commit/push` は本番 main に影響しないため許可する。 +# 他案不採用理由: +# 1) `-C` を全面許可する案は、`git -C status && git commit` の後続 commit が main で動く形を +# 誤許可するため不採用。 +# 2) 複合 shell 構文まで静的解析する案は誤許可リスクが高いため不採用。 +# 3) 従来どおり全部 deny する案は、AGENT-HUB の標準 worktree 運用を阻害するため不採用。 +# 対応: shell 制御演算子を含まない単発 git コマンドだけ `-C` の対象 dir を解決し、非 main branch かつ +# unsafe push でない場合だけ早期許可する。env / git-dir / namespace trick は従来どおり fail-closed。 +# [2026-08-02][fix] Wave B / #1258: 引用内の `-C` を git global option と数えない。 +# 背景: +# - ユーザー依頼意図: `git -C commit -m '... -C ...'` のようにメッセージへ `-C` と +# 書いただけで単発 feature commit が deny され、文書・回帰テストが書けない。 +# - 守るべき業務ルール: 引用外の複数 `-C` は従来どおり解決不能。引用済み本文の `-C` はデータ。 +# - 他案不採用理由: メッセージから `-C` 文字を禁止する案は説明文を歪める。複合への -C 対称化はしない。 +# 対応: quote-aware に引用外の `-C ` をちょうど1つだけ抽出し、それを target dir にする。 +single_unquoted_git_c_path() { + COMMAND_TEXT="$1" python3 - <<'PY' 2>/dev/null || true +import os + +text = os.environ.get("COMMAND_TEXT", "") +quote = None +escaped = False +paths = [] +i = 0 +while i < len(text): + ch = text[i] + if escaped: + escaped = False + i += 1 + continue + if ch == "\\" and quote != "'": + escaped = True + i += 1 + continue + if quote == "'": + if ch == "'": + quote = None + i += 1 + continue + if quote == '"': + if ch == '"': + quote = None + i += 1 + continue + if ch in ("'", '"'): + quote = ch + i += 1 + continue + if ch == "-" and i + 1 < len(text) and text[i + 1] == "C": + prev = text[i - 1] if i > 0 else " " + if prev.isspace() or i == 0: + j = i + 2 + while j < len(text) and text[j] in " \t": + j += 1 + if j < len(text) and text[j] not in " \t\n;'\"|&()": + start = j + while j < len(text) and text[j] not in " \t\n;'\"|&()": + j += 1 + paths.append(text[start:j]) + i = j + continue + i += 1 + +if quote is not None or escaped or len(paths) != 1: + raise SystemExit(0) +print(paths[0], end="") +PY +} + +single_git_c_target_dir() { + # 単発 `git -C commit/push` だけを解決する。 + # `git -C status && git commit` のような後続 git へ -C が効かない形は従来どおり解決しない。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]]|[;&|])(GIT_DIR|GIT_WORK_TREE|GIT_NAMESPACE)=' && return 0 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '(^|[[:space:]])(--git-dir|--work-tree|--namespace)([=[:space:]])' && return 0 + has_unquoted_shell_control "$COMMAND" && return 0 + # shell controlを除外済みの単発コマンドは、git本体とsubcommandだけを軽量に確認する。 + # ここで巨大な GIT_CMD 正規表現を再利用すると、引用本文を空白化した長い -C pathで + # EREのバックトラックが不安定になり、正当なfeature commit/pushを誤denyするため分離する。 + # [2026-08-02][fix] env / VAR=value prefix 付きの単発 git -C を解決対象に含める(issue #1344)。 + # 背景: + # - ユーザー依頼意図: `env FOO=bar git -C commit` / `FOO=bar git -C commit` + # が本軽量正規表現に一致せず未解決 → fail-closed で正当な feature commit/push まで + # 誤 deny されていた(PR #1343 codex-review が検出・再現ドライバで実測)。 + # - 守るべき業務ルール: GIT_DIR / GIT_WORK_TREE / GIT_NAMESPACE の assignment は本関数 + # 冒頭のガードが先に未解決へ倒す(実効 dir を -C 以外で動かす形は従来どおり保守的)。 + # 値に空白・引用を含む assignment は本パターンに一致せず未解決のまま(安全側)。 + # - 他案不採用理由: GIT_CMD 全体の再利用は上記バックトラック不安定のため不採用(既存判断)。 + echo "$COMMAND_FOR_GIT_MATCH" | grep -qE '^[[:space:]]*(env[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*(env[[:space:]]+)?(command[[:space:]]+)?git([[:space:]]+[^[:space:]]+)*[[:space:]]+(commit|push)([[:space:]]|$)' || return 0 + + local dir + dir="$(single_unquoted_git_c_path "$COMMAND")" + case "$dir" in + ""|"."|"./") return 0 ;; + "~") dir="$HOME" ;; + "~/"*) dir="${HOME}/${dir#\~/}" ;; + /*) ;; + *) dir="$CWD/$dir" ;; + esac + printf '%s' "$dir" +} + +# [2026-06-14][feat] / [2026-06-15][fix] 早期許可してはならない push が含まれるか(main 保護の fail-closed 判定)。 +# 引数 $1: 実効ターゲットディレクトリ(effective_target_dir の解決結果)。bare/remote-only push の宛先を +# この dir の push.default + upstream で判定するために使う。空なら bare push は解決不能=unsafe に倒す。 +# 早期許可(worktree feature への exit 0)を通してよいのは: +# 1) 明示的非 main push: git push [安全フラグ]* <非main・非wildcard・非colon の単一ブランチ> +# 2) [2026-06-15][fix] refspec 省略の bare push(git push / git push / git push --force-with-lease)で、 +# 実効 dir のカレントブランチ(=呼び出し側が非 main を保証済み)が push.default 上 main に波及しないもの。 +# `cd && git push --force-with-lease` 形(refspec 省略の常用フロー)を許可するための拡張。 +# それ以外(複数 ref / 値を取るオプション(-o 等) / --all/--mirror / wildcard / main 宛て / +# push.default=matching / upstream が main)は main を押しうるため unsafe=true を返す。 +# トークン単位で解析し、未知オプション(値を取りうる)が残れば unsafe に倒す(保守的)。 +has_unsafe_push() { + local eff_dir="${1:-}" + local segs seg + segs=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE "${GIT_CMD}[[:space:]]+push[^;&|]*" || true) + [ -z "$segs" ] && return 1 # push なし(commit only)→ 安全 + while IFS= read -r seg; do + [ -z "$seg" ] && continue + local args remote="" ref="" extra=0 + args=$(printf '%s' "$seg" | sed -E 's/^.*[[:space:]]push([[:space:]]|$)/ /') + # glob 展開を抑止して push 引数をトークン化(refspec 内の * がファイル展開されないように)。 + set -f + # shellcheck disable=SC2086 + set -- $args + set +f + while [ "$#" -gt 0 ]; do + case "$1" in + # [2026-06-16][fix] リダイレクトトークン(2>&1 / 2> / >file / 1>&2 / &>file 等)を無視する。 + # segment 抽出 [^;&|]* は `2>&1` の `&` で切れ `2>` が残るため、従来はこれを余分な refspec + # と誤認し extra=1 → unsafe → 正当な worktree push が誤 deny されていた。git の refname は + # `<` `>` を含めないため(refname 規則)、これらを含むトークンは refspec ではない=安全に無視できる。 + *'>'*|*'<'*) ;; + # 値を取らない安全フラグのみ消費。 + -u|--set-upstream|-f|--force|--force-with-lease|-q|--quiet|-v|--verbose|-n|--dry-run|--no-verify|--porcelain|--progress|--atomic|--tags|--follow-tags) ;; + -*) return 0 ;; # 未知/値を取るオプション(--all/--mirror/-o 等) → 解析不能 → unsafe + *) + if [ -z "$remote" ]; then remote="$1" + elif [ -z "$ref" ]; then ref="$1" + else extra=1; fi ;; + esac + shift + done + [ "$extra" = 1 ] && return 0 # ref が 2 個以上 → 曖昧 → unsafe + if [ -z "$ref" ]; then + # refspec 省略(git push / git push )→ カレントブランチを push.default に従って push する。 + # 呼び出し側で「実効 dir のカレントブランチ != main」を保証済み。main に波及する設定のみ unsafe。 + [ -z "$eff_dir" ] && return 0 # dir 未解決 → 宛先を検証できない → unsafe(fail-closed) + local pd up + pd=$(git -C "$eff_dir" config --get push.default 2>/dev/null || true) + case "$pd" in + matching) + return 0 ;; # 全 matching ブランチ(main 含む)を push しうる → unsafe + upstream|tracking) + # 設定上の upstream を push。main(またはそれを指す upstream)なら unsafe、解決不能も unsafe。 + up=$(git -C "$eff_dir" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || true) + { [ -z "$up" ] || echo "$up" | grep -qE '(^|/)main$'; } && return 0 ;; + *) + : ;; # simple(既定)/current/nothing/未設定 → カレント(非main)ブランチのみ push → 安全 + esac + continue + fi + echo "$ref" | grep -qE '^[A-Za-z0-9._/-]+$' || return 0 # : や * を含む → unsafe + [ "$ref" = "main" ] && return 0 + echo "$ref" | grep -qE '(^|/)main$' && return 0 # refs/heads/main 等 → unsafe + done <<< "$segs" + return 1 +} + +BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || true) + +# 複合コマンド: checkout/switch main && commit/push を検知 +if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(switch|checkout)([[:space:]]+-[^[:space:]]+)*[[:space:]]+main([[:space:]]|$).*${GIT_CMD}[[:space:]]+(commit|push)([[:space:]]|$)"; then + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+push"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi +fi + +# push コマンドからmain向けrefspecを検知 +PUSH_SEGMENTS=$(echo "$COMMAND_FOR_GIT_MATCH" | grep -oE "${GIT_CMD}[[:space:]]+push[^;&|]*" || true) +if [ -n "$PUSH_SEGMENTS" ]; then + while IFS= read -r push_segment; do + if echo "$push_segment" | grep -qE '(^|[[:space:]])\+?(refs/heads/)?main([[:space:]]|$)'; then + if [ "$BRANCH" != "main" ]; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + continue + fi + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$push_segment" | grep -qE '(^|[[:space:]])\+?[^[:space:]]*:(refs/heads/)?main([[:space:]]|$)'; then + if [ "$BRANCH" != "main" ]; then + _emit_deny_with_telemetry "$DENY_MSG" + fi + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+commit"; then + continue + fi + _emit_deny_with_telemetry "$DENY_MSG" + fi + done <<< "$PUSH_SEGMENTS" +fi + +# [2026-07-18][fix] env split-string内のgit writeはGIT_CMDへ展開できないため、先に拒否する。 +if command_uses_env_split_git_write; then + _emit_deny_with_telemetry "$DENY_MSG" +fi + +# git commit / git push を含まない場合は許可 +if ! echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(commit|push)"; then + exit 0 +fi + +# env の chdir は hook JSON の cwd と異なる実効branchへ切り替わる。完全解決せずfail-closed。 +if command_uses_env_chdir; then + _emit_deny_with_telemetry "$DENY_MSG" +fi + +if [ -z "$BRANCH" ]; then + exit 0 +fi + +# mainブランチの場合 — AI hook 経由では軽量変更でも commit / push を許可しない +if [ "$BRANCH" = "main" ]; then + # [2026-06-14][fix] worktree(別ディレクトリ・feature ブランチ)への commit / 非 main push を許可。 + # 背景: + # 依頼意図: ハーネスが Bash cwd を毎回 main 直下に戻すため、worktree 運用は + # `cd && git commit/push` になる。従来は $CWD(main) の枝で fail-closed deny し、 + # worktree(feature) への正当なコミット・PR push まで弾けて worktree 並行開発が成立しなかった。 + # 守るべき業務ルール: 実効ターゲット(先頭の単一 cd 先)のブランチが main 以外で、かつ main への push を + # 含まないなら、本番デプロイ(=main push/merge)に一切影響しないため許可する。 + # 他案不採用理由: + # 1) 何もしない案: worktree 並行開発(ユーザーの主要フロー)が不能のままで不便。 + # 2) commit/push を全面許可する案: main push の fail-open を生むため不可。実効ブランチ判定 + + # has_unsafe_push ガードで main 保護を厳密に保つ(曖昧/全ref/wildcard/main 宛て push は早期許可しない)。 + # 3) 実効 cwd を完全復元する案: 複数 cd・env トリック・サブシェル・-c まで追うのは複雑で誤許可リスク。 + # 先頭の単一 cd のみ解決し(-C は git 1 回しか効かないため不採用=deny)、env トリック/複数 cd/ + # サブシェル/eval/-c シェルは effective_target_branch が空を返す=従来 deny。 + # 注: 本フックは「権限ルールの実体(SSOT)」そのもの。別途の権限ドキュメント同期は不要(ここが正本)。 + if command_targets_other_dir; then + eff_dir="$(effective_target_dir)" + if [ -n "$eff_dir" ]; then + eff_branch="$(git -C "$eff_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ -n "$eff_branch" ] && [ "$eff_branch" != "main" ] && ! has_unsafe_push "$eff_dir"; then + exit 0 # 別 worktree/別リポの feature への commit / 安全な非 main push(refspec 省略含む)→ 許可 + fi + fi + eff_dir="$(single_git_c_target_dir)" + if [ -n "$eff_dir" ]; then + eff_branch="$(git -C "$eff_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ -n "$eff_branch" ] && [ "$eff_branch" != "main" ] && ! has_unsafe_push "$eff_dir"; then + exit 0 # 単発 `git -C commit/push` は -C が対象 git へだけ効くため許可 + fi + fi + fi + + # [2026-05-30][fix] PR #229 codex review NO-GO 追加修正 + # 背景: BRANCH==main かつ $CWD が軽量だけのとき、`git -C /other push`(refspec なし)等で + # 実効 cwd が /other に切り替わるコマンドが CWD の軽量差分で素通りしていた(line 309 残存fail-open)。 + # 対応: command_targets_other_dir なら CWD ベース判定を信頼せず、main 向けは fail-closed。 + # `git -C /other push origin feature` (CWD=main) など希少な workflow を deny する副作用は + # メイン保護のため許容(自然なワークフローは /other へ cd して実行)。 + if echo "$COMMAND_FOR_GIT_MATCH" | grep -qE "${GIT_CMD}[[:space:]]+(commit|push)"; then + _emit_deny_with_telemetry "$DENY_MSG" + fi +fi + +# main以外は許可 +exit 0 diff --git a/.kimi-code/hooks/scripts/block-main-commit.test.sh b/.kimi-code/hooks/scripts/block-main-commit.test.sh new file mode 100755 index 000000000..f7eb58096 --- /dev/null +++ b/.kimi-code/hooks/scripts/block-main-commit.test.sh @@ -0,0 +1,517 @@ +#!/usr/bin/env bash +set -euo pipefail + +# [2026-04-10][test] +# 背景: +# - 依頼意図: block-main-commit hook の docs-only 例外が再び main 直 push の穴にならないよう、 +# commit / push の軽量変更例外を回帰テストで固定する。 +# - 守るべき業務ルール: main 直コミット/プッシュの例外は Markdown 系ドキュメントと +# sync-state.json など明示 allowlist だけ。コード変更や HEAD:main は拒否する。 +# - 他案不採用理由: 手動確認だけに戻す案は、同じ制御フロー退行を次回レビューまで見逃すため不採用。 +# +# [2026-06-19][test] +# 背景: +# - PR422 / 配布先レビューで、先頭 `cd` を含む複数行コマンドや redirect 付き push の +# 作業ディレクトリ解決が誤 deny される一方、main 明示 push は拒否し続ける必要があると分かった。 +# - 守るべき業務ルール: feature worktree への安全な push は止めず、main 直 push / HEAD:main / +# 解決不能な `git -C` 経由 push は止める。 +# - 他案不採用理由: 実装コメントだけで済ませる案は、sed 抽出の微妙な退行を次の配布まで見逃すため不採用。 + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/block-main-commit.sh" +PASS=0 +FAIL=0 + +json_string() { + python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1" +} + +run_hook() { + local cwd="$1" + local command="$2" + printf '{"tool_name":"Bash","tool_input":{"cwd":%s,"command":%s}}\n' "$(json_string "$cwd")" "$(json_string "$command")" | bash "$SCRIPT" +} + +run_hook_raw() { + local payload="$1" + printf '%s' "$payload" | bash "$SCRIPT" +} + +run_hook_script() { + local cwd="$1" + local command="$2" + local script="$3" + printf '{"tool_name":"Bash","tool_input":{"cwd":%s,"command":%s}}\n' "$(json_string "$cwd")" "$(json_string "$command")" \ + | bash "$script" +} + +expect_allow() { + local name="$1" + local cwd="$2" + local command="$3" + local out + out="$(run_hook "$cwd" "$command" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision'; then + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + else + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + fi +} + +expect_block() { + local name="$1" + local cwd="$2" + local command="$3" + local out + out="$(run_hook "$cwd" "$command" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_raw() { + local name="$1" + local payload="$2" + local out + out="$(run_hook_raw "$payload" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block_with_script() { + local name="$1" + local cwd="$2" + local command="$3" + local script="$4" + local out + out="$(run_hook_script "$cwd" "$command" "$script" 2>&1)" + if printf '%s' "$out" | grep -q 'permissionDecision.*deny'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +main_repo="$tmp/main" +feature_repo="$tmp/feature" +mkdir -p "$main_repo" "$feature_repo" +git -C "$main_repo" init -q +git -C "$main_repo" checkout -q -b main +git -C "$main_repo" config user.email test@example.com +git -C "$main_repo" config user.name "Test User" +echo init > "$main_repo/README.md" +git -C "$main_repo" add README.md +git -C "$main_repo" commit -q -m init +git -C "$main_repo" update-ref refs/remotes/origin/main HEAD + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only commit は拒否" \ + "$main_repo" \ + "git commit -m docs" +expect_block \ + "main上の先頭空白付きcommit は拒否" \ + "$main_repo" \ + " git commit -m docs" +expect_block \ + "main上のenv経由commit は拒否" \ + "$main_repo" \ + "env git commit -m docs" +expect_block \ + "main上のenv -u経由commit は拒否" \ + "$main_repo" \ + "env -u UNUSED_FLAG git commit -m docs" +expect_block \ + "main上のcommand経由push は拒否" \ + "$main_repo" \ + "command git push origin main" +expect_block \ + "main上の変数代入 + env経由commit は拒否" \ + "$main_repo" \ + "FOO=1 env git commit -m docs" +expect_block \ + "main上のsingle quote空白値 + commit は拒否" \ + "$main_repo" \ + "FOO='a b' git commit -m docs" +expect_block \ + "main上のdouble quote空白値 + env経由commit は拒否" \ + "$main_repo" \ + 'FOO="a b" env git commit -m docs' +expect_block \ + "main上の変数代入 + command経由push は拒否" \ + "$main_repo" \ + "FOO=1 command git push origin main" +git -C "$main_repo" reset -q + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only push は拒否" \ + "$main_repo" \ + "git push origin main" +git -C "$main_repo" reset -q + +echo docs >> "$main_repo/README.md" +git -C "$main_repo" add README.md +expect_block \ + "main上のdocs-only commit && push は拒否" \ + "$main_repo" \ + "git commit -m docs && git push origin main" +git -C "$main_repo" reset -q + +mkdir -p "$main_repo/.cursor/rules" "$main_repo/.codex" +echo rule > "$main_repo/.cursor/rules/project.mdc" +git -C "$main_repo" add .cursor/rules/project.mdc +expect_block \ + "main上の.mdc commit は拒否" \ + "$main_repo" \ + "git commit -m rules" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.cursor" + +echo '{}' > "$main_repo/.codex/sync-state.json" +git -C "$main_repo" add .codex/sync-state.json +expect_block \ + "main上のsync-state.json commit は拒否" \ + "$main_repo" \ + "git commit -m sync" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.codex" + +mkdir -p "$main_repo/.claude/hooks" +echo v > "$main_repo/.claude/hooks/.hook-library-version" +git -C "$main_repo" add .claude/hooks/.hook-library-version +expect_block \ + "main上のhook library version commit は拒否" \ + "$main_repo" \ + "git commit -m hook-version" +git -C "$main_repo" reset -q +rm -rf "$main_repo/.claude" + +mkdir -p "$main_repo/src" +echo "export const value = 1;" > "$main_repo/src/app.ts" +git -C "$main_repo" add src/app.ts +expect_block \ + "main上のコード変更 commit は拒否" \ + "$main_repo" \ + "git commit -m code" +git -C "$main_repo" reset -q +rm -rf "$main_repo/src" + +git -C "$feature_repo" init -q +git -C "$feature_repo" checkout -q -b feature/test +git -C "$feature_repo" config user.email test@example.com +git -C "$feature_repo" config user.name "Test User" +echo init > "$feature_repo/README.md" +git -C "$feature_repo" add README.md +git -C "$feature_repo" commit -q -m init +git -C "$feature_repo" update-ref refs/remotes/origin/main HEAD +git -C "$feature_repo" branch --set-upstream-to=origin/main feature/test >/dev/null 2>&1 || true + +expect_block \ + "feature cwdからenv -C main commitは拒否" \ + "$feature_repo" \ + "env -C $main_repo git commit -m unsafe" + +expect_block \ + "feature cwdからenv --chdir main pushは拒否" \ + "$feature_repo" \ + "env --chdir=$main_repo git push" + +expect_block \ + "feature cwdからenv -S内のmain commitは拒否" \ + "$feature_repo" \ + "env -S 'git -C $main_repo commit -m unsafe'" + +expect_block \ + "feature cwdからenv --split-string内のmain pushは拒否" \ + "$feature_repo" \ + "env --split-string='git -C $main_repo push' ignored" + +# [2026-07-12][test] +# 背景: Codex が main checkout を cwd にしたまま専用 worktree へ単発 `git -C` commit する際、 +# conventional commit の scope 括弧や本文のセミコロンを shell 制御演算子と誤認して deny していた。 +# main 保護は維持しつつ、引用済みコミットメッセージ内の文字は引数データとして扱う必要がある。 +# 他案不採用理由: conventional commit の括弧だけを例外化するテストでは、引用済みのセミコロンや +# リダイレクト文字で同じ誤検知が再発するため、引用境界そのものを正負両方向で固定する。 +expect_allow \ + "-C feature commit の引用済み scope 括弧を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" + +expect_allow \ + "-C feature commit の引用済みセミコロンを許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix: first; second'" + +expect_allow \ + "-C feature commit の引用済み > を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: use > output'" + +# [2026-08-02][test] env / VAR= prefix 付き単発 -C feature commit の許可回帰(issue #1344)。 +# 背景: +# - ユーザー依頼意図: 旧 command_uses_env_chdir の貪欲マッチが git 側の -C を env の +# chdir と誤認し、正当な feature commit を誤 deny していた回帰を固定する。 +# - 守るべき業務ルール: env 自身の -C/--chdir・GIT_DIR 系 assignment の保守的 deny は +# 維持する(許可回帰と deny 回帰を対で置く)。 +# - 他案不採用理由: 許可側だけのテストでは、将来 env 判定を戻した時に chdir バイパスの +# deny が消えても検知できない。 +expect_allow \ + "env prefix の -C feature commit を許可" \ + "$main_repo" \ + "env FOO=bar git -C $feature_repo commit -m docs" +expect_allow \ + "VAR= prefix の -C feature commit を許可" \ + "$main_repo" \ + "FOO=bar git -C $feature_repo commit -m docs" +expect_block \ + "env 自身の -C (chdir) は従来どおり拒否" \ + "$feature_repo" \ + "env -C $main_repo git commit -m docs" +expect_block \ + "env GIT_DIR assignment は従来どおり保守的拒否" \ + "$main_repo" \ + "env GIT_DIR=$main_repo/.git git -C $feature_repo commit -m docs" +# PR #1354 codex-review Critical: 引数付き env オプション越しの chdir バイパスを deny 固定 +expect_block \ + "env -u 引数付きの env -C (chdir) main も拒否" \ + "$feature_repo" \ + "env -u FOO -C $main_repo git commit -m docs" +expect_block \ + "env --unset 引数付きの --chdir main も拒否" \ + "$feature_repo" \ + "env --unset FOO --chdir $main_repo git push origin main" +# 注: `env -i git -C commit` は single_git_c_target_dir が env オプションを +# 解決対象にしないため従来どおり保守的 deny(バイパスではなく安全側・許可回帰は置かない)。 + +expect_allow \ + "-C feature commit の引用済み < を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: use < input'" + +expect_allow \ + "-C feature commit のdouble quote済みメッセージを許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix(auth): allow feature worktree\"" + +# [2026-08-02][test] #1313 / #1256 +# 背景: 引用済みの commit message / PR本文に現れる `git push` や `git reset` を +# 実行コマンドと誤認すると、feature worktreeのcommitやガード修正PRを作れない。 +# 守るべき業務ルール: 本文系オプションの引数はデータとして扱い、引用外の実コマンドは拒否する。 +# 他案不採用理由: message 側の文字列を正規表現の例外へ追加する案は、例外列挙が際限なく増え +# 引用境界の正確な認識という根本対処を先送りするため不採用(PR #1343 codex-review 指摘の補完)。 +expect_allow \ + "-C feature commit message内のmain push文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs; git push origin main'" + +expect_allow \ + "-C feature commit message内のreset文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit --message='docs: git reset --hard は本文'" + +expect_allow \ + "single quote内のliteral command substitution文字列を許可" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs: literal \$(git push origin main)'" + +expect_allow \ + "PR本文内のmain push文字列を許可" \ + "$main_repo" \ + "gh pr create --body 'release note; git push origin main'" + +expect_allow \ + "Issue本文のreset文字列を許可" \ + "$main_repo" \ + "gh issue comment 1 --body='docs: git reset --hard は実行しない'" + +expect_block \ + "本文の外にあるmain pushは引き続き拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'docs' ; git push origin main" + +expect_block \ + "-C feature commit 後の非引用セミコロン複合コマンドは拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m fix; git commit -m unsafe" + +expect_block \ + "-C feature commit の command substitution は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix: \$(git status)\"" + +expect_block \ + "-C feature commit の backtick command substitution は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"fix: \`git status\`\"" + +scanner_fixture="$tmp/scanner-fixture" +mkdir -p "$scanner_fixture/scripts" "$scanner_fixture/lib" +cp "$SCRIPT" "$scanner_fixture/scripts/block-main-commit.sh" +cp "$(dirname "$SCRIPT")/../lib/hook-io.sh" "$scanner_fixture/lib/hook-io.sh" +sed -i.bak 's/COMMAND_TEXT="$1" python3/COMMAND_TEXT="$1" missing-python3/' "$scanner_fixture/scripts/block-main-commit.sh" +rm -f "$scanner_fixture/scripts/block-main-commit.sh.bak" +expect_block_with_script \ + "quote scanner の起動不能は fail-closed" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" \ + "$scanner_fixture/scripts/block-main-commit.sh" + +crash_scanner="$scanner_fixture/scanner-exit-2" +printf '#!/usr/bin/env bash\nexit 2\n' > "$crash_scanner" +chmod +x "$crash_scanner" +sed -i.bak "s|COMMAND_TEXT=\"\$1\" missing-python3|COMMAND_TEXT=\"\$1\" $crash_scanner|" "$scanner_fixture/scripts/block-main-commit.sh" +rm -f "$scanner_fixture/scripts/block-main-commit.sh.bak" +expect_block_with_script \ + "quote scanner の異常終了(rc=2)は fail-closed" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'fix(auth): allow feature worktree'" \ + "$scanner_fixture/scripts/block-main-commit.sh" + +expect_block \ + "-C feature commit の閉じていない single quote は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m 'broken" + +expect_block \ + "-C feature commit の閉じていない double quote は拒否" \ + "$main_repo" \ + "git -C $feature_repo commit -m \"broken" + +expect_allow \ + "先頭 cd + multiline の feature push を許可" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease +commit body with spaces" + +expect_allow \ + "先頭 cd + redirect 付き feature push を許可" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease 2>&1" + +expect_block \ + "先頭 cd でも main 明示 push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push origin main 2>&1" + +expect_block \ + "HEAD:main は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push origin HEAD:main" + +# [2026-07-18][test] +# 全CLI配布物へ同じ回帰テストを展開する際、Claude/Cursor/Geminiのhook-ioはKimi固有payloadを +# 入力契約に持たない。別CLIのI/O契約まで要求せず、Kimi/Codex/正本でだけKimi payloadを検証する。 +case "$SCRIPT" in + */.claude/*|*/.cursor/*|*/.gemini/*) + printf '[SKIP] Kimi Shell toolInput の HEAD:main は対象外ランタイム\n' + ;; + *) + expect_block_raw \ + "Kimi Shell toolInput の HEAD:main は拒否" \ + "{\"toolName\":\"Shell\",\"toolInput\":{\"cwd\":$(json_string "$main_repo"),\"command\":$(json_string "cd $feature_repo && git push origin HEAD:main")}}" + ;; +esac + +git -C "$feature_repo" config push.default matching +expect_block \ + "push.default=matching の bare push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push" + +git -C "$feature_repo" config push.default upstream +expect_block \ + "upstream が main の bare push は拒否" \ + "$main_repo" \ + "cd $feature_repo && git push --force-with-lease" + +git -C "$feature_repo" config push.default current +expect_block \ + "複数 cd は解決不能として拒否" \ + "$main_repo" \ + "cd $feature_repo && cd .. && git push" + +# [2026-07-11][test] jtt-apps 本番タグ push 事例(v2.4.37) +# 背景: single_git_c_target_dir()(PR #820)は単発 `git -C commit/push` のうち +# 実効ブランチが非main・かつ安全な push だけを許可する設計に変わっているが、本テストが +# 旧仕様(-C は常に解決不能=拒否)のまま残っていて、この設計変更を検出できずにいた。 +# 合わせて、コマンドに `2>&1` 等のリダイレクトが含まれるだけで誤って解決不能扱いになる +# 問題(DEPLOY_CHECKLIST.md のタグ push 手順が worktree 経由でも実行できなくなる不具合) +# も本ファイル修正で解消したため、そのケースも固定する。 +expect_allow \ + "-C 経由でも非mainブランチへの安全な push は許可" \ + "$main_repo" \ + "git -C $feature_repo push origin feature/test" + +expect_allow \ + "-C 経由 + redirect(2>&1) 付きの安全な push も許可" \ + "$main_repo" \ + "git -C $feature_repo push origin feature/test 2>&1" + +expect_block \ + "-C 経由でも main 宛て push は拒否" \ + "$main_repo" \ + "git -C $feature_repo push origin main" + +expect_block \ + "複数-Cは解決不能として拒否" \ + "$main_repo" \ + "git -C $tmp -C feature commit -m unsafe" + +# [2026-08-02][test] Wave B / #1258 / #1090 H1 +# 背景: +# - ユーザー依頼意図: main cwd から別リポ feature worktree へ commit/push する経路が +# 「無い」ように見える摩擦を、正本の実挙動(既に allow)で固定したい。 +# - 守るべき業務ルール: 先頭単一 `cd && git commit/push` と単発 +# `git -C commit/push` は non-main なら許可。複合への -C 対称化はしない。 +# - 他案不採用理由: helper 再発明や -C の複合対称化は後続 main 書き込みの誤許可を招く。 +# 注: 本 fixture の main_repo と feature_repo は別 git init(クロスリポ相当)。 +expect_allow \ + "クロスリポ相当: 先頭 cd + feature commit を許可" \ + "$main_repo" \ + "cd $feature_repo && git commit --allow-empty -m 'chore: cross-repo feature commit'" + +expect_allow \ + "クロスリポ相当: 単発 -C feature commit を許可" \ + "$main_repo" \ + "git -C $feature_repo commit --allow-empty -m 'chore: cross-repo -C commit'" + +expect_block \ + "-C feature の後続 commit へ対称化しない(複合は拒否)" \ + "$main_repo" \ + "git -C $feature_repo status && git commit --allow-empty -m unsafe" + +expect_block \ + "先頭 cd でも対象が main なら commit 拒否" \ + "$main_repo" \ + "cd $main_repo && git commit --allow-empty -m 'docs: still main'" + +expect_allow \ + "読み取り検索内の git push 文字列は許可" \ + "$main_repo" \ + 'rg -n "git push|post-merge-gate|workflow" hook-library scripts' + +TOTAL=$((PASS + FAIL)) +printf '\n=== block-main-commit.test.sh: %d/%d PASS ===\n' "$PASS" "$TOTAL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.kimi-code/hooks/scripts/block-skill-reverse-edit.sh b/.kimi-code/hooks/scripts/block-skill-reverse-edit.sh new file mode 100755 index 000000000..e1c4845e4 --- /dev/null +++ b/.kimi-code/hooks/scripts/block-skill-reverse-edit.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +# [2026-06-05][feat] Phase E: スキル参照一元化の逆流(SSOT汚染)ブロック +# 背景: +# - ユーザー依頼意図: スキルは AGENT-HUB を唯一の正本(SSOT)とし、各PJは +# .claude/skills/ の相対symlinkで参照する「参照一元化」へ移行済み +# (skill-reference-unification / AGENT-HUB PR #281・#282)。この構成では、PJ で +# 作業中に symlink経由でスキルファイル(.claude/skills//SKILL.md 等)を +# Write/Edit すると、symlink先の実体(AGENT-HUB/skills//...)がレビューなしで +# 直接書き換わり、参照中の全PJへ波及する(逆流)。ルール文だけでは AI が破る +# (遵守は確率的)ため、機械的にブロックして HUB のPR運用へ誘導したい。 +# - 守るべき業務ルール: スキル実体の編集は AGENT-HUB でブランチを切り +# PR→レビュー→マージ→各PJへ反映、の一方向に統一する。PJ側からの逆流編集は禁止。 +# - 他案不採用理由: +# 1) 警告のみ(非ブロック)案: AI は警告を無視して編集を続けるため SSOT 汚染を +# 防げず不採用。完全ブロックにする(ユーザー判断 2026-06-05「完全ブロック」)。 +# 2) パス文字列(.claude/skills/)だけで判定する案: PJ_LOCAL_EXCEPTION の実体コピー +# スキルや AGENT-HUB worktree 内の直接編集まで誤ブロックするため不採用。 +# realpath(symlink解決)で「実体が /skills/ か」「論理パスが hub の内か外か」 +# を見て、逆流(hub外の論理パス→hub内の実体)だけを deny する。 +# 3) hub パスをハードコードする案: worktree や別クローンで破綻するため、realpath を +# 遡って DISTRIBUTION.yaml を持つ skills 親を動的に hub root とみなす。 +# 4) CODEX_SCRIPT_MAP へ追加する案: PreToolUse(Write|Edit) は Codex のツール名体系と +# 異なり非対応(block-unauthorized-docs-file と同型)のため Claude 専用にする。 +# 対応: PreToolUse(Write|Edit|MultiEdit) で編集先 file_path を realpath 解決。実体が +# /skills//... (skills の親に DISTRIBUTION.yaml) かつ 論理パスが hub root の +# 外(=PJ の .claude/skills/ symlink経由)のときだけ deny。AGENT-HUB(worktree含む)内の +# 直接編集・PJの実体コピースキル・PJソースコードは素通り(fail-open: 逆流見逃しは +# 本番破壊ではないため、判定異常時は許可してAIの作業を止めない)。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny を記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +DENY_MSG='[hook:block-skill-reverse-edit] このスキルの正本(SSOT)は AGENT-HUB です。PJ の .claude/skills/(symlink)経由で実体を直接編集すると、レビューなしで参照中の全PJへ波及します(逆流)。\n\n対応手順:\n1. cd ~/business/AGENT-HUB\n2. git checkout -b feat/-update でブランチ作成\n3. skills// を編集\n4. gh pr create -> レビュー -> マージ(各PJへ自動反映)\n\n理由: スキルは1実体をHUBに一元管理(参照一元化)。PJ側からの編集はSSOT汚染になるためHUBのPR運用に統一します。' + +# emit_deny は hook-io.sh にもあるが reason を heredoc へ直接展開し JSON エスケープしない。 +# 将来 DENY_MSG に二重引用符等を含めても壊れないよう json.dumps でエスケープして deny を出す +# (block-unauthorized-docs-file.sh の emit_deny_safe と同型)。argv でなく env 経由で渡し安全化。 +emit_deny_safe() { + # telemetry(harness-checkup): deny を記録(記録失敗は無視・fail-open)。 + agent_hub_telemetry_log hook_deny block-skill-reverse-edit deny 2>/dev/null || true + HOOK_REASON="$1" python3 -c ' +import json, os +print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": os.environ.get("HOOK_REASON", "")}})) +' || true + exit 0 +} + +read_stdin +FILE_PATH=$(extract_file_path) + +# file_path を持たないツール入力は対象外 +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +# bash 前段フィルタ: スキル実体は必ず /skills/ 配下にある。パスに skills/ を含まない +# 大多数の編集は確実に対象外なので、python3 を起動せず即許可して発火コストを避ける。 +case "$FILE_PATH" in + */skills/*) : ;; # skills/ を含む → 詳細判定へ進む + *) exit 0 ;; # 含まない → 対象外(allow) +esac + +# 逆流判定は realpath 解決を伴うため python3 で行う(bash の realpath は未存在末端で +# 揺れるため)。verdict は "deny"(逆流) / "allow"(対象外 or HUB内直接編集)。 +verdict=$(HOOK_FILE_PATH="$FILE_PATH" python3 - <<'PY' 2>/dev/null || true +import os + +fp = os.environ.get("HOOK_FILE_PATH", "") +if not fp: + print("allow") + raise SystemExit(0) + +# 実体パス(symlink解決後)。os.path.realpath は末端が未存在でも経路上の symlink を +# 解決する(Write 新規作成に対応)。macOS の /var -> /private/var 等の上位 symlink も +# 正規化されるため、比較する hub もすべて realpath で揃える(prefix ずれ回避)。 +real = os.path.realpath(fp) + + +def find_hub_skill_root(real_path): + """real_path が /skills//... の形なら、DISTRIBUTION.yaml を持つ + skills 親(hub root, realpath)を返す。スキル実体でなければ None。""" + parts = real_path.split(os.sep) + for i, seg in enumerate(parts): + if seg == "skills" and i > 0: + hub_root = os.sep.join(parts[:i]) + if hub_root and os.path.isfile(os.path.join(hub_root, "DISTRIBUTION.yaml")): + return os.path.realpath(hub_root) + return None + + +def find_enclosing_hub(path): + """path(論理)を文字列的に上へ辿り、DISTRIBUTION.yaml を持つ最も近い祖先(realpath)を + 返す。symlink は辿らない(file_path が物理的にどの hub の中に在るかを見る)。 + 前提: bootstrap-skills.py は per-skill symlink(.claude/skills/)のみ生成し + .claude/skills/ ディレクトリ自体は実ディレクトリ。仮に .claude/skills/ 全体を hub への + symlink にする非標準構成では os.path.isfile が辿って誤許可しうるが、実環境では + bootstrap が生成しないため発生しない(fail-open 受容)。""" + cur = os.path.abspath(path) + while True: + if os.path.isfile(os.path.join(cur, "DISTRIBUTION.yaml")): + return os.path.realpath(cur) + parent = os.path.dirname(cur) + if parent == cur: + return None + cur = parent + + +real_hub = find_hub_skill_root(real) +if real_hub is None: + # スキル実体への書き込みではない(PJソース/実体コピースキル/通常ファイル) -> 対象外 + print("allow") + raise SystemExit(0) + +enclosing_hub = find_enclosing_hub(fp) +if enclosing_hub is not None and enclosing_hub == real_hub: + # file_path が物理的に属する hub と実体の hub が同一 -> HUB(worktree含む)内の直接編集 + print("allow") +else: + # file_path は hub の外(PJ)に在り、実体だけ hub 内 -> .claude/skills/ symlink 逆流 + print("deny") +PY +) + +if [ "$verdict" = "deny" ]; then + emit_deny_safe "$DENY_MSG" +fi + +exit 0 diff --git a/.kimi-code/hooks/scripts/block-skill-reverse-edit.test.sh b/.kimi-code/hooks/scripts/block-skill-reverse-edit.test.sh new file mode 100755 index 000000000..4cef02e2d --- /dev/null +++ b/.kimi-code/hooks/scripts/block-skill-reverse-edit.test.sh @@ -0,0 +1,137 @@ +#!/bin/bash + +# block-skill-reverse-edit.sh の回帰テスト。 +# 模擬 HUB(DISTRIBUTION.yaml + skills/ 実体)と模擬 PJ(.claude/skills/ が +# HUB 実体への相対symlink)を作り、逆流 deny / 各種許可ケースを検証する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK_PATH="$SCRIPT_DIR/block-skill-reverse-edit.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# --- 模擬 HUB(AGENT-HUB クローン相当) --- +HUB="$TMP/hub" +mkdir -p "$HUB/skills/demo-skill/references" "$HUB/skills/skills-manager" +: >"$HUB/DISTRIBUTION.yaml" +echo "demo" >"$HUB/skills/demo-skill/SKILL.md" +echo "mgr" >"$HUB/skills/skills-manager/SKILL.md" +# HUB 自身の .claude/skills/(skills 実体への相対symlink。bootstrap-skills.py 相当) +mkdir -p "$HUB/.claude/skills" +ln -s ../../skills/demo-skill "$HUB/.claude/skills/demo-skill" + +# --- 模擬 PJ(別ルートのプロジェクト) --- +PJ="$TMP/pj" +mkdir -p "$PJ/.claude/skills" "$PJ/src" "$PJ/.agents/skills/ext-skill" +# PJ の .claude/skills/ -> HUB の実体への相対symlink(参照一元化) +ln -s ../../../hub/skills/demo-skill "$PJ/.claude/skills/demo-skill" +# スキル名に 'skills' を含むケース(skills-manager) +ln -s ../../../hub/skills/skills-manager "$PJ/.claude/skills/skills-manager" +# PJ_LOCAL_EXCEPTION: 実体コピーのローカルスキル(symlink でない) +mkdir -p "$PJ/.claude/skills/local-skill" +echo "local" >"$PJ/.claude/skills/local-skill/SKILL.md" +# .agents/skills/ 外部管理スキル(DISTRIBUTION.yaml を持たない領域)への symlink +echo "ext" >"$PJ/.agents/skills/ext-skill/SKILL.md" +ln -s ../../.agents/skills/ext-skill "$PJ/.claude/skills/ext-skill" + +# --- PJ 自体が DISTRIBUTION.yaml を持つ(別 hub クローン)。HUB の skill を symlink --- +PJCLONE="$TMP/pj-clone" +mkdir -p "$PJCLONE/.claude/skills" +: >"$PJCLONE/DISTRIBUTION.yaml" +ln -s ../../../hub/skills/demo-skill "$PJCLONE/.claude/skills/demo-skill" + +# --- 別 hub(hub2, DISTRIBUTION.yaml あり)の skill を指す PJ2 --- +HUB2="$TMP/hub2" +mkdir -p "$HUB2/skills/demo-skill" +: >"$HUB2/DISTRIBUTION.yaml" +echo "demo2" >"$HUB2/skills/demo-skill/SKILL.md" +PJ2="$TMP/pj2" +mkdir -p "$PJ2/.claude/skills" +ln -s ../../../hub2/skills/demo-skill "$PJ2/.claude/skills/demo-skill" + +run_hook() { + # $1=file_path / $2=tool_name(既定 Write) / $3=tool_input のキー(既定 file_path) + local file_path="$1" + local tool_name="${2:-Write}" + local key="${3:-file_path}" + printf '{"tool_name":"%s","tool_input":{"%s":"%s"}}' "$tool_name" "$key" "$file_path" \ + | bash "$HOOK_PATH" +} + +run_hook_no_path() { + printf '{"tool_name":"Read","tool_input":{}}' | bash "$HOOK_PATH" +} + +assert_denied() { + local output="$1" label="$2" + # emit_deny_safe は json.dumps(セパレータにスペース)で出力するため空白0/1を許容。 + if ! printf '%s' "$output" | grep -qE '"permissionDecision": ?"deny"'; then + printf '[FAIL] %s : deny を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi + if ! printf '%s' "$output" | grep -qE '"permissionDecisionReason": ?'; then + printf '[FAIL] %s : permissionDecisionReason を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi + if printf '%s' "$output" | grep -qE '"reason": ?'; then + printf '[FAIL] %s : 旧 reason キーが残っている:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +assert_allowed() { + local output="$1" label="$2" + if [ -n "$output" ]; then + printf '[FAIL] %s : allow(無出力) を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +echo "1/15 PJ symlink 経由で SKILL.md 編集 -> deny(逆流)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md")" "pj symlink SKILL.md" + +echo "2/15 PJ symlink 経由で新規ファイル作成(未存在) -> deny(逆流)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/references/new.md")" "pj symlink new file" + +echo "3/15 HUB 内で skills/ を直接編集 -> allow(PR運用の本拠地)" +assert_allowed "$(run_hook "$HUB/skills/demo-skill/SKILL.md")" "hub direct skills edit" + +echo "4/15 HUB 内で .claude/skills/(自身のsymlink)経由 -> allow(worktree/HUB内)" +assert_allowed "$(run_hook "$HUB/.claude/skills/demo-skill/SKILL.md")" "hub .claude/skills symlink" + +echo "5/15 PJ の実体コピースキル(PJ_LOCAL_EXCEPTION) -> allow" +assert_allowed "$(run_hook "$PJ/.claude/skills/local-skill/SKILL.md")" "pj local real skill" + +echo "6/15 PJ のソースコード(.claude/skills 外) -> allow" +assert_allowed "$(run_hook "$PJ/src/foo.ts")" "pj source file" + +echo "7/15 file_path を持たないツール入力 -> allow(対象外)" +assert_allowed "$(run_hook_no_path)" "no file_path" + +echo "8/15 PJ symlink 経由で MultiEdit(file_path キー)の SKILL.md -> deny" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md" MultiEdit)" "pj symlink MultiEdit file_path" + +echo "9/15 PJ symlink 経由で Edit 単体 -> deny(matcher Edit カバレッジ)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md" Edit)" "pj symlink Edit" + +echo "10/15 PJ symlink 経由で MultiEdit(path キー) -> deny(実ペイロード形式)" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/SKILL.md" MultiEdit path)" "pj symlink MultiEdit path-key" + +echo "11/15 PJ symlink 経由で多段ネスト(references/api/v2/schema.md) -> deny" +assert_denied "$(run_hook "$PJ/.claude/skills/demo-skill/references/api/v2/schema.md")" "pj symlink deep nest" + +echo "12/15 PJ 自体が DISTRIBUTION.yaml を持つ(別hubクローン)が HUB の skill を symlink -> deny" +assert_denied "$(run_hook "$PJCLONE/.claude/skills/demo-skill/SKILL.md")" "pj-clone with own DISTRIBUTION.yaml" + +echo "13/15 別 hub(hub2)の skill を指す PJ2 symlink -> deny(multi-hub)" +assert_denied "$(run_hook "$PJ2/.claude/skills/demo-skill/SKILL.md")" "pj2 -> hub2 symlink" + +echo "14/15 .agents/skills/ 外部管理スキル(DISTRIBUTION.yaml なし) -> allow" +assert_allowed "$(run_hook "$PJ/.claude/skills/ext-skill/SKILL.md")" "pj .agents external skill" + +echo "15/15 スキル名に 'skills' を含む(skills-manager) symlink 経由 -> deny" +assert_denied "$(run_hook "$PJ/.claude/skills/skills-manager/SKILL.md")" "pj symlink skills-manager" + +echo "block-skill-reverse-edit hook tests passed (15 cases)" diff --git a/.kimi-code/hooks/scripts/block-unauthorized-docs-file.sh b/.kimi-code/hooks/scripts/block-unauthorized-docs-file.sh new file mode 100755 index 000000000..7e270a0ea --- /dev/null +++ b/.kimi-code/hooks/scripts/block-unauthorized-docs-file.sh @@ -0,0 +1,551 @@ +#!/bin/bash +# @description Blocks unauthorized new docs/ SSOT files from file-edit and shell commands. +# @module hook-library/block-unauthorized-docs-file +# @status stable + +# [2026-05-26][feat] +# 背景: +# - ユーザー依頼意図: dev-guardrails 適用 PJ で、AI が docs/prd/ 等の SSOT ディレクトリに +# 推測でファイル名を決めて勝手に新規ファイル(next-action.md 等)を作る事故を止めたい。 +# AI は一度作ったファイルを自分から消さないため、無断生成物が溜まり続ける。ルール文だけでは +# AI が破る(指示の遵守は確率的)ため、機械的にブロックする hook を併設する。 +# - 守るべき業務ルール: docs-structure-rules.md(dev-guardrails)。prd/ は固定3ファイル+archives、 +# その他 SSOT ディレクトリ(architecture/business/api/database/operation/benchmark/testing)と +# docs/ 直下は baseline 許可ファイルのみ。新規 SSOT は伸太郎殿の承認(図解で必要性を説明)後に +# docs/.ssot-allowlist へ登録してから作る。 +# - 他案不採用理由: +# 1) docs/ 配下を全面ブロックする案: design/ release-notes/ 等の作業用ディレクトリへの +# 正当な新規作成(「デザイン案を作って」等)まで止めるため不採用。構造化 SSOT +# ディレクトリと docs/直下に限定する。 +# 2) prompt 型 hook で LLM 判定する案: 非決定的でチャットにプロンプトが漏れる。確定的な +# command hook に統一する(hooks-structure-rule.md)。 +# 3) 既存ファイルもブロックする案: 更新(Edit/上書き)は自由であるべき。ディスク上に存在する +# ファイルは grandfather して素通りさせ、純粋な新規作成のみをブロックする。 +# 対応: PreToolUse(Write|Edit|MultiEdit|Bash) で docs/ 配下の新規ファイルを検査。構造化 SSOT ディレクトリ + +# docs/直下 + 未承認の新規 docs/ サブディレクトリを deny し、図解で承認を取るよう AI に指示する。 +# docs/.ssot-allowlist 自体は AI の抜け道になるため手動更新扱いにし、既存ファイルは素通り。 +# +# [2026-05-27][fix] +# 背景: +# - ユーザー依頼意図: docs/plan/ は「廃止」する。プランの本流は ~/.claude/plans/(Claude)や +# ~/.cursor/plans/ などグローバルへ移っており、各PJの docs/plan/ は古いファイルの堆積(jtt-cms 100件が +# 5/7 から放置等)になっていた。今後 docs/plan/ には新規ファイルを作らせたくない。 +# - 守るべき業務ルール: docs/plan/ は WORK_DIRS(素通り)から外し、完全禁止にする。思考用プランは +# docs/ の外(~/.claude/plans/)に出るため本 hook は発火しない。docs/plan/ への新規作成だけをブロックする。 +# - 他案不採用理由: docs/plan/ を allowlist で個別解禁する案は、廃止方針と矛盾し再堆積を招くため不採用。 +# design/ release-notes/ は現状の用途が明確でないため WORK_DIRS に残し、plan/ のみ完全禁止にする。 +# 対応: WORK_DIRS から plan を除外(design release-notes のみ)。docs/plan/ 新規には「~/.claude/plans/ へ」 +# という専用メッセージで deny する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# 構造化 SSOT ディレクトリ(固定ファイルセットを持つ=新規ファイルを承認制にする) +# design/ release-notes/ archives/ など作業用ディレクトリは含めない(素通りさせる)。 +# plan/ は廃止(プランは ~/.claude/plans/ 等のグローバルへ)。WORK_DIRS から外し完全禁止扱いにする。 +GATED_DIRS="prd architecture business api database operation benchmark testing" +WORK_DIRS="design release-notes" + +# baseline 許可(docs-structure-rules.md と一致。構造定義で既に承認済みの正本ファイル)。 +is_baseline_allowed() { + local rel="$1" # docs/ より後ろの相対パス。例: prd/prd-active.md + case "$rel" in + # docs/ 直下 SSOT + FEATURE_FLAGS.md | PERMISSIONS.md) return 0 ;; + # prd/(固定3ファイル + archives スナップショット) + prd/prd-active.md | prd/prd-upcoming.md | prd/prd-future.md) return 0 ;; + prd/archives/*) return 0 ;; + # architecture/(設計意図 + 条件付き SSOT) + architecture/database-design.md | architecture/api-design.md) return 0 ;; + architecture/infrastructure-design.md) return 0 ;; + architecture/WEBSOCKET_CHANNELS.md) return 0 ;; + # business/ + business/BUSINESS_RULES.md | business/business-design.md | business/ROLE_DEFINITIONS.md) return 0 ;; + # api/ + api/API_SSOT.md) return 0 ;; + # database/ + database/DB_SCHEMA.md | database/DB_SCHEMA_UPDATE_GUIDE.md | database/SCHEMA_RELATIONS.md) return 0 ;; + # operation/ + operation/PROD_OPERATION.md | operation/STAGING_OPERATION.md | operation/LOCAL_OPERATION.md) return 0 ;; + operation/NOTIFICATION.md | operation/DEPLOY_LOG.md) return 0 ;; + operation/DEPLOY_CHECKLIST.md | operation/ENV_VARIABLES.md) return 0 ;; + esac + return 1 +} + +# [2026-06-26][feat] +# 背景: +# - ユーザー依頼意図: dev-guardrails の per-app SSOT 命名統一(-.md・4カテゴリ化) +# に追随し、docs SSOT 承認制 hook の許可パターンを更新する。直前の統一で per-app docs +# は -business-rules.md / -operations.md / --design.md へ +# 命名変更されたが、hook は旧名固定のため新命名ファイルが誤ってブロックされていた。 +# - 守るべき業務ルール: per-app docs は apps//docs/ 配下に限定し、ファイル名プレフィックス +# が app 名と一致することを backreference(\1) で機械的に保証する。旧命名ファイルは移行期中の +# 安全のため grandfather として残す。root の docs/architecture/ は per-app から分離し、 +# root 専用扱いを維持する。 +# - 他案不採用理由: +# 1) root docs 判定ロジックに per-app 判定を混ぜる案: root 専用 architecture/ 等との +# 優先順位・エラーメッセージが複雑化し、root ロジックを変更したくない本件の制約に反するため不採用。 +# 2) 旧命名 BUSINESS_RULES.md / OPERATIONS_SSOT.md を即座に削除する案: 移行期中に旧ファイル +# が存在し得るため、誤って既存ファイルの更新をブロックする恐れがあり不採用。 +# 3) ワイルドカードで apps//docs/* を広く許可する案: app 名不一致の推測ファイルや +# 任意名 SSOT を通してしまい、承認制の意味が薄れるため不採用。 +# 対応: apps//docs/ 配下を新たに検査対象に加え、is_per_app_baseline_allowed() で +# regex backreference 付きの許可パターンを判定する。新命名 + 旧命名 + prd パターンを許可し、 +# それ以外は未承認 SSOT としてブロックする。 +is_per_app_baseline_allowed() { + local rel="$1" + python3 - "$rel" <<'PY' +import re +import sys +rel = sys.argv[1] +patterns = [ + # 新 naming(dev-guardrails per-app SSOT 命名統一: -.md) + r'^apps/([-_a-zA-Z0-9]+)/docs/business/\1-business-rules\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/operation/\1-operations\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/architecture/\1-[-_a-zA-Z0-9]+-design\.md$', + # 既存 prd pattern + r'^apps/([-_a-zA-Z0-9]+)/docs/prd/\1-prd-(active|upcoming|future)\.md$', + # 旧 business/operation(移行期 grandfather) + r'^apps/([-_a-zA-Z0-9]+)/docs/business/BUSINESS_RULES\.md$', + r'^apps/([-_a-zA-Z0-9]+)/docs/operation/OPERATIONS_SSOT\.md$', +] +for pat in patterns: + if re.match(pat, rel): + sys.exit(0) +sys.exit(1) +PY +} + +# [2026-06-19][fix] +# 背景: +# - jtt-apps レビューで、`auth-design.md` / `PASSWORD_GATES.md` / +# `PERFORMANCE_BASELINE.md` が存在しない PJ でも baseline 扱いとなり、 +# AI が無承認で新規 SSOT を作れる抜け道になると判明した。 +# - 守るべき業務ルール: 既存ファイルの更新は grandfather で許可するが、 +# PJ に存在しない条件付き SSOT の新規作成は docs/.ssot-allowlist 承認後に限る。 +# - 他案不採用理由: 全PJ共通 baseline に残す案は、存在しない SSOT を正本として +# 既成事実化できるため不採用。 + +# docs/.ssot-allowlist の glob パターンに一致するか(伸太郎殿が承認して追記したエントリ)。 +matches_allowlist_file() { + local rel="$1" + local allowlist="$2" + [ -f "$allowlist" ] || return 1 + local line trimmed + while IFS= read -r line || [ -n "$line" ]; do + trimmed="${line%%#*}" # 行コメント除去 + trimmed="$(printf '%s' "$trimmed" | tr -d '[:space:]')" # 空白除去 + [ -z "$trimmed" ] && continue + # case パターンとして glob 展開させるため $trimmed は unquoted + case "$rel" in + $trimmed) return 0 ;; + esac + done <"$allowlist" + return 1 +} + +# 安全な deny 出力(理由に改行・引用符を含められるよう python で JSON エスケープ)。 +emit_deny_safe() { + python3 - "$1" <<'PY' +import json +import sys +reason = sys.argv[1] +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } +})) +PY + exit 0 +} + +read_stdin + +# [2026-07-16][fix] +# 背景: +# - 依頼意図: Codex の apply_patch でも docs SSOT 承認制と docs/.ssot-allowlist 自己承認禁止を効かせる。 +# - 守るべき業務ルール: Codex の公式 hook 契約では Edit|Write matcher が apply_patch にも一致する。 +# matcher だけ配線して script 側で apply_patch を素通りさせてはならない。 +# - 他案不採用理由: changed_files を完了後だけ検査する案は、自己承認済み成果を worker に作らせた後で +# 止めるため不採用。PreToolUse で patch 対象を決定的に検査する。 +# 対応: apply_patch の patch/input から Add/Update/Delete/Move 対象を抽出し、既存の path gate へ渡す。 +# tool_name はトップレベルと bridge 環境変数から取得。matcher 設定がずれても fail-open を避けるため空は通す。 +TOOL_NAME=$(printf '%s' "$HOOK_INPUT" | python3 -c "import json,os,sys; d=json.load(sys.stdin); print(d.get('tool_name') or d.get('toolName') or d.get('name') or os.environ.get('CLAUDE_TOOL_NAME',''))" 2>/dev/null || true) +if [ -n "$TOOL_NAME" ] && [ "$TOOL_NAME" != "apply_patch" ] && [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "MultiEdit" ] && [ "$TOOL_NAME" != "WriteFile" ] && [ "$TOOL_NAME" != "StrReplaceFile" ] && [ "$TOOL_NAME" != "Bash" ] && [ "$TOOL_NAME" != "Shell" ]; then + exit 0 +fi + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" + +# docs/ 配下なら絶対パス + docs/ からの相対パスを返す。配下でなければ空。 +normalize_docs_path() { + FP="$1" ROOT="$PROJECT_DIR" python3 - <<'PY' 2>/dev/null || true +import os +# [2026-06-01][fix] codex PR#65 指摘②: abspath は symlink を解決しないため、 +# docs/ 自体や中間ディレクトリが symlink の場合に承認制を回避できた。realpath で +# symlink と相対(..)を実体パスに正規化してから docs/ 配下判定を行う。比較対象の +# docs も realpath で揃え、新規ファイル(末端未存在)は既存接頭辞だけ解決される。 +fp = os.environ.get("FP", "") +root = os.path.realpath(os.environ.get("ROOT", ".")) +if not fp: + print("") +else: + target = fp if os.path.isabs(fp) else os.path.join(root, fp) + ap = os.path.realpath(target) + docs = os.path.realpath(os.path.join(root, "docs")) + if ap == docs or ap.startswith(docs + os.sep): + print(ap + "\t" + os.path.relpath(ap, docs)) + else: + print("") +PY +} + +is_gated_rel() { + local rel="$1" + local top="$2" + if [ "$rel" = ".ssot-allowlist" ]; then + return 0 + fi + if [ -z "$top" ]; then + return 0 + fi + local d + for d in $GATED_DIRS; do + [ "$top" = "$d" ] && return 0 + done + for d in $WORK_DIRS; do + [ "$top" = "$d" ] && return 1 + done + # 未登録 docs// は docs-structure-rules の「追加ディレクトリ禁止」に合わせて承認制。 + return 0 +} + +check_docs_path() { + local file_path="$1" + local normalized target_path rel top deny_msg + normalized="$(normalize_docs_path "$file_path")" + [ -z "$normalized" ] && return 0 + target_path="${normalized%% *}" + rel="${normalized#* }" + + if [ "$rel" = ".ssot-allowlist" ]; then + deny_msg="[hook:block-unauthorized-docs] docs/.ssot-allowlist の AI 編集をブロックしました。 + +docs/.ssot-allowlist は未承認 SSOT 作成を許可する台帳なので、AI が自分で追記すると承認制の抜け道になります。 +伸太郎殿に図解で必要性を説明し、承認後は伸太郎殿の手動更新として扱ってください。" + emit_deny_safe "$deny_msg" + fi + + # 既存ファイルの更新・上書きは自由(新規作成のみ承認制) + [ -e "$target_path" ] && return 0 + + # 第1階層ディレクトリを判定(docs/直下ファイルは TOP="" 扱い) + case "$rel" in + */*) top="${rel%%/*}" ;; + *) top="" ;; + esac + + is_gated_rel "$rel" "$top" || return 0 + + # docs/plan/ は廃止。プランはグローバル(~/.claude/plans/ 等)へ作る。専用メッセージで deny。 + if [ "$top" = "plan" ]; then + deny_msg="[hook:block-unauthorized-docs] docs/plan/ への新規ファイル作成をブロックしました: docs/${rel} + +docs/plan/ は廃止されました。プランファイルは docs/ ではなくグローバルに作成してください: + - Claude Code のプラン → ~/.claude/plans/(プランモードが自動で書き出す) + - 各PJの docs/plan/ には新規プランを置かない(古いファイルの堆積を防ぐため) + +docs/.ssot-allowlist に plan/... を追加しても docs/plan/ の新規作成は許可されません。" + emit_deny_safe "$deny_msg" + fi + + # baseline / allowlist のいずれかに該当すれば許可 + is_baseline_allowed "$rel" && return 0 + matches_allowlist_file "$rel" "$PROJECT_DIR/docs/.ssot-allowlist" && return 0 + + # 未承認の新規 SSOT → ブロック + deny_msg="[hook:block-unauthorized-docs] docs/ 配下への未承認の新規 SSOT ファイル作成をブロックしました: docs/${rel} + +docs/ 配下の SSOT は承認制です(推測でファイル名を決めて勝手に作らない)。次の手順を踏んでください: + 1. 図解(ASCII)で「なぜこのファイルが必要か」「なぜ既存の構成(prd-active.md 等)では不足か」を伸太郎殿に説明する + 2. 伸太郎殿の承認を得る + 3. 承認後、docs/.ssot-allowlist を伸太郎殿の手動更新として1行追記してから再作成する + +ブロックされないもの: 既存ファイルの更新・編集 / baseline 固定 SSOT(prd-active.md 等)/ design/ release-notes/ 等の作業用ディレクトリ。 +詳細: .claude/skills/dev-guardrails/references/docs-structure-rules.md §7" + + emit_deny_safe "$deny_msg" +} + +# apps//docs/ 配下の正規化。root docs/ とは別の階層なので独立した検査を行う。 +# apps//docs/ 配下でなければ空を返す。 +normalize_per_app_docs_path() { + FP="$1" ROOT="$PROJECT_DIR" python3 - <<'PY' 2>/dev/null || true +import os +fp = os.environ.get("FP", "") +root = os.path.realpath(os.environ.get("ROOT", ".")) +if not fp: + print("") +else: + target = fp if os.path.isabs(fp) else os.path.join(root, fp) + ap = os.path.realpath(target) + apps_dir = os.path.realpath(os.path.join(root, "apps")) + if ap == apps_dir or not ap.startswith(apps_dir + os.sep): + print("") + else: + rel = os.path.relpath(ap, root) + parts = rel.split(os.sep) + # apps//docs/... のみ対象 + if len(parts) >= 4 and parts[2] == "docs": + print(ap + "\t" + rel) + else: + print("") +PY +} + +# apps//docs/ 配下の新規 SSOT 検査。root docs/ ロジックとは独立して動作する。 +check_per_app_docs_path() { + local file_path="$1" + local normalized target_path rel deny_msg + normalized="$(normalize_per_app_docs_path "$file_path")" + [ -z "$normalized" ] && return 0 + target_path="${normalized%% *}" + rel="${normalized#* }" + + # 既存ファイルの更新・上書きは自由(新規作成のみ承認制) + [ -e "$target_path" ] && return 0 + + # baseline のいずれかに該当すれば許可 + is_per_app_baseline_allowed "$rel" && return 0 + + # 未承認の新規 SSOT → ブロック + deny_msg="[hook:block-unauthorized-docs] apps//docs/ 配下への未承認の新規 SSOT ファイル作成をブロックしました: ${rel} + +apps//docs/ 配下の SSOT は承認制です(推測でファイル名を決めて勝手に作らない)。次の手順を踏んでください: + 1. 図解(ASCII)で「なぜこのファイルが必要か」「なぜ既存の構成(-prd-active.md 等)では不足か」を伸太郎殿に説明する + 2. 伸太郎殿の承認を得る + 3. 承認後、docs/.ssot-allowlist を伸太郎殿の手動更新として1行追記してから再作成する + +ブロックされないもの: 既存ファイルの更新・編集 / baseline 固定 SSOT(-business-rules.md 等)。 +詳細: .claude/skills/dev-guardrails/references/docs-structure-rules.md §11" + + emit_deny_safe "$deny_msg" +} + +if [ "$TOOL_NAME" = "apply_patch" ]; then + PATCH_TEXT=$(extract_field patch) + [ -n "$PATCH_TEXT" ] || PATCH_TEXT=$(extract_field input) + [ -n "$PATCH_TEXT" ] || emit_deny_safe "[hook:block-unauthorized-docs] apply_patch の対象パスを検査できないため、安全側でブロックしました。" + PATCH_PATHS=$(printf '%s\n' "$PATCH_TEXT" | python3 -c ' +import re, sys +paths = [] +for line in sys.stdin.read().splitlines(): + match = re.match(r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", line) + if not match: + match = re.match(r"^\*\*\* Move to: (.+)$", line) + if match: + paths.append(match.group(1).strip()) +for path in dict.fromkeys(paths): + print(path) +') + [ -n "$PATCH_PATHS" ] || emit_deny_safe "[hook:block-unauthorized-docs] apply_patch の対象パスを解釈できないため、安全側でブロックしました。" + while IFS= read -r candidate; do + [ -z "$candidate" ] && continue + check_docs_path "$candidate" + check_per_app_docs_path "$candidate" + done <<< "$PATCH_PATHS" + exit 0 +fi + +if [ "$TOOL_NAME" = "Bash" ] || [ "$TOOL_NAME" = "Shell" ]; then + COMMAND=$(extract_field command) + CWD=$(extract_field cwd) + [ -z "$CWD" ] && CWD="$PROJECT_DIR" + [ -z "$COMMAND" ] && exit 0 + printf '%s' "$COMMAND" | grep -Eq '(^|[[:space:];|&])(:>|[0-9]*>{1,2}|&>{1,2}|touch|cat[[:space:]].*([0-9]*>{1,2}|&>{1,2})|cp|mv|install|mkdir|tee|sed[[:space:]].*-i|perl[[:space:]].*-pi)' || exit 0 + CANDIDATES=$( + COMMAND_TEXT="$COMMAND" CWD_TEXT="$CWD" PROJECT_DIR="$PROJECT_DIR" python3 - <<'PY' +import os +import re +import shlex + +cmd = os.environ.get("COMMAND_TEXT", "") +root = os.path.abspath(os.environ.get("PROJECT_DIR", ".")) +current_cwd = os.path.abspath(os.environ.get("CWD_TEXT") or root) +metachars = {";", "|", "&", "<", ">", ">>", "&>", "&>>", "&&", "||"} +paths = [] + + +def resolve_path(token, cwd): + if not token or token in metachars or token.startswith("-") or token.startswith("$"): + return "" + if os.path.isabs(token): + return os.path.normpath(token) + return os.path.normpath(os.path.join(cwd, token)) + + +def add_path(token, cwd=None): + path = resolve_path(token, cwd or current_cwd) + if path: + paths.append(path) + + +def add_copy_like_paths(segment): + if not segment: + return + destination = resolve_path(segment[-1], current_cwd) + if not destination: + return + if os.path.isdir(destination) and len(segment) > 1: + # [2026-06-19][fix] + # 背景: + # - `cp foo.md docs/prd/` のように宛先が既存ディレクトリの場合、 + # `docs/prd/` 自体は既存なので grandfather 判定で許可されていた。 + # - 守るべき業務ルール: 実際に作られる `docs/prd/foo.md` を検査し、 + # 未承認 SSOT の新規作成は同じく止める。 + # - 他案不採用理由: docs ディレクトリ宛てを全面 deny すると、 + # allowlist 済みファイルのコピーまで止まり運用が粗くなるため不採用。 + for source in segment[:-1]: + name = os.path.basename(source.rstrip("/")) + if name and name not in {".", ".."}: + paths.append(os.path.join(destination, name)) + return + paths.append(destination) + + +def copy_like_operands(command, raw_tokens): + option_args = { + "cp": {"-S", "-t", "--suffix", "--target-directory"}, + "mv": {"-S", "-t", "--suffix", "--target-directory"}, + "install": {"-g", "-m", "-o", "-S", "-t", "--group", "--mode", "--owner", "--suffix", "--target-directory"}, + } + target_directory = None + operands = [] + index = 0 + while index < len(raw_tokens): + token = raw_tokens[index] + if token == "--": + operands.extend(raw_tokens[index + 1 :]) + break + if token.startswith("--target-directory="): + target_directory = token.split("=", 1)[1] + index += 1 + continue + if token.startswith("--") and token != "--": + option = token.split("=", 1)[0] + if "=" not in token and option in option_args.get(command, set()): + if option == "--target-directory" and index + 1 < len(raw_tokens): + target_directory = raw_tokens[index + 1] + index += 2 + continue + index += 1 + continue + if token.startswith("-") and token != "-": + short = token[:2] + if token == short and short in option_args.get(command, set()): + if short == "-t" and index + 1 < len(raw_tokens): + target_directory = raw_tokens[index + 1] + index += 2 + continue + if token.startswith("-t") and len(token) > 2: + target_directory = token[2:] + index += 1 + continue + index += 1 + continue + operands.append(token) + index += 1 + if target_directory: + operands.append(target_directory) + return operands + + +try: + lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + tokens = list(lexer) +except Exception: + tokens = [] + +i = 0 +while i < len(tokens): + tok = tokens[i] + if tok == "cd" and i + 1 < len(tokens): + target = tokens[i + 1] + if target not in metachars and not target.startswith("$"): + next_cwd = resolve_path(target, current_cwd) + if next_cwd: + current_cwd = next_cwd + i += 2 + continue + if (tok in {">", ">>", "&>", "&>>"} or re.match(r"^(?:(?:\d*)>{1,2}|&>{1,2})$", tok)) and i + 1 < len(tokens): + add_path(tokens[i + 1]) + i += 2 + continue + if tok in {"touch", "tee", "mkdir"}: + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + add_path(candidate) + if tok in {"cp", "mv", "install"}: + raw_segment = [] + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + raw_segment.append(candidate) + segment = copy_like_operands(tok, raw_segment) + if segment: + add_copy_like_paths(segment) + # [2026-05-27][fix] R2 follow-up: sed/perl の in-place 編集ターゲットも検査対象にする。 + # 背景: 前段 grep は sed -i / perl -pi を作成・編集系として検知するが、ここで対象ファイルを + # paths に追加していなかったため docs/.ssot-allowlist の AI 編集が素通りしていた。 + # 守るべき業務ルール: docs/.ssot-allowlist は既存ファイルでも AI 編集を必ず deny する。 + # 他案不採用理由: fallback を常時 docs/ パス抽出に戻す案は、PR本文や commit message の + # docs/ 言及を再び作成ターゲットと誤認するため不採用。 + if tok in {"sed", "perl"}: + for candidate in tokens[i + 1:]: + if candidate in metachars: + break + if candidate.startswith("-"): + continue + add_path(candidate) + i += 1 + +# [2026-05-27][fix] R2 誤検知: punctuation_chars lexer が 1 トークンも取れなかった +# (引用が壊れた・極端な複合コマンド) 場合に限り、最終手段として docs/ 明示パスを拾う。 +# 正常にトークン化できたコマンド (gh pr create --body "...docs/plan/..." / echo / git commit -m +# 等、説明テキストに docs/ を含むだけ) では作動させない。常時 fallback すると、PR 本文や +# コミットメッセージ中の docs/ 言及を作成ターゲットと誤認して deny してしまう (R2)。 +# 作成系のターゲットは上の operation-aware パス (リダイレクト/touch/tee/mkdir/cp/mv/install) が +# 既に網羅しており、トークン化が成功している限り fallback の追加カバレッジはノイズのみ。 +if not tokens: + try: + fallback_tokens = shlex.split(cmd, posix=True) + except Exception: + fallback_tokens = [] + for token in fallback_tokens: + if token.startswith("./docs/") or token.startswith("docs/"): + paths.append(token[2:] if token.startswith("./") else token) + for match in re.findall(r"(?:^|[\s\"'=<>])(\./docs/[^\s\"'`$;|&<>]+|docs/[^\s\"'`$;|&<>]+)", cmd): + paths.append(match[2:] if match.startswith("./") else match) +for path in dict.fromkeys(paths): + print(path) +PY + ) + while IFS= read -r candidate; do + [ -z "$candidate" ] && continue + check_docs_path "$candidate" + check_per_app_docs_path "$candidate" + done <<< "$CANDIDATES" + exit 0 +fi + +FILE_PATH=$(extract_file_path) +[ -z "$FILE_PATH" ] && exit 0 +check_docs_path "$FILE_PATH" +check_per_app_docs_path "$FILE_PATH" diff --git a/.kimi-code/hooks/scripts/block-unauthorized-docs-file.test.sh b/.kimi-code/hooks/scripts/block-unauthorized-docs-file.test.sh new file mode 100755 index 000000000..4edd38787 --- /dev/null +++ b/.kimi-code/hooks/scripts/block-unauthorized-docs-file.test.sh @@ -0,0 +1,329 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK_PATH="$SCRIPT_DIR/block-unauthorized-docs-file.sh" + +# 一時プロジェクトを作成(docs/ 構造・既存ファイル・allowlist を用意) +TMP_PROJECT="$(mktemp -d)" +trap 'rm -rf "$TMP_PROJECT"' EXIT +mkdir -p "$TMP_PROJECT/docs/prd/archives" \ + "$TMP_PROJECT/docs/architecture" \ + "$TMP_PROJECT/docs/operation" \ + "$TMP_PROJECT/docs/benchmark" \ + "$TMP_PROJECT/docs/plan" \ + "$TMP_PROJECT/docs/database" \ + "$TMP_PROJECT/src" \ + "$TMP_PROJECT/apps/koban-neko/docs/business" \ + "$TMP_PROJECT/apps/hyoka-wanko/docs/operation" \ + "$TMP_PROJECT/apps/chie-fukuro/docs/architecture" \ + "$TMP_PROJECT/apps/foo/docs/business" +# 既存ファイル(grandfather 対象) +: >"$TMP_PROJECT/docs/prd/prd-active.md" +: >"$TMP_PROJECT/docs/database/LEGACY_NOTES.md" # baseline 外だが既存 → 更新は許可される想定 +# allowlist 台帳(承認済みエントリ) +cat >"$TMP_PROJECT/docs/.ssot-allowlist" <<'EOF' +# 伸太郎殿承認済みの追加 SSOT +operation/INCIDENT_LOG.md +architecture/realtime-*.md +EOF + +run_hook() { + local tool_name="$1" + local file_path="$2" # 絶対パス推奨 + local payload + payload=$(python3 - "$tool_name" "$file_path" "$TMP_PROJECT" <<'PY' +import json +import sys +tool_name = sys.argv[1] +file_path = sys.argv[2] +tool_input = {} +if tool_name in {"Bash", "Shell"}: + tool_input["command"] = file_path + tool_input["cwd"] = sys.argv[3] if len(sys.argv) > 3 else "" +elif file_path: + tool_input["file_path"] = file_path +print(json.dumps({"tool_name": tool_name, "tool_input": tool_input}), end="") +PY +) + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +run_hook_raw() { + local payload="$1" + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +run_apply_patch_hook() { + local patch_text="$1" + local payload + payload=$(python3 - "$patch_text" <<'PY' +import json +import sys +print(json.dumps({"tool_name": "apply_patch", "tool_input": {"patch": sys.argv[1]}}), end="") +PY +) + printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP_PROJECT" bash "$HOOK_PATH" +} + +assert_denied() { + local output="$1" + local label="$2" + if ! OUT="$output" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if not payload.get("permissionDecisionReason"): + sys.exit(1) +if "reason" in payload: + sys.exit(1) +PY + then + printf '[FAIL] %s : deny を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +assert_allowed() { + local output="$1" + local label="$2" + if [ -n "$output" ]; then + printf '[FAIL] %s : allow(無出力) を期待したが:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +D="$TMP_PROJECT/docs" + +echo "1/21 prd/ への推測ファイル(next-action.md 新規)-> deny" +assert_denied "$(run_hook Write "$D/prd/next-action.md")" "prd/next-action.md" + +echo "2/21 prd/ baseline 固定ファイル(prd-future.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/prd/prd-future.md")" "prd/prd-future.md" + +echo "3/21 既存ファイル(prd-active.md 上書き)-> allow" +assert_allowed "$(run_hook Write "$D/prd/prd-active.md")" "prd/prd-active.md(existing)" + +echo "4/21 廃止された plan/ への新規 -> deny(docs/plan/ は廃止・~/.claude/plans へ)" +assert_denied "$(run_hook Write "$D/plan/2026-05-26-next-plan.md")" "plan/next-plan.md" + +echo "5/21 prd/archives/ スナップショット新規 -> allow" +assert_allowed "$(run_hook Write "$D/prd/archives/prd-active-2026-05.md")" "prd/archives/snapshot" + +echo "6/21 architecture/ baseline 外の新規(new-thing.md)-> deny" +assert_denied "$(run_hook Write "$D/architecture/new-thing.md")" "architecture/new-thing.md" + +echo "6a/21 database/ 未承認 root SSOT(NEW_RANDOM.md)-> deny" +assert_denied "$(run_hook Write "$D/database/NEW_RANDOM.md")" "database/NEW_RANDOM.md" + +echo "7/21 architecture/ baseline(database-design.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/database-design.md")" "architecture/database-design.md" + +echo "B1-1 architecture/auth-design.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/architecture/auth-design.md")" "architecture/auth-design.md" + +echo "B1-2 operation/PASSWORD_GATES.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/operation/PASSWORD_GATES.md")" "operation/PASSWORD_GATES.md" + +echo "B1-3 benchmark/PERFORMANCE_BASELINE.md(条件付きSSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/benchmark/PERFORMANCE_BASELINE.md")" "benchmark/PERFORMANCE_BASELINE.md" + +mkdir -p "$D/benchmark" +: >"$D/architecture/auth-design.md" +: >"$D/operation/PASSWORD_GATES.md" +: >"$D/benchmark/PERFORMANCE_BASELINE.md" + +echo "B1-4 architecture/auth-design.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/auth-design.md")" "architecture/auth-design.md(existing)" + +echo "B1-5 operation/PASSWORD_GATES.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Edit "$D/operation/PASSWORD_GATES.md")" "operation/PASSWORD_GATES.md(existing)" + +echo "B1-6 benchmark/PERFORMANCE_BASELINE.md(条件付きSSOT・既存)-> allow" +assert_allowed "$(run_hook Write "$D/benchmark/PERFORMANCE_BASELINE.md")" "benchmark/PERFORMANCE_BASELINE.md(existing)" + +echo "8/21 docs/ 直下の新規 SSOT(ROADMAP.md)-> deny" +assert_denied "$(run_hook Write "$D/ROADMAP.md")" "docs/ROADMAP.md" + +echo "9/21 docs/ 直下 baseline(FEATURE_FLAGS.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/FEATURE_FLAGS.md")" "docs/FEATURE_FLAGS.md" + +echo "9a/21 operation/PERMISSIONS.md(条件付き権限SSOT・不在)-> deny" +assert_denied "$(run_hook Write "$D/operation/PERMISSIONS.md")" "operation/PERMISSIONS.md" + +: >"$D/operation/PERMISSIONS.md" +echo "9b/21 operation/PERMISSIONS.md(条件付き権限SSOT・既存)-> allow" +assert_allowed "$(run_hook Edit "$D/operation/PERMISSIONS.md")" "operation/PERMISSIONS.md(existing)" + +echo "10/21 docs/ 外(src/foo.ts 新規)-> allow" +assert_allowed "$(run_hook Write "$D/../src/foo.ts")" "src/foo.ts" + +echo "11/21 allowlist 完全一致(operation/INCIDENT_LOG.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/operation/INCIDENT_LOG.md")" "operation/INCIDENT_LOG.md" + +echo "12/21 allowlist glob 一致(architecture/realtime-channels.md 新規)-> allow" +assert_allowed "$(run_hook Write "$D/architecture/realtime-channels.md")" "architecture/realtime-channels.md" + +echo "13/21 allowlist 台帳の新規/更新 -> deny" +assert_denied "$(run_hook Write "$D/.ssot-allowlist")" "docs/.ssot-allowlist" + +echo "13a/21 Kimi MultiEdit で allowlist 編集 -> deny" +assert_denied "$(run_hook MultiEdit "$D/.ssot-allowlist")" "MultiEdit docs/.ssot-allowlist" + +echo "13b/21 Kimi 旧 WriteFile で allowlist 編集 -> deny" +assert_denied "$(run_hook WriteFile "$D/.ssot-allowlist")" "WriteFile docs/.ssot-allowlist" + +echo "13c/21 Kimi 旧 StrReplaceFile で allowlist 編集 -> deny" +assert_denied "$(run_hook StrReplaceFile "$D/.ssot-allowlist")" "StrReplaceFile docs/.ssot-allowlist" + +echo "14/21 未登録 docs/ サブディレクトリへの新規 -> deny" +assert_denied "$(run_hook Write "$D/random/ROADMAP.md")" "docs/random/ROADMAP.md" + +echo "15/21 相対パスの既存ファイル更新(hook cwd がPJ外)-> allow" +(cd /tmp && assert_allowed "$(run_hook Write "docs/prd/prd-active.md")" "relative existing path") + +echo "16/21 Bash touch で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Bash "touch docs/prd/bash-next.md")" "bash touch docs/prd/bash-next.md" + +echo "17/21 Bash echo で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "echo architecture/foo.md >> docs/.ssot-allowlist")" "bash allowlist edit" + +echo "17a/21 Bash sed -i で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "sed -i.bak 's/foo/bar/' docs/.ssot-allowlist")" "bash sed allowlist edit" + +echo "17b/21 Bash perl -pi で allowlist 編集 -> deny" +assert_denied "$(run_hook Bash "perl -pi -e 's/foo/bar/' docs/.ssot-allowlist")" "bash perl allowlist edit" + +echo "17c/21 Bash cp で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp tmp-note.md docs/prd/")" "bash cp to docs/prd directory" + +echo "17d/21 Bash mv で既存 docs/architecture/ ディレクトリへ未承認SSOT移動 -> deny" +assert_denied "$(run_hook Bash "mv tmp-note.md docs/architecture/")" "bash mv to docs/architecture directory" + +echo "17e/21 Bash install で既存 docs/operation/ ディレクトリへ未承認SSOT配置 -> deny" +assert_denied "$(run_hook Bash "install tmp-note.md docs/operation/")" "bash install to docs/operation directory" + +mkdir -p "$TMP_PROJECT/tmp" +: >"$TMP_PROJECT/tmp/INCIDENT_LOG.md" +echo "17f/21 Bash install -m 644 で allowlist 済みSSOT配置 -> allow" +assert_allowed "$(run_hook Bash "install -m 644 tmp/INCIDENT_LOG.md docs/operation/")" "bash install mode allowlisted file" + +echo "17g/21 Bash install -m 644 で未承認SSOT配置 -> deny" +install_mode_output="$(run_hook Bash "install -m 644 tmp-note.md docs/operation/")" +assert_denied "$install_mode_output" "bash install mode to docs/operation directory" +if printf '%s' "$install_mode_output" | grep -q 'docs/operation/644'; then + printf '[FAIL] bash install mode option was treated as filename:\n%s\n' "$install_mode_output" >&2 + exit 1 +fi + +echo "17h/21 Bash cp -t で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp -t docs/prd tmp-note.md")" "bash cp -t docs/prd" + +echo "17i/21 Bash cp --target-directory= で既存 docs/prd/ ディレクトリへ未承認SSOTコピー -> deny" +assert_denied "$(run_hook Bash "cp --target-directory=docs/prd tmp-note.md")" "bash cp --target-directory docs/prd" + +echo "18/21 tool_name=Read(対象外)-> allow" +assert_allowed "$(run_hook Read "$D/prd/next-action.md")" "Read tool" + +echo "19/21 作業用ディレクトリ design/ への新規 -> allow(WORK_DIRS は維持)" +assert_allowed "$(run_hook Write "$D/design/new-mockup.md")" "design/new-mockup.md" + +echo "20/21 Bash cd 後の prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Bash "cd docs/prd && touch cd-next.md")" "bash cd docs/prd touch" + +echo "21/21 Kimi Shell で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook Shell "touch docs/prd/shell-next.md")" "shell touch docs/prd/shell-next.md" + +echo "21a/21 Kimi toolInput camelCase で prd/ 推測ファイル新規 -> deny" +assert_denied "$(run_hook_raw '{"toolName":"Shell","toolInput":{"command":"touch docs/prd/kimi-toolinput-next.md","cwd":"'"$TMP_PROJECT"'"}}')" "kimi toolInput shell docs/prd" + +# --- R2 誤検知回帰テスト(2026-05-27): 説明テキスト中の docs/ 言及を作成ターゲットと誤認しない --- +echo "R2-1 gh pr create の --body に docs/plan/ 言及(touch 含む)-> allow" +assert_allowed "$(run_hook Bash 'gh pr create --title x --body "removes docs/plan/ legacy; touch up wording"')" "R2 gh pr create body docs mention" + +echo "R2-2 git commit -m に docs/prd/ 言及(> 含む)-> allow" +assert_allowed "$(run_hook Bash 'git commit -m "drop docs/prd/cleanup-notes.md > archive"')" "R2 git commit msg docs mention" + +echo "R2-3 実リダイレクトでの docs/ 新規作成は引き続き deny(保護が残っていること)" +assert_denied "$(run_hook Bash "printf hi > docs/architecture/brand-new.md")" "R2 real redirect still denied" + +echo "R2-4 数値付きリダイレクトでの docs/ 新規作成 -> deny" +assert_denied "$(run_hook Bash "printf hi 2> docs/architecture/fd-new.md")" "R2 numeric redirect denied" + +echo "R2-5 stdout/stderr リダイレクトでの docs/ 新規作成 -> deny" +assert_denied "$(run_hook Bash "printf hi &> docs/architecture/amp-new.md")" "R2 amp redirect denied" + +# --- per-app baseline 新命名テスト(2026-06-26) --- +echo "PA-1/7 per-app 新命名 business 許可: apps/koban-neko/docs/business/koban-neko-business-rules.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/koban-neko/docs/business/koban-neko-business-rules.md")" "per-app business new naming" + +echo "PA-2/7 per-app 新命名 operation 許可: apps/hyoka-wanko/docs/operation/hyoka-wanko-operations.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/hyoka-wanko/docs/operation/hyoka-wanko-operations.md")" "per-app operation new naming" + +echo "PA-3/7 per-app 新命名 architecture 許可: apps/chie-fukuro/docs/architecture/chie-fukuro-rag-design.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/chie-fukuro/docs/architecture/chie-fukuro-rag-design.md")" "per-app architecture new naming" + +echo "PA-4/7 per-app prd 既存パターン許可: apps/foo/docs/prd/foo-prd-active.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/prd/foo-prd-active.md")" "per-app prd pattern" + +echo "PA-5/7 per-app 旧 business 命名 grandfather 許可: apps/foo/docs/business/BUSINESS_RULES.md" +assert_allowed "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/business/BUSINESS_RULES.md")" "per-app old business naming grandfather" + +echo "PA-6/7 per-app 任意名 docs ファイルはブロック維持: apps/foo/docs/business/random-notes.md" +assert_denied "$(run_hook Write "$TMP_PROJECT/apps/foo/docs/business/random-notes.md")" "per-app arbitrary name blocked" + +echo "PA-7/7 per-app app 名不一致はブロック: apps/koban-neko/docs/business/hyoka-wanko-business-rules.md" +assert_denied "$(run_hook Write "$TMP_PROJECT/apps/koban-neko/docs/business/hyoka-wanko-business-rules.md")" "per-app app name mismatch blocked" + +# --- Codex apply_patch hook 配線(2026-07-16) --- +echo "CX-1/5 Codex apply_patch で未承認 docs/prd 新規 -> deny" +assert_denied "$(run_apply_patch_hook $'*** Begin Patch\n*** Add File: docs/prd/codex-next.md\n+new\n*** End Patch')" "Codex apply_patch unauthorized docs" + +echo "CX-2/5 Codex apply_patch で docs/.ssot-allowlist 更新 -> deny" +assert_denied "$(run_apply_patch_hook $'*** Begin Patch\n*** Update File: docs/.ssot-allowlist\n@@\n+prd/codex-next.md\n*** End Patch')" "Codex apply_patch allowlist self-approval" + +echo "CX-3/5 Codex apply_patch で既存 docs/prd 更新 -> allow" +assert_allowed "$(run_apply_patch_hook $'*** Begin Patch\n*** Update File: docs/prd/prd-active.md\n@@\n+updated\n*** End Patch')" "Codex apply_patch existing docs" + +echo "CX-4/5 Codex apply_patch で src 新規 -> allow" +assert_allowed "$(run_apply_patch_hook $'*** Begin Patch\n*** Add File: src/codex.ts\n+export {};\n*** End Patch')" "Codex apply_patch non-docs" + +echo "CX-5/5 Codex apply_patch の対象欠損 -> deny" +assert_denied "$(run_hook_raw '{"tool_name":"apply_patch","tool_input":{}}')" "Codex apply_patch missing target" + +CODEX_HOOKS_JSON="$(cd "$SCRIPT_DIR/../.." && pwd)/hooks.json" +if [ -f "$CODEX_HOOKS_JSON" ]; then + echo "CX-REG Codex hooks.json で cross-runtime matcher 配線済み -> pass" + python3 - "$CODEX_HOOKS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + hooks = json.load(handle).get("hooks", {}).get("PreToolUse", []) +expected_tools = {"Bash", "Edit", "MultiEdit", "Shell", "StrReplaceFile", "Write", "WriteFile"} +registered = any( + expected_tools.issubset(set(entry.get("matcher", "").split("|"))) + and any( + "block-unauthorized-docs-file.sh" in hook.get("command", "") + for hook in entry.get("hooks", []) + ) + for entry in hooks +) +if not registered: + raise SystemExit("Codex hooks.json lacks the cross-runtime docs guard matcher set") +PY +fi + +echo "block-unauthorized-docs-file hook tests passed" diff --git a/.kimi-code/hooks/scripts/freshness-gate.sh b/.kimi-code/hooks/scripts/freshness-gate.sh new file mode 100755 index 000000000..7359fb42a --- /dev/null +++ b/.kimi-code/hooks/scripts/freshness-gate.sh @@ -0,0 +1,266 @@ +#!/bin/bash + +# [2026-03-03][feat] +# 背景: 77スキル中2つだけ日付マーカーあり。サブエージェントはコピー時スナップショット。 +# 手動チェックは現実的に不可能なため、SessionStart hookで毎セッション自動検出が必要。 +# staleness_check.sh(skill-organizer)は手動実行のみだった。skill-audit は 2026-07-13 に +# 正式スキル化(skills/skill-audit/)し、単一スキルの契約遵守を三値判定する。 +# 対応: SessionStart hookで軽量鮮度チェックを実行。 +# (1) hookバージョン差分 (2) スキル鮮度 (3) 依存バージョン乖離を検出。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「鮮度チェックが安全に動作し、監査時に迂回経路を残さないこと」。 +# 業務ルールとして、フック内で外部入力(ファイルパス)をコード文字列に直埋めしてはならない。 +# 代替案としてPythonワンライナーへパスを直接埋め込む実装を維持すると、 +# 特殊文字を含むパスで任意コード実行に繋がるため不採用。 +# 対応: Python呼び出しを引数渡しへ変更し、文字列埋め込みを廃止。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOKS_DIR="$SCRIPT_DIR/.." + +extract_last_verified() { + local skill_md="$1" + python3 - "$skill_md" <<'PY' 2>/dev/null || true +import re +import sys +from pathlib import Path + +skill_path = Path(sys.argv[1]) +try: + text = skill_path.read_text(encoding='utf-8') +except Exception: + print('') + raise SystemExit(0) + +m = re.search(r'last_verified:\s*(\d{4}-\d{2}-\d{2})', text) +print(m.group(1) if m else '') +PY +} + +extract_interval_days() { + local skill_md="$1" + python3 - "$skill_md" <<'PY' 2>/dev/null || echo "60" +import re +import sys +from pathlib import Path + +skill_path = Path(sys.argv[1]) +try: + text = skill_path.read_text(encoding='utf-8') +except Exception: + print('60') + raise SystemExit(0) + +m = re.search(r'interval_days:\s*(\d+)', text) +print(m.group(1) if m else '60') +PY +} + +# --- hookバージョンチェック --- +check_hook_version() { + local version_file="$HOOKS_DIR/.hook-library-version" + local agent_hub_version_file + + # AGENT-HUBのパスを環境変数またはデフォルトから取得 + local agent_hub_path="${AGENT_HUB_PATH:-$HOME/business/AGENT-HUB}" + agent_hub_version_file="$agent_hub_path/hook-library/VERSION" + + if [ ! -f "$version_file" ]; then + echo " - hook-library: バージョン情報なし(未デプロイ or 旧形式)" >&2 + return + fi + + local deployed_version + deployed_version="$(head -1 "$version_file" | sed 's/^v//' | cut -d' ' -f1)" + + if [ -f "$agent_hub_version_file" ]; then + local latest_version + latest_version="$(cat "$agent_hub_version_file" | tr -d '[:space:]')" + + if [ "$deployed_version" != "$latest_version" ]; then + echo " - hook-library: v${latest_version} が利用可能です(現在 v${deployed_version})" >&2 + fi + fi +} + +# --- スキル鮮度チェック --- +check_skill_freshness() { + local skills_dir + + # CLAUDE_PROJECT_DIR が設定されていればそのプロジェクトのスキルをチェック + if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then + skills_dir="$CLAUDE_PROJECT_DIR/.claude/skills" + else + skills_dir="$(pwd)/.claude/skills" + fi + + if [ ! -d "$skills_dir" ]; then + return + fi + + local today_epoch + today_epoch=$(date +%s) + local stale_skills="" + + # 各スキルのSKILL.mdからlast_verifiedを抽出 + for skill_dir in "$skills_dir"/*/; do + [ -d "$skill_dir" ] || continue + local skill_md="$skill_dir/SKILL.md" + [ -f "$skill_md" ] || continue + + local skill_name + skill_name="$(basename "$skill_dir")" + + local last_verified + last_verified="$(extract_last_verified "$skill_md")" + + if [ -z "$last_verified" ]; then + continue # last_verified未設定のスキルはスキップ(Phase 2で順次追加) + fi + + # 経過日数を計算 + local verified_epoch + verified_epoch=$(date -j -f "%Y-%m-%d" "$last_verified" +%s 2>/dev/null || date -d "$last_verified" +%s 2>/dev/null || echo "0") + + if [ "$verified_epoch" = "0" ]; then + continue + fi + + local days_ago=$(( (today_epoch - verified_epoch) / 86400 )) + + # freshness_check.interval_days を取得(デフォルト60日) + local interval + interval="$(extract_interval_days "$skill_md")" + + if [ "$days_ago" -gt "$interval" ]; then + stale_skills="$stale_skills\n - ${skill_name}: ${days_ago}日前(閾値: ${interval}日)" + fi + done + + if [ -n "$stale_skills" ]; then + echo -e " スキル鮮度:$stale_skills" >&2 + fi +} + +# [2026-05-21][feat] / [2026-05-25][refactor] +# 背景: +# - ユーザー依頼意図: 大原則 A「PWAを消して再登録は絶対にしない」と大原則 B「ネイティブアプリ模倣」の +# SSOT 文書が欠落している場合に、SessionStart 時に警告して AI セッションへ必読を促す。 +# 2026-05-25: jtt-apps ローカル限定だった本チェックを hook-library 正本へ upstream +# (/insights deep-check の --diff で「full deploy 時に jtt-apps から消える」ローカル限定実装と判明したため)。 +# - 守るべき業務ルール: SessionStart hook は常に exit 0(ブロックしない、情報提供のみ)。 +# hook-library は複数 PJ で共有されるため、PWA プロジェクト(public/sw.js または public/manifest.json を持つ) +# でのみ発火し、非 PWA PJ(jtt-cms 等)では誤警告させない。 +# - 他案不採用理由: +# 1) Stop hook で AI 最終出力を grep する案は false positive リスクが高すぎる +# (正当な「キャッシュクリア」言及まで誤ブロック)ため不採用。 +# 2) jtt-apps 限定の無条件チェックのまま据え置く案は、full deploy で hook-library 版に巻き戻り +# check_pwa_principles が消えるため不採用(2026-05-25 の deploy --diff で検出)。 +# 3) 無条件で全 PJ に配布する案は、PWA を持たない PJ で毎セッション誤警告を出すため不採用。 +# PWA 検出ゲートで発火対象を PWA PJ に限定する。 +# 対応: PWA 検出(public/sw.js または public/manifest.json)でゲートし、検出時のみ +# PWA_OPERATION_PRINCIPLE.md / PWA_NATIVE_APP_PARITY_RULE.md の存在を確認。メッセージは PJ 非依存化。 +# --- PWA 大原則 SSOT 存在確認(PWA プロジェクトのみ) --- +check_pwa_principles() { + local project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" + + # PWA プロジェクト判定: service worker または manifest を持つ場合のみ発火(非 PWA PJ では誤爆させない) + if [ ! -f "$project_dir/public/sw.js" ] && [ ! -f "$project_dir/public/manifest.json" ]; then + return + fi + + local pwa_op_principle="$project_dir/.claude/rules/general/PWA_OPERATION_PRINCIPLE.md" + local pwa_parity_rule="$project_dir/.claude/rules/general/PWA_NATIVE_APP_PARITY_RULE.md" + + if [ ! -f "$pwa_op_principle" ]; then + echo " ⚠️ PWA_OPERATION_PRINCIPLE.md (.claude/rules/general/) が存在しません。PWA 運用大原則 A (「PWAを消して再登録は絶対にしない」) の SSOT が欠落しています。" >&2 + fi + + if [ ! -f "$pwa_parity_rule" ]; then + echo " ⚠️ PWA_NATIVE_APP_PARITY_RULE.md (.claude/rules/general/) が存在しません。PWA 大原則 B (「ネイティブアプリ模倣」) の SSOT が欠落しています。" >&2 + fi +} + +# [2026-08-02][feat] ローカル main の behind をセッション開始時に警告する(issue #1327)。 +# 背景: +# - ユーザー依頼意図: セッション開始時のシステムプロンプトにはローカルの git log が載るため、 +# AI が「最新」と誤認して古いベースにコミットを積み、push 拒否 → worktree 作り直し → +# 幽霊 hook 誤爆(#1230 と重複)の手戻り連鎖が実測された(2026-08-02 jtt-cafe-pj)。 +# `git fetch` を1回打っていれば全て回避できたため、SessionStart で機械化する。 +# - 守るべき業務ルール: 警告のみで block しない(SessionStart は情報提供・常に exit 0)。 +# オフライン・認証不能・遅延時は fail-open(既存チェックと同じ精神)。 +# macOS 標準に GNU timeout が無いため bg + poll + kill で上限を実装し、 +# GIT_TERMINAL_PROMPT=0 / ssh BatchMode で認証プロンプトの hang を封じる。 +# - 他案不採用理由: PreToolUse(add/commit 時)検知の案 B は、警告が作業途中に割り込み +# ベース選択の時点(worktree 作成)に間に合わない。システムプロンプト側への ahead/behind +# 併記(案 C)は Claude Code 本体の変更で当方から変更不能。 +check_main_behind() { + local repo_root behind fetch_pid waited + repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 + git -C "$repo_root" rev-parse --verify -q refs/heads/main >/dev/null 2>&1 || return 0 + git -C "$repo_root" remote get-url origin >/dev/null 2>&1 || return 0 + ( + export GIT_TERMINAL_PROMPT=0 + export GIT_SSH_COMMAND="ssh -oBatchMode=yes -oConnectTimeout=3" + exec git -C "$repo_root" fetch -q origin "+refs/heads/main:refs/remotes/origin/main" + ) >/dev/null 2>&1 & + fetch_pid=$! + waited=0 + while kill -0 "$fetch_pid" 2>/dev/null; do + if [ "$waited" -ge 50 ]; then + # 5秒(0.1s x 50)で fetch を打ち切り fail-open(オフライン・低速回線) + kill "$fetch_pid" 2>/dev/null || true + wait "$fetch_pid" 2>/dev/null || true + return 0 + fi + sleep 0.1 + waited=$((waited + 1)) + done + wait "$fetch_pid" 2>/dev/null || return 0 + behind="$(git -C "$repo_root" rev-list --count main..origin/main 2>/dev/null)" || return 0 + case "$behind" in ''|*[!0-9]*) return 0 ;; esac + if [ "$behind" -gt 0 ]; then + echo " ⚠ ローカル main が origin/main より ${behind} コミット遅れています(fetch 実行済み)。" >&2 + echo " 冒頭の Recent commits はローカル基準です。古いベースへのコミットを避けるため、" >&2 + echo " worktree / branch は origin/main から作成してください。" >&2 + fi + return 0 +} + +# --- メイン実行 --- +main() { + local warnings="" + + # 一時ファイルで警告を収集 + local tmp_file + tmp_file=$(mktemp) + trap "rm -f '$tmp_file'" EXIT + + check_hook_version 2>"$tmp_file" + warnings="$(cat "$tmp_file")" + + check_skill_freshness 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + check_pwa_principles 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + check_main_behind 2>"$tmp_file" + warnings="$warnings$(cat "$tmp_file")" + + if [ -n "$warnings" ]; then + echo "" >&2 + echo "🔍 [freshness-gate] 鮮度チェック結果:" >&2 + echo "$warnings" >&2 + echo "" >&2 + echo " 詳細: skills/skill-audit の audit_skill.py または staleness_check.sh で確認してください" >&2 + echo "" >&2 + fi + + # SessionStart hookは常にexit 0(ブロックしない、情報提供のみ) + exit 0 +} + +main diff --git a/.kimi-code/hooks/scripts/handover-preflight.sh b/.kimi-code/hooks/scripts/handover-preflight.sh new file mode 100755 index 000000000..df4a9af71 --- /dev/null +++ b/.kimi-code/hooks/scripts/handover-preflight.sh @@ -0,0 +1,353 @@ +#!/bin/bash +# UserPromptSubmit hook for Handover hints. +# Quiet by default. Prints only when the prompt asks for +# "続き", "引き継ぎ書つくって", "引き継ぎ", "作業終了", "終了整理", "Closeout整理", +# "ふり返り", "振り返り", "ふりかえり", +# "handover", compatibility "takeover", or when HANDOVER_PREFLIGHT_FORCE=1 is set. +# +# [2026-06-30][refactor] +# 背景: +# - ユーザー依頼意図: ユーザー向けの引き継ぎ名を Takeover から Handover へ寄せ、 +# plan / Typinator / hook の入口名を揃えたい。 +# - 守るべき業務ルール: 旧 `takeover` / `continuation` 発話、旧 env、旧 +# `~/.agent-hub/takeovers` の保存済みデータは壊さず、互換入口として残す。 +# - 他案不採用理由: 旧 hook を即削除する案は既存 settings の command を壊す。 +# 新旧を同格にする案は正本名が再び揺れるため不採用。 +# 対応: `handover-preflight` を正本にし、旧 `takeover-preflight` は wrapper から本ファイルを呼ぶ。 + +set -euo pipefail + +RAW_INPUT="$(cat || true)" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +HOOK_INPUT="$RAW_INPUT" command python3 - "$PROJECT_DIR" <<'PY' +import json +import os +import re +import sys +from pathlib import Path + +project_dir = Path(sys.argv[1]).resolve() +raw = os.environ.get("HOOK_INPUT", "") + + +def prompt_from_payload(text: str) -> str: + if not text.strip(): + return "" + try: + payload = json.loads(text) + except Exception: + return text + if not isinstance(payload, dict): + return "" + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str): + return value + nested = payload.get("tool_input") + if isinstance(nested, dict): + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = nested.get(key) + if isinstance(value, str): + return value + return "" + + +def unquote_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def candidate_alias_paths() -> list[Path]: + paths: list[Path] = [] + env_path = os.environ.get("HANDOVER_ALIASES_PATH", "").strip() + if env_path: + paths.append(Path(env_path).expanduser()) + compat_env = os.environ.get("TAKEOVER_ALIASES_PATH", "").strip() + if compat_env: + paths.append(Path(compat_env).expanduser()) + legacy_env = os.environ.get("AGENT_MEMORY_ALIASES_PATH", "").strip() + if legacy_env: + paths.append(Path(legacy_env).expanduser()) + paths.append(project_dir / "agent-memory" / "aliases.yaml") + paths.append(Path("/Users/shintaro/business/AGENT-HUB/agent-memory/aliases.yaml")) + return paths + + +def load_apps(aliases_path: Path) -> list[dict[str, object]]: + apps: list[dict[str, object]] = [] + current_project: str | None = None + current: dict[str, object] | None = None + in_aliases = False + + for raw_line in aliases_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].rstrip() + if not line.strip(): + continue + + project_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if project_match: + current_project = project_match.group(1) + current = None + in_aliases = False + continue + + app_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if app_match and current_project: + current = { + "project": current_project, + "canonical_name": app_match.group(1), + "aliases": [], + } + apps.append(current) + in_aliases = False + continue + + if current is None: + continue + + kv_match = re.match(r"^ ([A-Za-z0-9_]+):\s*(.*)$", line) + if kv_match: + key = kv_match.group(1) + value = unquote_scalar(kv_match.group(2)) + in_aliases = key == "aliases" + if key != "aliases": + current[key] = value + continue + + alias_match = re.match(r"^ -\s*(.+?)\s*$", line) + if in_aliases and alias_match: + aliases = current.setdefault("aliases", []) + if isinstance(aliases, list): + aliases.append(unquote_scalar(alias_match.group(1))) + + return apps + + +def find_aliases_path() -> Path | None: + for path in candidate_alias_paths(): + if path.is_file(): + return path + return None + + +TRIGGER_RE = re.compile( + r"(続き|終了整理|Closeout整理|ふり返り|振り返り|ふりかえり|引継ぎ書つくって|引き継ぎ|作業終了|handover|takeover|continuation|continuation-closeout)", + re.IGNORECASE, +) +NEGATED_CONTINUATION_RE = re.compile( + r"続き\s*(?:ではなくて|ではなく|ではない|でなく|でない|じゃなくて|じゃなく|じゃない|" + r"はなく|はない|は不要|不要|はいらない|いらない|なく|ない)" +) +NEGATED_CLOSEOUT_KEYWORDS = ("終了整理", "Closeout整理", "ふり返り", "振り返り", "ふりかえり", "作業終了") +NEGATED_CLOSEOUT_SUFFIXES = ( + "ではない", + "ではないです", + "ではなく", + "ではなくて", + "でない", + "でないです", + "じゃない", + "じゃないです", + "はない", + "はいらない", + "は不要", + "必要ない", + "不要", + "要らない", + "いらない", + "ない", +) +NEGATED_PUNCTUATION = re.compile(r"[\s、。.!?!?ー−‐\\-]") +MAX_ALIAS_TRIGGER_DISTANCE = 32 + + +def normalize_for_negation(value: str) -> str: + return NEGATED_PUNCTUATION.sub("", value.casefold()) + + +def is_negated_closeout_trigger(prompt: str, trigger: str) -> bool: + folded = normalize_for_negation(prompt) + normalized_trigger = normalize_for_negation(trigger) + index = 0 + while True: + index = folded.find(normalized_trigger, index) + if index < 0: + return False + tail = folded[index + len(normalized_trigger):] + for suffix in NEGATED_CLOSEOUT_SUFFIXES: + if tail.startswith(normalize_for_negation(suffix)): + return True + index += len(normalized_trigger) + + +def positive_trigger_spans(prompt: str) -> list[tuple[int, int]]: + spans: list[tuple[int, int]] = [] + for match in TRIGGER_RE.finditer(prompt): + tail = prompt[match.start() : match.end() + 12] + if match.group(1) == "続き" and NEGATED_CONTINUATION_RE.match(tail): + continue + if match.group(1) in NEGATED_CLOSEOUT_KEYWORDS and is_negated_closeout_trigger(prompt, match.group(1)): + continue + spans.append(match.span()) + return spans + + +def alias_near_trigger(prompt: str, name: str, trigger_spans: list[tuple[int, int]]) -> bool: + if not name: + return False + for name_match in re.finditer(re.escape(name), prompt, flags=re.IGNORECASE): + for trigger_start, trigger_end in trigger_spans: + if name_match.end() <= trigger_start: + distance = trigger_start - name_match.end() + else: + distance = name_match.start() - trigger_end + if 0 <= distance <= MAX_ALIAS_TRIGGER_DISTANCE: + return True + return False + + +def matched_app(prompt: str, apps: list[dict[str, object]]) -> dict[str, object] | None: + folded_prompt = prompt.casefold() + trigger_spans = positive_trigger_spans(prompt) + for app in apps: + names: list[str] = [] + for key in ("canonical_name", "display_name"): + value = app.get(key) + if isinstance(value, str): + names.append(value) + aliases = app.get("aliases") + if isinstance(aliases, list): + names.extend(str(alias) for alias in aliases) + + for name in names: + if trigger_spans and alias_near_trigger(prompt, name, trigger_spans): + return app + if name and name.casefold() in folded_prompt: + return app + return None + + +def project_from_cwd(path: Path) -> str: + if (path / "DISTRIBUTION.yaml").is_file() and (path / "hook-registry.yaml").is_file(): + return "AGENT-HUB" + text = str(path) + checks = [ + ("AGENT-HUB", "/AGENT-HUB"), + ("jtt-system", "/jtt-system"), + ("jtt-apps", "/jtt-apps"), + ("jtt-cms", "/jtt-cms"), + ("jtt-cafe-pj", "/jtt-cafe-pj"), + ("hermes", "/mac-mini-server/hermes"), + ] + for project, marker in checks: + if marker in text: + return project + if (path / "pnpm-workspace.yaml").is_file() and (path / "apps").is_dir(): + return "jtt-system" + return "non-pj" + + +def scope_from_cwd(project: str, path: Path) -> str: + parts = path.parts + if project == "jtt-system" and "apps" in parts: + idx = parts.index("apps") + if idx + 1 < len(parts): + return parts[idx + 1] + if project == "AGENT-HUB": + for marker in ("skills", "hook-library", "snippet-prompts", "agent-memory"): + if marker in parts: + idx = parts.index(marker) + if idx + 1 < len(parts): + return parts[idx + 1] + return marker + return "root" + + +def handover_path(project: str, scope: str) -> str: + return str(Path.home() / ".agent-hub" / "handovers" / project / scope / "current.md") + + +def legacy_path(project: str, scope: str) -> str: + return str(Path.home() / ".agent-hub" / "takeovers" / project / scope / "current.md") + + +PROJECT_CLAUDE_MEMORY_PATHS = { + "AGENT-HUB": "-Users-shintaro-business-AGENT-HUB", + "bank-payment-automator": "-Users-shintaro-business-bank-payment-automator", + "hermes": "-Users-shintaro-mac-mini-server-hermes", + "jtt-apps": "-Users-shintaro-Herd-jtt-apps", + "jtt-cafe-pj": "-Users-shintaro-business-jtt-cafe-pj", + "jtt-cms": "-Users-shintaro-LLM-Dev-jtt-cms", + "jtt-system": "-Users-shintaro-jtt-system", +} + + +def claude_memory_path(project: str, app: dict[str, object] | None) -> str: + if app is not None: + configured = app.get("claude_memory_path") + if isinstance(configured, str) and configured: + return configured + encoded = PROJECT_CLAUDE_MEMORY_PATHS.get(project) + if not encoded: + return "未登録" + return str(Path.home() / ".claude" / "projects" / encoded / "memory" / "MEMORY.md") + + +def print_hint(app: dict[str, object] | None, forced: bool) -> None: + manual_path = "skills/handover-manual/references/handover.md" + reflection_path = "agent-memory/registry/reflection-policy.md" + placement_path = "agent-memory/registry/placement-policy.md" + + if app is not None: + project = str(app.get("project") or project_from_cwd(project_dir)) + scope = str(app.get("canonical_name") or scope_from_cwd(project, project_dir)) + display = app.get("display_name") or scope + else: + project = project_from_cwd(project_dir) + scope = scope_from_cwd(project, project_dir) + display = scope + + print("handover preflight:") + print(f"- scope: {project}/{scope}") + print(f"- handover_path: {handover_path(project, scope)}") + print(f"- legacy_path: {legacy_path(project, scope)}") + print(f"- claude_memory: {claude_memory_path(project, app)}") + print(f"- manual: {manual_path}") + print(f"- reflection-policy: {reflection_path}") + print(f"- placement-policy: {placement_path}") + # [2026-07-18][fix] + # 背景: closeoutでPJ固有の短期状態までGBrain候補に混ざり、人間の判断原則と技術台帳の境界が曖昧だった。 + # 守るべき業務ルール: GBrain候補はユーザーしか判断できない原則へ抽象化し、技術/PJ情報はTech GBrainかSSOTへ置く。 + # 他案不採用理由: 候補を全件GBrainへ送る案は確認負荷と重複を増やすため不採用。 + print("- closeout: 未完了 / 次回やること / Tech G-Brain候補 / GBrain候補 / SSOT昇格候補を分ける") + print("- gbrain: 技術名・PJ固有名・短期状態は候補にせず、人間の判断原則へ抽象化") + print("- handover_update: 未完了がある時だけ current.md を更新") + if forced and app is None: + print("- alias: 未検出。cwdから推定") + elif app is not None: + print(f"- app: {display}") + + +prompt = prompt_from_payload(raw) +forced = ( + os.environ.get("HANDOVER_PREFLIGHT_FORCE", "0") == "1" + or os.environ.get("TAKEOVER_PREFLIGHT_FORCE", "0") == "1" + or os.environ.get("AGENT_MEMORY_PREFLIGHT_FORCE", "0") == "1" +) + +if not forced and not positive_trigger_spans(prompt): + raise SystemExit(0) + +aliases_path = find_aliases_path() +app = None +if aliases_path is not None: + try: + app = matched_app(prompt, load_apps(aliases_path)) + except Exception: + app = None + +print_hint(app, forced) +PY diff --git a/.kimi-code/hooks/scripts/handover-preflight.test.sh b/.kimi-code/hooks/scripts/handover-preflight.test.sh new file mode 100755 index 000000000..0dd3f95e1 --- /dev/null +++ b/.kimi-code/hooks/scripts/handover-preflight.test.sh @@ -0,0 +1,156 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +fi +HOOK="$SCRIPT_DIR/handover-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +extract_field() { + printf "%s\n" "$1" | sed -n "s/^-[[:space:]]*$2: //p" +} + +is_agent_hub_source_repo() { + [ -f "$REPO_ROOT/DISTRIBUTION.yaml" ] && [ -f "$REPO_ROOT/hook-registry.yaml" ] +} + +assert_exact_scope() { + local output="$1" + local expected="$2" + local scope + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + [ "$scope" = "$expected" ] || fail "scope が期待値と一致しない: $output" +} + +assert_scoped_path() { + local output="$1" + local category="$2" + local scope + local expected + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + expected="$HOME/.agent-hub/$category/$scope/current.md" + printf "%s\n" "$output" | grep -Fq "$expected" \ + || fail "$category が scope と一致しない: $output" +} + +assert_claude_memory_path() { + local output="$1" + local marker="$2" + local memory_path + + memory_path="$(extract_field "$output" "claude_memory")" + [ -n "$memory_path" ] || fail "claude_memory が取得できない: $output" + case "$memory_path" in + *"/.claude/projects/"*"/memory/MEMORY.md") + : + ;; + *) + fail "claude_memory の形式が想定外: $memory_path" + ;; + esac + case "$memory_path" in + *"$marker"*) + : + ;; + *) + fail "claude_memory が期待するPJを示していない: $memory_path" + ;; + esac +} + +run_hook() { + local prompt="$1" + local project_dir="${2:-$REPO_ROOT}" + printf '{"user_prompt": "%s"}' "$prompt" | CLAUDE_PROJECT_DIR="$project_dir" bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +negative_output="$(run_hook "評価わんこについて。続きではなく概要を教えて")" +[ -z "$negative_output" ] || fail "否定文は無音であるべき: $negative_output" + +negative_reflection_output="$(run_hook "ふり返りは不要です")" +[ -z "$negative_reflection_output" ] || fail "否定文は無音であるべき: $negative_reflection_output" + +negative_hiragana_reflection_output="$(run_hook "ふりかえりはいらない")" +[ -z "$negative_hiragana_reflection_output" ] || fail "ひらがな否定文は無音であるべき: $negative_hiragana_reflection_output" + +hyoka_output="$(run_hook "評価わんこの続き")" +echo "$hyoka_output" | grep -q "handover preflight:" \ + || fail "handover preflight が出ない: $hyoka_output" +assert_scoped_path "$hyoka_output" "handovers" +assert_scoped_path "$hyoka_output" "takeovers" +echo "$hyoka_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "handover manual が出ない: $hyoka_output" + +admin_output="$(run_hook "引継ぎ書つくって")" +assert_scoped_path "$admin_output" "handovers" + +closeout_output="$(run_hook "作業終了。今回の内容を Handover に整理して")" +echo "$closeout_output" | grep -q "placement-policy" \ + || fail "作業終了で placement-policy が出ない: $closeout_output" +echo "$closeout_output" | grep -q "reflection-policy" \ + || fail "作業終了で reflection-policy が出ない: $closeout_output" +echo "$closeout_output" | grep -q "未完了 / 次回やること / Tech G-Brain候補 / GBrain候補 / SSOT昇格候補" \ + || fail "分類分離の案内が出ない: $closeout_output" +echo "$closeout_output" | grep -q "未完了がある時だけ current.md を更新" \ + || fail "handover更新条件の案内が出ない: $closeout_output" + +jtt_apps_reflection_output="$(run_hook "jtt-appsにふり返りを依頼")" +echo "$jtt_apps_reflection_output" | grep -q "handover preflight:" \ + || fail "jtt-appsのふり返りで preflight が出ない: $jtt_apps_reflection_output" +echo "$jtt_apps_reflection_output" | grep -q "scope: jtt-apps/root" \ + || fail "jtt-appsのscopeが出ない: $jtt_apps_reflection_output" +assert_claude_memory_path "$jtt_apps_reflection_output" "Herd-jtt-apps" + +jtt_apps_hiragana_reflection_output="$(run_hook "jtt-appsのふりかえりをお願い")" +echo "$jtt_apps_hiragana_reflection_output" | grep -q "scope: jtt-apps/root" \ + || fail "jtt-appsのひらがなふりかえりでscopeが出ない: $jtt_apps_hiragana_reflection_output" + +jtt_cms_reflection_output="$(run_hook "ふり返りをお願い" "/Users/shintaro/LLM-Dev/jtt-cms")" +echo "$jtt_cms_reflection_output" | grep -q "handover preflight:" \ + || fail "jtt-cmsのふり返りで preflight が出ない: $jtt_cms_reflection_output" +echo "$jtt_cms_reflection_output" | grep -q "scope: jtt-cms/root" \ + || fail "jtt-cmsのscopeが出ない: $jtt_cms_reflection_output" +assert_scoped_path "$jtt_cms_reflection_output" "handovers" +assert_claude_memory_path "$jtt_cms_reflection_output" "LLM-Dev-jtt-cms" + +jtt_system_reflection_output="$(run_hook "ふり返りをお願い" "/Users/shintaro/jtt-system")" +echo "$jtt_system_reflection_output" | grep -q "scope: jtt-system/root" \ + || fail "jtt-systemのscopeが出ない: $jtt_system_reflection_output" + +if is_agent_hub_source_repo; then + agent_hub_reflection_output="$(run_hook "ふり返りをお願い" "$REPO_ROOT")" + assert_exact_scope "$agent_hub_reflection_output" "AGENT-HUB/root" + assert_scoped_path "$agent_hub_reflection_output" "handovers" +fi + +compat_output="$(run_hook "continuation-closeout")" +echo "$compat_output" | grep -q "handover preflight:" \ + || fail "continuation-closeout 互換 trigger が出ない: $compat_output" + +handover_output="$(run_hook "handover")" +echo "$handover_output" | grep -q "handover preflight:" \ + || fail "handover trigger が出ない: $handover_output" + +force_output="$(printf '{"user_prompt": "ただの相談"}' | HANDOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$force_output" | grep -q "handover preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "alias: 未検出" || fail "FORCE時に alias 推定が出ない: $force_output" + +compat_force_output="$(printf '{"user_prompt": "ただの相談"}' | TAKEOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$compat_force_output" | grep -q "handover preflight:" || fail "旧TAKEOVER_PREFLIGHT_FORCE時の preflight が出ない: $compat_force_output" + +echo "PASS: handover-preflight" diff --git a/.kimi-code/hooks/scripts/post-merge-gate.sh b/.kimi-code/hooks/scripts/post-merge-gate.sh new file mode 100755 index 000000000..c10c54ba7 --- /dev/null +++ b/.kimi-code/hooks/scripts/post-merge-gate.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) post-merge gate. +# `gh pr merge` の直接実行を止め、マージ担当者が ccprmerd 正本を読む wrapper へ誘導する。 +# [2026-06-20][feat] +# 背景: +# - ユーザー依頼意図: マージ担当者がマージ作業の中で必ず `;ccprmerd` 相当の +# Typinator 正本を読み、マージ後確認まで含めて進める運用にしたい。 +# - 守るべき業務ルール: マージ処理は「PRレビュー → マージ時チェックリスト読み込み → +# マージ → 同じチェックリストで反映確認」までを一連の作業として扱う。 +# - 他案不採用理由: SKILL.md に手順だけ書く案は、AI が直接 `gh pr merge` を叩く経路を残し、 +# ccprmerd 読み込み漏れを機械的に防げないため不採用。 +# 対応: PreToolUse(Bash) で直接 `gh pr merge` を deny し、`merge-pr.py` 経由へ誘導する。 +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: dirty cleanup のレビューで、`xargs gh pr merge` が直接マージ禁止を迂回できると判明した。 +# - 守るべき業務ルール: 実行ラッパーを挟んでも `gh pr merge` は merge-pr.py 経由へ統一する。 +# - 他案不採用理由: 単純な文字列検索は説明文を誤検知し、`xargs` 全面禁止は無関係な利用まで止めるため不採用。 +# 対応: xargs のオプションを除いた実行コマンドを既存の gh サブコマンド解析へ渡す。 +# [2026-07-18][fix] +# 背景: +# - ユーザー依頼意図: web2context のレビューで、`nohup gh pr merge` が直接マージ禁止を迂回できると判明した。 +# - 守るべき業務ルール: 実行方法を変える標準ラッパーを挟んでも merge-pr.py 経由を強制する。 +# - 他案不採用理由: `nohup` だけを個別検知する案は `setsid` / `nice` で同じ抜け道を残すため不採用。 +# 対応: 副作用のない実行ラッパー3種と各オプションを prefix parser で正規化する。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +# telemetry(harness-checkup): deny/バイパスを記録。lib 無しでも壊れない no-op fallback。 +# 注意: `set -euo pipefail` 下で `. 存在しないファイル` は `||` フォールバックを素通りして +# シェルごと終了する(bash の source 失敗は errexit 免除の対象外)。存在チェックを先に行い、 +# 未配布(telemetry-lib.sh 未同期の配布先)でも deny 本体を絶対に壊さない。 +if [ -f "$SCRIPT_DIR/telemetry-lib.sh" ]; then + . "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || true +fi +if ! declare -f agent_hub_telemetry_log >/dev/null 2>&1; then + agent_hub_telemetry_log() { :; } +fi + +read_stdin + +COMMAND="$(extract_field command)" + +if [ -z "$COMMAND" ]; then + printf '{"continue":true}\n' + exit 0 +fi + +if [ "${AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE:-0}" = "1" ]; then + # telemetry(harness-checkup): 緊急バイパスを記録(黙って通さない)。 + agent_hub_telemetry_log hook_bypass post-merge-gate allow '{"env":"AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE"}' 2>/dev/null || true + printf '{"continue":true}\n' + exit 0 +fi + +if COMMAND_TEXT="$COMMAND" python3 - <<'PY' +from __future__ import annotations + +import os +import re +import shlex +import sys + +command = os.environ.get("COMMAND_TEXT", "") +RESERVED_PREFIXES = {"if", "while", "until"} + +def normalize_newline_separators(text: str) -> str: + """Turn unquoted newlines into command separators before tokenization.""" + result: list[str] = [] + quote = None + escaped = False + for char in text: + if escaped: + result.append(char) + escaped = False + continue + if char == "\\" and quote != "'": + result.append(char) + escaped = True + continue + if quote is not None: + result.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + result.append(char) + continue + result.append(";" if char in "\r\n" else char) + return "".join(result) + +def split_segments(text: str) -> list[list[str]]: + try: + lexer = shlex.shlex(normalize_newline_separators(text), posix=True, punctuation_chars=";&|(){}") + lexer.whitespace_split = True + tokens = list(lexer) + except Exception: + return [] + segments: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token and all(ch in ";&|(){}" for ch in token): + if current: + segments.append(current) + current = [] + else: + current.append(token) + if current: + segments.append(current) + return segments + +def split_segments_with_dynamic_commands(text: str) -> list[list[str]]: + """Keep the normal parse and add a view where command expansions are one token.""" + masked = re.sub(r"\$\([^()\r\n]*\)", "$DYNAMIC_COMMAND", text) + masked = re.sub(r"\$\{[^{}\r\n]+\}", "$DYNAMIC_COMMAND", masked) + segments = split_segments(text) + if masked != text: + segments.extend(split_segments(masked)) + return segments + +def iter_backticks(text: str) -> list[str]: + chunks: list[str] = [] + start = None + escaped = False + quote = None + for index, char in enumerate(text): + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if quote == "'": + if char == "'": + quote = None + continue + if start is None and char in {"'", '"'}: + # 二重引用符内の ' は literal(single-quote モードに入れない)。 + # これを怠ると `echo "'`...`'"` で backtick command-sub を見逃す。 + if char == "'" and quote == '"': + continue + quote = None if quote == char else char + continue + if char != "`": + continue + if start is None: + start = index + 1 + else: + chunks.append(text[start:index]) + start = None + return chunks + +def iter_dollar_subshells(text: str) -> list[str]: + chunks: list[str] = [] + index = 0 + quote = None + escaped = False + while index < len(text): + char = text[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\": + escaped = True + index += 1 + continue + if char == "'" and quote != '"': + quote = None if quote == "'" else "'" + index += 1 + continue + if char == '"' and quote != "'": + quote = None if quote == '"' else '"' + index += 1 + continue + if quote == "'" or not text.startswith("$(", index): + index += 1 + continue + start = index + depth = 1 + cursor = start + 2 + inner_quote = None + inner_escaped = False + while cursor < len(text): + char = text[cursor] + if inner_escaped: + inner_escaped = False + elif char == "\\": + inner_escaped = True + elif inner_quote: + if char == inner_quote: + inner_quote = None + elif char in {"'", '"'}: + inner_quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + chunks.append(text[start + 2:cursor]) + break + cursor += 1 + index = cursor + 1 + return chunks + +def strip_prefix(tokens: list[str]) -> list[str]: + index = 0 + while index < len(tokens): + token = os.path.basename(tokens[index]) + if "=" in tokens[index] and tokens[index].split("=", 1)[0].replace("_", "A").isalnum(): + index += 1 + continue + if token in {"command", "builtin", "exec"}: + index += 1 + if index < len(tokens) and tokens[index] == "-p": + index += 1 + continue + if token == "time": + index += 1 + if index < len(tokens) and tokens[index] == "-p": + index += 1 + continue + if token == "sudo": + index += 1 + while index < len(tokens) and tokens[index].startswith("-"): + opt = tokens[index] + index += 1 + if opt in {"-u", "-g", "-h", "-p", "-C", "-T"} and index < len(tokens): + index += 1 + continue + if token == "env": + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt in {"-u", "--unset", "-C", "--chdir"} and index + 1 < len(tokens): + index += 2 + continue + if opt.startswith("-"): + index += 1 + continue + if "=" in opt and opt.split("=", 1)[0].replace("_", "A").isalnum(): + index += 1 + continue + break + continue + if token in {"nohup", "setsid"}: + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt == "--": + index += 1 + break + if not opt.startswith("-"): + break + index += 1 + continue + if token == "nice": + index += 1 + while index < len(tokens): + opt = tokens[index] + if opt == "--": + index += 1 + break + if opt in {"-n", "--adjustment"} and index + 1 < len(tokens): + index += 2 + continue + if opt.startswith("--adjustment=") or (opt.startswith("-") and opt[1:].lstrip("+").isdigit()): + index += 1 + continue + break + continue + break + return tokens[index:] + +def gh_subcommand(tokens: list[str]) -> list[str]: + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "gh": + return [] + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "--": + index += 1 + break + if token in {"-R", "--repo", "--hostname", "--config"}: + index += 2 + continue + if token.startswith("-R") and len(token) > 2: + index += 1 + continue + if token.startswith("--repo=") or token.startswith("--hostname=") or token.startswith("--config="): + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def indirect_subcommand(tokens: list[str]) -> list[str]: + """Fail closed when a dynamic command position could expand to gh.""" + tokens = strip_prefix(tokens) + if not tokens: + return [] + command = tokens[0] + is_simple_var = command.startswith("$") and command[1:].replace("_", "A").isalnum() + is_dynamic_expansion = ( + (command.startswith("${") and command.endswith("}")) + or command.startswith("$(") + or command.startswith("`") + ) + if not (is_simple_var or is_dynamic_expansion): + return [] + # [2026-07-18][fix] + # 背景: + # - PR1018再レビューで `${GH:-gh}` / `${GH?err}` / `$(printf gh)` のような + # command-position expansionが単純変数判定を外れ、直接mergeを実行できると判明した。 + # - 守るべき業務ルール: 実行ファイルを静的に確定できない `pr merge` はfail-closedにする。 + # - 他案不採用理由: shell parameter expansionを評価してghか判定する案は、default/error演算子や + # command substitutionの実行環境を再実装することになり、別形式で再びfail-openするため不採用。 + # 対応: 動的command tokenの後ろからpr subcommand境界を探し、展開形式を限定せず拒否する。 + for index, token in enumerate(tokens[1:], start=1): + if token == "pr": + return tokens[index:] + return [] + +def strip_pr_options(tokens: list[str]) -> list[str]: + index = 0 + while index < len(tokens): + token = tokens[index] + if token in {"-R", "--repo", "--hostname", "--config"}: + index += 2 + continue + if token.startswith("-R") and len(token) > 2: + index += 1 + continue + if token.startswith("--repo=") or token.startswith("--hostname=") or token.startswith("--config="): + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def xargs_command(tokens: list[str]) -> list[str]: + """Return the command executed by xargs, or an empty list.""" + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "xargs": + return [] + options_with_value = { + "-a", "--arg-file", "-d", "--delimiter", "-E", "--eof", "-I", "--replace", "-J", + "-L", "--max-lines", "-n", "--max-args", "-P", "--max-procs", + "-R", "-S", "-s", "--max-chars", + } + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "--": + return tokens[index + 1:] + if token in options_with_value: + index += 2 + continue + if token.startswith("--") and "=" in token: + index += 1 + continue + if token.startswith(("-d", "-E", "-I", "-J", "-L", "-n", "-P", "-R", "-S", "-s")) and len(token) > 2: + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + break + return tokens[index:] + +def find_exec_command(tokens: list[str]) -> list[str]: + """Return the command passed to find -exec/-execdir, or an empty list.""" + tokens = strip_prefix(tokens) + if not tokens or os.path.basename(tokens[0]) != "find": + return [] + for index, token in enumerate(tokens): + if token in {"-exec", "-execdir"}: + return tokens[index + 1:] + return [] + +def candidate_commands(segment: list[str]) -> list[list[str]]: + candidates = [segment] + for index, token in enumerate(segment[:-1]): + if token in RESERVED_PREFIXES: + candidates.append(segment[index + 1:]) + return candidates + +def contains_generated_shell_command(text: str, depth: int) -> bool: + """Detect a direct merge emitted by printf/echo inside command substitution.""" + for chunk in iter_dollar_subshells(text) + iter_backticks(text): + for segment in split_segments(chunk): + stripped = strip_prefix(segment) + if not stripped or os.path.basename(stripped[0]) not in {"echo", "printf"}: + continue + for token in stripped[1:]: + if contains_direct_merge(token, depth + 1): + return True + return False + +def contains_direct_merge(text: str, depth: int = 0) -> bool: + if depth > 3: + return False + for chunk in iter_backticks(text): + if contains_direct_merge(chunk, depth + 1): + return True + for chunk in iter_dollar_subshells(text): + if contains_direct_merge(chunk, depth + 1): + return True + for segment in split_segments_with_dynamic_commands(text): + for candidate in candidate_commands(segment): + commands = [candidate] + wrapped = xargs_command(candidate) + if wrapped: + commands.append(wrapped) + find_wrapped = find_exec_command(candidate) + if find_wrapped: + commands.append(find_wrapped) + for nested_tokens in (wrapped, find_wrapped): + if nested_tokens: + nested_text = " ".join(shlex.quote(token) for token in nested_tokens) + if contains_direct_merge(nested_text, depth + 1): + return True + for command_tokens in commands: + sub = gh_subcommand(command_tokens) + if not sub: + sub = indirect_subcommand(command_tokens) + if sub and sub[0] == "pr": + pr_sub = strip_pr_options(sub[1:]) + if pr_sub and pr_sub[0] == "merge": + return True + stripped = strip_prefix(segment) + if stripped and os.path.basename(stripped[0]) == "eval": + for token in stripped[1:]: + if contains_direct_merge(token, depth + 1): + return True + if stripped and os.path.basename(stripped[0]) in {"bash", "sh", "zsh"}: + for i, token in enumerate(stripped[1:], start=1): + if token in {"-c", "-lc"} and i + 1 < len(stripped): + payload = stripped[i + 1] + if contains_generated_shell_command(payload, depth) or contains_direct_merge(payload, depth + 1): + return True + return False + +sys.exit(0 if contains_direct_merge(command) else 1) +PY +then + # telemetry(harness-checkup): deny を記録(fail-open)。 + agent_hub_telemetry_log hook_deny post-merge-gate deny 2>/dev/null || true + # [2026-07-31][docs] Issue #1105: 回避策を deny メッセージに明示する + # 背景: + # - 報告は「PR 本文(--body)に説明目的でコマンド例を書いただけでブロックされる」だったが、実測すると + # ブロックされるのは **二重引用符内に backtick / $() で書いた場合だけ**で、これは bash が実際に + # コマンド置換として実行する形=真陽性だった(単一引用符・素のテキスト・--body-file は通る)。 + # - よって Issue の第一案「判定対象を実行される先頭コマンドに限定する」は採らない。採ると + # `--body "$(...)"` のような本物の実行経路を見逃し、正しい安全検査を弱めるため。 + # - 実際に不足していたのは「なぜ止まったか・どう書けば通るか」の案内なので、Issue の第二案 + # (メッセージへ回避策を明示)だけを実施する。 + emit_deny "[hook:post-merge-gate] 直接の gh pr merge は禁止です。マージ担当者が ccprmerd 正本を読むため、python3 ~/business/AGENT-HUB/skills/post-merge/scripts/merge-pr.py を使ってください。 +説明文・PR 本文にコマンド例を書いただけで止まった場合: 二重引用符の中の backtick や \$() は bash が実際に実行するため検知対象です。単一引用符で囲むか --body-file を使ってください。 +リリース昇格 / forward-merge(head が main 等の長寿命ブランチ)の PR は、既定の --squash だと履歴が乖離します。--method merge --no-delete-branch --no-cleanup を明示してください。 +緊急時のみ AGENT_HUB_ALLOW_DIRECT_GH_PR_MERGE=1 を明示できます。" +fi + +printf '{"continue":true}\n' diff --git a/.kimi-code/hooks/scripts/post-merge-gate.test.sh b/.kimi-code/hooks/scripts/post-merge-gate.test.sh new file mode 100755 index 000000000..b45a94b46 --- /dev/null +++ b/.kimi-code/hooks/scripts/post-merge-gate.test.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/post-merge-gate.sh" +PASS=0 +FAIL=0 + +run_hook() { + local command="$1" + printf '{"tool_name":"Bash","tool_input":{"command":%s}}\n' "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$command")" | bash "$SCRIPT" +} + +run_shell_hook() { + local command="$1" + printf '{"tool_name":"Shell","tool_input":{"command":%s}}\n' "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$command")" | bash "$SCRIPT" +} + +expect_block() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +payload = data.get("hookSpecificOutput", {}) +if payload.get("hookEventName") != "PreToolUse": + sys.exit(1) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +if "[hook:post-merge-gate]" not in payload.get("permissionDecisionReason", ""): + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_allow() { + local name="$1" + local command="$2" + local out + out="$(run_hook "$command" 2>&1)" + if printf '%s' "$out" | grep -q '"continue":true'; then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_shell_block() { + local name="$1" + local command="$2" + local out + out="$(run_shell_hook "$command" 2>&1)" + if OUT="$out" python3 - <<'PY' +import json +import os +import sys + +data = json.loads(os.environ["OUT"]) +payload = data.get("hookSpecificOutput", {}) +if payload.get("permissionDecision") != "deny": + sys.exit(1) +PY + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_block "direct gh pr merge" "gh pr merge 123 --squash --delete-branch" +expect_block "repo option gh pr merge" "gh --repo owner/repo pr merge 123 --squash" +expect_block "short repo option gh pr merge" "gh -Rowner/repo pr merge 123" +expect_block "pr-level repo option gh pr merge" "gh pr --repo owner/repo merge 123" +expect_block "pr-level short repo option gh pr merge" "gh pr -Rowner/repo merge 123" +expect_block "command prefix gh pr merge" "command gh pr merge 123" +expect_block "shell nested gh pr merge" "bash -lc 'gh pr merge 123 --squash'" +expect_block "wrapper mention does not bypass direct merge" "echo merge-pr.py && gh pr merge 123 --squash" +expect_block "if statement gh pr merge" "if gh pr merge 123 --squash; then echo ok; fi" +expect_block "while statement gh pr merge" "while gh pr merge 123; do break; done" +expect_block "eval gh pr merge" "eval \"gh pr merge 123 --squash\"" +expect_block "backtick gh pr merge" "echo \`gh pr merge 123\`" +expect_block "quoted dollar subshell gh pr merge" "echo \"\$(gh pr merge 123)\"" +expect_block "double-quoted single-quote dollar subshell bypass" "echo \"'\$(gh pr merge 123)'\"" +expect_block "double-quoted single-quote backtick bypass" "echo \"'\`gh pr merge 123\`'\"" +expect_block "pipe through xargs gh pr merge" "printf '123\\n' | xargs gh pr merge" +expect_block "xargs with options gh pr merge" "xargs -n1 gh pr merge <<<123" +expect_block "macOS xargs replacement gh pr merge" "xargs -J % gh pr merge % <<<123" +expect_block "macOS xargs size gh pr merge" "xargs -S 255 gh pr merge <<<123" +expect_block "macOS xargs replacements gh pr merge" "xargs -R 1 gh pr merge <<<123" +expect_block "GNU xargs delimiter gh pr merge" "printf '123\\n' | xargs -d '\\n' gh pr merge" +expect_block "newline separated gh pr merge" $'printf ok\ngh pr merge 123' +expect_block "shell generated gh pr merge" "bash -c \"\$(printf 'gh pr merge 123')\"" +expect_block "find exec gh pr merge" "find . -exec gh pr merge 123 {} \\;" +expect_block "xargs shell nested gh pr merge" "printf '123\\n' | xargs sh -c 'gh pr merge \"\$0\"'" +expect_block "find shell nested gh pr merge" "find . -exec sh -c 'gh pr merge 123' \\;" +expect_block "variable command gh pr merge" "GH=gh; \"\$GH\" pr merge 123" +expect_block "default parameter expansion gh pr merge" 'GH=gh; "${GH:-gh}" pr merge 123' +expect_block "error parameter expansion gh pr merge" 'GH=gh; "${GH?err}" pr merge 123' +expect_block "command substitution gh pr merge" '$(printf gh) pr merge 123' +expect_block "nohup gh pr merge" "nohup gh pr merge 123" +expect_block "setsid gh pr merge" "setsid -f gh pr merge 123" +expect_block "nice gh pr merge" "nice -n 5 gh pr merge 123" +expect_shell_block "Shell tool gh pr merge" "gh pr merge 123" + +expect_allow "pr view allowed" "gh pr view 123" +expect_allow "wrapper allowed" "python3 ~/business/AGENT-HUB/skills/post-merge/scripts/merge-pr.py 123 --confirm-read" +expect_allow "text mention allowed" "echo 'gh pr merge 123 should use wrapper'" +expect_allow "single quoted dollar subshell text allowed" "echo '\$(gh pr merge 123)'" +expect_allow "single quoted backtick text allowed" "echo '\`gh pr merge 123\`'" + +# [2026-07-31][test] Issue #1105: deny メッセージが回避策を案内することを固定する。 +# 実測の結果、ブロックされるのは二重引用符内の backtick / $()(bash が実際に実行する形=真陽性)だけで、 +# 単一引用符・素のテキスト・--body-file は上の expect_allow 群のとおり通る。よって判定ロジックは変えず、 +# 「なぜ止まったか・どう書けば通るか」を案内するメッセージだけを追加した。その回帰を固定する。 +expect_deny_message_contains() { + local name="$1" + local command="$2" + local needle="$3" + local out + out="$(run_hook "$command" 2>&1)" + if OUT="$out" NEEDLE="$needle" python3 - <<'PYCHECK' +import json +import os +import sys + +try: + data = json.loads(os.environ["OUT"]) +except Exception as exc: + print(f"invalid json: {exc}", file=sys.stderr) + sys.exit(1) +reason = data.get("hookSpecificOutput", {}).get("permissionDecisionReason", "") +sys.exit(0 if os.environ["NEEDLE"] in reason else 1) +PYCHECK + then + printf '[PASS] %s\n' "$name" + PASS=$((PASS + 1)) + else + printf '[FAIL] %s: %s\n' "$name" "$out" + FAIL=$((FAIL + 1)) + fi +} + +expect_deny_message_contains "deny message points at --body-file workaround" "gh pr merge 123" "--body-file" +expect_deny_message_contains "deny message explains single quotes" "gh pr merge 123" "単一引用符" +expect_deny_message_contains "deny message still points at the wrapper" "gh pr merge 123" "merge-pr.py" + +printf 'post-merge-gate tests: %s passed, %s failed\n' "$PASS" "$FAIL" +test "$FAIL" -eq 0 diff --git a/.kimi-code/hooks/scripts/pre-implementation-check.sh b/.kimi-code/hooks/scripts/pre-implementation-check.sh new file mode 100755 index 000000000..4dc2732fd --- /dev/null +++ b/.kimi-code/hooks/scripts/pre-implementation-check.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# UserPromptSubmit フック — 軽量リマインダー(重い処理はしない) +# docs/ の構成を検出し、3層読み込み戦略のリマインダーを出力 +# +# 設置先: .claude/hooks/scripts/pre-implementation-check.sh +# トリガー: UserPromptSubmit +# タイムアウト: 5秒 +# +# [2026-03-21][fix] +# 背景: +# - ユーザー依頼意図: PR30レビューで、実装前リマインダーを Claude が次の行動判断に使える状態へ直したい。 +# - 守るべき業務ルール: UserPromptSubmit の非ブロッキング hook は、モデルへ渡したい文言を stdout に出す必要がある。 +# - 他案不採用理由: stderr へ出す方式のままでは、警告文が人間向けログに留まり、実装前コンテキストとして機能しない。 +# 対応: 非ブロッキング成功のまま stdout 出力へ統一し、プロジェクト構成に応じたリマインダーを Claude に渡す。 +# +# [2026-04-26][fix] +# 背景: +# - ユーザー依頼意図: AGENT-HUB の UserPromptSubmit hook が毎回大きなリマインダーを表示し、 +# hook失敗のように見えて作業体験を悪化させているため静かにしたい。 +# - 守るべき業務ルール: CaD確認自体はAGENT-HUB運用で必須。ただし通常プロンプトごとに可視出力して +# 失敗表示と混同させてはいけない。 +# - 他案不採用理由: +# 1) stderrへ戻す案はモデル文脈に渡らず、PR30で不採用済みのため不採用。 +# 2) settingsだけ残して実体を削除する案は hook 実行時の参照切れを再発させるため不採用。 +# 3) CaDリマインダーを完全削除する案は必須運用を失うため不採用。 +# 対応: 通常は無音成功にし、明示的に `AGENT_HUB_SHOW_PRE_IMPL_REMINDER=1` を指定した場合だけ stdout に出す。 + +# プロジェクトルートを検出 +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" + +if [ "${AGENT_HUB_SHOW_PRE_IMPL_REMINDER:-0}" != "1" ]; then + exit 0 +fi + +if [ -d "$PROJECT_DIR/docs/business" ]; then + # docs/business/ が存在する場合: 3層読み込み戦略リマインダー + cat <<'REMINDER' +⚠️ SSOT 3層読み込み戦略を実行せよ: +Layer 1: CLAUDE.md + rules + prd-active Context Summary +Layer 2: business-design.md / BUSINESS_RULES.md の目次→関係セクション特定 +Layer 3: 変更スコープに応じたSSOTの該当セクションだけ全文読み ++ CaD不採用パターンをブロックリスト化 → サブエージェントに引き渡し ++ PM Agent の直接実装禁止 → サブエージェントに委譲 +REMINDER +else + # docs/business/ が存在しない場合: CaD確認リマインダー + cat <<'REMINDER' +⚠️ CaD確認必須: 変更対象の不採用理由をブロックリスト化 → サブエージェントに引き渡し +REMINDER +fi diff --git a/.kimi-code/hooks/scripts/stop-quality-check.sh b/.kimi-code/hooks/scripts/stop-quality-check.sh new file mode 100755 index 000000000..05085b2c2 --- /dev/null +++ b/.kimi-code/hooks/scripts/stop-quality-check.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。薄いラッパーでlib/の共通ロジックを呼び出す。 +# 対応: Stop → lib/quality-check-common.sh の run_quality_check_hook を呼出。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/quality-check-common.sh" + +run_quality_check_hook \ + "stop-quality-check" \ + "$SCRIPT_DIR/.." \ + "No file changes detected - research/planning task, skipping quality check." diff --git a/.kimi-code/hooks/scripts/storage-url-pr-gate.sh b/.kimi-code/hooks/scripts/storage-url-pr-gate.sh new file mode 100755 index 000000000..8e8452887 --- /dev/null +++ b/.kimi-code/hooks/scripts/storage-url-pr-gate.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。PreToolUseでPR作成前にStorage URL全件検証。 +# 対応: jtt-cms storage-url-pr-gate.sh をポート。lib/hook-io.sh + lib/storage-url-common.py を使用。 +# +# [2026-03-04][fix] +# 背景: ユーザー意図は「PR作成前ゲートが環境差で無効化されず、常に同じ判定になること」。 +# 業務ルールとして、セキュリティ/品質ゲートは fail-open(失敗時素通り)を禁止する。 +# 代替案として `origin/main` 固定 + `|| true` を維持すると、 +# ブランチ構成差やremote未設定時に検査がスキップされるため不採用。 +# 対応: ベースブランチ解決を動的化し、diff取得や検査失敗時は明示denyに変更。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/hook-io.sh" + +resolve_base_ref() { + local cwd="$1" + + # 1) origin/HEAD を優先 + local remote_head + remote_head="$(git -C "$cwd" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || true)" + if [ -n "$remote_head" ]; then + echo "${remote_head#refs/remotes/}" + return 0 + fi + + # 2) origin/main または origin/master + if git -C "$cwd" rev-parse --verify origin/main >/dev/null 2>&1; then + echo "origin/main" + return 0 + fi + if git -C "$cwd" rev-parse --verify origin/master >/dev/null 2>&1; then + echo "origin/master" + return 0 + fi + + # 3) 最後のフォールバック: ローカル main/master + if git -C "$cwd" rev-parse --verify main >/dev/null 2>&1; then + echo "main" + return 0 + fi + if git -C "$cwd" rev-parse --verify master >/dev/null 2>&1; then + echo "master" + return 0 + fi + + return 1 +} + +read_stdin +COMMAND=$(extract_field command) + +# [2026-05-27][fix] issue #201 +# 背景: +# ユーザー依頼意図: `gh pr create`(複数空白)や `gh --repo owner/repo pr create` のように +# gh のグローバルオプション付き呼び出しが固定文字列 `gh pr create` に一致せず +# fail-open(ゲートをスルー)する脆弱性を修正したい。 +# 守るべき業務ルール: セキュリティ/品質ゲートは fail-open 禁止(2026-03-04 CaD と同型)。 +# 他案不採用理由: +# 1) `grep -qF "gh pr create"` を維持しつつ空白を `[[:space:]]*` に変えるだけ → 長形式オプション +# (--repo, --base 等) を見逃すため不採用。 +# 2) コマンド全体を解析する案 → shlex が必要で bash のみより複雑。正規表現の方が保守しやすい。 +# 対応: grep -qE で gh のグローバルオプション(短形式 -R / 長形式 --repo 等)と複数空白を許容する正規表現に変更。 +# [2026-05-27][fix] review follow-up: +# --repo / -R のように値を別トークンで取るグローバルオプションも消費する。値なしオプションだけを +# 許容する旧パターンでは `gh --repo owner/repo pr create` が early exit して fail-open するため不採用。 +# [2026-05-28][fix] issue #210 / v3.5.6 regression fix: +# gh が許容する連結形式 `-Rowner/repo`(値を別トークンにせず短縮形へ glue)も消費する。 +# #201/#213 hardening で `-R[^[:space:]]+` 分岐が脱落し、`gh -Rowner/repo pr create` が +# GH_GLOBAL_OPTS にマッチせず early exit → storage URL gate を fail-open する退化が入っていた。 +# `-[A-Za-z]+` 分岐は `-Rowner/repo` の `/` で止まるため連結 repo 値を消費できない。実機検証で +# gh は `-Rowner/repo` を受理するため(git の連結 `-C/path` は逆に弾かれる)、本分岐の復活が必須。 +readonly GH_GLOBAL_OPTS='([[:space:]]+((-R|--repo|--hostname)[[:space:]]+[^[:space:]]+|-R[^[:space:]]+|--repo=[^[:space:]]+|--hostname=[^[:space:]]+|-[A-Za-z]+|--[A-Za-z0-9_-]+))*' +if ! echo "$COMMAND" | grep -qE "gh${GH_GLOBAL_OPTS}[[:space:]]+pr[[:space:]]+create"; then + exit 0 +fi + +CWD=$(extract_field cwd) +if [ -z "$CWD" ]; then + CWD="." +fi + +BASE_REF="" +if ! BASE_REF="$(resolve_base_ref "$CWD")"; then + emit_deny "[hook:storage-url-pr-gate] 比較対象ブランチ(origin/HEAD, main, master)を解決できません。ベースブランチを取得してから再実行してください。" +fi + +set +e +CHANGED_FILES=$(git -C "$CWD" diff --name-only --diff-filter=ACMR "$BASE_REF"...HEAD 2>/dev/null) +DIFF_STATUS=$? +set -e + +if [ "$DIFF_STATUS" -ne 0 ]; then + emit_deny "[hook:storage-url-pr-gate] 変更ファイル差分の取得に失敗しました(base: $BASE_REF)。リポジトリ状態を確認してください。" +fi + +if [ -z "$CHANGED_FILES" ]; then + exit 0 +fi + +MIGRATION_FILES=$(echo "$CHANGED_FILES" | grep -E '^supabase/migrations/.*\.sql$' || true) +if [ -z "$MIGRATION_FILES" ]; then + exit 0 +fi + +FILE_ARGS=() +while IFS= read -r mf; do + FILE_ARGS+=("$CWD/$mf") +done <<< "$MIGRATION_FILES" + +set +e +DENY_REASON=$(python3 "$SCRIPT_DIR/../lib/storage-url-common.py" gate "${FILE_ARGS[@]}" 2>/dev/null) +GATE_STATUS=$? +set -e + +if [ "$GATE_STATUS" -eq 0 ]; then + exit 0 +fi + +if [ "$GATE_STATUS" -eq 1 ] && [ -n "$DENY_REASON" ]; then + emit_deny "$DENY_REASON" +fi + +emit_deny "[hook:storage-url-pr-gate] Storage URL検証処理でエラーが発生しました。ログを確認して再実行してください。" diff --git a/.kimi-code/hooks/scripts/subagent-quality-check.sh b/.kimi-code/hooks/scripts/subagent-quality-check.sh new file mode 100755 index 000000000..795fe76bf --- /dev/null +++ b/.kimi-code/hooks/scripts/subagent-quality-check.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# [2026-03-03][refactor] +# 背景: hook-libraryコンポーネント化。薄いラッパーでlib/の共通ロジックを呼び出す。 +# 対応: SubagentStop → lib/quality-check-common.sh の run_quality_check_hook を呼出。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/quality-check-common.sh" + +run_quality_check_hook \ + "subagent-quality-check" \ + "$SCRIPT_DIR/.." \ + "No file changes detected - research/planning agent, skipping quality check." \ + "false" diff --git a/.kimi-code/hooks/scripts/takeover-preflight.sh b/.kimi-code/hooks/scripts/takeover-preflight.sh new file mode 100755 index 000000000..0a3cc6160 --- /dev/null +++ b/.kimi-code/hooks/scripts/takeover-preflight.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Compatibility wrapper. Handover is the canonical preflight name. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec bash "$SCRIPT_DIR/handover-preflight.sh" diff --git a/.kimi-code/hooks/scripts/takeover-preflight.test.sh b/.kimi-code/hooks/scripts/takeover-preflight.test.sh new file mode 100755 index 000000000..b388fa494 --- /dev/null +++ b/.kimi-code/hooks/scripts/takeover-preflight.test.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +fi +HOOK="$SCRIPT_DIR/takeover-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +extract_field() { + printf "%s\n" "$1" | sed -n "s/^-[[:space:]]*$2: //p" +} + +is_agent_hub_source_repo() { + [ -f "$REPO_ROOT/DISTRIBUTION.yaml" ] && [ -f "$REPO_ROOT/hook-registry.yaml" ] +} + +assert_exact_scope() { + local output="$1" + local expected="$2" + local scope + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + [ "$scope" = "$expected" ] || fail "scope が期待値と一致しない: $output" +} + +assert_scoped_path() { + local output="$1" + local category="$2" + local scope + local expected + + scope="$(extract_field "$output" "scope")" + [ -n "$scope" ] || fail "scope が取得できない: $output" + expected="$HOME/.agent-hub/$category/$scope/current.md" + printf "%s\n" "$output" | grep -Fq "$expected" \ + || fail "$category が scope と一致しない: $output" +} + +run_hook() { + local prompt="$1" + printf '{"user_prompt": "%s"}' "$prompt" | CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +negative_output="$(run_hook "評価わんこについて。続きではなく概要を教えて")" +[ -z "$negative_output" ] || fail "否定文は無音であるべき: $negative_output" + +negative_finish_output="$(run_hook "終了整理は不要です")" +[ -z "$negative_finish_output" ] || fail "否定文は無音であるべき: $negative_finish_output" + +negative_closeout_output="$(run_hook "Closeout整理はいらない")" +[ -z "$negative_closeout_output" ] || fail "否定文は無音であるべき: $negative_closeout_output" + +negative_work_output="$(run_hook "作業終了ではないです")" +[ -z "$negative_work_output" ] || fail "否定文は無音であるべき: $negative_work_output" + +representative_prompt="作業終了。今回の内容を終了整理して。GBrain候補は僕の確認待ち、SSOTとTech G-Brainは自動判定で。未完了がある時だけTakeoverも更新して。" +representative_output="$(run_hook "$representative_prompt")" +echo "$representative_output" | grep -q "handover preflight:" \ + || fail "代表入力文で preflight が出ない: $representative_output" +echo "$representative_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "代表入力文で handover manual が出ない: $representative_output" + +closeout_word_output="$(run_hook "終了整理")" +echo "$closeout_word_output" | grep -q "handover preflight:" \ + || fail "終了整理単独で preflight が出ない: $closeout_word_output" + +closeout_compat_output="$(run_hook "Closeout整理")" +echo "$closeout_compat_output" | grep -q "handover preflight:" \ + || fail "Closeout整理で preflight が出ない: $closeout_compat_output" + +hyoka_output="$(run_hook "評価わんこの続き")" +echo "$hyoka_output" | grep -q "handover preflight:" \ + || fail "handover preflight が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q ".agent-hub/handovers/jtt-system/hyoka-wanko/current.md" \ + || fail "評価わんこの handover_path が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q ".agent-hub/takeovers/jtt-system/hyoka-wanko/current.md" \ + || fail "評価わんこの legacy_path が出ない: $hyoka_output" +echo "$hyoka_output" | grep -q "skills/handover-manual/references/handover.md" \ + || fail "handover manual が出ない: $hyoka_output" + +admin_output="$(run_hook "引継ぎ書つくって")" +assert_scoped_path "$admin_output" "handovers" + +compat_output="$(run_hook "continuation-closeout")" +echo "$compat_output" | grep -q "handover preflight:" \ + || fail "continuation-closeout 互換 trigger が出ない: $compat_output" + +force_output="$(printf '{"user_prompt": "ただの相談"}' | TAKEOVER_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$force_output" | grep -q "handover preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "alias: 未検出" || fail "FORCE時に alias 推定が出ない: $force_output" + +compat_force_output="$(printf '{"user_prompt": "ただの相談"}' | AGENT_MEMORY_PREFLIGHT_FORCE=1 CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK")" +echo "$compat_force_output" | grep -q "handover preflight:" || fail "旧AGENT_MEMORY_PREFLIGHT_FORCE 時の preflight が出ない: $compat_force_output" + +if is_agent_hub_source_repo; then + agent_hub_reflection_output="$(run_hook "ふり返りをお願い")" + assert_exact_scope "$agent_hub_reflection_output" "AGENT-HUB/root" + assert_scoped_path "$agent_hub_reflection_output" "handovers" +fi + +echo "PASS: takeover-preflight" diff --git a/.kimi-code/hooks/scripts/telemetry-lib.sh b/.kimi-code/hooks/scripts/telemetry-lib.sh new file mode 100755 index 000000000..163432ccf --- /dev/null +++ b/.kimi-code/hooks/scripts/telemetry-lib.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# telemetry-lib.sh — shared harness telemetry function. +# +# Provides: agent_hub_telemetry_log [meta_json] +# +# 絶対方針: fail-open。 +# - いかなるエラーでも exit 0・ブロックしない・stdout に出力しない。 +# - git / date / python3 / mkdir のいずれかが欠損・失敗しても黙って return 0。 +# - AGENT_HUB_TELEMETRY_DISABLE=1 で完全無効化(何もしない)。 +# - 外部ネットワーク不使用。ローカル JSONL 追記のみ。 +# +# 他 hook からの読み込み(配布先で lib が無くても壊さない no-op fallback): +# . "$(dirname "$0")/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } +# +# 出力先: ${AGENT_HUB_TELEMETRY_DIR:-$HOME/.agent-hub/telemetry}/YYYY-MM-DD.jsonl +# レコード: {"ts","tool","pj","event_type","name","outcome","meta"} + +# 注意: 本ファイルは他 hook から `source` されるため set -e を使わない。 +# 呼び出し元(block-main-commit.sh 等)が set -euo pipefail を設定済みの場合、 +# ここでの未定義変数や失敗コマンドは親の set -e で source 全体を中断しうる。 +# そのため全ての変数参照は ${VAR:-} 形式とし、外部コマンドは || true で包む。 + +agent_hub_telemetry_log() { + # fail-open: 無効化フック + [ "${AGENT_HUB_TELEMETRY_DISABLE:-0}" = "1" ] && return 0 + + local event_type="${1:-}" + local name="${2:-}" + local outcome="${3:-}" + local meta_json="${4:-}" + + # 引数不足でも黙って返す(ブロックしない) + [ -z "$event_type" ] && return 0 + + # 出力ディレクトリ解決(環境変数で上書き可。テスト用) + local base_dir="${AGENT_HUB_TELEMETRY_DIR:-${HOME:-}/.agent-hub/telemetry}" + local date_str + date_str="$(date +%Y-%m-%d 2>/dev/null || echo unknown)" + [ -z "$date_str" ] && date_str="unknown" + local out_file="$base_dir/$date_str.jsonl" + + # ディレクトリ作成(失敗は無視 → 後段の追記も失敗して return 0 に至る) + [ -d "$base_dir" ] || mkdir -p "$base_dir" 2>/dev/null || true + + # pj 解決(優先順: 環境変数 > CLAUDE_PROJECT_DIR > git root basename > PWD basename) + local pj="${AGENT_HUB_TELEMETRY_PJ:-}" + if [ -z "$pj" ]; then + if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then + pj="${CLAUDE_PROJECT_DIR##*/}" + else + local git_root="" + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ]; then + pj="${git_root##*/}" + else + pj="${PWD##*/}" + fi + fi + fi + [ -z "$pj" ] && pj="unknown" + + # ISO8601 UTC タイムスタンプ + local ts + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" + + # [2026-07-07][feat] harness phaseB: telemetry tool名を T_TOOL 由来で上書き可にする + local tool="${T_TOOL:-claude-code}" + + # JSON 1 行を組み立てて追記(python3 で値を escape-safe に)。 + # python3 が無い環境では純 bash で最小エスケープして追記する(fail-open)。 + if command -v python3 >/dev/null 2>&1; then + T_EVENT="$event_type" \ + T_NAME="$name" \ + T_OUTCOME="$outcome" \ + T_PJ="$pj" \ + T_TOOL="$tool" \ + T_TS="$ts" \ + T_META="$meta_json" \ + T_OUT="$out_file" \ + python3 - <<'PY' 2>/dev/null || true +import json +import os + + +def as_str(value: str) -> str: + return value if isinstance(value, str) else "" + + +meta_raw = os.environ.get("T_META", "") +meta_value = {} +if meta_raw: + try: + decoded = json.loads(meta_raw) + if isinstance(decoded, dict): + meta_value = decoded + else: + meta_value = {"value": decoded} + except Exception: + # JSON でなければ文字列として保持(破損させない) + meta_value = {"raw": meta_raw} + +record = { + "ts": as_str(os.environ.get("T_TS", "")), + "tool": as_str(os.environ.get("T_TOOL", "claude-code")), + "pj": as_str(os.environ.get("T_PJ", "")), + "event_type": as_str(os.environ.get("T_EVENT", "")), + "name": as_str(os.environ.get("T_NAME", "")), + "outcome": as_str(os.environ.get("T_OUTCOME", "")), + "meta": meta_value, +} + +out_path = os.environ.get("T_OUT", "") +if not out_path: + raise SystemExit(0) + +try: + with open(out_path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") +except Exception: + pass +PY + else + # python3 無し: JSON パーサ/シリアライザが無いため meta_json を構造化オブジェクトとして + # 安全に組み込めない。 + # [2026-07-04][fix] Codexレビュー対応(PR #670 🟡1): + # 背景: 旧実装は meta_json(呼び出し元が渡す生JSON片、例 {"label":"foo"})に対して + # 文字列用の _telemetry_escape をそのまま適用したうえで "meta":%s (無クォート)へ + # 埋め込んでいた。meta_json 内にダブルクォート/バックスラッシュが含まれると + # エスケープと JSON 構造が二重に競合し、不正な JSONL 行になり得た。 + # 守るべき業務ルール: telemetry は fail-open かつ JSONL を絶対に壊さない。 + # 他案不採用理由: meta_json を素朴な文字列置換で「JSON オブジェクトとして」再構築する案は、 + # ネスト・エスケープの全パターンを網羅できずシェルだけでの安全な JSON 生成は非現実的なため不採用。 + # 対応: python3 無し環境では meta は常に空オブジェクト{}に固定し、元データは + # meta_raw に「文字列値」として安全にエスケープして退避する(構造は壊さず、情報も欠落させない)。 + _telemetry_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/ }" + s="${s//$'\r'/ }" + s="${s//$'\t'/ }" + printf '%s' "$s" + } + if [ -n "$meta_json" ]; then + printf '{"ts":"%s","tool":"%s","pj":"%s","event_type":"%s","name":"%s","outcome":"%s","meta":{},"meta_raw":"%s"}\n' \ + "$(_telemetry_escape "$ts")" \ + "$(_telemetry_escape "$tool")" \ + "$(_telemetry_escape "$pj")" \ + "$(_telemetry_escape "$event_type")" \ + "$(_telemetry_escape "$name")" \ + "$(_telemetry_escape "$outcome")" \ + "$(_telemetry_escape "$meta_json")" \ + >> "$out_file" 2>/dev/null || true + else + printf '{"ts":"%s","tool":"%s","pj":"%s","event_type":"%s","name":"%s","outcome":"%s","meta":{}}\n' \ + "$(_telemetry_escape "$ts")" \ + "$(_telemetry_escape "$tool")" \ + "$(_telemetry_escape "$pj")" \ + "$(_telemetry_escape "$event_type")" \ + "$(_telemetry_escape "$name")" \ + "$(_telemetry_escape "$outcome")" \ + >> "$out_file" 2>/dev/null || true + fi + fi + + return 0 +} diff --git a/.kimi-code/hooks/scripts/telemetry-log.sh b/.kimi-code/hooks/scripts/telemetry-log.sh new file mode 100755 index 000000000..f154df33a --- /dev/null +++ b/.kimi-code/hooks/scripts/telemetry-log.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# telemetry-log.sh — Claude Code hook entry for harness telemetry. +# +# Claude Code の PreToolUse / PostToolUse / SessionStart / Stop / SubagentStop で呼ばれる。 +# stdin の hook JSON を読み、ツール名/イベントから event_type を判定して 1 行 JSON を追記する。 +# +# 絶対方針: fail-open。 +# - いかなる入力・エラーでも exit 0・ブロックしない・stdout には何も出さない +# (PreToolUse で空 stdout = 許可継続。テレメトリが原因でツールを止めない)。 +# - AGENT_HUB_TELEMETRY_DISABLE=1 で完全無効化。 +# - 外部ネットワーク不使用。 +# +# event_type マッピング: +# tool_name=Skill → skill_fire, name=スキル名 +# tool_name=Task/Agent → subagent_start, name=subagent_type +# hook_event=SessionStart → session_start +# hook_event=Stop → session_stop +# hook_event=SubagentStop → subagent_stop +# その他の tool_name 付きツール → tool_use, name=tool_name +# (event/tool ともに取れない場合は記録しない) + +# set -e を使わない(fail-open 優先)。 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# 共有関数を読み込み。lib が無い配布先でも壊れない no-op fallback。 +. "$SCRIPT_DIR/telemetry-lib.sh" 2>/dev/null || agent_hub_telemetry_log(){ :; } + +# stdin を1回だけ読む(hook JSON)。 +input="$(cat 2>/dev/null || true)" + +# 無効化フックはここでも早抜け(呼び出しコストを避ける)。 +if [ "${AGENT_HUB_TELEMETRY_DISABLE:-0}" = "1" ]; then + exit 0 +fi + +# hook JSON を解析して event_type / name / outcome を決定し、lib 関数へ渡す。 +# python3 が読めれば解析、なければ何もしない(fail-open)。 +parsed="$(HOOK_INPUT="$input" python3 - <<'PY' 2>/dev/null || true +import json +import os +import sys + + +def get_string(data, *keys): + for key in keys: + value = data.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def get_tool_input(data): + ti = data.get("tool_input") + if isinstance(ti, dict): + return ti + ti = data.get("toolInput") + if isinstance(ti, dict): + return ti + return {} + + +raw = os.environ.get("HOOK_INPUT", "") +try: + data = json.loads(raw) if raw else {} +except Exception: + data = {} + +if not isinstance(data, dict): + data = {} + +hook_event = get_string(data, "hook_event_name", "hookEventName") +tool_name = get_string(data, "tool_name", "toolName") +agent_type = get_string(data, "agent_type", "agentType") +ti = get_tool_input(data) + +event_type = "" +name = "" + +if hook_event == "SessionStart": + event_type = "session_start" + name = "session" +elif hook_event == "Stop": + event_type = "session_stop" + name = "session" +elif hook_event == "SubagentStop": + # [2026-07-04][fix] Codexレビュー対応(PR #670 🟡2): + # 背景: ファイル冒頭コメントは SubagentStop でも呼ばれる前提だったが、 + # tool_name を伴わない SubagentStop はどの分岐にも一致せず event_type が + # 空のまま記録漏れ(サブエージェント終了が観測されない)になっていた。 + # 対応: SubagentStop を明示的に subagent_stop として記録する。 + event_type = "subagent_stop" + name = "subagent" +elif tool_name == "Skill": + event_type = "skill_fire" + # [2026-07-23][fix] + # 背景: 現行runtimeが tool_input.skill へ変わり、旧nameだけでは空観測になった。 + # 守る契約: skillを正本として読み、旧name/skill_nameは互換入力として維持する。 + # 他案不採用: 旧キー専用へ戻すと現行payloadを再び欠損させるため採らない。 + name = get_string(ti, "skill", "name", "skill_name") +elif tool_name in ("Task", "Agent"): + event_type = "subagent_start" + name = get_string(ti, "subagent_type", "subtype") or agent_type +elif tool_name: + event_type = "tool_use" + name = tool_name + +if not event_type: + # 記録対象が無い → 何も出力しない + sys.exit(0) + +# タブ区切りで shell へ返す(name にタブが含まれる可能性は低いが、念のため除去) +name = name.replace("\t", " ").replace("\n", " ") +print("\t".join([event_type, name, "ok"])) +PY +)" + +# python3 が何も返さなければテレメトリ追記しない(fail-open)。 +if [ -n "$parsed" ]; then + event_type="${parsed%%$'\t'*}" + rest="${parsed#*$'\t'}" + name="${rest%%$'\t'*}" + outcome="${rest#*$'\t'}" + agent_hub_telemetry_log "$event_type" "$name" "$outcome" 2>/dev/null || true +fi + +exit 0 diff --git a/.kimi-code/hooks/scripts/telemetry-log.test.sh b/.kimi-code/hooks/scripts/telemetry-log.test.sh new file mode 100755 index 000000000..e1afcb38e --- /dev/null +++ b/.kimi-code/hooks/scripts/telemetry-log.test.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# telemetry-log.test.sh — telemetry-log.sh のフックエントリ専用回帰テスト。 +# +# 背景(jtt-apps PR #964 の Codex レビュー起点): +# telemetry-log.sh / telemetry-lib.sh 自体の網羅テストは scripts/test-telemetry-hook.sh +# (AGENT-HUB 自身の CI・.github/workflows/ci.yml「Hook integration tests」で実行)が担う。 +# 一方、hook-library/scripts/*.test.sh は「配布先 PJ に script_map 経由で同梱し、配布後の +# hook 単体を再検証できる」サイドカーの規約(block-main-commit.test.sh 等と同型)。 +# telemetry-log だけこのサイドカーが無く、配布先で telemetry-log.sh 単体の動作を +# 再確認する手段が欠けていたため新設する。 +# +# 検証内容(3点。scripts/test-telemetry-hook.sh の該当項目のサブセット): +# 1. Skill ツールの hook JSON を stdin に与えると JSONL が1行増える +# 2. AGENT_HUB_TELEMETRY_DISABLE=1 で何も書かず exit 0 +# 3. 壊れた JSON 入力でも exit 0(fail-open) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/telemetry-log.sh" + +PASS=0 +FAIL=0 + +pass() { printf '[PASS] %s\n' "$1"; PASS=$((PASS + 1)); } +fail() { printf '[FAIL] %s: %s\n' "$1" "$2" >&2; FAIL=$((FAIL + 1)); } + +# telemetry-lib.sh の出力先は AGENT_HUB_TELEMETRY_DIR で上書き可能(テスト用)。 +# 本物の ~/.agent-hub/telemetry/ を汚さないよう一時ディレクトリへ差し替える。 +TEST_TMP="$(mktemp -d)" +trap 'rm -rf "$TEST_TMP"' EXIT +export AGENT_HUB_TELEMETRY_DIR="$TEST_TMP/telemetry" +unset AGENT_HUB_TELEMETRY_DISABLE || true +unset AGENT_HUB_TELEMETRY_PJ || true + +count_lines() { + local files + files="$(ls -1 "$AGENT_HUB_TELEMETRY_DIR"/*.jsonl 2>/dev/null || true)" + if [ -z "$files" ]; then + echo 0 + return + fi + cat $files 2>/dev/null | wc -l | tr -d '[:space:]' +} + +last_line() { + local files + files="$(ls -1 "$AGENT_HUB_TELEMETRY_DIR"/*.jsonl 2>/dev/null || true)" + if [ -z "$files" ]; then + echo "" + return + fi + cat $files 2>/dev/null | tail -n1 +} + +# ── 1/3: Skill ツールの hook JSON → JSONL が1行増える ──────────────── +BEFORE=$(count_lines) +printf '{"hook_event_name":"PreToolUse","tool_name":"Skill","tool_input":{"name":"plan-approval"}}' \ + | bash "$HOOK" 2>/dev/null +AFTER=$(count_lines) +if [ "$AFTER" -gt "$BEFORE" ]; then + LAST_LINE="$(last_line)" + if printf '%s' "$LAST_LINE" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["event_type"]=="skill_fire" and d["name"]=="plan-approval"' 2>/dev/null; then + pass "Skill発火のhook JSONでJSONLが1行増える" + else + fail "Skill発火のJSONL内容" "想定外の内容: $LAST_LINE" + fi +else + fail "Skill発火でJSONLが増える" "行数が増えなかった(before=$BEFORE after=$AFTER)" +fi + +# ── 2/3: AGENT_HUB_TELEMETRY_DISABLE=1 で何も書かず exit 0 ─────────── +BEFORE=$(count_lines) +DISABLE_OUT="$(printf '{"hook_event_name":"PreToolUse","tool_name":"Skill","tool_input":{"name":"nope"}}' \ + | AGENT_HUB_TELEMETRY_DISABLE=1 bash "$HOOK" 2>/dev/null; echo "rc=$?")" +AFTER=$(count_lines) +if [ "$BEFORE" = "$AFTER" ] && printf '%s' "$DISABLE_OUT" | grep -q 'rc=0'; then + pass "AGENT_HUB_TELEMETRY_DISABLE=1で何も書かずexit 0" +else + fail "AGENT_HUB_TELEMETRY_DISABLE=1" "行数変化(before=$BEFORE after=$AFTER) または非0終了: $DISABLE_OUT" +fi + +# ── 3/3: 壊れた JSON でも exit 0(fail-open) ─────────────────────────── +BROKEN_OUT="$(printf 'not json at all {{{' | bash "$HOOK" 2>/dev/null; echo "rc=$?")" +if printf '%s' "$BROKEN_OUT" | grep -q 'rc=0'; then + pass "壊れたJSON入力でもexit 0(fail-open)" +else + fail "壊れたJSON入力" "exit 0 にならなかった: $BROKEN_OUT" +fi + +# 空 stdin も fail-open で exit 0 であることも併せて確認(壊れたJSON系の代表的な派生形)。 +EMPTY_OUT="$(printf '' | bash "$HOOK" 2>/dev/null; echo "rc=$?")" +if printf '%s' "$EMPTY_OUT" | grep -q 'rc=0'; then + pass "空stdinでもexit 0(fail-open)" +else + fail "空stdin" "exit 0 にならなかった: $EMPTY_OUT" +fi + +echo "" +echo "=== telemetry-log.test.sh: $PASS passed, $FAIL failed ===" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/.kimi-code/mcp.json b/.kimi-code/mcp.json new file mode 100644 index 000000000..ca0e72311 --- /dev/null +++ b/.kimi-code/mcp.json @@ -0,0 +1,55 @@ +{ + "mcpServers": { + "agentmemory-agentmemory": { + "command": "/bin/bash", + "args": [ + "/Users/shintaro/business/AGENT-HUB/scripts/agentmemory-mcp-remote.sh", + "agentmemory" + ] + }, + "ai-worker-mcp": { + "command": "/Users/shintaro/business/AGENT-HUB/tools/ai-worker-mcp/bin/ai-worker-mcp" + }, + "codebase-context-engine-agentmemory": { + "command": "/bin/bash", + "args": [ + "/Users/shintaro/business/AGENT-HUB/scripts/codex-mcp-remote-with-env.sh", + "http://shintaros-mac-mini:8847/mcp", + "CODEBASE_CONTEXT_ENGINE_MCP_API_KEY" + ] + }, + "context7": { + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp@3.2.0" + ], + "env": { + "npm_config_cache": "${HOME}/.kimi-code/npm-cache" + } + }, + "shintaro-gbrain": { + "command": "/bin/bash", + "args": [ + "/Users/shintaro/business/AGENT-HUB/scripts/mcp-remote-oauth.sh", + "https://gbrain-mcp.jtt.cafe/mcp", + "shintaro-gbrain" + ] + }, + "stitch": { + "url": "https://stitch.googleapis.com/mcp", + "type": "http", + "headers": { + "X-Goog-Api-Key": "${env:STITCH_API_KEY}" + } + }, + "tech-gbrain": { + "command": "/bin/bash", + "args": [ + "/Users/shintaro/business/AGENT-HUB/scripts/mcp-remote-oauth.sh", + "https://gbrain-mcp.jtt.cafe/mcp", + "tech-gbrain" + ] + } + } +} diff --git a/.kimi-code/sync-state.json b/.kimi-code/sync-state.json new file mode 100644 index 000000000..a22d2f689 --- /dev/null +++ b/.kimi-code/sync-state.json @@ -0,0 +1,51 @@ +{ + "tool": "kimi-sync", + "target": "kimi-code-cli", + "generated_at": "2026-08-03T15:02:31.557712+00:00", + "source_commit": "b135841", + "project_root": ".", + "project_agents_md": ".kimi-code/AGENTS.md", + "skills_link": "managed by sync-runtime-skills.py (manifest v2)", + "copied_hooks": [ + ".hook-library-version", + "lib/code-quality-check.md", + "lib/hook-io.sh", + "lib/quality-check-common.sh", + "lib/storage-url-common.py", + "scripts/block-destructive-git.sh", + "scripts/block-destructive-git.test.sh", + "scripts/block-main-commit.sh", + "scripts/block-main-commit.test.sh", + "scripts/block-skill-reverse-edit.sh", + "scripts/block-skill-reverse-edit.test.sh", + "scripts/block-unauthorized-docs-file.sh", + "scripts/block-unauthorized-docs-file.test.sh", + "scripts/freshness-gate.sh", + "scripts/handover-preflight.sh", + "scripts/handover-preflight.test.sh", + "scripts/post-merge-gate.sh", + "scripts/post-merge-gate.test.sh", + "scripts/pre-implementation-check.sh", + "scripts/stop-quality-check.sh", + "scripts/storage-url-pr-gate.sh", + "scripts/subagent-quality-check.sh", + "scripts/takeover-preflight.sh", + "scripts/takeover-preflight.test.sh", + "scripts/telemetry-lib.sh", + "scripts/telemetry-log.sh", + "scripts/telemetry-log.test.sh" + ], + "hook_library_version": "v3.6.37", + "hook_count": 11, + "mcp_server_count": 7, + "user_config_path": "${KIMI_CODE_HOME:-~/.kimi-code}/config.toml", + "trusted_projects_path": "${KIMI_CODE_HOME:-~/.kimi-code}/trusted-projects.json", + "default_thinking": true, + "machine_scope": "not-run", + "legacy_kimi_dir_cleanup": "separate-proof-gated-migration-only", + "warnings": [ + "SessionStart は観測系 event のため Kimi では重い hook を生成しません", + "SubagentStop は観測系 event のため Kimi では重い hook を生成しません", + "PostToolUse は観測系 event のため Kimi では重い hook を生成しません: telemetry-log.sh" + ] +} diff --git a/.opencode/plugins/runtime-sync.js b/.opencode/plugins/runtime-sync.js new file mode 100644 index 000000000..d60bb17bf --- /dev/null +++ b/.opencode/plugins/runtime-sync.js @@ -0,0 +1,204 @@ +// AUTO-GENERATED by sync-opencode-from-cc.py +// Claude Code 正本の hook 方針を OpenCode plugin へ最小変換した派生物。 +import { spawnSync } from "node:child_process" +import { existsSync } from "node:fs" + +const PROJECT_ROOT = process.cwd() +const RUNTIME_SPEC = { + "before": { + "block_main_commit": true, + "block_destructive_git": true, + "pre_pr_check_on_git_push": false + }, + "after": { + "write_edit": { + "auto_catalog": false, + "check_frontmatter": false, + "validate_skills": false, + "validate_ssot": false, + "validate_prompt_ssot": false + } + } +} + +function parseHookDeny(stdout) { + if (!stdout) return null + try { + const payload = JSON.parse(stdout) + const hs = payload && payload.hookSpecificOutput + if (hs && hs.permissionDecision === "deny") { + return String(hs.permissionDecisionReason || hs.reason || "blocked by Claude-compatible hook") + } + } catch (e) {} + return null +} + +function runProcess(command, args = [], { + cwd = PROJECT_ROOT, + env = {}, + stdin = "", + blockOnError = false, +} = {}) { + const result = spawnSync(command, args, { + cwd, + env: { ...process.env, ...env }, + encoding: "utf-8", + input: stdin, + }) + + if (blockOnError) { + const deny = parseHookDeny(result.stdout) + if (deny) throw new Error(deny) + } + + if (result.status === 0) { + return + } + + const message = + [result.stdout, result.stderr].filter(Boolean).join("\n").trim() || + `command failed: ${command} ${args.join(" ")}` + if (blockOnError) { + throw new Error(message) + } + console.warn(message) +} + +function buildHookPayload(command, cwd) { + return JSON.stringify({ + tool_input: { + command, + cwd, + }, + }) +} + +function resolveHookScript(name) { + const candidates = [ + `${PROJECT_ROOT}/.claude/hooks/scripts/${name}`, + `${PROJECT_ROOT}/hook-library/scripts/${name}`, + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + return candidates[0] +} + +function extractPathsFromPatch(patchText) { + const paths = new Set() + for (const line of String(patchText || "").split("\n")) { + const match = line.match(/^\*\*\* (?:Add File|Update File|Delete File): (.+)$/) + if (match) { + paths.add(match[1]) + } + } + return [...paths] +} + +function validatePath(filePath) { + if (!filePath) return + const normalizedPath = String(filePath) + + if (RUNTIME_SPEC.after.write_edit.auto_catalog) { + runProcess("bash", [`${PROJECT_ROOT}/scripts/auto-catalog-trigger.sh`], { cwd: PROJECT_ROOT }) + } + + if (RUNTIME_SPEC.after.write_edit.check_frontmatter && /skills\/.*SKILL\.md$/.test(normalizedPath)) { + runProcess( + "python3", + [`${PROJECT_ROOT}/scripts/check_frontmatter.py`, "--warn", normalizedPath], + { + cwd: PROJECT_ROOT, + env: { CLAUDE_FILE_PATH: normalizedPath }, + }, + ) + } + + if (RUNTIME_SPEC.after.write_edit.validate_skills && /skills\/.*SKILL\.md$/.test(normalizedPath)) { + runProcess( + "bash", + [`${PROJECT_ROOT}/scripts/validate-skills.sh`, "--warn", normalizedPath], + { + cwd: PROJECT_ROOT, + env: { CLAUDE_FILE_PATH: normalizedPath }, + }, + ) + } + + if ( + RUNTIME_SPEC.after.write_edit.validate_ssot && + /(DISTRIBUTION\.yaml|project-registry\.yaml|hook-registry\.yaml)$/.test(normalizedPath) + ) { + runProcess("bash", [`${PROJECT_ROOT}/scripts/validate-ssot-consistency.sh`], { cwd: PROJECT_ROOT }) + } + + if (RUNTIME_SPEC.after.write_edit.validate_prompt_ssot && /snippet-prompts\/.*\.md$/.test(normalizedPath)) { + runProcess( + "bash", + [`${PROJECT_ROOT}/scripts/validate-prompt-ssot-consistency.sh`, "--warn", normalizedPath], + { + cwd: PROJECT_ROOT, + env: { CLAUDE_FILE_PATH: normalizedPath }, + }, + ) + } +} + +export const RuntimeSyncPlugin = async () => { + return { + "tool.execute.before": async (input, output) => { + if (input.tool !== "bash") return + + const command = String(output.args?.command || "") + const cwd = String(output.args?.cwd || PROJECT_ROOT) + + if (RUNTIME_SPEC.before.block_main_commit) { + runProcess( + "bash", + [resolveHookScript("block-main-commit.sh")], + { + cwd: PROJECT_ROOT, + stdin: buildHookPayload(command, cwd), + blockOnError: true, + }, + ) + } + + if (RUNTIME_SPEC.before.block_destructive_git) { + runProcess( + "bash", + [resolveHookScript("block-destructive-git.sh")], + { + cwd: PROJECT_ROOT, + stdin: buildHookPayload(command, cwd), + blockOnError: true, + }, + ) + } + + if (RUNTIME_SPEC.before.pre_pr_check_on_git_push && command.includes("git push")) { + runProcess( + "bash", + [`${PROJECT_ROOT}/scripts/pre-pr-check.sh`], + { + cwd: PROJECT_ROOT, + blockOnError: true, + }, + ) + } + }, + + "tool.execute.after": async (input, output) => { + if (input.tool === "write" || input.tool === "edit") { + validatePath(output.args?.filePath || output.args?.path) + return + } + + if (input.tool === "apply_patch") { + for (const filePath of extractPathsFromPatch(output.args?.patchText || output.patchText)) { + validatePath(filePath) + } + } + }, + } +} diff --git a/.opencode/sync-state.json b/.opencode/sync-state.json new file mode 100644 index 000000000..dc3efaaa0 --- /dev/null +++ b/.opencode/sync-state.json @@ -0,0 +1,46 @@ +{ + "tool": "opencode", + "generated_at": "2026-08-03T23:55:39.500992+09:00", + "source_commit": "c801ec1", + "project_root": "/Users/shintaro/.codex/worktrees/agentmemory-wave4-agentmemory", + "mcp_source": { + "registry": "/Users/shintaro/business/AGENT-HUB/docs/codex-mcp-registry.yaml", + "definitions": "/Users/shintaro/business/AGENT-HUB/docs/codex-mcp-definitions.yaml", + "project": "agentmemory", + "enabled_mcp": [ + "agentmemory-agentmemory", + "ai-worker-mcp", + "codebase-context-engine-agentmemory", + "context7", + "shintaro-gbrain", + "stitch", + "tech-gbrain" + ], + "optional_mcp": [] + }, + "copied_agents": [ + "backend-architect.md", + "backend-developer.md", + "chatgpt-image-creator.md", + "document-writer.md", + "frontend-developer.md", + "implementation-auditor.md", + "qa-reviewer.md", + "quality-engineer.md", + "stitch-screen-creator.md", + "technical-writer.md", + "test-runner.md" + ], + "warnings": [ + "PreToolUse command 未対応のため未変換: bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/scripts/storage-url-pr-gate.sh\"", + "PreToolUse matcher=Write|Edit|MultiEdit は OpenCode plugin へ未変換", + "PreToolUse matcher=Bash|Edit|MultiEdit|Shell|StrReplaceFile|Write|WriteFile は OpenCode plugin へ未変換", + "PreToolUse command 未対応のため未変換: bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/scripts/post-merge-gate.sh\"", + "PreToolUse matcher=Skill|Task|Agent は OpenCode plugin へ未変換", + "SessionStart は OpenCode plugin へ未変換のため対象外", + "UserPromptSubmit は OpenCode plugin へ未変換のため対象外", + "SubagentStop は OpenCode plugin へ未変換のため対象外", + "Stop は OpenCode plugin へ未変換のため対象外", + "PostToolUse matcher=Skill|Task|Agent は OpenCode plugin へ未変換" + ] +} diff --git a/AGENTS.md b/AGENTS.md index 9a5a55467..c3cc512a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,35 @@ + +# AgentMemory + +AgentMemory is the project-scoped MCP memory service used by the JTT agent +harness. Keep the TypeScript MCP server, its tests, and the Mac mini runtime +configuration consistent when changing this repository. + +Read `AGENTS.md` for architecture and change-surface requirements before +editing implementation code. Generated agent-harness files are managed by +AGENT-HUB; do not hand-edit them. + + +## Harness inheritance (generated) + +This block is generated by `scripts/sync-project-constitution.py`. +Project-specific rules outside this block remain authoritative. +Harness asset selection is owned only by `registries/harness-manifest.yaml`; +older text that calls `DISTRIBUTION.yaml` a skill/MCP/hook selection SSOT is superseded. + +- canonical project: `agentmemory` +- harness type: `mcp-server` +- harness type chain: `dev -> mcp-server` +- effective hash: `80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f` +- constitution assets: + - `agents-md` (selected_by=`global`, inheritance_id=`cebc562da0384df8`) + - `claude-md` (selected_by=`global`, inheritance_id=`5da8780b1008377e`) + - `gbrain-md` (selected_by=`global`, inheritance_id=`de26219fcac2c60c`) + - `gemini-md` (selected_by=`global`, inheritance_id=`d2fe0f94dbdf9274`) + + +## Project-specific instructions + # agentmemory — Agent Instructions ## Architecture @@ -122,3 +154,1272 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). - 12 hooks, 15 skills - 260+ iii functions - 1,428+ tests + +## 詳細ルール +全ルール統合版は本ファイル(AGENTS.md)に集約。編集元は PJ の `CLAUDE.md` + AGENT-HUB manifest v2 が選ぶ canonical rules とし、手動編集ではなく再生成で同期する。 + + + +# AI モデル選定指標(GLM 5.2 / Kimi K2.7・K3) + +全 PJ 共通。コード実装をAIエージェントに任せる際の初期ヒューリスティック。 + +> ⚠️ これは**法則ではなく初期判断**。母数が小さい(初期 n=4 + 追加観測・人が見ながら実行)。矛盾する観測が出たら現状を優先し、実測ログ(references)を更新すること。 + +--- + +## 0-bis. Codex 指名時の固定ルール + +- **`codexで実装` / `Codexで実装` / `codex実装` / `Codex実装`** と言われた時だけ、Codex 実装として扱う。 +- Codex 実装の正式設定名は **model = `gpt-5.3-codex-spark`**, **model_reasoning_effort = `high`**(既定。旧既定 `medium`。SWE-Bench Pro 実測で high→xhigh の上げ幅は1pt未満のため常時 xhigh は費用対効果が低い)。 +- 起動例は `codex exec -m gpt-5.3-codex-spark -c model_reasoning_effort=high`。 +- **`xhigh` はユーザーが明示指定した時だけ使う**。軽微タスクは `medium` を明示指定する。AI が自動・既定・推測で `xhigh` を選ばない。 +- **「実装」だけでは Codex 固定にしない**。Cursor / Kimi / GLM / Claude / Codex のどれで進めるかを文脈で判断し、不明なら確認する。 +- **Spark は AI Worker MCP の auto routing 候補に対等参加する**(適材適所+残量バランス・絶対優先ではない)。原因不明バグ・設計判断・DB移行・大規模リファクタ・コンテキストが大きい仕事は Spark に固執せず、auto が適材適所で他 worker(GLM/Kimi/Gemini)へ回避する。 +- **「レビュー」または「codexでレビュー」** は既存の `codex-review` 導線を使う。実装専用の `gpt-5.3-codex-spark` 固定には巻き込まない。 + +--- + +## 4. 使い分けガイド(第一候補) + +| タスク種別 | 第一候補 | 理由 | +|-----------|---------|------| +| 仕様が明確・クリーンさ重視・UI/結線・お手本コード | **GLM 5.2** | 簡潔・範囲内に収まりやすい・速い | +| 複雑・セキュリティ/堅牢性が重要なバックエンド | **Kimi K2.7 Code** | 安全性を自力で深掘り・テスト厚い | +| どちらでも可 | いずれか | ただし下記ガードを必ず付ける | + +### Kimi 内モデル選択(決定論的) + +`agents.yaml.worker_delegation.kimi_model_routing` を正本とし、優先順は、明示 `provider_model` → 長大/推定不能な巨大contextの `k3` → 明示的な速度優先かつ3倍quota許容時の `kimi-for-coding-highspeed` → 通常の `kimi-for-coding` とする。 + +- K3条件: `requires_long_context=true`、推定contextが212,992 token超、または推定不能かつraw UTF-8が512KiB超。`max`、上限1,048,576 token。 +- 選定結果: `reason_code` / `selected_model` / `estimated_context` / `fallback_reason` を必ず残す。 +- K3切替: 新sessionを開始し、必要情報の要約だけを渡す。履歴を丸ごと移送しない。 + +GLM 5.2 の正式運用は high / max のみ(デフォルト high・他の値はルーティングのバリデーションで拒否される)。母数は n=4 の初期観測であり法則ではない(冒頭⚠️参照)。 + +--- + +## 5. 運用上の必須ガード(モデルの弱点を相殺する) + +- **完了の定義を検証可能に**(Kimi の過大申告対策): 「スクショは git にコミット」「テストは緑のログを示す」等、"やったと言うだけ"を許さない。 +- **スコープを超えるなを明示**(Kimi の過剰実装対策): 「指定範囲のみ。追加の堅牢化は別 PR」。 +- **長時間タスクは声がけ / 自動継続**(GLM の停滞対策)。 +- **リポの前提を渡す**(GLM の取り違え対策): 言語・パッケージ管理の前提を明記。 +- **既存 CaD コメント規約に倣わせる**: 新規関数・ブロック追加時は対象ファイルの既存様式(日付・種別・背景3点)に倣うと明記する。 + +--- + +## 6. 候補提案とディスパッチ + +実装委譲・並列実装の話題が出たら §4 を根拠に「GLM 5.2 向き / Kimi向き」を 1 行理由つきで先に提案し、Kimi内のK2.7/K3は上記契約で選ぶ。ディスパッチ実行は `agent-dispatch` スキルへ(未導入環境では §4・§5 のみ使う)。役割分担: 方針選定・委譲・進捗確認・結果回収 = Claude / Codex。実行は `agents.yaml` の有効 provider だけを AI Worker MCP 経由で行う。プロンプトには §5 の必須ガードを必ず織り込む。 + +詳細手順は `skills/agent-dispatch/` を参照(本ルールは方針、skill は手順=DRY)。 + +--- + +## 8. 関連 + +- `skills/agent-dispatch/` — `agents.yaml` と AI Worker MCP を使う worker 委譲手順(本ルールの実行系) +- `skills/kimi-sync/` — Kimi CLI のPJアタッチ(`sync-kimi-from-cc.py`) +- `.claude/rules/general/response-style.md` — 出力簡潔性 +- `.claude/rules/general/visual-progress-map.md` — 進捗可視化 +- `dotfiles/kimi/config.toml.base` — Kimi Code CLI の loop/permission 既定(`max_steps_per_turn` 等) +- 実測ログ・スコアカード・OpenCode Go 選定指標の全文: `/skills/agent-dispatch/references/model-selection-evidence.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: 実測ログ・スコアカードは references(上記)へ追記し、本ルールには足さない(再肥大化防止)。** + + + +# ブランチ運用ルール + +## main ブランチへの直接コミット・プッシュ + +AI エージェントの通常作業では、**main ブランチへの直接コミット・プッシュは禁止**。 + +Markdown、`sync-state.json`、AI ツール設定、AGENT-HUB 運用設定、MCP 台帳などの軽量変更でも、 +AI は main へ直接 commit / push しない。必ず専用 worktree + feature branch を作成し、PR 経由でマージする。 + +人間が明示的に「今回は main に直接反映してよい」と承認した場合、または初回 repo 作成直後で +PR 導線がまだ存在しない場合だけ例外になりうる。AI はこの例外を自己判断で使わず、理由を作業ログに残す。 + +(過去に運用設定・hook配布物等を段階的に allowlist で main 直接許可した経緯があるが、2026-06-23〜2026-07-01 +で全撤回済み。allowlist 変遷史の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照)。 + +## 理由 + +- main checkout は複数 AI / 複数セッションで共有されやすく、軽量変更でも HEAD を掴むと競合や cleanup 失敗の原因になる +- Markdown や設定だけでも、PR にするとレビュー履歴・CI・merge 後確認・worktree cleanup が同じ型で残る +- ツールごとに例外を残すと、Claude / Codex / Cursor / Kimi / Antigravity 間で運用がずれる +- main の最新化は `git pull` ではなく、fetch-only と detached HEAD / 専用 verify worktree で確認すれば足りる + + +## AGENT-HUB の CI とマージ根拠(2026-07-30 STEP 4) + +AGENT-HUB の CI は `workflow_dispatch` + `ci/light` ラベル方式(pull_request 自動トリガーは 2026-07-24 に削除済み)。 +PR に checks が無い場合のマージ根拠は `merge-pr.py` のローカル軽量ゲート(`registries/merge-gate-suite.yaml`)。 +台帳未整備のリポでは従来どおり checks 0 件で通す(詳細: `skills/post-merge/SKILL.md`)。 + +## 配布クローズアウト責任 + +AGENT-HUB から各 PJ へ配布した差分は、配布を実行した AI / 担当者が最後まで閉じる。 + +対象: `scripts/deploy-agent-bundle.py` / `scripts/deploy-hooks.py` / `scripts/sync-agents.py` / +`scripts/bootstrap-skills.py` / `scripts/deploy-skills.py` / `scripts/deploy-rules.py` / +`/publish-deploy` など、上記を呼ぶ配布コマンド。 + +配布先 PJ に tracked 差分が出た場合は、feature branch 作成 → 配布差分だけ commit → PR 作成 → CI/review 確認 → +`merge-pr` でマージ → fetch-only + detached HEAD / verify worktree で取り込み確認 → worktree/branch cleanup → +`git status --short` clean 確認、まで一連で完了する(詳細な完了条件・禁止・例外の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` 参照)。 + +禁止: 「これは自分が修正したファイルではない」として配布差分を放置する/未コミットのまま終了する/ +main 直接 push で済ませる/`--push` の成功だけで完了扱いにする。 + +例外(dry-run のみ・差分なし・既存WIPで安全に branch できない・権限やCI failureで merge できない)の場合も、 +対象 PJ・残っている差分・止めた理由・次の安全な一手を報告する。 + +## 事前計画ステップ + +タスク開始時、変更を伴う作業か確認する(コード変更・JSON/YAML変更・`*.sh`変更・Markdown/sync-state/AIツール設定などの軽量変更)。 +AI 作業で変更がある場合、**最初に専用 worktree + feature branch を作成**してから編集を始める。AI 作業では `main` を checkout しない。 +コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +読み取りだけの場合、またはすでに専用 worktree / feature branch 内にいる場合は新規 worktree を作らなくてよい。 +AGENT-HUB から各 PJ へ配布した tracked 差分も「配布クローズアウト責任」に従う。 + +## pre-commit hook 違反後のピボット + +万一 hook(`hook-library/scripts/block-main-commit.sh`)にブロックされた場合は、変更を退避(stash/patch)→ +専用 worktree で feature branch 作成 → 変更復元 → commit/push → PR 作成、の順で復旧する。main の HEAD は +無変更のまま維持されることを確認する。詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 関連フック + +`hook-library/scripts/block-main-commit.sh` が上記ルールを自動判定・ブロックする。 + +## 関連ルール + +- `.claude/rules/general/worktree-rule.md` — 並列セッション時の worktree 利用 +- `.claude/rules/general/sub-agent-scope-contract.md` — サブエージェント delegate 時の制約 +- `~/business/AGENT-HUB/docs/worktree-operations.md` — allowlist変遷史・配布クローズアウト責任詳細・事前計画コマンド列・pre-commit hookピボット手順の正本 + +--- + +**追記ルール: 実測事例・変遷史・長文手順は `~/business/AGENT-HUB/docs/worktree-operations.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** + + + +# 建設的異議(言いなり禁止・グローバル憲法) + +## 原則 + +AI は**言いなりにならない**。ユーザー指示が現実・制約・過去の不採用判断(CaD)と衝突するとき、迎合せず次の 3 点を必ず行う。 + +1. **現実的制約の明確な指摘** — 無理なものは「無理です」と根拠付きで言う(時間・技術・運用・既存 SSOT・過去の不採用理由)。 +2. **根拠付きの代替案** — 達成したい意図を保ちつつ、実行可能な別ルートを 2〜3 択で提示する(推奨を 1 行添える)。 +3. **保守・メンテナンス観点の改善提案** — 指示に従うだけでなく、「こういう仕組みを入れるべき」と AI から先出しする(出生登録・正本参照・陳腐化防止など)。 + +**最終決定は常にユーザー**。AI は異見を述べたうえで、ユーザーが選んだ方向に従う。 + +## 発火場面 + +| フェーズ | 異議の出し方 | +|---------|-------------| +| **提案・設計** | plan-approval の HTML プランに「🤔 AI の異見」欄で記載(テンプレ側は別 PR で欄追加予定)。プラン提示前に衝突があれば先に異議を出す | +| **実装** | 着手前または実装中に制約・不採用判断との衝突を検知したら、実装を止めて代替案を提示 | +| **レビュー** | codex-review 等の指摘が個人開発スケールに過剰なときも、レビュー結果に対して異議・優先度の再整理を提案できる | + +判断に迷う場合は**異議を出す側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 作法 + +- **根拠必須**: 「良くない」だけでなく、なぜ無理か・何が起きるかを平易語で 1〜2 文。 +- **平易語 + 選択肢**: visual-progress-map §5 に従い、技術用語だけで問わない。速さ・安全・見た目への影響など、ユーザーが判断できる軸に翻訳する。 +- **推奨を添える**: 2〜3 択のうち推奨を明示(「(推奨)」+ 理由 1 行)。 +- **短い同意への再確認**: ユーザーが「お願い」「はい」だけ返したとき、次の一手を 1 文で要約してから進める(response-style と整合)。 + +## 個人開発スケールと例外 + +- **前提**: 本リポ群は個人開発(1 人・非エンジニアオーナー)。大規模チーム向けのプロセス・過度な抽象化・仮想的大規模負荷対策を**無条件で推奨しない**。 +- **過剰エンタープライズ提案への異議**: 「全 PJ に同じ監査パイプライン」「専用 infra チーム前提の運用」等は、意図が明確でない限り異議を唱える。 +- **例外(厳格維持)**: + - **(a)** セキュリティ・データ消失・金銭に関わる指摘はスケールに関係なく常に厳格。 + - **(b)** 顧客向けシステム(jtt-cms の予約・お客様導線・決済・個人情報を扱う画面/API)はエンタープライズ相当の厳格さを維持。 + +codex-review のレビュー観点にも同校正が内蔵されている(プロンプト文字列参照)。 + +## メンテナンス観点の先出し例 + +- 新スキル・hook・ドキュメントを作るとき → `checkup-registry.yaml` への出生登録を提案。 +- 手順・閾値・API 名をハードコードしそうなとき → 正本参照(ライブ読み・SSOT symlink)を提案。 +- 外部 API・ライブラリ版数を書くとき → 最終確認日の記載を提案。 + +## 関連 + +- `.claude/rules/general/response-style.md` — 出力簡潔性・確認の書き方 +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・技術判断の平易化(§5) +- `.claude/rules/general/plan-approval-gate.md` — 実装前 HTML プラン承認(🤔 AI の異見欄と接続) +- `.claude/rules/general/plan-commitment-tracking.md` — 承認済みプラン条項の実行追跡 +- `skills/adversarial-review/SKILL.md` — **本ルールの手順 SSOT**(dev / business の 2 モード・発火条件・証拠水準・自己反証・分布点検)。本ルールは義務、スキルは手順の二段構えとし、手順本文をここへ複製しない +- `skills/codex-review/SKILL.md` — レビュー時の個人開発スケール校正 + +# hooks 構造ルール + + + +## チェックリストMDの配置 + +| 正しい配置 | 禁止 | +| ----------------------------------------------------- | -------------------------- | +| `hook-library/lib/code-quality-check.md` | `hook-library/prompts/` | +| `hook-library/checklists/security/security-review-check.md` | 任意の新規サブディレクトリ | + +配布後の PJ 側でも同じ規約に従う: + +| 正しい配置 | 禁止 | +| -------------------------------------------- | -------------------------- | +| `.claude/hooks/lib/code-quality-check.md` | `.claude/hooks/prompts/` | +| `.claude/hooks/lib/security-review-check.md` | 任意の新規サブディレクトリ | + +## 禁止事項 + +- `prompts/` ディレクトリの作成・復活(AGENT-HUB / 配布先 PJ いずれも) +- `quality-check-common.sh` のチェックリスト参照パスを `lib/` 以外に変更 +- `supabase-sql-review.md` の復活(`security-review-check.md` と重複していた削除済みファイル) +- `ui-quality-gate.json` の復活(`type: "prompt"` でレビュー LLM に丸投げする方式は失敗時にプロンプト原文がチャットに漏れるため廃止。UI 品質チェックは `code-quality-check.md` の `ui-quality-jp` domain を `subagent-quality-check.sh` / `stop-quality-check.sh` がファイル参照型で reason に出す形で完結する) +- `hook-registry.yaml` の `checklist.security` に `KNOWN_SECURITY_CHECKLISTS` allow-list 外の名前を書くこと(`scripts/deploy-hooks.py` が fail-fast で拒否する) +- 対象PJを明示せずに hook を追加・配布すること。新規 hook は「必要な PJ」「不要な PJ」「Codex/Augment へ載せるか」を AGENT-HUB セッションで決めてから `hook-registry.yaml` に登録する。 +- `/hook-publish` の復活。project 配布applyは + `scripts/sync-agents.py --project --project-root ` だけを公開入口とし、物理 hook writerを単独実行しない。 + +## hook 追加・配布フロー + +1. AGENT-HUB セッションで hook の目的と対象PJを決める。 +2. `hook-library/scripts/`、`hook-library/settings/`、`scripts/deploy-hooks.py` の script map、`hook-registry.yaml` を同一PRで更新する。 +3. `scripts/sync-agents.py --project --dry-run` で全 surface の同一generation差分を確認する。 +4. 実配布が必要ならcleanな専用linked worktreeを明示してfull applyする。複数PJでも明示リストを1件ずつ処理する。 +5. 個別 writer の `--all` は使わない。全PJの一括同期は別の明示承認とscope確認を必要とする。 + +## チェックリスト注入方式 + +| 方式 | 説明 | +| ------------------------ | --------------------------------------------------------- | +| ファイル参照型(採用) | reason にファイルパスを記載し、AIがReadツールで読む | +| インライン注入型(廃止) | reason にチェックリスト全文を埋め込む(チャットが埋まる) | + +reason にチェックリスト全文を埋め込まないこと。AI が Read ツールでファイルを読む形にすることで、ユーザーのチャット視認性を確保する。 + +## 配布スクリプトによる強制ガード(`scripts/deploy-hooks.py:merge_settings()`) + +| ガード | 役割 | +| --- | --- | +| `_strip_prompt_type_hooks()` | `type: "prompt"` の hook をマージ時に強制除去。インライン注入型の混入を配布パイプラインで遮断する | +| `_dedupe_hooks_by_command()` | `(matcher, paths, command)` ベースで dedupe。dict 完全一致比較が空白・キー順差で破綻し、過去 jtt-cms に重複 4 件(`prettier-format` / `seo-check` / `storage-url-check` / `block-main-commit`)が混入した実績の再発防止 | + +これらのガードと `--all --confirm-all-hook-scope` の安全弁を外す変更は禁止。検証スクリプト `scripts/test-deploy-hooks-merge-settings.sh` がガードの挙動を回帰チェックする。 + +## 理由 + +`scripts/deploy-hooks.py`(テンプレート配布スクリプト)は配布先 PJ の `lib/` にチェックリストMDをデプロイする。`quality-check-common.sh`(runtime)が異なるパスを参照すると、新規 PJ セットアップ後に品質チェックリストが見つからず approve が素通りする。 + +`security-review-check.md` の内容は SECURITY DEFINER / RLS / `crm.` schema 等 Supabase + Postgres 専用のため、Supabase を使わない PJ には配布しない(registry の `checklist.security` を空配列にする)。 + +# 最新スタック確認ルール(context7 必須) + +## 対象ライブラリ(AI カットオフ後・急速更新) + +以下を**実装・デバッグ・設定変更する前に必ず** context7 で最新 docs を取得する。 +記憶だけで書かない(古い API を使うと動かない・型エラー・ビルド失敗を引き起こす)。 + +| ライブラリ / フレームワーク | 主な罠 | +|----------------------------|--------| +| **Next.js 16+** | `middleware` → `proxy.ts` に改名(Next15→16)、`cookies()`/`headers()` は非同期=`await` 必須(Next15で async 化・16で同期アクセス廃止)、App Router キャッシュ挙動変更 | +| **React 19+** | Next15 以降は React19 前提。`use()`, Server Actions の型・挙動変更 | +| **@serwist/next** / **serwist** | SW ビルド設定・`defaultCache` API が頻繁変更。Turbopack 非対応(`--webpack` 必須) | +| **motion 12+** (`motion/react`) | `motion-plus` API、`AnimatePresence`・`useSpring` 型変更 | +| **Tailwind CSS v4+** | `@config` 廃止・CSS ファースト設定に移行(`tailwind.config.js` 非推奨) | +| **drizzle-orm** | マイグレーション API・スキーマ定義が毎 minor で変わりやすい | +| **vaul** | ドロワー API・`snapPoints` 型が変わっている可能性 | +| **sonner** | `toast()` オプション・`Toaster` props の更新 | + +## 必須手順 + +1. `mcp__context7__resolve-library-id` でライブラリの context7 ID を取得 +2. `mcp__context7__query-docs` で最新 docs を取得してから実装 +3. context7 が使えない環境は `WebFetch` で公式 docs を取得(記憶補完のみでの実装禁止) + +``` +例: Next.js 16 の proxy.ts (旧 middleware) を実装する前に + → resolve-library-id "next.js" → query-docs "proxy middleware" +例: serwist defaultCache を設定する前に + → resolve-library-id "@serwist/next" → query-docs "defaultCache" +``` + +## 古い API の使用禁止 + +- **Next15 以前の同期 `cookies()`**: Next16 では非推奨。`await cookies()` を前提に書く(context7 で確認) +- **`middleware.ts`(Next16 では `proxy.ts`)**: 名前が変わった。context7 で確認してから書く +- **Pages Router 前提のコード**: App Router が前提。`getServerSideProps` 等を新規に書かない +- **React18 前提の型**: React19 の型変化(`children: ReactNode` の必須化等)を確認してから書く +- **旧 `motion/react` 型**: `motion-plus` の型は memory だけで書かない + +## 関連 + +- `skills/dev-guardrails` — フェーズ別ワークフロー・品質ゲート +- `skills/pwa-guardrails` — serwist 配線・PWA 品質チェックリスト(context7 が必要になる代表例を列挙) + + + +# 横断チェック台帳(mandate-registry)への登録ルール + +## 原則 + +「これは全アプリで必要だ」という横断的な気づきは、ルール追記だけで終わらせず**台帳へ1行登録する**。 + +理由: ルールファイルへの追記は**新規開発にしか効かない**。既存アプリへの適用漏れは、機械が乖離を提示しない限り再指摘が起きるまで発火しない。台帳へ登録しておけば `mandate-audit.py` が未対応アプリを一覧化し、記憶や注意力に頼らず気づける。 + +## 発火条件(トリガー) + +伸太郎殿が以下のような**横断指摘**をしたとき: + +- 「これは全アプリで必要」 +- 「横展開すべき」 +- 「他のアプリでも同じ対応が要る」 + +判断に迷う場合は**登録する側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 必須手順 + +1. **重複確認**: `registries/mandate-registry.yaml` を `id` / `title_ja` で grep し、同種の項目が既に無いか確認する(複数 AI による二重登録防止)。 +2. **1行登録**: 無ければ台帳へ1エントリ追加する。`reason` には経緯1行+日付を必須で入れる。 +3. **報告**: 登録したことを利用者へ報告する(黙って追加しない)。 + +## 監査 + +「横断監査して」等の発話で `python3 scripts/mandate-audit.py` を実行し、結果を提示する。作業対象アプリが決まっているセッションでは `--app ` で絞り込む。 + +## 回答の記録 + +台帳の `status` フィールドは利用者の回答をそのまま反映する: + +| 利用者の回答 | 記録する値 | +|------|-----------| +| 「後で」 | `snoozed:YYYY-MM-DD` | +| 「対象外」 | `na` | +| 対応 PR がマージされた | `done` | + +## 限界の明示 + +`check: manual` の項目は**目視消込**であり、**監査が緑でも全部 OK を意味しない**。機械(`mandate-audit.py`)が見えるのは台帳に記録された静的な項目だけであり、実装が実際にルールへ適合しているかは別途確認が要る。 + +## スキーマ・規約の正本 + +台帳のフィールド定義・規約①②(`check:script` の実行前提・登録前の重複確認義務)は `registries/mandate-registry.yaml` のヘッダコメントが正本。本ルールへ複製しない。 + +## 試行フェーズ + +2026-08-17 目安で、登録実績・提案件数・`status` 更新のコストを振り返る。セッション開始 hook による自動提案の採否は、その振り返りを踏まえて別プランで判断する(今は hook 化しない)。 + +--- + +**追記ルール: 実測事例・長文手順は台帳ヘッダ/別 doc へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** + + + +# MCP API キー管理規範(AGENT-HUB SSOT) + +JTT 関連の MCP(asana-mcp / jtt-smaregi-mcp / smaregi-docs / google-chat-mcp / google-docs-mcp / jtt-spreadsheet-mcp 等)の API キーは **AGENT-HUB を SSOT として一元管理**する。 + +詳細手順(復旧・ローテーション・実装経緯・実例)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。本ルールは義務・禁止事項だけを持つ。 + +## SSOT + +| 役割 | 場所 | 状態 | +|------|------|------| +| 実値(秘匿) | `~/.config/agent-hub/.env` | コミット対象外、各マシンで作成 | +| 名前テンプレート(公開) | `~/business/AGENT-HUB/dotfiles/.env.example` | git 管理、新マシン bootstrap で参照 | +| 環境変数 export | `~/.zshrc.local` の `set -a; source ~/.config/agent-hub/.env; set +a` | bootstrap.sh が初期セットアップ | + +## スコープ振り分け規範 + +| MCP 種別 | 配布先 | 同期スクリプト | +|---------|--------|---------------| +| 全 PJ 共通で必要な MCP | `asset_contract.global.include.mcp` から各clientの宣言surfaceへ | manifestが所有者として指す単一writer | +| harness type共通の MCP(例: Laravel Boost) | `asset_contract.harness_types..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 固有の業務 MCP | `asset_contract.projects..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 個別環境の例外(例: Supabase stg/prod) | project layerと明示local-exception契約へ宣言 | writerが保護・描画。生成surfaceの手編集は禁止 | + +理由: User scope に PJ 固有 MCP を入れると「使わない PJ でも表示・接続試行・認証エラー表示」が起きる。PJ別の使用意図はmanifestのproject layerが表現し、client別catalogは選択根拠にしない。 + +**Gmail の扱い(2026-05-25 更新 / 2026-07-20選択経路更新)**: 自前 gmail-mcp は 2026-05-21 に一度凍結したが、公式 Gmail のツール不足(ラベル CRUD / Triage / 添付取得欠如)が判明し **2026-05-25 に Project scope (jtt-cafe-pj) で復活**。接続definitionはStreamable HTTP `/mcp` + X-API-Keyを維持する。採否はjtt-cafe-pjのmanifest project layer、client対応可否は同じeffective MCPに対するsurface契約で判定する。 + +**Supabase の stg / prod 2 環境並列 (jtt-cms)**: `supabase-prod` / `supabase-stg` の2 assetを命名規約として必須にする(`supabase` 単独名・env-agnostic な `mcp__supabase__*` 表記は禁止)。実例・OAuth手順の詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## Claude Code の `${VAR}` 補間仕様 + +**Claude Code は `mcpServers[*].headers["X-API-Key"]` 等の値を `${VAR}` 補間しない**(User scope / Project scope どちらも同じ)。 + +→ sync スクリプトは `~/.config/agent-hub/.env` から実値を読み出し、`/.mcp.json` / `~/.claude.json` には実値を書き込む。 + +→ よって `.mcp.json` は **gitignore 必須**(実値がコミットされないように)。AGENT-HUB の SSOT は環境変数名のみ保持し、各マシンで sync 実行時に実値展開する。同じ理由で `~/.claude.json` / `.gemini/settings.json` / `.cursor/mcp.json` / `.kimi-code/mcp.json` も全て gitignore 必須(対象ファイルと生成元の gitignore 必須リストは `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。 + +## 禁止事項 + +1. **`~/mcp-servers//.env` へ直書き禁止**。`asana-mcp/.env` `jtt-smaregi-mcp/.env` 等に API キーを置かない。発見次第 `~/.config/agent-hub/.env` へ移行し、ローカル `.env` は `# moved to ~/.config/agent-hub/.env (AGENT-HUB SSOT)` のコメントだけ残す +2. **`~/.zshrc.local` に `_mcp_load_key_from_env` のような分散ロード関数を新設禁止**。AGENT-HUB SSOT の bootstrap フロー(`set -a; source ~/.config/agent-hub/.env; set +a`)を使う +3. **git 管理対象ファイルに API キー実値を平文で書かない**。ドキュメント(README / SKILL.md / 設計書)では `` または env 変数名 `${ASANA_MCP_API_KEY}` で表記する +4. **ローカル生成物へ手作業で API キー実値を書かない**。`.mcp.json` / `~/.claude.json` は gitignore 済みであることを前提に、sync スクリプトだけが `~/.config/agent-hub/.env` から実値展開して書き込む +5. **管理対象ファイルでの URL クエリパラメータ方式(`?api_key=...`)禁止**。Cloud Run の監査ログに URL ごとキーが残るため、`.mcp.json` / `~/.claude.json` / `.codex/config.toml` など AGENT-HUB が生成する設定は `headers: {"X-API-Key": "${...}"}` のヘッダー方式に統一する + +**Claude.ai 例外**: Claude.ai コネクタで `X-API-Key` ヘッダーを設定できない場合のみ、asana-mcp は `https://asana-mcp-vaibinqqva-an.a.run.app/mcp?api_key=` 形式を使ってよい。この例外は Claude.ai 手動登録専用で、AGENT-HUB の生成物には書かない。 + +## 再発防止: sync スクリプトのハードエラー化 + +`scripts/sync-claude-global-mcp.py`、`scripts/sync-claude-project-mcp.py`、`scripts/sync-codex-mcp-configs.py`、`scripts/sync-cursor-mcp-configs.py`、`skills/{gemini,kimi,opencode,augment}-sync/scripts/sync-*-from-cc.py` は、env_key が未解決(`~/.config/agent-hub/.env` に無い/空文字)の場合に **literal `${VAR}` を書き込まず exit 1** すること。 + +理由・過去の実害(jtt-cms で `smaregi-docs` MCP の認証エラーが反復した根本原因)は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## 再発防止: MANAGED block の重複キー除去 + TOML 検証(Codex / 2026-06-02〜) + +`scripts/lib/user_mcp_sync_lib.py` の `replace_managed_block` は、①同名野良エントリの自動除去 ②書き込み前 TOML パース検証、を担保する(MANAGED 対象でない手書き MCP は保護する)。実装経緯・障害の症状は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` の「Codex config TOML 重複キー」節を参照。 + +## 復旧手順(MCP Auth エラー時) + +詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md`。要旨: ①env が読めているか確認 ②`~/.claude.json` の literal `${VAR}` 残存検査 ③対象projectを公開入口から再同期 ④Claude Code を再起動。 + +## ローテーション手順 + +API キーローテーション時の 7 ステップ(新キー発行 → SSOT 更新 → dry-run 確認 → full apply → 個別sync禁止 → 各PJ再起動 → 旧キー失効)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。旧キーを `dotfiles/.env.example` のコメントに「廃止済み」として残してはいけない。 + +## User scope MCP 同期フレームワーク (2026-05-21〜) + +User scope MCP (`~/./...`) の SSOT 一元管理は **user-mcp スキル**が管轄する(User scope / Project scope の設計と担当 sync スクリプトの対応表は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。新エージェント追加 5 ステップ (CLAUDE.md 9-13 参照): `skills/user-mcp/SKILL.md`。 + +## 関連 + +- `dotfiles/.env.example` — 名前テンプレート +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 復旧手順・ローテーション手順・実装経緯・User scope同期フレームワーク対応表の詳細正本 +- `skills/user-mcp/SKILL.md` — User scope MCP 5 ツール統一管理スキル(sync スクリプト一覧はここに集約) +- `scripts/lib/user_mcp_sync_lib.py` — 5 sync 共通 lib (env / registry / MANAGED block / 検証) +- `scripts/sync-claude-project-mcp.py` — Project scope 同期 +- `scripts/codex-mcp-remote-with-env.sh` — Codex 用 SSE → stdio bridge +- `~/business/AGENT-HUB/docs/codex-mcp-registry.yaml` `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — 台帳 +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 障害復旧ランブック +- `docs/reference/project-roots.md` — プロジェクトルート規約 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** + + + +# メモリ参照ルール + + + +## 基本方針 + +memory は、前回までの作業状態・人物名・用語・過去の判断を思い出すための**参照補助**である。 +売上・タスク・勤怠・予約・確定ルールの正本ではない。 + +以下のケースに該当するとき、応答を出す前に `~/.claude/projects/*/memory/MEMORY.md` および同階層の個別メモリファイルを検索する: + +- **人名・略称・愛称**に遭遇したとき(読み方・関係性が記録されている可能性) +- **PJ 固有用語・コードネーム**に遭遇したとき +- **過去の不採用判断**を覆そうとしているとき +- ユーザーが「あの〜」「以前話した〜」等の指示語で参照しているとき + +## 検索手順 + +`~/.claude/projects/*/memory/` 配下を検索し、`MEMORY.md` のインデックスから該当する個別ファイルを特定して読み、応答に反映する。 + +## 該当メモリがあった場合 + +- メモリの内容を踏まえて応答する +- メモリの記述が古い可能性がある場合は、現在の状態(コード・設定・正本MCP・Markdown SSOT)と突き合わせる +- 矛盾があれば**現状を優先**し、メモリの更新を提案する + +## JTT 業務情報の正本 + +| 情報 | 正本 | +|------|------| +| 売上・取引・商品実績 | スマレジ / `jtt-smaregi-mcp` | +| 施策・担当・期限・進捗 | Asana / `asana-mcp` | +| 勤怠・シフト・出勤者 | 出パンダ / 将来の Depanda MCP | +| 予約・来店予定 | よやくま / 将来の Yoyakuma MCP | +| 確定した方針・ルール・議事録 | プロジェクトの Markdown SSOT | +| 横断分析・再利用する学び | G-Brain | +| 作業途中の短期文脈 | Claude / Codex / Hermes の memory | + +memory と正本が矛盾する場合は、正本を優先する。G-Brain は検索・分析・要約の層であり、MCP から取得した生データの保管先にはしない。 + +**Asana のどこに何があるか**(workspace / project gid / section 構造 / 周期 PJ の命名規則)は +`~/business/AGENT-HUB/docs/reference/asana-project-map.md` が地図。gid を推測せず、まずこの地図を引く。 +地図には参照先だけがあり、タスクの中身は載せない(中身は `asana-mcp` でその場で取る)。 + +## 該当メモリがなかった場合 + +- 推測で補完せず、ユーザーに直接確認する +- 確認後、必要に応じて新規メモリとして記録する(auto memory ルール参照) + +## 関連 + +- グローバル auto memory: `/Users/shintaro/.claude/CLAUDE.md` の「auto memory」セクション +- PJ 別 auto memory: `~/.claude/projects//memory/` + +# 実装前に HTML プランで承認を仰ぐルール(強制) + +## 原則 + +中規模以上の**実装に着手する前に、必ず HTML で実装プランを提示し、利用者の明示承認(「この実装でいい」)を得てから着手する**。テキストだけで合意したつもりにならない。 + +理由: 非エンジニアの利用者には「どの画面がどう変わるか」がテキストでは伝わりにくく、着手後に手戻りが多発する。給与v1の説明 HTML のような見せ方を毎回・自動で出して認識を合わせ「手戻りゼロ」を狙う。 + +これは `ui-stitch-mandatory` と同じく**ルールが義務を担い、手順はスキルに置く**二段構え。手順 SSOT は `skills/plan-approval/SKILL.md`(本ルールは手順を複製せず参照する)。 + + + +## 必須手順 + +1. **プラン作成基準をライブ読み**: `skills/plan-approval` が `resolve-pj-prompt.py --phase plan` を実行し、PJ 別のプラン基準(`snippet-prompts/Typinator/plan/`。専用未作成 PJ は汎用 `dev-plan`)を読む。 +2. **HTML プランを作る(固定テンプレを必ず使う・独自デザイン禁止)**: 正本テンプレをコピーし中身だけ差し替える(通常=`plan-template.html`、AI worker 委譲時=`plan-template-aiworker.html`)。必須のビジュアル要素は下記「中身」節を参照。 +3. **提示して承認を待つ(iPhone でも PC でも、両方の届け方を毎回使う)**: HTML プランは**必ず Write ツールで実体の `.html` ファイルとして作成する**。**禁止**: ① HTML 本文をチャットに貼り付ける、② Bash ヒアドキュメントで書き出す(どちらも iPhone で生コードになる)。作成後は毎回 `open ` で PC ブラウザにも表示する。**タップ用ファイルカード作成と open による PC ブラウザ表示の両方を毎回必須とする**。末尾に「この実装でいいですか?(進めて / 直す / やめる)」を置き、**承認なしに実装へ進まない**。短い同意だけで進めず、次の一手を1文に要約して再確認する。保存先は作業中 PJ の gitignore 済み一時パス(`claude-plans/` 等)、slug は短く、共有 URL は1行で提示する(詳細: `skills/plan-approval/SKILL.md` §5)。 +4. **承認直後に 📋 コミットメント台帳を全件タスク化する**: HTML プランの台帳の各行を、着手前に `TaskCreate` で 1 行 = 1 タスク化してから実装へ進む。台帳が全消化(実施済み or 明示保留)になるまで「完了」と宣言しない。詳細は `.claude/rules/general/plan-commitment-tracking.md`。 + - **AI worker を 1 度でも使う計画は必須**: 「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れ、タスク化する(テンプレに既定行として焼き込み済み・消さない)。 +5. **承認後は標準パイプラインを通す**: 実装(dev-guardrails)→ codexレビュー → 実装監査 → CI → SSOT 同期確認 → マージ。本番投入は人間ゲート。 + +## HTMLプランの中身 + +中身の構成部品(🎯目的・🖼️前後比較・🗺️ユーザーストーリー・🔀画面遷移図・📚メニュー構成図・🧩変える物一覧・🤔AI の異見・🔭一段上の視点・📋コミットメント台帳 等の必須要素一覧)はテンプレ正本(`references/plan-template.html` の `parts` マニフェスト)と `skills/plan-approval/SKILL.md` が正本。ここに複製しない。 + +## 適用トリガー + +新機能・新画面、データの形を変える変更(DB 構造変更)、複数ファイルにまたがる実装、画面の見た目・挙動が変わる変更。判断に迷う場合は提示する側に倒す。 + +## 例外(HTMLプラン不要) + +- 誤字・1行修正など、見た目・挙動の方針が変わらないもの +- 純粋な調査・質問への回答、会話だけで完結する話 +- 利用者が「今回は要らない」と明示したとき +- UI に変化を伴わない純粋なロジック修正(ただし複数ファイル・データ変更を伴うなら提示する) + +## 接続・関連 + +手順: `skills/plan-approval/SKILL.md`(テンプレ・保存規約・承認ループ・3層視覚化)。プラン基準: `snippet-prompts/Typinator/plan/[PLAN-INPUT]_-plan.md`。進捗: `visual-progress-map.md`。UI確定: `ui-stitch-mandatory.md`。承認後: `skills/dev-guardrails/SKILL.md`(各フェーズは `resolve-pj-prompt.py` 同一リゾルバ)。モデル委譲: `ai-model-selection.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** + + + + + +# プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) + +## 原則 + +承認済みプラン本文に書かれた**全ての commitment / 条項**を、実装着手前に **1 項目 = 1 タスク**へ起票する。 +プランの箱(HTML / テキスト)を静的ドキュメントで終わらせない。承認は一度きりの儀式ではなく、 +実行ループ全体で参照し続ける**生きたチェックリスト(plan-as-live-checklist)**として扱う。 + +## なぜ + +プランに「〜不具合時は正本を直す」等の条項があっても、主要タスクだけ起票すると、長い実行ループ(`/compact` でプラン本文が能動コンテキストから外れる)で**一度も発火せず**未実施のまま「完了」と誤宣言する。これは**全 PJ で再発し得る構造欠陥**。実例は `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。 + +## 必須手順 + +1. **承認直後に台帳化**: プラン本文の commitment / 条項(「〜不具合時」「〜したら」「最後に〜」「後で」「別プラン」「TODO」「フォローアップ」「要〜判断」の類)を全て抽出し、着手前に **TaskCreate で 1 項目 = 1 タスク**化する。**📋 コミットメント台帳セクションが空でないのに、未タスク化のまま実装へ進まない**。 + - **AI worker を 1 度でも使う計画なら、「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れる**(テンプレ既定行・消さない)。無ければ台帳は未完成。 +2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 +3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 + - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 +4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 +5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 +6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。 + - **`/goal` 等の反復発火チェッカーへの対応**: 人間ゲート行に反復発火する場合、AI は §6 の優先(明示保留=全消化・虚偽✓禁止)を **1 度だけ根拠付きで提示して停止**し、以後は最小限の再表明に留める(無限反復・迎合的な虚偽✓化をしない)。実測は `commitment-examples.md`。 + +## 恒久原則(proactive) + +繰り返す同種の摩擦・失敗は、**利用者の指摘を待たず**観測した時点で「最後に正本を直す」を既定の最終ステップとして計画へ自分から組み込む。委譲ジョブの失敗・失速もコミット監視だけに頼らず能動的にポーリングして検知する(`skills/agent-dispatch` の「失敗の能動検知」と対)。 + +## 接続 + +義務: `plan-approval-gate.md`。手順: `skills/plan-approval/SKILL.md`。実例: `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。進捗可視化: `visual-progress-map.md`。能動検知: `skills/agent-dispatch/SKILL.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** + + + +# ハードコード排除・参照型設計(グローバル憲法) + +## 原則 + +**ハードコード(直書き)をしない。** 設定値・手順・API 名・パス・思想・スタイルなど、2 箇所以上で必要になる情報は、**正本(SSOT)を 1 箇所に置き、他はそれをライブ参照する**(参照型設計)。 + +- どうしても直書きが必要な場合は、**1 箇所に集約**し、なぜ集約先を作ったのかを CaD コメント等に残す。 +- 「そこだけ直書き」を積み重ねると、後から値がずれる・改訂が反映されない・矛盾が生まれる。これは規模の大小を問わず起きる。 + +この原則は、サブエージェント作成・SSOT 構築・hook 実装・YAML 台帳・skill・rule のどの作業でも同じ扱いにする。特定のフェーズだけに適用される限定ルールではない。 + +## 参照型の実例 + +- **設計思想**: `~/business/AGENT-HUB/docs/design/design-philosophy.md` に集約し、UI/デザインに関わる各所(stitch-screen-creator 等のサブエージェント、UI 作成フロー)からライブ参照する。思想を各 PJ の rule や skill に複製しない。 +- **MCP キー**: `~/.config/agent-hub/.env` に実値を集約し、各 PJ の `.mcp.json` や sync スクリプトはそこから展開する(`.claude/rules/general/mcp-key-management.md`)。`~/mcp-servers//.env` への直書きは禁止。 +- **スキル手順**: 各 rule は手順を複製せず、手順 SSOT(skill)をライブ読みで参照する(例: `plan-approval-gate.md` が `skills/plan-approval/SKILL.md` を参照する二段構え)。 + +## 発火場面 + +- 新しい設定値・API 名・閾値・手順・文言などを**2 箇所以上に書きそうになった時**。 +- 既存の rule / skill / doc の内容を**別ファイルにコピーして使いたくなった時**(コピーせず参照にする)。 +- サブエージェントや AI worker への delegate プロンプトに、正本にある情報を**そのまま貼り付けたくなった時**(正本のパスを渡し、読ませる方を優先する)。 +- **配布物(他 PJ へ配る rule / agent / skill)から AGENT-HUB 専用ファイルを参照する時**: 相対パス(`docs/design/...`)ではなく**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)で書く。配布先 PJ の実行 cwd はその PJ 自身であり、相対パスでは正本を解決できず参照が壊れる(2026-07-07 実装監査が検出)。 + +## 個人開発スケールとの両立 + +`.claude/rules/general/constructive-dissent.md`「個人開発スケールと例外」節を参照(同根の原則・過剰な抽象化を避け素朴な解決を優先する基準)。 + +## 関連 + +- `.claude/rules/general/constructive-dissent.md` — 言いなり禁止・グローバル憲法(同種の常時ロード規範。メンテナンス観点の先出し提案として「正本参照」を挙げている) +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 参照型設計の実例(G-Brain 正本からの派生ドキュメント) +- `.claude/rules/general/mcp-key-management.md` — MCP API キー一元管理(参照型設計の実例) + +この原則は G-Brain の上流原則 `principle-single-source-of-truth-reference`(伸太郎殿の開発大原則)と同根である。 + + + +# 出力簡潔性ルール + +## 基本方針 + +- 中間状態(「これからこうします」「次にこれを実行します」)の冗長な説明を避ける +- 概念的な説明より具体例・差分・コマンドを優先する +- 段落より bullet list を優先する +- 同じ情報を 2 回繰り返さない(タスクツールで進捗を可視化している場合は、テキストで重ねて述べない) + +## 避けるべき出力パターン + +- 「〜について説明します」「以下に〜します」等の予告フレーズ +- 完了済みタスクの再要約(diff や git log が SSOT) +- 「もし〜の場合は〜」の仮定列挙(実行結果を待ってから判断する) +- 「これは〜という意味です」型の自明な解説 + +## 確認するときの書き方 + +ユーザーに判断を仰ぐときは、選択肢を簡潔に列挙し、推奨案を 1 行で示す: + +良い例: `Tier 2 まで実行 / REN-1 のみ / 全部 のどれにしますか?推奨: Tier 2 まで` + +悪い例(冗長すぎ): 長文で各選択肢のメリット・デメリットを 3 段落ずつ説明 + +## 完了報告の書き方 + +- 変更したファイル一覧(path のみ) +- 主な変更点(1 行ずつ) +- 確認してほしい点(あれば、1-2 件) + +過剰な「お疲れさまでした」「素晴らしい結果でした」等の挨拶は不要。 + + + +## URL・リンクの出力 + +AI が URL やファイルパスを出力するとき、**URL の直後に全角括弧・句読点(`)` `。` `、` `」` など)を隣接させない**。ターミナルや Markdown のリンク解釈がその記号まで URL に取り込み、リンクが壊れる(404)ため。 + +- URL は原則**独立した行**に置く(前後に説明文があっても URL 単独の行にする)。 +- 文中に置く場合は URL の直後に**半角スペースか改行**を入れ、全角記号を隣接させない。必要なら `< >` または バッククォートで囲む。 +- 悪い例: `詳細は https://example.com/path)。`(`)。` まで URL に食われて 404)。 +- 良い例: 説明文の後に改行して `https://example.com/path` を単独行で出す。 + +# レスポンシブ UI は全 viewport に足す(普遍ルール・常時適用) + +## 原則(絶対・例外なし) + +レスポンシブな画面にボタン・リンク・ナビ等の UI 要素を**新規に足す**ときは、**モバイル表示とデスクトップ表示の両方**(存在する全ブレークポイント)に足す。片方だけに足すと、もう片方の画面幅でその要素が**消える**。これは開発の普遍ルールであり、条件付きにしない。 + +多くのレスポンシブ実装は同じ内容を画面幅で出し分ける: +- モバイル: 上部バー等(例 `.appbar`)を表示し、サイドバーを隠す +- デスクトップ(例 `@media (min-width:1024px)`): サイドバー等(例 `.side-*`)を表示し、上部バーを隠す + +このとき片方の枠にだけ要素を足すと、もう片方では `display:none` により非表示になる。 + +## 必須 + +1. UI 要素を足すとき、**全 viewport バリアント**(モバイル枠 / デスクトップ枠 / その他ブレークポイント)の**すべて**に足す。 +2. 追加要素が**各画面幅で実際に表示される**ことを確認してから完了にする(該当 CSS の `display:none` / media query の出し分けを読み、隠れる枠だけに足していないか確認する)。 +3. コードレビュー・実装監査でも「新規 UI 要素が全 viewport で見えるか」を必須確認項目にする。 + +## 実例(この規則ができた経緯) + +2026-07-05 cron-dashboard で「🩺 健診」ナビを最初モバイルの上部バー(`.appbar`)だけに足した結果、`.appbar` が `@media (min-width:1024px)` で `display:none` になるため **PC 幅で恒久的に非表示**になり、実装監査がブロッカーとして検出した。デスクトップのサイドバー(`.side-brand` 隣)にも足して解消。片側だけ追加は完了ではない。 + +## 接続 + +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・現在地マップ +- `.claude/rules/general/ui-stitch-mandatory.md` — UI/デザインは Stitch を通す +- `skills/dev-guardrails/SKILL.md` — 実装ガードレール + + + +# settings.json 等の保護テストと正当な配線変更の共存ルール + +## 原則 + +`.claude/` `.codex/` `.cursor/` `.gemini/` `.kimi-code/` `.augment/` `.opencode/` `.githooks/` +`claude-plans/` `node_modules/` 配下および `.env` / `.env.*` は、`tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` +が **プレフィックス一致で広く保護対象と判定**する(実装: `skills/handover-manual/scripts/resolve-handover-path.py` の `is_protected_path()`、 +`PROTECTED_PREFIXES`)。テストは `origin/main` とのマージベース以降 + working tree + staged の変更ファイルを走査し、 +保護対象なのに `allowed_managed_placements`(テスト内のallowlist)に無いパスがあれば **fail** する。 + +この判定は粗い(ディレクトリ丸ごと保護)ため、telemetry 配線・新規ルール追加・hook 再配備など +**正当な変更でも毎回検知される**。これは仕様であり、バグではない。正当な変更を安全に通す手順は以下。 + +## 必須手順 + +1. **まず中央配布経路で済まないか確認する**: project harness は `scripts/sync-agents.py --project --dry-run` + で全surfaceを確認し、承認後だけcleanな専用linked worktreeへfull applyする。個別writerを手で連結しない。 +2. **どうしても直接編集が必要なら、同一PRで `allowed_managed_placements` に追加する**: + `tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` 内のセットへ、 + 変更した具体パスと日付・理由コメントを添えて追記する(例: `# [YYYY-MM-DD][fix] PR#nnn で〜が弾かれた。理由。`)。 + 既存の fix-forward 例(PR#637 `ai-model-selection.md` / PR#666 `constructive-dissent.md` / + PR#695 `responsive-both-viewports.md` / PR#736 `dotfiles/.env.example`)と同じパターンに倣う。 +3. **保護テストの検査ロジック自体を弱めない**: `is_protected_path()` のプレフィックス判定や + `changed_paths_for_protected_check()` の走査範囲を変更・無効化しない。許可は必ず allowlist の + 個別パス追加で行う(一括 skip・正規表現の緩和は禁止)。 +4. **テスト赤のままマージしない**: `python3 -m pytest tests/test_handover_manual.py -q` を PR 作成前に + ローカル実行し green を確認する。CI の同テストが赤の状態での merge は `branch-rule.md` の + CI 緑ゲートに反する(#742 の再発防止)。 + +## 関連 + +- `.claude/rules/general/branch-rule.md` — main 直接コミット禁止・CI緑ゲート +- `.claude/rules/general/plan-commitment-tracking.md` — workaround 自問(正本修正を先送りしない) +- `.claude/rules/general/hooks-structure-rule.md` — hook 配置の隣接ルール(配布経由の管理配置) +- `tests/test_handover_manual.py` — 保護テスト本体・allowlist 実体 +- `skills/handover-manual/scripts/resolve-handover-path.py` — `is_protected_path()` / `PROTECTED_PREFIXES` 実装 + + + +# サブエージェント Scope Contract + +サブエージェント(Task / Agent tool)に作業を委譲するとき、delegate 元のプロンプトに**必ず以下 3 項目(コード探索を伴う場合は §4、UI/デザインを伴う場合は §5 を足す)を含める**。制定経緯・テンプレート全文は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。 + +## 1. allowed_files(編集を許可するファイル) + +委譲先が編集してよいファイルパスを明示的に列挙する。 + +例: `「allowed_files: src/api/auth.ts のみ。他は read-only」` + +## 2. forbidden_actions(禁止する操作) + +委譲先が**してはいけない**操作を明示する。よくある禁止例: + +- `auto-format で quote replacement や import 並び替えを実行しない` +- `スコープ外のファイルを編集しない(読み取りは可)` +- `テストの skip / xit を追加しない` +- `existing CaD コメントを削除しない` + +## 3. verify before return(返却前の検証手順) + +委譲先が作業完了を報告する前に実行する検証を指定する。 + +例: +- `git diff --name-only で編集ファイル一覧が allowed_files と一致することを確認` +- `lint / typecheck を実行してエラーが出ないことを確認` +- `想定外の編集があった場合は revert してから報告` + +## 4. context-engine first(コード探索を伴う委譲・Explore 含む) + +委譲タスクが**コードの場所・関数・route・呼び出し関係・影響範囲の探索**を含むなら、prompt に必ず入れる: + +- 「まず `codebase-context-engine` を使う(`grep`/`Read` を先に走らせない)。遅延ツールは + `select:mcp__codebase-context-engine__list_projects,hybrid_search,search_graph,get_code_snippet` でロード」 +- **解決済みの `project` 名を親が渡す**(親が `list_projects` を見て明示)。 + `preferred_project` がある場合はそれを使う。 + `project_scope: ambiguous_worktrees` の場合は、現在の cwd と一致する `root_path` / `preferred_project_candidates` を親が選んでから渡す。 + subagent に `private-tmp-cbm-...` の長いミラー名を推測させない。 +- 「索引はミラー=当日新規/変更したファイルは未反映なので、その分だけ `Read` 併用」 + +理由: 候補圧縮で速く・低コスト(多数 grep/Read を回避)。subagent は本ルールを自動継承しないため親が prompt 注入必須(追加経緯は詳細ドキュメント参照)。 + +## 5. design-philosophy first(UI/デザインを伴う委譲時) + +委譲タスクが**UI・画面・デザイン・レイアウト・コンポーネントの作成/変更**を含むなら、親が prompt に必ず入れる: + +- 「まず `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)を Read してから着手する」を**必読指定**する。 +- 必ず該当ファイルの**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)を渡す(委譲先の実行 cwd は消費先PJであり、相対パスでは解決不能なため)。 +- Stitch を使う画面作成は、`stitch-screen-creator` グローバルエージェント(設計思想を step0 で必読にしている)へ委譲するのが既定。 + +理由: AI Worker(Kimi/Codex/Cursor/GLM 等)自身にデザインセンスが無くても、親が設計思想 doc を必読で渡せば思想に沿った画面を作れる。渡さないと委譲先が自己流判断でずれる。 + +## delegate プロンプトのテンプレート・親側の verify ステップ + +テンプレート全文と、親セッションが `git diff --stat` / `git diff -- ` で確認する verify コマンド列は +`~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。allowed_files 外に変更が混入していた場合は +revert し、delegate にやり直しを指示する。 + +--- + +**追記ルール: 制定経緯・テンプレート全文の詳細は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` へ書き、本ルールには義務・トリガーだけ足す(再肥大化防止)。** + + + +# UI / デザインは必ず Stitch を通すルール(強制) + +制定経緯(2026-05-27 新設判断・2026-07-20 MCP選択正本切替)は `skills/stitch/SKILL.md` の +「ui-stitch-mandatory 制定経緯」節を参照。 + +## 原則 + +UI / 画面 / デザイン / レイアウト / コンポーネントの**新規作成・見た目の変更**依頼は、**必ず Stitch**(`skills/stitch` + Stitch MCP `mcp__stitch__*`)でデザインを生成し、**伸太郎殿が実物を見て確定してから実装に進む**。 + +理由: UI は AI とユーザーの言語的意思疎通が難しく、テキストだけで合意したつもりで実装すると手戻りが多発する。Stitch で生成した実物を見て双方の認識を合わせることで「手戻りゼロ」を狙う。 + +## 必須手順 + +1. **Stitch でデザイン案を生成**(**最低 3・最大 5(ケースバイケース)**)。1 案だけ出して進めるのは**禁止**。 +2. **伸太郎殿が Stitch Web(プロジェクト URL)で比較・確定**する。 +3. **確定したデザインだけ**を基に実装する(`.stitch/` 出力 / DESIGN.md を参照)。 + +## 適用トリガー + +「UI を作って」「画面作って」「デザイン(して)」「レイアウト変更」「コンポーネント新規」など(`skills/stitch` の triggers と整合)。判断に迷う場合は Stitch を通す側に倒す。 + +## データ格納ルール(リポジトリルート汚染防止) + +Stitch 由来のファイルを散らかさないため、保存先を固定する: + +| データ | 置き場所 | +|--------|---------| +| ① デザイン案の比較 | **Stitch Web(プロジェクト URL)で見る** → 全候補をローカル保存しない | +| ② 確定したデザイン | `.stitch/<システム名>/<画面名>/`(`code.html` + `screen.png`)にだけ Export | +| ③ MCP 取得データの一時保存 | **temp ディレクトリ**(その PJ の `/tmp/` 等・gitignored) | +| ④ リポジトリルート直下・任意の場所 | **保存禁止**(ゴミファイル堆積を防ぐ) | + +- `.stitch/` は**Stitch を使う PJ ごとに gitignore する**(生成物はコミットしない)。配布先 PJ へ広げる場合は、その PJ 側の `.gitignore` 変更を別途同じ変更束に含める。 +- 「とりあえずルートに HTML を置く」は**禁止**。必ず上記 ① 〜 ③ のいずれかに収める。 + +## 例外(Stitch 不要) + +- 既存 UI の微修正(typo 修正・1 色だけ変更など、**見た目の方針が変わらない**もの)。 +- UI に変化を伴わない純粋なロジック修正。 + +## MCP 前提 + +Stitch MCPの接続definitionは`~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml`、project採否は +`registries/harness-manifest.yaml#asset_contract` のeffective `mcp` setを正とする。 +未接続時は `scripts/sync-agents.py --project --dry-run` で継承・surface・envを確認し、apply後にfresh clientでruntime proofを取る。 + +## 接続 + +- 手順 SSOT: `skills/stitch/SKILL.md`(プロンプトテンプレ・`.stitch/` 規約・DESIGN.md 抽出・MCP 前提)。本ルールは手順を複製せず参照する。 +- dev フローの普遍 UI ルール(Tailwind 等)は `skills/dev-guardrails/SKILL.md`(2-10 ほか)の上に乗る。業務 PJ は `skills/business-guardrails/SKILL.md`。 +- 要件固め・実装フローでの発火点: `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md`。 +- Stitch でデザインを作る際は `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)に従うこと。本ルールは思想本文を複製せず参照する。 +- 「Stitchで作って」の委譲は `agents/global/stitch-screen-creator.md`(着手前に設計思想 doc を必読)が実行役を担う。 + +## 関連 + +- `skills/stitch/SKILL.md` — Stitch ワークフロー SSOT +- `skills/dev-guardrails/SKILL.md` / `skills/business-guardrails/SKILL.md` — ガードレール +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 発火フロー +- `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — Stitch MCPのtransport / 認証definition +- `registries/harness-manifest.yaml` — global / harness type / projectの採否とsurface契約 +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 伸太郎殿の設計思想 SSOT +- `agents/global/stitch-screen-creator.md` — Stitch 画面作成グローバルエージェント + +--- + +**追記ルール: 制定経緯・実測詳細は `skills/stitch/SKILL.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** + + + +# 図解・現在地マップ・非エンジニア用語ルール + +全 AI・全作業共通の SSOT。ユーザー(非エンジニア)が現在地・ゴール・次の一手を必ず把握できる状態を保つための図解描画ルール。**通常の実装・Issue/PR/PRD 確認・調査でも、§1-bis のトリガーに該当したら skill 抜きで図解を出す**。 + +**テンプレ・実例・置換表の全文は references へ。本ルールは義務とトリガーだけ(再肥大化防止)。** + +## 0. モード判定(開発 / 業務) + +この図解は **2 モード**を持つ。テンプレは共通で、語彙は references の置換表で読み替える(DRY)。 + +| モード | 対象 PJ(デフォルト) | 性質 | ペア guardrails | +|--------|---------------------|------|----------------| +| **開発** | jtt-apps / jtt-cms / jtt-shift-mobile-app / *-mcp 等 | GitHub PR フロー中心 | dev-guardrails | +| **業務** | jtt-cafe-pj / non-pj | 戦略・施策・KPI 中心 | business-guardrails | + +- `jtt-cafe-pj` は business PJ。曖昧なら §5 に従い平易語で確認してから描く(推測しない)。 +- 最小読み替え: PR/Issue/merge/本番投入 → 戦略スコープ/KPI/意思決定/本番運用。詳細は references。 + +## 1. 地図描画タイミング + +| タイミング | 出すもの | +|-----------|---------| +| セッション開始直後 | `.claude/parallel-run-state/*.json` があれば冒頭で全体地図を ASCII 表示(複数あれば選択を仰ぐ) | +| /brainstorm 各フェーズ遷移時 | Phase 1→2→3 移行直前にミニ地図(§3) | +| /parallel-run 各ステップ完了時 | Step 完了報告+次 Step 前に全体地図を再描画 | +| 通常作業中 | §1-bis 該当時は skill 抜きでも L1 ASCII 図解を出す | +| オンデマンド | 「地図」「現在地」「進捗」の発話で即時再描画 | + +`gh pr list --state all` は開始時1回+オンデマンド時のみ呼ぶ(API節約)。再描画は状態ファイルのキャッシュを優先。 + +## 1-bis. skill 非依存の常時発火トリガー(バランス型) + +skill 非起動時でも、以下のいずれかに該当したら L1 ASCII 図解を出す(指示なしで出るのが本ルール最大の目的)。 + +| トリガー | 出す図の例 | +|---------|-----------| +| ① 3 つ以上の要素・手順・選択肢の説明 | 箇条マップ / 比較表 / フロー | +| ② 「今どこ・次どこ」の現在地・進捗 | 5 段階地図 / ミニ地図 | +| ③ Issue/PR/PRD/仕様書を読んで方針を伝える | 関係図 / 要約マップ / フェーズ図 | +| ④ バグ修正の「原因 → 対処」説明 | 原因 → 対処フロー | +| ⑤⑥ 複数ファイル横断の整理・依存関係説明 | 依存ツリー / フロー図 | +| ⑦ 進捗・週次レビュー・残り作業 | **ゴール地図(§2-bis)**。羅列で終わらせない | +| ⑧ AI Worker MCP へ複数 provider 委譲/状態確認 | **AI Worker 進捗図**(references)。provider名でなく作業内容・現在地を主役にする | + +議論を伴う説明・プランはチャットの L1 要点図解を基本とする。L2 HTMLカードは見た目の比較が必要な時、またはユーザー希望時だけ使う(実装承認プランは plan-approval-gate.md 優先)。③④⑦も専用skill化せず本ルールで発火。 + +### 出さない場面(うるささ回避) + +- 単純な一問一答、1 ステップで完結する短い事実回答、「図はいらない」明示時 + +図形式は自由。**重い L2/L3 は使わず L1 ASCII をデフォルト**にし、図を要約として使う。 + +## 2-bis. ゴール地図(骨子) + +§1-bis⑦で出す。やったこと羅列で終わらせず、計画全体・残り・次の一手・ゴール妥当性を同時に出す。 + +必須 7 要素: ①🎯最終ゴール+達成条件 ②全体スコープ ③✅済 ④⬜未(漏れ) ⑤◀次の一手 ⑥残数 ⑦⚠️ゴール妥当性レビュー。 + +短絡禁止: 「実装が終わった=ゴール達成」「施策を打った=成果(KPI)達成」と書かない(本番運用・撤退基準判定まで未達)。骨子: 📍ゴール/つくる→テスト→🚧本番投入→🏁本番=ゴール/✅済・⬜未・◀次の一手。 + +全体スコープ・未着手は PRD / Issue / git log を実読して埋める(推測禁止)。フルテンプレは references 参照。 + +## 3. ミニ地図テンプレート(/brainstorm 用) + +``` +[ 現在地 ] /brainstorm Phase X/3 +✅ Phase 1: 要件聞き取り +🔵 Phase 2: 不明点深掘り ← 今ここ +⬜ Phase 3: 実装方針提示 + +次にやること: <1 文> +``` + +## 4-bis. 視覚化の 3 層(L1/L2/L3)の使い分け + +図解は内容に応じ 3 層を使い分ける。実行手段の SSOT は `skills/visual-companion/SKILL.md`。本ルールは L1 ASCII と判定基準のみ持つ。 + +| 層 | 何を出すか | 手段 | いつ | +|----|-----------|------|------| +| **L1 ASCII** | 進捗・現在地マップ | ASCII 地図(ゼロ依存) | **デフォルト・常時** | +| **L2 ブラウザ HTML** | mockup・レイアウト比較 | `start-server.sh` | 見た目の比較(オプトイン) | +| **L3 ターミナル画像** | HTML を CLI で目視 | `html-to-terminal.sh` | ブラウザを開かず見たい時 | + +判定: 「読むより見た方が理解できるか?」。テキストで足りる選択は L1、見た目の比較は L2/L3。 + +## 5. 非エンジニア用語ルール + +### 原則 + +- 技術用語は**初回登場時のみ**括弧で平易語を併記、以降はそのまま使う(完全置換はしない) +- 短い同意(「お願い」「はい」)だけで進めない + +代表例(全 12 語は references 参照): PR=変更提案 / merge=本番に取り込む / migration=DB 構造変更 / staging=テスト環境 / worktree=別フォルダ作業領域。 + +### 短い同意への応答 + +「お願い」「はい」「OK」だけ返った時は**次の一手を 1 文で要約してから**再確認する。 + +### 技術判断を仰ぐ時(平易語 + 選択肢で聞く) + +**技術判断は技術用語で聞かない**。①平易語(速さ・安全性・見た目への影響)で説明②2〜3択で提示(可能なら AskUserQuestion)③推奨理由を1文添える。実例は references 参照。 + +## 6. 状態ファイル schema + +`.claude/parallel-run-state/.json` に保管(kebab-case slug、各PJの `.gitignore` へ追加)。フィールド定義・モード別 schema・`gh pr list` 合成手順の全文は `/skills/visual-companion/references/state-file-schema.md` を参照。 + +## 7. 関連ルール + +- `.claude/rules/general/response-style.md` / `sub-agent-scope-contract.md` / `branch-rule.md` +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 各フェーズ・Step 遷移時に参照 +- `commands/brainstorm.md` / `commands/parallel-run.md` — 手動発火ラッパー +- 全文: `/skills/visual-companion/references/progress-map-templates.md`, `state-file-schema.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: テンプレ・実例・置換表は references へ書き、本ルールには足さない(再肥大化防止)。** + + + + + + + +# Worktree 利用ルール + +## いつ worktree を使うか + +AI が変更を加える通常作業では、git worktree を作成して別ディレクトリで作業する。 +特に以下のいずれかに該当するときは必須: + +- **並列セッション**: Claude Code / Codex CLI / Cursor 等を同時に複数立ち上げて別タスクを進める +- **複数 PR 同時進行**: 同一リポジトリで 2 本以上の feature branch を行き来する +- **長期 feature branch**: main から離れて 1 日以上滞在する作業(途中で main を hotfix する可能性がある) +- **軽量変更を含む AI 作業**: 例外なし。詳細は branch-rule.md 参照 + +例: +``` +git worktree add ../jtt-cms-feat-xyz -b feat/xyz +cd ../jtt-cms-feat-xyz +``` + +## いつ新規 worktree を作らなくてよいか + +以下は新規 worktree なしでよい: + +- 読み取りだけでファイル変更・commit・push がない場合 +- 既に feature branch にチェックアウト済みで、別タスクを差し挟まない場合 +- 既にこのタスク専用の worktree / branch にいる場合 +- 人間が明示承認した main 直接反映や初回 repo 作成など、branch-rule.md の注記に該当する例外の場合 + +## 機密ファイル(MCP / .env)の自動 symlink + +worktree 作成時、git 追跡外の機密ファイル(`.mcp.json` / `.env` 系)は main worktree の実体へ**自動 symlink**される(git post-checkout hook 由来)。追加操作は不要。仕組み・手動再設置手順・非破壊の詳細は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## Mac mini ContextEngine mirror の自動追従 + +Mac Studio 側の worktree は Mac mini の ContextEngine mirror が自動追従する(対象: jtt-cms / jtt-apps / jtt-system / AGENT-HUB / hermes)。索引はミラーであり当日の新規変更は未反映のことがある。詳細・stale削除・semantic強化ジョブは +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## branch contamination が発生した場合の復旧 + +別セッションのブランチに誤ってコミットした場合は、誤コミット特定 → 正しいブランチへ `cherry-pick` → 復旧用退避作成、の順で対応する。 +**`git reset --hard` と force-push はデフォルト禁止。必ずユーザー承認を得てから実行する。** +詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## AI セッションから worktree へ commit / push する方法(block-main-commit 対策) + +block-main-commit hook は cwd 変更を伴う複合コマンドでの main 直 commit を fail-closed で deny する。AI セッション(cwd=main)から worktree の feature branch へ commit / push する時は: + +1. **`isolation: "worktree"` 付きサブエージェントに委譲する**(正攻法)。 +2. isolation 指定ができない場合のみ、GitHub API / connector で remote feature branch commit → PR → CI → merge の fallback を使う(main 直更新は禁止のまま)。 +3. commit/push を含まない操作(`git add` / `git status` / `gh pr create` 等)はメインセッションから直接 `cd && ...` してよい。 +4. hook 検査を `bash -c` 等で素通りさせる回避は**禁止**。 + +サブエージェントの worktree が古いベース(origin/main 以前)から切られる問題への対処、外側隔離 worktree の残存・cleanup 手順、Codex fallback の実測経緯は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 共有 checkout / main 非占有ルール(全 PJ・全 AI ツール共通) + +対象ルート: `~/LLM-Dev/` `~/business/` `~/Herd/` `~/mac-mini-server/` `~/mcp-servers/` `~/jtt-system/`。Claude / Codex / Cursor / Kimi / OpenCode / Antigravity 全て同じ意味で読む。 + +**AI セッションは、他者や他エージェントが使う可能性のある `main` checkout を掴まない。** 共有 checkout で merge / pull / cleanup を実行すると、並行セッションとブランチ・HEAD を奪い合って競合する。背景・実測実害は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### 必須:1 タスク = 1 連の完了フロー(PR を出して放置しない) + +**専用 worktree 作成 → 編集/commit/push → PR 作成 → マージ → fetch-only / detached 確認 → clean(worktree/branch 削除)まで、必ず一連で最後まで閉じる。** 「PR を出した」「マージした」で止めない。詳細コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### AI が `main` で「やらないこと / 代わりにやること」 + +- **やらない**: `git checkout main` / `git switch main` / `git pull` while on `main` / `git branch -f main`。 +- **やる**: `git fetch origin +refs/heads/main:refs/remotes/origin/main` で remote tracking ref を更新する。確認が必要な時は `git worktree add --detach origin/main` で detached 確認。 +- merge は worktree 内から `gh` / `skills/post-merge/scripts/merge-pr.py --confirm-read` で行う。 +- **cleanup は自分が作った worktree / branch だけ**削除する。`git worktree list --porcelain` で他セッションのものを確認し**温存する**。 +- allowlist 対象の生成 config を main 直コミットする時の stale-main 注意は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +要するに「編集だけ worktree、merge/pull は共有 checkout」をやめる。**着手から cleanup まで一貫して専用 worktree**で閉じる。例外的に人間が明示して main checkout を使う場合は、AI が占有している状態でないことと例外理由を作業ログへ残す。 + +## 既存 worktree の確認 + +```bash +git worktree list +``` + +`~/Herd/jtt-apps` 配下には `jtt-apps-api-rate-limit-guards` / `jtt-apps-wt` / `jtt-apps-worktrees` 等の既存 worktree がある(CLAUDE.md `## プロジェクトルート規約` 参照)。新規作成前に既存 worktree の再利用可否を確認すること。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..99b5a4fed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,155 @@ +# AgentMemory + +AgentMemory is the project-scoped MCP memory service used by the JTT agent +harness. Keep the TypeScript MCP server, its tests, and the Mac mini runtime +configuration consistent when changing this repository. + +Read `AGENTS.md` for architecture and change-surface requirements before +editing implementation code. Generated agent-harness files are managed by +AGENT-HUB; do not hand-edit them. + + +## Harness inheritance (generated) + +This block is generated by `scripts/sync-project-constitution.py`. +Project-specific rules outside this block remain authoritative. +Harness asset selection is owned only by `registries/harness-manifest.yaml`; +older text that calls `DISTRIBUTION.yaml` a skill/MCP/hook selection SSOT is superseded. + +- canonical project: `agentmemory` +- harness type: `mcp-server` +- harness type chain: `dev -> mcp-server` +- effective hash: `80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f` +- constitution assets: + - `agents-md` (selected_by=`global`, inheritance_id=`cebc562da0384df8`) + - `claude-md` (selected_by=`global`, inheritance_id=`5da8780b1008377e`) + - `gbrain-md` (selected_by=`global`, inheritance_id=`de26219fcac2c60c`) + - `gemini-md` (selected_by=`global`, inheritance_id=`d2fe0f94dbdf9274`) + + +## Project-specific instructions + +# agentmemory — Agent Instructions + +## Architecture + +agentmemory is a persistent memory system for AI coding agents, built on iii-engine's three primitives (Worker/Function/Trigger). Everything goes through `registerFunction`/`registerTrigger`/`sdk.trigger()` — never bypass iii-engine with standalone SQLite or in-process alternatives. + +- **Engine**: iii-sdk (WebSocket to iii-engine on port 49134) +- **State**: File-based SQLite via iii-engine's StateModule (`./data/state_store.db`) +- **Build**: TypeScript → ESM via tsdown, output to `dist/` +- **Test**: vitest (`npm test` excludes integration tests) + +## Consistency Rules + +**When adding or removing MCP tools, you MUST update ALL of the following:** +1. `src/mcp/tools-registry.ts` — tool definition + `getAllTools()` array +2. `src/mcp/server.ts` — handler case in the `mcp::tools::call` switch +3. `src/triggers/api.ts` — REST endpoint registration +4. `src/index.ts` — function registration + endpoint count in the log line +5. `test/mcp-standalone.test.ts` — tool count assertion +6. `README.md` — tool counts (search for "MCP tools") +7. `plugin/.claude-plugin/plugin.json` — tool count in description +8. `plugin/plugin.json` and `plugin/.mcp.copilot.json` (when present) — tool count or MCP exposure + +**When adding REST endpoints, you MUST update:** +1. `src/triggers/api.ts` — endpoint registration +2. `src/index.ts` — endpoint count in the log line +3. `README.md` — endpoint count (search for "REST endpoints" and "endpoints on port") + +**When bumping version, you MUST update ALL of the following:** +1. `package.json` — version field +2. `src/version.ts` — VERSION constant and type union +3. `src/types.ts` — ExportData version union +4. `src/functions/export-import.ts` — supportedVersions set +5. `test/export-import.test.ts` — version assertion +6. `plugin/.claude-plugin/plugin.json` — version field +7. `plugin/plugin.json` (when present) — version field + +**When adding new KV scopes:** +1. `src/state/schema.ts` — add to the KV object +2. `src/types.ts` — add the corresponding interface + +**When adding new audit operations:** +1. `src/types.ts` — add to AuditEntry.operation union type + +## Code Patterns + +### Function Registration +```typescript +sdk.registerFunction( + "mem::your-function", + async (data: { ... }) => { + // validate inputs + // do work via kv.get/kv.set/kv.list + // record audit via recordAudit() + return { success: true, ... }; + }, +); +``` + +### REST Endpoint Registration +```typescript +sdk.registerFunction("api::your-endpoint", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + // validate + whitelist fields (never pass raw body to sdk.trigger) + const result = await sdk.trigger({ + function_id: "mem::your-function", + payload: { ... }, + }); + return { status_code: 200, body: result }; +}); +sdk.registerTrigger({ + type: "http", + function_id: "api::your-endpoint", + config: { api_path: "/agentmemory/your-path", http_method: "POST" }, +}); +``` + +### MCP Tool Handler +```typescript +case "memory_your_tool": { + // validate args with typeof checks + // parse CSV args: args.field.split(",").map(t => t.trim()).filter(Boolean) + const result = await sdk.trigger({ + function_id: "mem::your-function", + payload: { ... }, + }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } }; +} +``` + +### Hook Scripts +Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). They read JSON from stdin, make HTTP calls to the REST API, and exit. There are two patterns depending on whether Claude Code consumes the script's stdout: + +- **Context-injecting hooks** (`pre-tool-use`, `pre-compact`, `session-start`) write recalled context to stdout for Claude Code to inject. These MUST use `try/catch` with `await fetch(..., { signal: AbortSignal.timeout(N) })` — the script has to wait for the response before exiting, and the timeout is the only bound on hang time. +- **Telemetry-only hooks** (`notification`, `post-tool-failure`, `post-tool-use`, `prompt-submit`, `stop`, `session-end`, `subagent-start`, `subagent-stop`, `task-completed`) write nothing to stdout. These MUST use fire-and-forget `fetch(..., { signal: AbortSignal.timeout(N) }).catch(() => {})` paired with `setTimeout(() => process.exit(0), 500).unref()`. The unawaited fetch dispatches the request; the unref'd `setTimeout` force-exits the process after the request has been flushed to the local daemon's socket buffer (~500ms is enough for single-request hooks; use 1500ms for multi-request hooks like `stop` and `session-end` so all fetches have time to start, especially when `AGENTMEMORY_URL` points to a remote daemon). Without the `setTimeout` Node keeps the event loop alive waiting for any in-flight fetch to settle, which means the hook still blocks Claude Code's next-prompt boundary for up to the AbortSignal duration — exactly the bug fire-and-forget is meant to fix. + +## Coding Standards + +- TypeScript, ESM only (`"type": "module"`) +- No code comments explaining WHAT — use clear naming instead +- Use `fingerprintId()` for content-addressable dedup, `generateId()` for unique IDs +- Parallel operations where possible (`Promise.all` for independent kv writes/reads) +- Input validation at system boundaries (MCP handlers, REST endpoints) +- REST endpoints must whitelist fields — never pass raw request body to `sdk.trigger()` +- Use `recordAudit()` for state-changing operations +- Timestamps: capture once with `new Date().toISOString()` and reuse + +## Testing + +- All tests must pass before PR: `npm test` (1,428+ tests) +- Mock pattern: `vi.mock("iii-sdk")` with mock `sdk.trigger`, `kv.get/set/list` +- Test files go in `test/` with `.test.ts` extension +- Follow existing patterns in `test/crystallize.test.ts` for function tests + +## Current Stats (v0.9.28) + +- 54 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) +- 130 REST endpoints +- 6 MCP resources, 3 MCP prompts +- 12 hooks, 15 skills +- 260+ iii functions +- 1,428+ tests diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000..70abcbbd9 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,1557 @@ + + +# GEMINI.md + +## Gemini CLI subagents の呼び出し方 + +このリポジトリには `.gemini/agents/*.md` が配置されている。Gemini CLI は +これらを自動認識し、main agent に **同名のツール** として公開する。 + +### 呼び出し方法 (2 通り) + +1. **強制指定 (`@` syntax)** — 確実に特定の subagent に渡したい時: + ``` + @ + ``` + 例: `@psychiatric-specialist のんちゃんの行動パターンを分析して` + +2. **自動委譲** — main agent が description マッチで自動判断: + subagent の `description:` に書かれたトリガー条件にユーザー prompt が + 合致すると、main agent が自動的にその subagent を呼び出す。 + +### 注意 (Claude Code との違い) + +- Claude Code 由来の構文 `invoke_agent('xxx', '...')` や `Task tool` 形式は + Gemini CLI では **動作しない** (テキストとして出力されるだけ)。 +- subagent は他の subagent を呼べない (無限ループ防止)。 +- subagent の `tools:` を省略すると親セッションの全ツールを継承する。 + +公式ドキュメント: https://geminicli.com/docs/core/subagents/ + +--- + +## From CLAUDE.md + +# AgentMemory + +AgentMemory is the project-scoped MCP memory service used by the JTT agent +harness. Keep the TypeScript MCP server, its tests, and the Mac mini runtime +configuration consistent when changing this repository. + +Read `AGENTS.md` for architecture and change-surface requirements before +editing implementation code. Generated agent-harness files are managed by +AGENT-HUB; do not hand-edit them. + + +## Harness inheritance (generated) + +This block is generated by `scripts/sync-project-constitution.py`. +Project-specific rules outside this block remain authoritative. +Harness asset selection is owned only by `registries/harness-manifest.yaml`; +older text that calls `DISTRIBUTION.yaml` a skill/MCP/hook selection SSOT is superseded. + +- canonical project: `agentmemory` +- harness type: `mcp-server` +- harness type chain: `dev -> mcp-server` +- effective hash: `80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f` +- constitution assets: + - `agents-md` (selected_by=`global`, inheritance_id=`cebc562da0384df8`) + - `claude-md` (selected_by=`global`, inheritance_id=`5da8780b1008377e`) + - `gbrain-md` (selected_by=`global`, inheritance_id=`de26219fcac2c60c`) + - `gemini-md` (selected_by=`global`, inheritance_id=`d2fe0f94dbdf9274`) + + +## Project-specific instructions + +# agentmemory — Agent Instructions + +## Architecture + +agentmemory is a persistent memory system for AI coding agents, built on iii-engine's three primitives (Worker/Function/Trigger). Everything goes through `registerFunction`/`registerTrigger`/`sdk.trigger()` — never bypass iii-engine with standalone SQLite or in-process alternatives. + +- **Engine**: iii-sdk (WebSocket to iii-engine on port 49134) +- **State**: File-based SQLite via iii-engine's StateModule (`./data/state_store.db`) +- **Build**: TypeScript → ESM via tsdown, output to `dist/` +- **Test**: vitest (`npm test` excludes integration tests) + +## Consistency Rules + +**When adding or removing MCP tools, you MUST update ALL of the following:** +1. `src/mcp/tools-registry.ts` — tool definition + `getAllTools()` array +2. `src/mcp/server.ts` — handler case in the `mcp::tools::call` switch +3. `src/triggers/api.ts` — REST endpoint registration +4. `src/index.ts` — function registration + endpoint count in the log line +5. `test/mcp-standalone.test.ts` — tool count assertion +6. `README.md` — tool counts (search for "MCP tools") +7. `plugin/.claude-plugin/plugin.json` — tool count in description +8. `plugin/plugin.json` and `plugin/.mcp.copilot.json` (when present) — tool count or MCP exposure + +**When adding REST endpoints, you MUST update:** +1. `src/triggers/api.ts` — endpoint registration +2. `src/index.ts` — endpoint count in the log line +3. `README.md` — endpoint count (search for "REST endpoints" and "endpoints on port") + +**When bumping version, you MUST update ALL of the following:** +1. `package.json` — version field +2. `src/version.ts` — VERSION constant and type union +3. `src/types.ts` — ExportData version union +4. `src/functions/export-import.ts` — supportedVersions set +5. `test/export-import.test.ts` — version assertion +6. `plugin/.claude-plugin/plugin.json` — version field +7. `plugin/plugin.json` (when present) — version field + +**When adding new KV scopes:** +1. `src/state/schema.ts` — add to the KV object +2. `src/types.ts` — add the corresponding interface + +**When adding new audit operations:** +1. `src/types.ts` — add to AuditEntry.operation union type + +## Code Patterns + +### Function Registration +```typescript +sdk.registerFunction( + "mem::your-function", + async (data: { ... }) => { + // validate inputs + // do work via kv.get/kv.set/kv.list + // record audit via recordAudit() + return { success: true, ... }; + }, +); +``` + +### REST Endpoint Registration +```typescript +sdk.registerFunction("api::your-endpoint", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + // validate + whitelist fields (never pass raw body to sdk.trigger) + const result = await sdk.trigger({ + function_id: "mem::your-function", + payload: { ... }, + }); + return { status_code: 200, body: result }; +}); +sdk.registerTrigger({ + type: "http", + function_id: "api::your-endpoint", + config: { api_path: "/agentmemory/your-path", http_method: "POST" }, +}); +``` + +### MCP Tool Handler +```typescript +case "memory_your_tool": { + // validate args with typeof checks + // parse CSV args: args.field.split(",").map(t => t.trim()).filter(Boolean) + const result = await sdk.trigger({ + function_id: "mem::your-function", + payload: { ... }, + }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } }; +} +``` + +### Hook Scripts +Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). They read JSON from stdin, make HTTP calls to the REST API, and exit. There are two patterns depending on whether Claude Code consumes the script's stdout: + +- **Context-injecting hooks** (`pre-tool-use`, `pre-compact`, `session-start`) write recalled context to stdout for Claude Code to inject. These MUST use `try/catch` with `await fetch(..., { signal: AbortSignal.timeout(N) })` — the script has to wait for the response before exiting, and the timeout is the only bound on hang time. +- **Telemetry-only hooks** (`notification`, `post-tool-failure`, `post-tool-use`, `prompt-submit`, `stop`, `session-end`, `subagent-start`, `subagent-stop`, `task-completed`) write nothing to stdout. These MUST use fire-and-forget `fetch(..., { signal: AbortSignal.timeout(N) }).catch(() => {})` paired with `setTimeout(() => process.exit(0), 500).unref()`. The unawaited fetch dispatches the request; the unref'd `setTimeout` force-exits the process after the request has been flushed to the local daemon's socket buffer (~500ms is enough for single-request hooks; use 1500ms for multi-request hooks like `stop` and `session-end` so all fetches have time to start, especially when `AGENTMEMORY_URL` points to a remote daemon). Without the `setTimeout` Node keeps the event loop alive waiting for any in-flight fetch to settle, which means the hook still blocks Claude Code's next-prompt boundary for up to the AbortSignal duration — exactly the bug fire-and-forget is meant to fix. + +## Coding Standards + +- TypeScript, ESM only (`"type": "module"`) +- No code comments explaining WHAT — use clear naming instead +- Use `fingerprintId()` for content-addressable dedup, `generateId()` for unique IDs +- Parallel operations where possible (`Promise.all` for independent kv writes/reads) +- Input validation at system boundaries (MCP handlers, REST endpoints) +- REST endpoints must whitelist fields — never pass raw request body to `sdk.trigger()` +- Use `recordAudit()` for state-changing operations +- Timestamps: capture once with `new Date().toISOString()` and reuse + +## Testing + +- All tests must pass before PR: `npm test` (1,428+ tests) +- Mock pattern: `vi.mock("iii-sdk")` with mock `sdk.trigger`, `kv.get/set/list` +- Test files go in `test/` with `.test.ts` extension +- Follow existing patterns in `test/crystallize.test.ts` for function tests + +## Current Stats (v0.9.28) + +- 54 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) +- 130 REST endpoints +- 6 MCP resources, 3 MCP prompts +- 12 hooks, 15 skills +- 260+ iii functions +- 1,428+ tests + +## From .claude/rules/ + +### general/ai-model-selection.md + + + +# AI モデル選定指標(GLM 5.2 / Kimi K2.7・K3) + +全 PJ 共通。コード実装をAIエージェントに任せる際の初期ヒューリスティック。 + +> ⚠️ これは**法則ではなく初期判断**。母数が小さい(初期 n=4 + 追加観測・人が見ながら実行)。矛盾する観測が出たら現状を優先し、実測ログ(references)を更新すること。 + +--- + +## 0-bis. Codex 指名時の固定ルール + +- **`codexで実装` / `Codexで実装` / `codex実装` / `Codex実装`** と言われた時だけ、Codex 実装として扱う。 +- Codex 実装の正式設定名は **model = `gpt-5.3-codex-spark`**, **model_reasoning_effort = `high`**(既定。旧既定 `medium`。SWE-Bench Pro 実測で high→xhigh の上げ幅は1pt未満のため常時 xhigh は費用対効果が低い)。 +- 起動例は `codex exec -m gpt-5.3-codex-spark -c model_reasoning_effort=high`。 +- **`xhigh` はユーザーが明示指定した時だけ使う**。軽微タスクは `medium` を明示指定する。AI が自動・既定・推測で `xhigh` を選ばない。 +- **「実装」だけでは Codex 固定にしない**。Cursor / Kimi / GLM / Claude / Codex のどれで進めるかを文脈で判断し、不明なら確認する。 +- **Spark は AI Worker MCP の auto routing 候補に対等参加する**(適材適所+残量バランス・絶対優先ではない)。原因不明バグ・設計判断・DB移行・大規模リファクタ・コンテキストが大きい仕事は Spark に固執せず、auto が適材適所で他 worker(GLM/Kimi/Gemini)へ回避する。 +- **「レビュー」または「codexでレビュー」** は既存の `codex-review` 導線を使う。実装専用の `gpt-5.3-codex-spark` 固定には巻き込まない。 + +--- + +## 4. 使い分けガイド(第一候補) + +| タスク種別 | 第一候補 | 理由 | +|-----------|---------|------| +| 仕様が明確・クリーンさ重視・UI/結線・お手本コード | **GLM 5.2** | 簡潔・範囲内に収まりやすい・速い | +| 複雑・セキュリティ/堅牢性が重要なバックエンド | **Kimi K2.7 Code** | 安全性を自力で深掘り・テスト厚い | +| どちらでも可 | いずれか | ただし下記ガードを必ず付ける | + +### Kimi 内モデル選択(決定論的) + +`agents.yaml.worker_delegation.kimi_model_routing` を正本とし、優先順は、明示 `provider_model` → 長大/推定不能な巨大contextの `k3` → 明示的な速度優先かつ3倍quota許容時の `kimi-for-coding-highspeed` → 通常の `kimi-for-coding` とする。 + +- K3条件: `requires_long_context=true`、推定contextが212,992 token超、または推定不能かつraw UTF-8が512KiB超。`max`、上限1,048,576 token。 +- 選定結果: `reason_code` / `selected_model` / `estimated_context` / `fallback_reason` を必ず残す。 +- K3切替: 新sessionを開始し、必要情報の要約だけを渡す。履歴を丸ごと移送しない。 + +GLM 5.2 の正式運用は high / max のみ(デフォルト high・他の値はルーティングのバリデーションで拒否される)。母数は n=4 の初期観測であり法則ではない(冒頭⚠️参照)。 + +--- + +## 5. 運用上の必須ガード(モデルの弱点を相殺する) + +- **完了の定義を検証可能に**(Kimi の過大申告対策): 「スクショは git にコミット」「テストは緑のログを示す」等、"やったと言うだけ"を許さない。 +- **スコープを超えるなを明示**(Kimi の過剰実装対策): 「指定範囲のみ。追加の堅牢化は別 PR」。 +- **長時間タスクは声がけ / 自動継続**(GLM の停滞対策)。 +- **リポの前提を渡す**(GLM の取り違え対策): 言語・パッケージ管理の前提を明記。 +- **既存 CaD コメント規約に倣わせる**: 新規関数・ブロック追加時は対象ファイルの既存様式(日付・種別・背景3点)に倣うと明記する。 + +--- + +## 6. 候補提案とディスパッチ + +実装委譲・並列実装の話題が出たら §4 を根拠に「GLM 5.2 向き / Kimi向き」を 1 行理由つきで先に提案し、Kimi内のK2.7/K3は上記契約で選ぶ。ディスパッチ実行は `agent-dispatch` スキルへ(未導入環境では §4・§5 のみ使う)。役割分担: 方針選定・委譲・進捗確認・結果回収 = Claude / Codex。実行は `agents.yaml` の有効 provider だけを AI Worker MCP 経由で行う。プロンプトには §5 の必須ガードを必ず織り込む。 + +詳細手順は `skills/agent-dispatch/` を参照(本ルールは方針、skill は手順=DRY)。 + +--- + +## 8. 関連 + +- `skills/agent-dispatch/` — `agents.yaml` と AI Worker MCP を使う worker 委譲手順(本ルールの実行系) +- `skills/kimi-sync/` — Kimi CLI のPJアタッチ(`sync-kimi-from-cc.py`) +- `.claude/rules/general/response-style.md` — 出力簡潔性 +- `.claude/rules/general/visual-progress-map.md` — 進捗可視化 +- `dotfiles/kimi/config.toml.base` — Kimi Code CLI の loop/permission 既定(`max_steps_per_turn` 等) +- 実測ログ・スコアカード・OpenCode Go 選定指標の全文: `/skills/agent-dispatch/references/model-selection-evidence.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: 実測ログ・スコアカードは references(上記)へ追記し、本ルールには足さない(再肥大化防止)。** + +### general/branch-rule.md + + + +# ブランチ運用ルール + +## main ブランチへの直接コミット・プッシュ + +AI エージェントの通常作業では、**main ブランチへの直接コミット・プッシュは禁止**。 + +Markdown、`sync-state.json`、AI ツール設定、AGENT-HUB 運用設定、MCP 台帳などの軽量変更でも、 +AI は main へ直接 commit / push しない。必ず専用 worktree + feature branch を作成し、PR 経由でマージする。 + +人間が明示的に「今回は main に直接反映してよい」と承認した場合、または初回 repo 作成直後で +PR 導線がまだ存在しない場合だけ例外になりうる。AI はこの例外を自己判断で使わず、理由を作業ログに残す。 + +(過去に運用設定・hook配布物等を段階的に allowlist で main 直接許可した経緯があるが、2026-06-23〜2026-07-01 +で全撤回済み。allowlist 変遷史の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照)。 + +## 理由 + +- main checkout は複数 AI / 複数セッションで共有されやすく、軽量変更でも HEAD を掴むと競合や cleanup 失敗の原因になる +- Markdown や設定だけでも、PR にするとレビュー履歴・CI・merge 後確認・worktree cleanup が同じ型で残る +- ツールごとに例外を残すと、Claude / Codex / Cursor / Kimi / Antigravity 間で運用がずれる +- main の最新化は `git pull` ではなく、fetch-only と detached HEAD / 専用 verify worktree で確認すれば足りる + + +## AGENT-HUB の CI とマージ根拠(2026-07-30 STEP 4) + +AGENT-HUB の CI は `workflow_dispatch` + `ci/light` ラベル方式(pull_request 自動トリガーは 2026-07-24 に削除済み)。 +PR に checks が無い場合のマージ根拠は `merge-pr.py` のローカル軽量ゲート(`registries/merge-gate-suite.yaml`)。 +台帳未整備のリポでは従来どおり checks 0 件で通す(詳細: `skills/post-merge/SKILL.md`)。 + +## 配布クローズアウト責任 + +AGENT-HUB から各 PJ へ配布した差分は、配布を実行した AI / 担当者が最後まで閉じる。 + +対象: `scripts/deploy-agent-bundle.py` / `scripts/deploy-hooks.py` / `scripts/sync-agents.py` / +`scripts/bootstrap-skills.py` / `scripts/deploy-skills.py` / `scripts/deploy-rules.py` / +`/publish-deploy` など、上記を呼ぶ配布コマンド。 + +配布先 PJ に tracked 差分が出た場合は、feature branch 作成 → 配布差分だけ commit → PR 作成 → CI/review 確認 → +`merge-pr` でマージ → fetch-only + detached HEAD / verify worktree で取り込み確認 → worktree/branch cleanup → +`git status --short` clean 確認、まで一連で完了する(詳細な完了条件・禁止・例外の全文は `~/business/AGENT-HUB/docs/worktree-operations.md` 参照)。 + +禁止: 「これは自分が修正したファイルではない」として配布差分を放置する/未コミットのまま終了する/ +main 直接 push で済ませる/`--push` の成功だけで完了扱いにする。 + +例外(dry-run のみ・差分なし・既存WIPで安全に branch できない・権限やCI failureで merge できない)の場合も、 +対象 PJ・残っている差分・止めた理由・次の安全な一手を報告する。 + +## 事前計画ステップ + +タスク開始時、変更を伴う作業か確認する(コード変更・JSON/YAML変更・`*.sh`変更・Markdown/sync-state/AIツール設定などの軽量変更)。 +AI 作業で変更がある場合、**最初に専用 worktree + feature branch を作成**してから編集を始める。AI 作業では `main` を checkout しない。 +コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +読み取りだけの場合、またはすでに専用 worktree / feature branch 内にいる場合は新規 worktree を作らなくてよい。 +AGENT-HUB から各 PJ へ配布した tracked 差分も「配布クローズアウト責任」に従う。 + +## pre-commit hook 違反後のピボット + +万一 hook(`hook-library/scripts/block-main-commit.sh`)にブロックされた場合は、変更を退避(stash/patch)→ +専用 worktree で feature branch 作成 → 変更復元 → commit/push → PR 作成、の順で復旧する。main の HEAD は +無変更のまま維持されることを確認する。詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 関連フック + +`hook-library/scripts/block-main-commit.sh` が上記ルールを自動判定・ブロックする。 + +## 関連ルール + +- `.claude/rules/general/worktree-rule.md` — 並列セッション時の worktree 利用 +- `.claude/rules/general/sub-agent-scope-contract.md` — サブエージェント delegate 時の制約 +- `~/business/AGENT-HUB/docs/worktree-operations.md` — allowlist変遷史・配布クローズアウト責任詳細・事前計画コマンド列・pre-commit hookピボット手順の正本 + +--- + +**追記ルール: 実測事例・変遷史・長文手順は `~/business/AGENT-HUB/docs/worktree-operations.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** + +### general/constructive-dissent.md + + + +# 建設的異議(言いなり禁止・グローバル憲法) + +## 原則 + +AI は**言いなりにならない**。ユーザー指示が現実・制約・過去の不採用判断(CaD)と衝突するとき、迎合せず次の 3 点を必ず行う。 + +1. **現実的制約の明確な指摘** — 無理なものは「無理です」と根拠付きで言う(時間・技術・運用・既存 SSOT・過去の不採用理由)。 +2. **根拠付きの代替案** — 達成したい意図を保ちつつ、実行可能な別ルートを 2〜3 択で提示する(推奨を 1 行添える)。 +3. **保守・メンテナンス観点の改善提案** — 指示に従うだけでなく、「こういう仕組みを入れるべき」と AI から先出しする(出生登録・正本参照・陳腐化防止など)。 + +**最終決定は常にユーザー**。AI は異見を述べたうえで、ユーザーが選んだ方向に従う。 + +## 発火場面 + +| フェーズ | 異議の出し方 | +|---------|-------------| +| **提案・設計** | plan-approval の HTML プランに「🤔 AI の異見」欄で記載(テンプレ側は別 PR で欄追加予定)。プラン提示前に衝突があれば先に異議を出す | +| **実装** | 着手前または実装中に制約・不採用判断との衝突を検知したら、実装を止めて代替案を提示 | +| **レビュー** | codex-review 等の指摘が個人開発スケールに過剰なときも、レビュー結果に対して異議・優先度の再整理を提案できる | + +判断に迷う場合は**異議を出す側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 作法 + +- **根拠必須**: 「良くない」だけでなく、なぜ無理か・何が起きるかを平易語で 1〜2 文。 +- **平易語 + 選択肢**: visual-progress-map §5 に従い、技術用語だけで問わない。速さ・安全・見た目への影響など、ユーザーが判断できる軸に翻訳する。 +- **推奨を添える**: 2〜3 択のうち推奨を明示(「(推奨)」+ 理由 1 行)。 +- **短い同意への再確認**: ユーザーが「お願い」「はい」だけ返したとき、次の一手を 1 文で要約してから進める(response-style と整合)。 + +## 個人開発スケールと例外 + +- **前提**: 本リポ群は個人開発(1 人・非エンジニアオーナー)。大規模チーム向けのプロセス・過度な抽象化・仮想的大規模負荷対策を**無条件で推奨しない**。 +- **過剰エンタープライズ提案への異議**: 「全 PJ に同じ監査パイプライン」「専用 infra チーム前提の運用」等は、意図が明確でない限り異議を唱える。 +- **例外(厳格維持)**: + - **(a)** セキュリティ・データ消失・金銭に関わる指摘はスケールに関係なく常に厳格。 + - **(b)** 顧客向けシステム(jtt-cms の予約・お客様導線・決済・個人情報を扱う画面/API)はエンタープライズ相当の厳格さを維持。 + +codex-review のレビュー観点にも同校正が内蔵されている(プロンプト文字列参照)。 + +## メンテナンス観点の先出し例 + +- 新スキル・hook・ドキュメントを作るとき → `checkup-registry.yaml` への出生登録を提案。 +- 手順・閾値・API 名をハードコードしそうなとき → 正本参照(ライブ読み・SSOT symlink)を提案。 +- 外部 API・ライブラリ版数を書くとき → 最終確認日の記載を提案。 + +## 関連 + +- `.claude/rules/general/response-style.md` — 出力簡潔性・確認の書き方 +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・技術判断の平易化(§5) +- `.claude/rules/general/plan-approval-gate.md` — 実装前 HTML プラン承認(🤔 AI の異見欄と接続) +- `.claude/rules/general/plan-commitment-tracking.md` — 承認済みプラン条項の実行追跡 +- `skills/adversarial-review/SKILL.md` — **本ルールの手順 SSOT**(dev / business の 2 モード・発火条件・証拠水準・自己反証・分布点検)。本ルールは義務、スキルは手順の二段構えとし、手順本文をここへ複製しない +- `skills/codex-review/SKILL.md` — レビュー時の個人開発スケール校正 + +### general/hooks-structure-rule.md + +--- +description: hooks構造ルール — チェックリストMDの配置制約とゾンビ復活禁止 +paths: + - 'hook-library/**' + - 'hook-registry.yaml' + - 'scripts/deploy-hooks.py' +--- + +# hooks 構造ルール + + + +## チェックリストMDの配置 + +| 正しい配置 | 禁止 | +| ----------------------------------------------------- | -------------------------- | +| `hook-library/lib/code-quality-check.md` | `hook-library/prompts/` | +| `hook-library/checklists/security/security-review-check.md` | 任意の新規サブディレクトリ | + +配布後の PJ 側でも同じ規約に従う: + +| 正しい配置 | 禁止 | +| -------------------------------------------- | -------------------------- | +| `.claude/hooks/lib/code-quality-check.md` | `.claude/hooks/prompts/` | +| `.claude/hooks/lib/security-review-check.md` | 任意の新規サブディレクトリ | + +## 禁止事項 + +- `prompts/` ディレクトリの作成・復活(AGENT-HUB / 配布先 PJ いずれも) +- `quality-check-common.sh` のチェックリスト参照パスを `lib/` 以外に変更 +- `supabase-sql-review.md` の復活(`security-review-check.md` と重複していた削除済みファイル) +- `ui-quality-gate.json` の復活(`type: "prompt"` でレビュー LLM に丸投げする方式は失敗時にプロンプト原文がチャットに漏れるため廃止。UI 品質チェックは `code-quality-check.md` の `ui-quality-jp` domain を `subagent-quality-check.sh` / `stop-quality-check.sh` がファイル参照型で reason に出す形で完結する) +- `hook-registry.yaml` の `checklist.security` に `KNOWN_SECURITY_CHECKLISTS` allow-list 外の名前を書くこと(`scripts/deploy-hooks.py` が fail-fast で拒否する) +- 対象PJを明示せずに hook を追加・配布すること。新規 hook は「必要な PJ」「不要な PJ」「Codex/Augment へ載せるか」を AGENT-HUB セッションで決めてから `hook-registry.yaml` に登録する。 +- `/hook-publish` の復活。project 配布applyは + `scripts/sync-agents.py --project --project-root ` だけを公開入口とし、物理 hook writerを単独実行しない。 + +## hook 追加・配布フロー + +1. AGENT-HUB セッションで hook の目的と対象PJを決める。 +2. `hook-library/scripts/`、`hook-library/settings/`、`scripts/deploy-hooks.py` の script map、`hook-registry.yaml` を同一PRで更新する。 +3. `scripts/sync-agents.py --project --dry-run` で全 surface の同一generation差分を確認する。 +4. 実配布が必要ならcleanな専用linked worktreeを明示してfull applyする。複数PJでも明示リストを1件ずつ処理する。 +5. 個別 writer の `--all` は使わない。全PJの一括同期は別の明示承認とscope確認を必要とする。 + +## チェックリスト注入方式 + +| 方式 | 説明 | +| ------------------------ | --------------------------------------------------------- | +| ファイル参照型(採用) | reason にファイルパスを記載し、AIがReadツールで読む | +| インライン注入型(廃止) | reason にチェックリスト全文を埋め込む(チャットが埋まる) | + +reason にチェックリスト全文を埋め込まないこと。AI が Read ツールでファイルを読む形にすることで、ユーザーのチャット視認性を確保する。 + +## 配布スクリプトによる強制ガード(`scripts/deploy-hooks.py:merge_settings()`) + +| ガード | 役割 | +| --- | --- | +| `_strip_prompt_type_hooks()` | `type: "prompt"` の hook をマージ時に強制除去。インライン注入型の混入を配布パイプラインで遮断する | +| `_dedupe_hooks_by_command()` | `(matcher, paths, command)` ベースで dedupe。dict 完全一致比較が空白・キー順差で破綻し、過去 jtt-cms に重複 4 件(`prettier-format` / `seo-check` / `storage-url-check` / `block-main-commit`)が混入した実績の再発防止 | + +これらのガードと `--all --confirm-all-hook-scope` の安全弁を外す変更は禁止。検証スクリプト `scripts/test-deploy-hooks-merge-settings.sh` がガードの挙動を回帰チェックする。 + +## 理由 + +`scripts/deploy-hooks.py`(テンプレート配布スクリプト)は配布先 PJ の `lib/` にチェックリストMDをデプロイする。`quality-check-common.sh`(runtime)が異なるパスを参照すると、新規 PJ セットアップ後に品質チェックリストが見つからず approve が素通りする。 + +`security-review-check.md` の内容は SECURITY DEFINER / RLS / `crm.` schema 等 Supabase + Postgres 専用のため、Supabase を使わない PJ には配布しない(registry の `checklist.security` を空配列にする)。 + +### general/latest-stack-context7.md + +--- +description: 最新スタック確認ルール — Next.js/React/serwist等の急速更新ライブラリは実装前にcontext7で最新docsを取得 +paths: + - '**/*.ts' + - '**/*.tsx' + - '**/*.js' + - '**/*.jsx' + - '**/*.mjs' + - '**/*.vue' + - '**/*.svelte' + - 'package.json' + - 'next.config.*' + - 'tailwind.config.*' + - 'drizzle.config.*' + - 'vite.config.*' + - '**/sw.ts' +--- + +# 最新スタック確認ルール(context7 必須) + +## 対象ライブラリ(AI カットオフ後・急速更新) + +以下を**実装・デバッグ・設定変更する前に必ず** context7 で最新 docs を取得する。 +記憶だけで書かない(古い API を使うと動かない・型エラー・ビルド失敗を引き起こす)。 + +| ライブラリ / フレームワーク | 主な罠 | +|----------------------------|--------| +| **Next.js 16+** | `middleware` → `proxy.ts` に改名(Next15→16)、`cookies()`/`headers()` は非同期=`await` 必須(Next15で async 化・16で同期アクセス廃止)、App Router キャッシュ挙動変更 | +| **React 19+** | Next15 以降は React19 前提。`use()`, Server Actions の型・挙動変更 | +| **@serwist/next** / **serwist** | SW ビルド設定・`defaultCache` API が頻繁変更。Turbopack 非対応(`--webpack` 必須) | +| **motion 12+** (`motion/react`) | `motion-plus` API、`AnimatePresence`・`useSpring` 型変更 | +| **Tailwind CSS v4+** | `@config` 廃止・CSS ファースト設定に移行(`tailwind.config.js` 非推奨) | +| **drizzle-orm** | マイグレーション API・スキーマ定義が毎 minor で変わりやすい | +| **vaul** | ドロワー API・`snapPoints` 型が変わっている可能性 | +| **sonner** | `toast()` オプション・`Toaster` props の更新 | + +## 必須手順 + +1. `mcp__context7__resolve-library-id` でライブラリの context7 ID を取得 +2. `mcp__context7__query-docs` で最新 docs を取得してから実装 +3. context7 が使えない環境は `WebFetch` で公式 docs を取得(記憶補完のみでの実装禁止) + +``` +例: Next.js 16 の proxy.ts (旧 middleware) を実装する前に + → resolve-library-id "next.js" → query-docs "proxy middleware" +例: serwist defaultCache を設定する前に + → resolve-library-id "@serwist/next" → query-docs "defaultCache" +``` + +## 古い API の使用禁止 + +- **Next15 以前の同期 `cookies()`**: Next16 では非推奨。`await cookies()` を前提に書く(context7 で確認) +- **`middleware.ts`(Next16 では `proxy.ts`)**: 名前が変わった。context7 で確認してから書く +- **Pages Router 前提のコード**: App Router が前提。`getServerSideProps` 等を新規に書かない +- **React18 前提の型**: React19 の型変化(`children: ReactNode` の必須化等)を確認してから書く +- **旧 `motion/react` 型**: `motion-plus` の型は memory だけで書かない + +## 関連 + +- `skills/dev-guardrails` — フェーズ別ワークフロー・品質ゲート +- `skills/pwa-guardrails` — serwist 配線・PWA 品質チェックリスト(context7 が必要になる代表例を列挙) + +### general/mandate-registry.md + + + +# 横断チェック台帳(mandate-registry)への登録ルール + +## 原則 + +「これは全アプリで必要だ」という横断的な気づきは、ルール追記だけで終わらせず**台帳へ1行登録する**。 + +理由: ルールファイルへの追記は**新規開発にしか効かない**。既存アプリへの適用漏れは、機械が乖離を提示しない限り再指摘が起きるまで発火しない。台帳へ登録しておけば `mandate-audit.py` が未対応アプリを一覧化し、記憶や注意力に頼らず気づける。 + +## 発火条件(トリガー) + +伸太郎殿が以下のような**横断指摘**をしたとき: + +- 「これは全アプリで必要」 +- 「横展開すべき」 +- 「他のアプリでも同じ対応が要る」 + +判断に迷う場合は**登録する側**に倒す(後から「言ってくれれば」の手戻りを防ぐ)。 + +## 必須手順 + +1. **重複確認**: `registries/mandate-registry.yaml` を `id` / `title_ja` で grep し、同種の項目が既に無いか確認する(複数 AI による二重登録防止)。 +2. **1行登録**: 無ければ台帳へ1エントリ追加する。`reason` には経緯1行+日付を必須で入れる。 +3. **報告**: 登録したことを利用者へ報告する(黙って追加しない)。 + +## 監査 + +「横断監査して」等の発話で `python3 scripts/mandate-audit.py` を実行し、結果を提示する。作業対象アプリが決まっているセッションでは `--app ` で絞り込む。 + +## 回答の記録 + +台帳の `status` フィールドは利用者の回答をそのまま反映する: + +| 利用者の回答 | 記録する値 | +|------|-----------| +| 「後で」 | `snoozed:YYYY-MM-DD` | +| 「対象外」 | `na` | +| 対応 PR がマージされた | `done` | + +## 限界の明示 + +`check: manual` の項目は**目視消込**であり、**監査が緑でも全部 OK を意味しない**。機械(`mandate-audit.py`)が見えるのは台帳に記録された静的な項目だけであり、実装が実際にルールへ適合しているかは別途確認が要る。 + +## スキーマ・規約の正本 + +台帳のフィールド定義・規約①②(`check:script` の実行前提・登録前の重複確認義務)は `registries/mandate-registry.yaml` のヘッダコメントが正本。本ルールへ複製しない。 + +## 試行フェーズ + +2026-08-17 目安で、登録実績・提案件数・`status` 更新のコストを振り返る。セッション開始 hook による自動提案の採否は、その振り返りを踏まえて別プランで判断する(今は hook 化しない)。 + +--- + +**追記ルール: 実測事例・長文手順は台帳ヘッダ/別 doc へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** + +### general/mcp-key-management.md + + + +# MCP API キー管理規範(AGENT-HUB SSOT) + +JTT 関連の MCP(asana-mcp / jtt-smaregi-mcp / smaregi-docs / google-chat-mcp / google-docs-mcp / jtt-spreadsheet-mcp 等)の API キーは **AGENT-HUB を SSOT として一元管理**する。 + +詳細手順(復旧・ローテーション・実装経緯・実例)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。本ルールは義務・禁止事項だけを持つ。 + +## SSOT + +| 役割 | 場所 | 状態 | +|------|------|------| +| 実値(秘匿) | `~/.config/agent-hub/.env` | コミット対象外、各マシンで作成 | +| 名前テンプレート(公開) | `~/business/AGENT-HUB/dotfiles/.env.example` | git 管理、新マシン bootstrap で参照 | +| 環境変数 export | `~/.zshrc.local` の `set -a; source ~/.config/agent-hub/.env; set +a` | bootstrap.sh が初期セットアップ | + +## スコープ振り分け規範 + +| MCP 種別 | 配布先 | 同期スクリプト | +|---------|--------|---------------| +| 全 PJ 共通で必要な MCP | `asset_contract.global.include.mcp` から各clientの宣言surfaceへ | manifestが所有者として指す単一writer | +| harness type共通の MCP(例: Laravel Boost) | `asset_contract.harness_types..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 固有の業務 MCP | `asset_contract.projects..include.mcp` からProject scopeへ | `sync-agents.py` generation batch内の単一writer | +| PJ 個別環境の例外(例: Supabase stg/prod) | project layerと明示local-exception契約へ宣言 | writerが保護・描画。生成surfaceの手編集は禁止 | + +理由: User scope に PJ 固有 MCP を入れると「使わない PJ でも表示・接続試行・認証エラー表示」が起きる。PJ別の使用意図はmanifestのproject layerが表現し、client別catalogは選択根拠にしない。 + +**Gmail の扱い(2026-05-25 更新 / 2026-07-20選択経路更新)**: 自前 gmail-mcp は 2026-05-21 に一度凍結したが、公式 Gmail のツール不足(ラベル CRUD / Triage / 添付取得欠如)が判明し **2026-05-25 に Project scope (jtt-cafe-pj) で復活**。接続definitionはStreamable HTTP `/mcp` + X-API-Keyを維持する。採否はjtt-cafe-pjのmanifest project layer、client対応可否は同じeffective MCPに対するsurface契約で判定する。 + +**Supabase の stg / prod 2 環境並列 (jtt-cms)**: `supabase-prod` / `supabase-stg` の2 assetを命名規約として必須にする(`supabase` 単独名・env-agnostic な `mcp__supabase__*` 表記は禁止)。実例・OAuth手順の詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## Claude Code の `${VAR}` 補間仕様 + +**Claude Code は `mcpServers[*].headers["X-API-Key"]` 等の値を `${VAR}` 補間しない**(User scope / Project scope どちらも同じ)。 + +→ sync スクリプトは `~/.config/agent-hub/.env` から実値を読み出し、`/.mcp.json` / `~/.claude.json` には実値を書き込む。 + +→ よって `.mcp.json` は **gitignore 必須**(実値がコミットされないように)。AGENT-HUB の SSOT は環境変数名のみ保持し、各マシンで sync 実行時に実値展開する。同じ理由で `~/.claude.json` / `.gemini/settings.json` / `.cursor/mcp.json` / `.kimi-code/mcp.json` も全て gitignore 必須(対象ファイルと生成元の gitignore 必須リストは `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。 + +## 禁止事項 + +1. **`~/mcp-servers//.env` へ直書き禁止**。`asana-mcp/.env` `jtt-smaregi-mcp/.env` 等に API キーを置かない。発見次第 `~/.config/agent-hub/.env` へ移行し、ローカル `.env` は `# moved to ~/.config/agent-hub/.env (AGENT-HUB SSOT)` のコメントだけ残す +2. **`~/.zshrc.local` に `_mcp_load_key_from_env` のような分散ロード関数を新設禁止**。AGENT-HUB SSOT の bootstrap フロー(`set -a; source ~/.config/agent-hub/.env; set +a`)を使う +3. **git 管理対象ファイルに API キー実値を平文で書かない**。ドキュメント(README / SKILL.md / 設計書)では `` または env 変数名 `${ASANA_MCP_API_KEY}` で表記する +4. **ローカル生成物へ手作業で API キー実値を書かない**。`.mcp.json` / `~/.claude.json` は gitignore 済みであることを前提に、sync スクリプトだけが `~/.config/agent-hub/.env` から実値展開して書き込む +5. **管理対象ファイルでの URL クエリパラメータ方式(`?api_key=...`)禁止**。Cloud Run の監査ログに URL ごとキーが残るため、`.mcp.json` / `~/.claude.json` / `.codex/config.toml` など AGENT-HUB が生成する設定は `headers: {"X-API-Key": "${...}"}` のヘッダー方式に統一する + +**Claude.ai 例外**: Claude.ai コネクタで `X-API-Key` ヘッダーを設定できない場合のみ、asana-mcp は `https://asana-mcp-vaibinqqva-an.a.run.app/mcp?api_key=` 形式を使ってよい。この例外は Claude.ai 手動登録専用で、AGENT-HUB の生成物には書かない。 + +## 再発防止: sync スクリプトのハードエラー化 + +`scripts/sync-claude-global-mcp.py`、`scripts/sync-claude-project-mcp.py`、`scripts/sync-codex-mcp-configs.py`、`scripts/sync-cursor-mcp-configs.py`、`skills/{gemini,kimi,opencode,augment}-sync/scripts/sync-*-from-cc.py` は、env_key が未解決(`~/.config/agent-hub/.env` に無い/空文字)の場合に **literal `${VAR}` を書き込まず exit 1** すること。 + +理由・過去の実害(jtt-cms で `smaregi-docs` MCP の認証エラーが反復した根本原因)は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。 + +## 再発防止: MANAGED block の重複キー除去 + TOML 検証(Codex / 2026-06-02〜) + +`scripts/lib/user_mcp_sync_lib.py` の `replace_managed_block` は、①同名野良エントリの自動除去 ②書き込み前 TOML パース検証、を担保する(MANAGED 対象でない手書き MCP は保護する)。実装経緯・障害の症状は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` の「Codex config TOML 重複キー」節を参照。 + +## 復旧手順(MCP Auth エラー時) + +詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md`。要旨: ①env が読めているか確認 ②`~/.claude.json` の literal `${VAR}` 残存検査 ③対象projectを公開入口から再同期 ④Claude Code を再起動。 + +## ローテーション手順 + +API キーローテーション時の 7 ステップ(新キー発行 → SSOT 更新 → dry-run 確認 → full apply → 個別sync禁止 → 各PJ再起動 → 旧キー失効)の全文は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照。旧キーを `dotfiles/.env.example` のコメントに「廃止済み」として残してはいけない。 + +## User scope MCP 同期フレームワーク (2026-05-21〜) + +User scope MCP (`~/./...`) の SSOT 一元管理は **user-mcp スキル**が管轄する(User scope / Project scope の設計と担当 sync スクリプトの対応表は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` を参照)。新エージェント追加 5 ステップ (CLAUDE.md 9-13 参照): `skills/user-mcp/SKILL.md`。 + +## 関連 + +- `dotfiles/.env.example` — 名前テンプレート +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 復旧手順・ローテーション手順・実装経緯・User scope同期フレームワーク対応表の詳細正本 +- `skills/user-mcp/SKILL.md` — User scope MCP 5 ツール統一管理スキル(sync スクリプト一覧はここに集約) +- `scripts/lib/user_mcp_sync_lib.py` — 5 sync 共通 lib (env / registry / MANAGED block / 検証) +- `scripts/sync-claude-project-mcp.py` — Project scope 同期 +- `scripts/codex-mcp-remote-with-env.sh` — Codex 用 SSE → stdio bridge +- `~/business/AGENT-HUB/docs/codex-mcp-registry.yaml` `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — 台帳 +- `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` — 障害復旧ランブック +- `docs/reference/project-roots.md` — プロジェクトルート規約 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は `~/business/AGENT-HUB/docs/runbooks/mcp-auth-recovery.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** + +### general/memory-lookups.md + + + +# メモリ参照ルール + + + +## 基本方針 + +memory は、前回までの作業状態・人物名・用語・過去の判断を思い出すための**参照補助**である。 +売上・タスク・勤怠・予約・確定ルールの正本ではない。 + +以下のケースに該当するとき、応答を出す前に `~/.claude/projects/*/memory/MEMORY.md` および同階層の個別メモリファイルを検索する: + +- **人名・略称・愛称**に遭遇したとき(読み方・関係性が記録されている可能性) +- **PJ 固有用語・コードネーム**に遭遇したとき +- **過去の不採用判断**を覆そうとしているとき +- ユーザーが「あの〜」「以前話した〜」等の指示語で参照しているとき + +## 検索手順 + +`~/.claude/projects/*/memory/` 配下を検索し、`MEMORY.md` のインデックスから該当する個別ファイルを特定して読み、応答に反映する。 + +## 該当メモリがあった場合 + +- メモリの内容を踏まえて応答する +- メモリの記述が古い可能性がある場合は、現在の状態(コード・設定・正本MCP・Markdown SSOT)と突き合わせる +- 矛盾があれば**現状を優先**し、メモリの更新を提案する + +## JTT 業務情報の正本 + +| 情報 | 正本 | +|------|------| +| 売上・取引・商品実績 | スマレジ / `jtt-smaregi-mcp` | +| 施策・担当・期限・進捗 | Asana / `asana-mcp` | +| 勤怠・シフト・出勤者 | 出パンダ / 将来の Depanda MCP | +| 予約・来店予定 | よやくま / 将来の Yoyakuma MCP | +| 確定した方針・ルール・議事録 | プロジェクトの Markdown SSOT | +| 横断分析・再利用する学び | G-Brain | +| 作業途中の短期文脈 | Claude / Codex / Hermes の memory | + +memory と正本が矛盾する場合は、正本を優先する。G-Brain は検索・分析・要約の層であり、MCP から取得した生データの保管先にはしない。 + +**Asana のどこに何があるか**(workspace / project gid / section 構造 / 周期 PJ の命名規則)は +`~/business/AGENT-HUB/docs/reference/asana-project-map.md` が地図。gid を推測せず、まずこの地図を引く。 +地図には参照先だけがあり、タスクの中身は載せない(中身は `asana-mcp` でその場で取る)。 + +## 該当メモリがなかった場合 + +- 推測で補完せず、ユーザーに直接確認する +- 確認後、必要に応じて新規メモリとして記録する(auto memory ルール参照) + +## 関連 + +- グローバル auto memory: `/Users/shintaro/.claude/CLAUDE.md` の「auto memory」セクション +- PJ 別 auto memory: `~/.claude/projects//memory/` + +### general/plan-approval-gate.md + +# 実装前に HTML プランで承認を仰ぐルール(強制) + +## 原則 + +中規模以上の**実装に着手する前に、必ず HTML で実装プランを提示し、利用者の明示承認(「この実装でいい」)を得てから着手する**。テキストだけで合意したつもりにならない。 + +理由: 非エンジニアの利用者には「どの画面がどう変わるか」がテキストでは伝わりにくく、着手後に手戻りが多発する。給与v1の説明 HTML のような見せ方を毎回・自動で出して認識を合わせ「手戻りゼロ」を狙う。 + +これは `ui-stitch-mandatory` と同じく**ルールが義務を担い、手順はスキルに置く**二段構え。手順 SSOT は `skills/plan-approval/SKILL.md`(本ルールは手順を複製せず参照する)。 + + + +## 必須手順 + +1. **プラン作成基準をライブ読み**: `skills/plan-approval` が `resolve-pj-prompt.py --phase plan` を実行し、PJ 別のプラン基準(`snippet-prompts/Typinator/plan/`。専用未作成 PJ は汎用 `dev-plan`)を読む。 +2. **HTML プランを作る(固定テンプレを必ず使う・独自デザイン禁止)**: 正本テンプレをコピーし中身だけ差し替える(通常=`plan-template.html`、AI worker 委譲時=`plan-template-aiworker.html`)。必須のビジュアル要素は下記「中身」節を参照。 +3. **提示して承認を待つ(iPhone でも PC でも、両方の届け方を毎回使う)**: HTML プランは**必ず Write ツールで実体の `.html` ファイルとして作成する**。**禁止**: ① HTML 本文をチャットに貼り付ける、② Bash ヒアドキュメントで書き出す(どちらも iPhone で生コードになる)。作成後は毎回 `open ` で PC ブラウザにも表示する。**タップ用ファイルカード作成と open による PC ブラウザ表示の両方を毎回必須とする**。末尾に「この実装でいいですか?(進めて / 直す / やめる)」を置き、**承認なしに実装へ進まない**。短い同意だけで進めず、次の一手を1文に要約して再確認する。保存先は作業中 PJ の gitignore 済み一時パス(`claude-plans/` 等)、slug は短く、共有 URL は1行で提示する(詳細: `skills/plan-approval/SKILL.md` §5)。 +4. **承認直後に 📋 コミットメント台帳を全件タスク化する**: HTML プランの台帳の各行を、着手前に `TaskCreate` で 1 行 = 1 タスク化してから実装へ進む。台帳が全消化(実施済み or 明示保留)になるまで「完了」と宣言しない。詳細は `.claude/rules/general/plan-commitment-tracking.md`。 + - **AI worker を 1 度でも使う計画は必須**: 「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れ、タスク化する(テンプレに既定行として焼き込み済み・消さない)。 +5. **承認後は標準パイプラインを通す**: 実装(dev-guardrails)→ codexレビュー → 実装監査 → CI → SSOT 同期確認 → マージ。本番投入は人間ゲート。 + +## HTMLプランの中身 + +中身の構成部品(🎯目的・🖼️前後比較・🗺️ユーザーストーリー・🔀画面遷移図・📚メニュー構成図・🧩変える物一覧・🤔AI の異見・🔭一段上の視点・📋コミットメント台帳 等の必須要素一覧)はテンプレ正本(`references/plan-template.html` の `parts` マニフェスト)と `skills/plan-approval/SKILL.md` が正本。ここに複製しない。 + +## 適用トリガー + +新機能・新画面、データの形を変える変更(DB 構造変更)、複数ファイルにまたがる実装、画面の見た目・挙動が変わる変更。判断に迷う場合は提示する側に倒す。 + +## 例外(HTMLプラン不要) + +- 誤字・1行修正など、見た目・挙動の方針が変わらないもの +- 純粋な調査・質問への回答、会話だけで完結する話 +- 利用者が「今回は要らない」と明示したとき +- UI に変化を伴わない純粋なロジック修正(ただし複数ファイル・データ変更を伴うなら提示する) + +## 接続・関連 + +手順: `skills/plan-approval/SKILL.md`(テンプレ・保存規約・承認ループ・3層視覚化)。プラン基準: `snippet-prompts/Typinator/plan/[PLAN-INPUT]_-plan.md`。進捗: `visual-progress-map.md`。UI確定: `ui-stitch-mandatory.md`。承認後: `skills/dev-guardrails/SKILL.md`(各フェーズは `resolve-pj-prompt.py` 同一リゾルバ)。モデル委譲: `ai-model-selection.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** + +### general/plan-commitment-tracking.md + + + + + +# プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) + +## 原則 + +承認済みプラン本文に書かれた**全ての commitment / 条項**を、実装着手前に **1 項目 = 1 タスク**へ起票する。 +プランの箱(HTML / テキスト)を静的ドキュメントで終わらせない。承認は一度きりの儀式ではなく、 +実行ループ全体で参照し続ける**生きたチェックリスト(plan-as-live-checklist)**として扱う。 + +## なぜ + +プランに「〜不具合時は正本を直す」等の条項があっても、主要タスクだけ起票すると、長い実行ループ(`/compact` でプラン本文が能動コンテキストから外れる)で**一度も発火せず**未実施のまま「完了」と誤宣言する。これは**全 PJ で再発し得る構造欠陥**。実例は `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。 + +## 必須手順 + +1. **承認直後に台帳化**: プラン本文の commitment / 条項(「〜不具合時」「〜したら」「最後に〜」「後で」「別プラン」「TODO」「フォローアップ」「要〜判断」の類)を全て抽出し、着手前に **TaskCreate で 1 項目 = 1 タスク**化する。**📋 コミットメント台帳セクションが空でないのに、未タスク化のまま実装へ進まない**。 + - **AI worker を 1 度でも使う計画なら、「AI worker 摩擦時は該当正本を worktree→PR→merge→fetch-only / detached 確認→cleanup で修正」の条項を台帳に必ず入れる**(テンプレ既定行・消さない)。無ければ台帳は未完成。 +2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 +3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 + - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 +4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 +5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 +6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。 + - **`/goal` 等の反復発火チェッカーへの対応**: 人間ゲート行に反復発火する場合、AI は §6 の優先(明示保留=全消化・虚偽✓禁止)を **1 度だけ根拠付きで提示して停止**し、以後は最小限の再表明に留める(無限反復・迎合的な虚偽✓化をしない)。実測は `commitment-examples.md`。 + +## 恒久原則(proactive) + +繰り返す同種の摩擦・失敗は、**利用者の指摘を待たず**観測した時点で「最後に正本を直す」を既定の最終ステップとして計画へ自分から組み込む。委譲ジョブの失敗・失速もコミット監視だけに頼らず能動的にポーリングして検知する(`skills/agent-dispatch` の「失敗の能動検知」と対)。 + +## 接続 + +義務: `plan-approval-gate.md`。手順: `skills/plan-approval/SKILL.md`。実例: `~/business/AGENT-HUB/skills/plan-approval/references/commitment-examples.md`。進捗可視化: `visual-progress-map.md`。能動検知: `skills/agent-dispatch/SKILL.md`。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** + +### general/reference-over-hardcode.md + + + +# ハードコード排除・参照型設計(グローバル憲法) + +## 原則 + +**ハードコード(直書き)をしない。** 設定値・手順・API 名・パス・思想・スタイルなど、2 箇所以上で必要になる情報は、**正本(SSOT)を 1 箇所に置き、他はそれをライブ参照する**(参照型設計)。 + +- どうしても直書きが必要な場合は、**1 箇所に集約**し、なぜ集約先を作ったのかを CaD コメント等に残す。 +- 「そこだけ直書き」を積み重ねると、後から値がずれる・改訂が反映されない・矛盾が生まれる。これは規模の大小を問わず起きる。 + +この原則は、サブエージェント作成・SSOT 構築・hook 実装・YAML 台帳・skill・rule のどの作業でも同じ扱いにする。特定のフェーズだけに適用される限定ルールではない。 + +## 参照型の実例 + +- **設計思想**: `~/business/AGENT-HUB/docs/design/design-philosophy.md` に集約し、UI/デザインに関わる各所(stitch-screen-creator 等のサブエージェント、UI 作成フロー)からライブ参照する。思想を各 PJ の rule や skill に複製しない。 +- **MCP キー**: `~/.config/agent-hub/.env` に実値を集約し、各 PJ の `.mcp.json` や sync スクリプトはそこから展開する(`.claude/rules/general/mcp-key-management.md`)。`~/mcp-servers//.env` への直書きは禁止。 +- **スキル手順**: 各 rule は手順を複製せず、手順 SSOT(skill)をライブ読みで参照する(例: `plan-approval-gate.md` が `skills/plan-approval/SKILL.md` を参照する二段構え)。 + +## 発火場面 + +- 新しい設定値・API 名・閾値・手順・文言などを**2 箇所以上に書きそうになった時**。 +- 既存の rule / skill / doc の内容を**別ファイルにコピーして使いたくなった時**(コピーせず参照にする)。 +- サブエージェントや AI worker への delegate プロンプトに、正本にある情報を**そのまま貼り付けたくなった時**(正本のパスを渡し、読ませる方を優先する)。 +- **配布物(他 PJ へ配る rule / agent / skill)から AGENT-HUB 専用ファイルを参照する時**: 相対パス(`docs/design/...`)ではなく**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)で書く。配布先 PJ の実行 cwd はその PJ 自身であり、相対パスでは正本を解決できず参照が壊れる(2026-07-07 実装監査が検出)。 + +## 個人開発スケールとの両立 + +`.claude/rules/general/constructive-dissent.md`「個人開発スケールと例外」節を参照(同根の原則・過剰な抽象化を避け素朴な解決を優先する基準)。 + +## 関連 + +- `.claude/rules/general/constructive-dissent.md` — 言いなり禁止・グローバル憲法(同種の常時ロード規範。メンテナンス観点の先出し提案として「正本参照」を挙げている) +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 参照型設計の実例(G-Brain 正本からの派生ドキュメント) +- `.claude/rules/general/mcp-key-management.md` — MCP API キー一元管理(参照型設計の実例) + +この原則は G-Brain の上流原則 `principle-single-source-of-truth-reference`(伸太郎殿の開発大原則)と同根である。 + +### general/response-style.md + + + +# 出力簡潔性ルール + +## 基本方針 + +- 中間状態(「これからこうします」「次にこれを実行します」)の冗長な説明を避ける +- 概念的な説明より具体例・差分・コマンドを優先する +- 段落より bullet list を優先する +- 同じ情報を 2 回繰り返さない(タスクツールで進捗を可視化している場合は、テキストで重ねて述べない) + +## 避けるべき出力パターン + +- 「〜について説明します」「以下に〜します」等の予告フレーズ +- 完了済みタスクの再要約(diff や git log が SSOT) +- 「もし〜の場合は〜」の仮定列挙(実行結果を待ってから判断する) +- 「これは〜という意味です」型の自明な解説 + +## 確認するときの書き方 + +ユーザーに判断を仰ぐときは、選択肢を簡潔に列挙し、推奨案を 1 行で示す: + +良い例: `Tier 2 まで実行 / REN-1 のみ / 全部 のどれにしますか?推奨: Tier 2 まで` + +悪い例(冗長すぎ): 長文で各選択肢のメリット・デメリットを 3 段落ずつ説明 + +## 完了報告の書き方 + +- 変更したファイル一覧(path のみ) +- 主な変更点(1 行ずつ) +- 確認してほしい点(あれば、1-2 件) + +過剰な「お疲れさまでした」「素晴らしい結果でした」等の挨拶は不要。 + + + +## URL・リンクの出力 + +AI が URL やファイルパスを出力するとき、**URL の直後に全角括弧・句読点(`)` `。` `、` `」` など)を隣接させない**。ターミナルや Markdown のリンク解釈がその記号まで URL に取り込み、リンクが壊れる(404)ため。 + +- URL は原則**独立した行**に置く(前後に説明文があっても URL 単独の行にする)。 +- 文中に置く場合は URL の直後に**半角スペースか改行**を入れ、全角記号を隣接させない。必要なら `< >` または バッククォートで囲む。 +- 悪い例: `詳細は https://example.com/path)。`(`)。` まで URL に食われて 404)。 +- 良い例: 説明文の後に改行して `https://example.com/path` を単独行で出す。 + +### general/responsive-both-viewports.md + +--- +name: responsive-both-viewports +description: レスポンシブ画面にUI要素(ボタン/リンク/ナビ)を足す時は必ず全viewport(モバイル/デスクトップ)に足し、各画面幅で表示を確認してから完了にする普遍ルール +paths: + - '**/*.tsx' + - '**/*.jsx' + - '**/*.js' + - '**/*.vue' + - '**/*.svelte' + - '**/*.astro' + - '**/*.blade.php' + - '**/*.html' + - '**/*.css' + - '**/*.scss' + - '**/components/**' +--- + +# レスポンシブ UI は全 viewport に足す(普遍ルール・常時適用) + +## 原則(絶対・例外なし) + +レスポンシブな画面にボタン・リンク・ナビ等の UI 要素を**新規に足す**ときは、**モバイル表示とデスクトップ表示の両方**(存在する全ブレークポイント)に足す。片方だけに足すと、もう片方の画面幅でその要素が**消える**。これは開発の普遍ルールであり、条件付きにしない。 + +多くのレスポンシブ実装は同じ内容を画面幅で出し分ける: +- モバイル: 上部バー等(例 `.appbar`)を表示し、サイドバーを隠す +- デスクトップ(例 `@media (min-width:1024px)`): サイドバー等(例 `.side-*`)を表示し、上部バーを隠す + +このとき片方の枠にだけ要素を足すと、もう片方では `display:none` により非表示になる。 + +## 必須 + +1. UI 要素を足すとき、**全 viewport バリアント**(モバイル枠 / デスクトップ枠 / その他ブレークポイント)の**すべて**に足す。 +2. 追加要素が**各画面幅で実際に表示される**ことを確認してから完了にする(該当 CSS の `display:none` / media query の出し分けを読み、隠れる枠だけに足していないか確認する)。 +3. コードレビュー・実装監査でも「新規 UI 要素が全 viewport で見えるか」を必須確認項目にする。 + +## 実例(この規則ができた経緯) + +2026-07-05 cron-dashboard で「🩺 健診」ナビを最初モバイルの上部バー(`.appbar`)だけに足した結果、`.appbar` が `@media (min-width:1024px)` で `display:none` になるため **PC 幅で恒久的に非表示**になり、実装監査がブロッカーとして検出した。デスクトップのサイドバー(`.side-brand` 隣)にも足して解消。片側だけ追加は完了ではない。 + +## 接続 + +- `.claude/rules/general/visual-progress-map.md` — 非エンジニア用語・現在地マップ +- `.claude/rules/general/ui-stitch-mandatory.md` — UI/デザインは Stitch を通す +- `skills/dev-guardrails/SKILL.md` — 実装ガードレール + +### general/settings-protection-coexistence.md + +--- +name: settings-protection-coexistence +description: settings.json等の保護テスト(直接編集ブロック)と、telemetry配線等の正当な変更を共存させる手順。テスト赤のままマージしない +paths: + - '.claude/**' + - '.codex/**' + - '.cursor/**' + - '.gemini/**' + - '.kimi-code/**' + - '.augment/**' + - '.opencode/**' + - '.githooks/**' + - 'tests/test_handover_manual.py' + - 'skills/handover-manual/scripts/resolve-handover-path.py' +--- + + + +# settings.json 等の保護テストと正当な配線変更の共存ルール + +## 原則 + +`.claude/` `.codex/` `.cursor/` `.gemini/` `.kimi-code/` `.augment/` `.opencode/` `.githooks/` +`claude-plans/` `node_modules/` 配下および `.env` / `.env.*` は、`tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` +が **プレフィックス一致で広く保護対象と判定**する(実装: `skills/handover-manual/scripts/resolve-handover-path.py` の `is_protected_path()`、 +`PROTECTED_PREFIXES`)。テストは `origin/main` とのマージベース以降 + working tree + staged の変更ファイルを走査し、 +保護対象なのに `allowed_managed_placements`(テスト内のallowlist)に無いパスがあれば **fail** する。 + +この判定は粗い(ディレクトリ丸ごと保護)ため、telemetry 配線・新規ルール追加・hook 再配備など +**正当な変更でも毎回検知される**。これは仕様であり、バグではない。正当な変更を安全に通す手順は以下。 + +## 必須手順 + +1. **まず中央配布経路で済まないか確認する**: project harness は `scripts/sync-agents.py --project --dry-run` + で全surfaceを確認し、承認後だけcleanな専用linked worktreeへfull applyする。個別writerを手で連結しない。 +2. **どうしても直接編集が必要なら、同一PRで `allowed_managed_placements` に追加する**: + `tests/test_handover_manual.py::test_protected_paths_are_not_directly_edited` 内のセットへ、 + 変更した具体パスと日付・理由コメントを添えて追記する(例: `# [YYYY-MM-DD][fix] PR#nnn で〜が弾かれた。理由。`)。 + 既存の fix-forward 例(PR#637 `ai-model-selection.md` / PR#666 `constructive-dissent.md` / + PR#695 `responsive-both-viewports.md` / PR#736 `dotfiles/.env.example`)と同じパターンに倣う。 +3. **保護テストの検査ロジック自体を弱めない**: `is_protected_path()` のプレフィックス判定や + `changed_paths_for_protected_check()` の走査範囲を変更・無効化しない。許可は必ず allowlist の + 個別パス追加で行う(一括 skip・正規表現の緩和は禁止)。 +4. **テスト赤のままマージしない**: `python3 -m pytest tests/test_handover_manual.py -q` を PR 作成前に + ローカル実行し green を確認する。CI の同テストが赤の状態での merge は `branch-rule.md` の + CI 緑ゲートに反する(#742 の再発防止)。 + +## 関連 + +- `.claude/rules/general/branch-rule.md` — main 直接コミット禁止・CI緑ゲート +- `.claude/rules/general/plan-commitment-tracking.md` — workaround 自問(正本修正を先送りしない) +- `.claude/rules/general/hooks-structure-rule.md` — hook 配置の隣接ルール(配布経由の管理配置) +- `tests/test_handover_manual.py` — 保護テスト本体・allowlist 実体 +- `skills/handover-manual/scripts/resolve-handover-path.py` — `is_protected_path()` / `PROTECTED_PREFIXES` 実装 + +### general/sub-agent-scope-contract.md + + + +# サブエージェント Scope Contract + +サブエージェント(Task / Agent tool)に作業を委譲するとき、delegate 元のプロンプトに**必ず以下 3 項目(コード探索を伴う場合は §4、UI/デザインを伴う場合は §5 を足す)を含める**。制定経緯・テンプレート全文は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。 + +## 1. allowed_files(編集を許可するファイル) + +委譲先が編集してよいファイルパスを明示的に列挙する。 + +例: `「allowed_files: src/api/auth.ts のみ。他は read-only」` + +## 2. forbidden_actions(禁止する操作) + +委譲先が**してはいけない**操作を明示する。よくある禁止例: + +- `auto-format で quote replacement や import 並び替えを実行しない` +- `スコープ外のファイルを編集しない(読み取りは可)` +- `テストの skip / xit を追加しない` +- `existing CaD コメントを削除しない` + +## 3. verify before return(返却前の検証手順) + +委譲先が作業完了を報告する前に実行する検証を指定する。 + +例: +- `git diff --name-only で編集ファイル一覧が allowed_files と一致することを確認` +- `lint / typecheck を実行してエラーが出ないことを確認` +- `想定外の編集があった場合は revert してから報告` + +## 4. context-engine first(コード探索を伴う委譲・Explore 含む) + +委譲タスクが**コードの場所・関数・route・呼び出し関係・影響範囲の探索**を含むなら、prompt に必ず入れる: + +- 「まず `codebase-context-engine` を使う(`grep`/`Read` を先に走らせない)。遅延ツールは + `select:mcp__codebase-context-engine__list_projects,hybrid_search,search_graph,get_code_snippet` でロード」 +- **解決済みの `project` 名を親が渡す**(親が `list_projects` を見て明示)。 + `preferred_project` がある場合はそれを使う。 + `project_scope: ambiguous_worktrees` の場合は、現在の cwd と一致する `root_path` / `preferred_project_candidates` を親が選んでから渡す。 + subagent に `private-tmp-cbm-...` の長いミラー名を推測させない。 +- 「索引はミラー=当日新規/変更したファイルは未反映なので、その分だけ `Read` 併用」 + +理由: 候補圧縮で速く・低コスト(多数 grep/Read を回避)。subagent は本ルールを自動継承しないため親が prompt 注入必須(追加経緯は詳細ドキュメント参照)。 + +## 5. design-philosophy first(UI/デザインを伴う委譲時) + +委譲タスクが**UI・画面・デザイン・レイアウト・コンポーネントの作成/変更**を含むなら、親が prompt に必ず入れる: + +- 「まず `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)を Read してから着手する」を**必読指定**する。 +- 必ず該当ファイルの**絶対パス**(`~/business/AGENT-HUB/docs/design/design-philosophy.md`)を渡す(委譲先の実行 cwd は消費先PJであり、相対パスでは解決不能なため)。 +- Stitch を使う画面作成は、`stitch-screen-creator` グローバルエージェント(設計思想を step0 で必読にしている)へ委譲するのが既定。 + +理由: AI Worker(Kimi/Codex/Cursor/GLM 等)自身にデザインセンスが無くても、親が設計思想 doc を必読で渡せば思想に沿った画面を作れる。渡さないと委譲先が自己流判断でずれる。 + +## delegate プロンプトのテンプレート・親側の verify ステップ + +テンプレート全文と、親セッションが `git diff --stat` / `git diff -- ` で確認する verify コマンド列は +`~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` を参照。allowed_files 外に変更が混入していた場合は +revert し、delegate にやり直しを指示する。 + +--- + +**追記ルール: 制定経緯・テンプレート全文の詳細は `~/business/AGENT-HUB/docs/architecture/sub-agent-scope-contract-details.md` へ書き、本ルールには義務・トリガーだけ足す(再肥大化防止)。** + +### general/ui-stitch-mandatory.md + + + +# UI / デザインは必ず Stitch を通すルール(強制) + +制定経緯(2026-05-27 新設判断・2026-07-20 MCP選択正本切替)は `skills/stitch/SKILL.md` の +「ui-stitch-mandatory 制定経緯」節を参照。 + +## 原則 + +UI / 画面 / デザイン / レイアウト / コンポーネントの**新規作成・見た目の変更**依頼は、**必ず Stitch**(`skills/stitch` + Stitch MCP `mcp__stitch__*`)でデザインを生成し、**伸太郎殿が実物を見て確定してから実装に進む**。 + +理由: UI は AI とユーザーの言語的意思疎通が難しく、テキストだけで合意したつもりで実装すると手戻りが多発する。Stitch で生成した実物を見て双方の認識を合わせることで「手戻りゼロ」を狙う。 + +## 必須手順 + +1. **Stitch でデザイン案を生成**(**最低 3・最大 5(ケースバイケース)**)。1 案だけ出して進めるのは**禁止**。 +2. **伸太郎殿が Stitch Web(プロジェクト URL)で比較・確定**する。 +3. **確定したデザインだけ**を基に実装する(`.stitch/` 出力 / DESIGN.md を参照)。 + +## 適用トリガー + +「UI を作って」「画面作って」「デザイン(して)」「レイアウト変更」「コンポーネント新規」など(`skills/stitch` の triggers と整合)。判断に迷う場合は Stitch を通す側に倒す。 + +## データ格納ルール(リポジトリルート汚染防止) + +Stitch 由来のファイルを散らかさないため、保存先を固定する: + +| データ | 置き場所 | +|--------|---------| +| ① デザイン案の比較 | **Stitch Web(プロジェクト URL)で見る** → 全候補をローカル保存しない | +| ② 確定したデザイン | `.stitch/<システム名>/<画面名>/`(`code.html` + `screen.png`)にだけ Export | +| ③ MCP 取得データの一時保存 | **temp ディレクトリ**(その PJ の `/tmp/` 等・gitignored) | +| ④ リポジトリルート直下・任意の場所 | **保存禁止**(ゴミファイル堆積を防ぐ) | + +- `.stitch/` は**Stitch を使う PJ ごとに gitignore する**(生成物はコミットしない)。配布先 PJ へ広げる場合は、その PJ 側の `.gitignore` 変更を別途同じ変更束に含める。 +- 「とりあえずルートに HTML を置く」は**禁止**。必ず上記 ① 〜 ③ のいずれかに収める。 + +## 例外(Stitch 不要) + +- 既存 UI の微修正(typo 修正・1 色だけ変更など、**見た目の方針が変わらない**もの)。 +- UI に変化を伴わない純粋なロジック修正。 + +## MCP 前提 + +Stitch MCPの接続definitionは`~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml`、project採否は +`registries/harness-manifest.yaml#asset_contract` のeffective `mcp` setを正とする。 +未接続時は `scripts/sync-agents.py --project --dry-run` で継承・surface・envを確認し、apply後にfresh clientでruntime proofを取る。 + +## 接続 + +- 手順 SSOT: `skills/stitch/SKILL.md`(プロンプトテンプレ・`.stitch/` 規約・DESIGN.md 抽出・MCP 前提)。本ルールは手順を複製せず参照する。 +- dev フローの普遍 UI ルール(Tailwind 等)は `skills/dev-guardrails/SKILL.md`(2-10 ほか)の上に乗る。業務 PJ は `skills/business-guardrails/SKILL.md`。 +- 要件固め・実装フローでの発火点: `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md`。 +- Stitch でデザインを作る際は `~/business/AGENT-HUB/docs/design/design-philosophy.md`(伸太郎殿の設計思想 SSOT)に従うこと。本ルールは思想本文を複製せず参照する。 +- 「Stitchで作って」の委譲は `agents/global/stitch-screen-creator.md`(着手前に設計思想 doc を必読)が実行役を担う。 + +## 関連 + +- `skills/stitch/SKILL.md` — Stitch ワークフロー SSOT +- `skills/dev-guardrails/SKILL.md` / `skills/business-guardrails/SKILL.md` — ガードレール +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 発火フロー +- `~/business/AGENT-HUB/docs/codex-mcp-definitions.yaml` — Stitch MCPのtransport / 認証definition +- `registries/harness-manifest.yaml` — global / harness type / projectの採否とsurface契約 +- `~/business/AGENT-HUB/docs/design/design-philosophy.md` — 伸太郎殿の設計思想 SSOT +- `agents/global/stitch-screen-creator.md` — Stitch 画面作成グローバルエージェント + +--- + +**追記ルール: 制定経緯・実測詳細は `skills/stitch/SKILL.md` へ書き、本ルールには義務・トリガー・禁止事項だけ足す(再肥大化防止)。** + +### general/visual-progress-map.md + + + +# 図解・現在地マップ・非エンジニア用語ルール + +全 AI・全作業共通の SSOT。ユーザー(非エンジニア)が現在地・ゴール・次の一手を必ず把握できる状態を保つための図解描画ルール。**通常の実装・Issue/PR/PRD 確認・調査でも、§1-bis のトリガーに該当したら skill 抜きで図解を出す**。 + +**テンプレ・実例・置換表の全文は references へ。本ルールは義務とトリガーだけ(再肥大化防止)。** + +## 0. モード判定(開発 / 業務) + +この図解は **2 モード**を持つ。テンプレは共通で、語彙は references の置換表で読み替える(DRY)。 + +| モード | 対象 PJ(デフォルト) | 性質 | ペア guardrails | +|--------|---------------------|------|----------------| +| **開発** | jtt-apps / jtt-cms / jtt-shift-mobile-app / *-mcp 等 | GitHub PR フロー中心 | dev-guardrails | +| **業務** | jtt-cafe-pj / non-pj | 戦略・施策・KPI 中心 | business-guardrails | + +- `jtt-cafe-pj` は business PJ。曖昧なら §5 に従い平易語で確認してから描く(推測しない)。 +- 最小読み替え: PR/Issue/merge/本番投入 → 戦略スコープ/KPI/意思決定/本番運用。詳細は references。 + +## 1. 地図描画タイミング + +| タイミング | 出すもの | +|-----------|---------| +| セッション開始直後 | `.claude/parallel-run-state/*.json` があれば冒頭で全体地図を ASCII 表示(複数あれば選択を仰ぐ) | +| /brainstorm 各フェーズ遷移時 | Phase 1→2→3 移行直前にミニ地図(§3) | +| /parallel-run 各ステップ完了時 | Step 完了報告+次 Step 前に全体地図を再描画 | +| 通常作業中 | §1-bis 該当時は skill 抜きでも L1 ASCII 図解を出す | +| オンデマンド | 「地図」「現在地」「進捗」の発話で即時再描画 | + +`gh pr list --state all` は開始時1回+オンデマンド時のみ呼ぶ(API節約)。再描画は状態ファイルのキャッシュを優先。 + +## 1-bis. skill 非依存の常時発火トリガー(バランス型) + +skill 非起動時でも、以下のいずれかに該当したら L1 ASCII 図解を出す(指示なしで出るのが本ルール最大の目的)。 + +| トリガー | 出す図の例 | +|---------|-----------| +| ① 3 つ以上の要素・手順・選択肢の説明 | 箇条マップ / 比較表 / フロー | +| ② 「今どこ・次どこ」の現在地・進捗 | 5 段階地図 / ミニ地図 | +| ③ Issue/PR/PRD/仕様書を読んで方針を伝える | 関係図 / 要約マップ / フェーズ図 | +| ④ バグ修正の「原因 → 対処」説明 | 原因 → 対処フロー | +| ⑤⑥ 複数ファイル横断の整理・依存関係説明 | 依存ツリー / フロー図 | +| ⑦ 進捗・週次レビュー・残り作業 | **ゴール地図(§2-bis)**。羅列で終わらせない | +| ⑧ AI Worker MCP へ複数 provider 委譲/状態確認 | **AI Worker 進捗図**(references)。provider名でなく作業内容・現在地を主役にする | + +議論を伴う説明・プランはチャットの L1 要点図解を基本とする。L2 HTMLカードは見た目の比較が必要な時、またはユーザー希望時だけ使う(実装承認プランは plan-approval-gate.md 優先)。③④⑦も専用skill化せず本ルールで発火。 + +### 出さない場面(うるささ回避) + +- 単純な一問一答、1 ステップで完結する短い事実回答、「図はいらない」明示時 + +図形式は自由。**重い L2/L3 は使わず L1 ASCII をデフォルト**にし、図を要約として使う。 + +## 2-bis. ゴール地図(骨子) + +§1-bis⑦で出す。やったこと羅列で終わらせず、計画全体・残り・次の一手・ゴール妥当性を同時に出す。 + +必須 7 要素: ①🎯最終ゴール+達成条件 ②全体スコープ ③✅済 ④⬜未(漏れ) ⑤◀次の一手 ⑥残数 ⑦⚠️ゴール妥当性レビュー。 + +短絡禁止: 「実装が終わった=ゴール達成」「施策を打った=成果(KPI)達成」と書かない(本番運用・撤退基準判定まで未達)。骨子: 📍ゴール/つくる→テスト→🚧本番投入→🏁本番=ゴール/✅済・⬜未・◀次の一手。 + +全体スコープ・未着手は PRD / Issue / git log を実読して埋める(推測禁止)。フルテンプレは references 参照。 + +## 3. ミニ地図テンプレート(/brainstorm 用) + +``` +[ 現在地 ] /brainstorm Phase X/3 +✅ Phase 1: 要件聞き取り +🔵 Phase 2: 不明点深掘り ← 今ここ +⬜ Phase 3: 実装方針提示 + +次にやること: <1 文> +``` + +## 4-bis. 視覚化の 3 層(L1/L2/L3)の使い分け + +図解は内容に応じ 3 層を使い分ける。実行手段の SSOT は `skills/visual-companion/SKILL.md`。本ルールは L1 ASCII と判定基準のみ持つ。 + +| 層 | 何を出すか | 手段 | いつ | +|----|-----------|------|------| +| **L1 ASCII** | 進捗・現在地マップ | ASCII 地図(ゼロ依存) | **デフォルト・常時** | +| **L2 ブラウザ HTML** | mockup・レイアウト比較 | `start-server.sh` | 見た目の比較(オプトイン) | +| **L3 ターミナル画像** | HTML を CLI で目視 | `html-to-terminal.sh` | ブラウザを開かず見たい時 | + +判定: 「読むより見た方が理解できるか?」。テキストで足りる選択は L1、見た目の比較は L2/L3。 + +## 5. 非エンジニア用語ルール + +### 原則 + +- 技術用語は**初回登場時のみ**括弧で平易語を併記、以降はそのまま使う(完全置換はしない) +- 短い同意(「お願い」「はい」)だけで進めない + +代表例(全 12 語は references 参照): PR=変更提案 / merge=本番に取り込む / migration=DB 構造変更 / staging=テスト環境 / worktree=別フォルダ作業領域。 + +### 短い同意への応答 + +「お願い」「はい」「OK」だけ返った時は**次の一手を 1 文で要約してから**再確認する。 + +### 技術判断を仰ぐ時(平易語 + 選択肢で聞く) + +**技術判断は技術用語で聞かない**。①平易語(速さ・安全性・見た目への影響)で説明②2〜3択で提示(可能なら AskUserQuestion)③推奨理由を1文添える。実例は references 参照。 + +## 6. 状態ファイル schema + +`.claude/parallel-run-state/.json` に保管(kebab-case slug、各PJの `.gitignore` へ追加)。フィールド定義・モード別 schema・`gh pr list` 合成手順の全文は `/skills/visual-companion/references/state-file-schema.md` を参照。 + +## 7. 関連ルール + +- `.claude/rules/general/response-style.md` / `sub-agent-scope-contract.md` / `branch-rule.md` +- `skills/brainstorm/SKILL.md` / `skills/parallel-run/SKILL.md` — 各フェーズ・Step 遷移時に参照 +- `commands/brainstorm.md` / `commands/parallel-run.md` — 手動発火ラッパー +- 全文: `/skills/visual-companion/references/progress-map-templates.md`, `state-file-schema.md` + +`` は中央ハブrepoのルートを表す(標準配置は `~/business/AGENT-HUB`、別環境では実際の配置先)。 + +**追記ルール: テンプレ・実例・置換表は references へ書き、本ルールには足さない(再肥大化防止)。** + +### general/worktree-rule.md + + + + + + + +# Worktree 利用ルール + +## いつ worktree を使うか + +AI が変更を加える通常作業では、git worktree を作成して別ディレクトリで作業する。 +特に以下のいずれかに該当するときは必須: + +- **並列セッション**: Claude Code / Codex CLI / Cursor 等を同時に複数立ち上げて別タスクを進める +- **複数 PR 同時進行**: 同一リポジトリで 2 本以上の feature branch を行き来する +- **長期 feature branch**: main から離れて 1 日以上滞在する作業(途中で main を hotfix する可能性がある) +- **軽量変更を含む AI 作業**: 例外なし。詳細は branch-rule.md 参照 + +例: +``` +git worktree add ../jtt-cms-feat-xyz -b feat/xyz +cd ../jtt-cms-feat-xyz +``` + +## いつ新規 worktree を作らなくてよいか + +以下は新規 worktree なしでよい: + +- 読み取りだけでファイル変更・commit・push がない場合 +- 既に feature branch にチェックアウト済みで、別タスクを差し挟まない場合 +- 既にこのタスク専用の worktree / branch にいる場合 +- 人間が明示承認した main 直接反映や初回 repo 作成など、branch-rule.md の注記に該当する例外の場合 + +## 機密ファイル(MCP / .env)の自動 symlink + +worktree 作成時、git 追跡外の機密ファイル(`.mcp.json` / `.env` 系)は main worktree の実体へ**自動 symlink**される(git post-checkout hook 由来)。追加操作は不要。仕組み・手動再設置手順・非破壊の詳細は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## Mac mini ContextEngine mirror の自動追従 + +Mac Studio 側の worktree は Mac mini の ContextEngine mirror が自動追従する(対象: jtt-cms / jtt-apps / jtt-system / AGENT-HUB / hermes)。索引はミラーであり当日の新規変更は未反映のことがある。詳細・stale削除・semantic強化ジョブは +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## branch contamination が発生した場合の復旧 + +別セッションのブランチに誤ってコミットした場合は、誤コミット特定 → 正しいブランチへ `cherry-pick` → 復旧用退避作成、の順で対応する。 +**`git reset --hard` と force-push はデフォルト禁止。必ずユーザー承認を得てから実行する。** +詳細手順は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## AI セッションから worktree へ commit / push する方法(block-main-commit 対策) + +block-main-commit hook は cwd 変更を伴う複合コマンドでの main 直 commit を fail-closed で deny する。AI セッション(cwd=main)から worktree の feature branch へ commit / push する時は: + +1. **`isolation: "worktree"` 付きサブエージェントに委譲する**(正攻法)。 +2. isolation 指定ができない場合のみ、GitHub API / connector で remote feature branch commit → PR → CI → merge の fallback を使う(main 直更新は禁止のまま)。 +3. commit/push を含まない操作(`git add` / `git status` / `gh pr create` 等)はメインセッションから直接 `cd && ...` してよい。 +4. hook 検査を `bash -c` 等で素通りさせる回避は**禁止**。 + +サブエージェントの worktree が古いベース(origin/main 以前)から切られる問題への対処、外側隔離 worktree の残存・cleanup 手順、Codex fallback の実測経緯は +`~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +## 共有 checkout / main 非占有ルール(全 PJ・全 AI ツール共通) + +対象ルート: `~/LLM-Dev/` `~/business/` `~/Herd/` `~/mac-mini-server/` `~/mcp-servers/` `~/jtt-system/`。Claude / Codex / Cursor / Kimi / OpenCode / Antigravity 全て同じ意味で読む。 + +**AI セッションは、他者や他エージェントが使う可能性のある `main` checkout を掴まない。** 共有 checkout で merge / pull / cleanup を実行すると、並行セッションとブランチ・HEAD を奪い合って競合する。背景・実測実害は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### 必須:1 タスク = 1 連の完了フロー(PR を出して放置しない) + +**専用 worktree 作成 → 編集/commit/push → PR 作成 → マージ → fetch-only / detached 確認 → clean(worktree/branch 削除)まで、必ず一連で最後まで閉じる。** 「PR を出した」「マージした」で止めない。詳細コマンド列は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +### AI が `main` で「やらないこと / 代わりにやること」 + +- **やらない**: `git checkout main` / `git switch main` / `git pull` while on `main` / `git branch -f main`。 +- **やる**: `git fetch origin +refs/heads/main:refs/remotes/origin/main` で remote tracking ref を更新する。確認が必要な時は `git worktree add --detach origin/main` で detached 確認。 +- merge は worktree 内から `gh` / `skills/post-merge/scripts/merge-pr.py --confirm-read` で行う。 +- **cleanup は自分が作った worktree / branch だけ**削除する。`git worktree list --porcelain` で他セッションのものを確認し**温存する**。 +- allowlist 対象の生成 config を main 直コミットする時の stale-main 注意は `~/business/AGENT-HUB/docs/worktree-operations.md` を参照。 + +要するに「編集だけ worktree、merge/pull は共有 checkout」をやめる。**着手から cleanup まで一貫して専用 worktree**で閉じる。例外的に人間が明示して main checkout を使う場合は、AI が占有している状態でないことと例外理由を作業ログへ残す。 + +## 既存 worktree の確認 + +```bash +git worktree list +``` + +`~/Herd/jtt-apps` 配下には `jtt-apps-api-rate-limit-guards` / `jtt-apps-wt` / `jtt-apps-worktrees` 等の既存 worktree がある(CLAUDE.md `## プロジェクトルート規約` 参照)。新規作成前に既存 worktree の再利用可否を確認すること。 + +--- + +**追記ルール: 実測事例・復旧手順・長文詳細は移設先(references / docs)へ書き、本ルールには義務とトリガーだけ足す(再肥大化防止)。** diff --git a/opencode.json b/opencode.json new file mode 100644 index 000000000..9c905a171 --- /dev/null +++ b/opencode.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [ + "AGENTS.md", + "CLAUDE.md" + ], + "mcp": { + "agentmemory-agentmemory": { + "type": "local", + "enabled": true, + "command": [ + "/bin/bash", + "/Users/shintaro/business/AGENT-HUB/scripts/agentmemory-mcp-remote.sh", + "agentmemory" + ] + }, + "ai-worker-mcp": { + "type": "local", + "enabled": true, + "command": [ + "/Users/shintaro/business/AGENT-HUB/tools/ai-worker-mcp/bin/ai-worker-mcp" + ] + }, + "codebase-context-engine-agentmemory": { + "type": "remote", + "enabled": true, + "url": "http://shintaros-mac-mini:8847/mcp", + "headers": { + "X-API-Key": "{env:CODEBASE_CONTEXT_ENGINE_MCP_API_KEY}" + } + }, + "context7": { + "type": "local", + "enabled": true, + "command": [ + "npx", + "-y", + "@upstash/context7-mcp@3.2.0" + ] + }, + "shintaro-gbrain": { + "type": "remote", + "enabled": true, + "url": "https://gbrain-mcp.jtt.cafe/mcp" + }, + "stitch": { + "type": "remote", + "enabled": true, + "url": "https://stitch.googleapis.com/mcp", + "headers": { + "X-Goog-Api-Key": "{env:STITCH_API_KEY}" + } + }, + "tech-gbrain": { + "type": "remote", + "enabled": true, + "url": "https://gbrain-mcp.jtt.cafe/mcp" + } + } +} diff --git a/tui.json b/tui.json new file mode 100644 index 000000000..7ff40d25b --- /dev/null +++ b/tui.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/tui.json", + "mouse": false +} From b81c185aefb98865573d442147c39eb6ae026f3a Mon Sep 17 00:00:00 2001 From: ShintaroKawakami Date: Wed, 5 Aug 2026 13:34:07 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(security):=20=E3=82=AF=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=83=B3=E3=83=88=E5=88=A5=20MCP=20=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=82=92=20gitignore=20=E3=81=97=E8=BF=BD=E8=B7=A1?= =?UTF-8?q?=E3=81=8B=E3=82=89=E5=A4=96=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gbrain-recall 配布時、.gemini/settings.json に GCP の実キーが入ったまま追跡対象となり GitHub push protection がブロックした(push は拒否され、漏えいは発生していない)。 - .gemini/settings.json / .cursor/mcp.json / .kimi-code/mcp.json を .gitignore へ追加 - 上記 3 ファイルを git rm --cached で追跡から除外(ローカル実体は保持) - 実値の SSOT は ~/.config/agent-hub/.env、生成は sync が毎回ローカルで行う (.claude/rules/general/mcp-key-management.md) 他 PJ(jtt-cms 等)は既に同じ除外を持っており、本 PJ だけ欠けていた。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WVy3m6uEuqSxwZWQJQHTye --- .cursor/mcp.json | 42 ---------------------------------- .gitignore | 10 +++++++++ .kimi-code/mcp.json | 55 --------------------------------------------- 3 files changed, 10 insertions(+), 97 deletions(-) delete mode 100644 .cursor/mcp.json delete mode 100644 .kimi-code/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json deleted file mode 100644 index d3b8017dd..000000000 --- a/.cursor/mcp.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "mcpServers": { - "agentmemory": { - "type": "stdio", - "command": "/bin/bash", - "args": [ - "/Users/shintaro/business/AGENT-HUB/scripts/agentmemory-mcp-remote.sh", - "agentmemory" - ] - }, - "ai-worker-mcp": { - "type": "stdio", - "command": "/Users/shintaro/business/AGENT-HUB/tools/ai-worker-mcp/bin/ai-worker-mcp" - }, - "codebase-context-engine": { - "url": "http://shintaros-mac-mini:8847/mcp", - "headers": { - "X-API-Key": "${env:CODEBASE_CONTEXT_ENGINE_MCP_API_KEY}" - } - }, - "context7": { - "type": "stdio", - "command": "npx", - "args": [ - "-y", - "@upstash/context7-mcp@3.2.0" - ] - }, - "shintaro-gbrain": { - "url": "https://gbrain-mcp.jtt.cafe/mcp" - }, - "stitch": { - "url": "https://stitch.googleapis.com/mcp", - "headers": { - "X-Goog-Api-Key": "${env:STITCH_API_KEY}" - } - }, - "tech-gbrain": { - "url": "https://gbrain-mcp.jtt.cafe/mcp" - } - } -} diff --git a/.gitignore b/.gitignore index 14a4ffe3a..245ef8e80 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,13 @@ eval/data/longmemeval/ .kimi-code/skills/* .opencode/agents/* # AGENT-HUB MANAGED: harness-generated-surfaces END + +# [2026-08-05][fix][SEC] ツール別 MCP / クライアント設定の派生物は git 追跡しない +# - 背景: ハーネス配布時、.gemini/settings.json に GCP 実キーが入ったまま追跡対象となり +# GitHub push protection がブロックした(push 拒否・漏えいは未発生)。 +# - 守るべき業務ルール: mcp-key-management(実値の SSOT は ~/.config/agent-hub/.env。 +# クライアント別生成物はローカル展開のみ)。他 PJ(jtt-cms 等)と同じ除外に揃える。 +# - 他案不採用理由: 実キーを ${VAR} 参照へ書き換える案は Gemini が補間しないため動作しない。 +.gemini/settings.json +.cursor/mcp.json +.kimi-code/mcp.json diff --git a/.kimi-code/mcp.json b/.kimi-code/mcp.json deleted file mode 100644 index ca0e72311..000000000 --- a/.kimi-code/mcp.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "mcpServers": { - "agentmemory-agentmemory": { - "command": "/bin/bash", - "args": [ - "/Users/shintaro/business/AGENT-HUB/scripts/agentmemory-mcp-remote.sh", - "agentmemory" - ] - }, - "ai-worker-mcp": { - "command": "/Users/shintaro/business/AGENT-HUB/tools/ai-worker-mcp/bin/ai-worker-mcp" - }, - "codebase-context-engine-agentmemory": { - "command": "/bin/bash", - "args": [ - "/Users/shintaro/business/AGENT-HUB/scripts/codex-mcp-remote-with-env.sh", - "http://shintaros-mac-mini:8847/mcp", - "CODEBASE_CONTEXT_ENGINE_MCP_API_KEY" - ] - }, - "context7": { - "command": "npx", - "args": [ - "-y", - "@upstash/context7-mcp@3.2.0" - ], - "env": { - "npm_config_cache": "${HOME}/.kimi-code/npm-cache" - } - }, - "shintaro-gbrain": { - "command": "/bin/bash", - "args": [ - "/Users/shintaro/business/AGENT-HUB/scripts/mcp-remote-oauth.sh", - "https://gbrain-mcp.jtt.cafe/mcp", - "shintaro-gbrain" - ] - }, - "stitch": { - "url": "https://stitch.googleapis.com/mcp", - "type": "http", - "headers": { - "X-Goog-Api-Key": "${env:STITCH_API_KEY}" - } - }, - "tech-gbrain": { - "command": "/bin/bash", - "args": [ - "/Users/shintaro/business/AGENT-HUB/scripts/mcp-remote-oauth.sh", - "https://gbrain-mcp.jtt.cafe/mcp", - "tech-gbrain" - ] - } - } -} From 6a41d87478b7f55d719ae71d44c0e96ff7af00c1 Mon Sep 17 00:00:00 2001 From: ShintaroKawakami Date: Wed, 5 Aug 2026 13:34:37 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore(harness):=20AGENT-HUB=20=E3=81=8B?= =?UTF-8?q?=E3=82=89=E3=83=8F=E3=83=BC=E3=83=8D=E3=82=B9=E9=85=8D=E5=B8=83?= =?UTF-8?q?=EF=BC=88gbrain-recall=20=E3=83=AB=E3=83=BC=E3=83=AB=20+=20?= =?UTF-8?q?=E5=89=8D=E5=87=A6=E7=90=86=20hook=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会話中に AI が G-Brain / agentmemory を読みに行く「リコール層」を全 PJ へ展開する配布差分。 正本は AGENT-HUB(registries/harness-manifest.yaml の global include)。本 PJ 側では手編集しない。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WVy3m6uEuqSxwZWQJQHTye --- .agent-hub/harness-generation.json | 384 +++++++++++------- .agent/rules/gbrain-recall.md | 62 +++ .agent/rules/plan-commitment-tracking.md | 10 + .../general/.agent-hub-materializations.json | 7 +- .claude/rules/general/gbrain-recall.md | 62 +++ .../rules/general/plan-commitment-tracking.md | 10 + .codex/hooks.json | 9 + .../hooks/scripts/gbrain-recall-preflight.sh | 82 ++++ .../scripts/gbrain-recall-preflight.test.sh | 32 ++ .codex/sync-state.json | 4 +- .cursor/hooks.json | 5 + .../hooks/scripts/gbrain-recall-preflight.sh | 82 ++++ .../scripts/gbrain-recall-preflight.test.sh | 32 ++ .cursor/rules/10-runtime-sync.mdc | 8 + .cursor/rules/general/gbrain-recall.mdc | 69 ++++ .../general/plan-commitment-tracking.mdc | 10 + .cursor/sync-state.json | 12 +- .../hooks/scripts/gbrain-recall-preflight.sh | 82 ++++ .../scripts/gbrain-recall-preflight.test.sh | 32 ++ .gemini/sync-state.json | 13 +- .gitignore | 1 - .kimi-code/hooks/managed-hooks.json | 5 + .../hooks/scripts/gbrain-recall-preflight.sh | 82 ++++ .../scripts/gbrain-recall-preflight.test.sh | 32 ++ .kimi-code/sync-state.json | 8 +- .opencode/sync-state.json | 7 +- AGENTS.md | 75 +++- CLAUDE.md | 2 +- GEMINI.md | 77 +++- 29 files changed, 1129 insertions(+), 167 deletions(-) create mode 100644 .agent/rules/gbrain-recall.md create mode 100644 .claude/rules/general/gbrain-recall.md create mode 100755 .codex/hooks/scripts/gbrain-recall-preflight.sh create mode 100755 .codex/hooks/scripts/gbrain-recall-preflight.test.sh create mode 100755 .cursor/hooks/scripts/gbrain-recall-preflight.sh create mode 100755 .cursor/hooks/scripts/gbrain-recall-preflight.test.sh create mode 100644 .cursor/rules/general/gbrain-recall.mdc create mode 100755 .gemini/hooks/scripts/gbrain-recall-preflight.sh create mode 100755 .gemini/hooks/scripts/gbrain-recall-preflight.test.sh create mode 100755 .kimi-code/hooks/scripts/gbrain-recall-preflight.sh create mode 100755 .kimi-code/hooks/scripts/gbrain-recall-preflight.test.sh diff --git a/.agent-hub/harness-generation.json b/.agent-hub/harness-generation.json index a3c405b68..8bb45a49a 100644 --- a/.agent-hub/harness-generation.json +++ b/.agent-hub/harness-generation.json @@ -4,22 +4,22 @@ ], "canonical_project": "agentmemory", "effective_hashes": { - "antigravity": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", - "claude": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", - "codex": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", - "cursor": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", - "kimi": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", - "opencode": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f", - "warp": "80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f" + "antigravity": "7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c", + "claude": "7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c", + "codex": "7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c", + "cursor": "7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c", + "kimi": "7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c", + "opencode": "7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c", + "warp": "7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c" }, "harness_type": "mcp-server", "ledger_writer": { "writer_id": "sync-agents", - "writer_version": "sha256:4000a0f869bf95a58f0acf31e3c806ae896d72766d080fba7575031c99abf8c3" + "writer_version": "sha256:a1805de664860ab76a295a3638814daa0a32e1f87e22c2a7021a746ea0753340" }, "resolver_version": "1.1.0", "schema_version": 2, - "source_fingerprint": "sha256:1f2c35236a329a1c3635abee5175ac2b0e29a2313e42ce72b2c7946afc50b13c", + "source_fingerprint": "sha256:b76740c8ce6a655120750562b21ca340ef04a00f940f5c9b33facf6fc80c60b0", "surfaces": [ { "asset_revisions": [ @@ -53,9 +53,9 @@ } ], "client": "antigravity", - "content_digest": "sha256:33f8c8ccd6a2e1f3e2a91f5dfc38102211e7d9cca3aa1f402f4098199d60d8a0", + "content_digest": "sha256:840c55f99f8265f3aa5f4d13c6a7c996cda2ea78242aef8f096955bccb2fb510", "kind": "ai_client", - "materialization_digest": "sha256:74b7a4cfd30c3ee59f6473d82f73cface756f10e858309dfc9c6a209fbffc905", + "materialization_digest": "sha256:5f53afc3b01998a7014b15c7ae760ee713c94da72f67c7edd175278b63eb6861", "probe": "stable-sync-state", "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", "symlink_policy": "forbidden", @@ -167,15 +167,15 @@ } ], "client": "antigravity", - "content_digest": "sha256:40a5ecb0799ba5fe42193d457eefec8eac73e744178c1deb8c265251c5e86d9e", + "content_digest": "sha256:dc3c802f69fb65459abbc8a5a8aa11a1245d38bb4141afc462f9b8470d53a333", "kind": "command", - "materialization_digest": "sha256:994aa8fb3c00ad7deaced4566f1a08f3414d3287e751cc5a660244728b1fe0a2", + "materialization_digest": "sha256:53c2d8adefc92cd63a5ae7904763912dcae834f95408c79949a1f42b858a9db4", "probe": "fresh-command-discovery", "source_revision": "sha256:f262e701cd117ebfc294b96e40580eab67d29d903dc416e095b7fe92a6a76594", "symlink_policy": "forbidden", "target": ".agent/workflows", "writer_id": "sync-project-commands", - "writer_version": "sha256:fbf5d36c472ac0ec7d506e7bf6a2ef717ab0e0ea3e53d6e2e73ad6d0a0bb1ab0" + "writer_version": "sha256:c76b9dd7f280a41a6b95dabd85fdc381a23c218960e7b5a1bfed8e2c60c5b4cb" }, { "asset_revisions": [ @@ -197,9 +197,9 @@ } ], "client": "antigravity", - "content_digest": "sha256:011b8d16a8c32bf0cd72f82978afabec826ec621565739912e1cf4c867fa2d59", + "content_digest": "sha256:f262d213c5d5f090a4891051d6dd224a4fdbfccb38acc40ebf0c17ac76888e13", "kind": "constitution", - "materialization_digest": "sha256:cd22c2beaf219f864c344ec60c69b6260f264aaec59ebfd198318f815d724c42", + "materialization_digest": "sha256:b1764355449fa13878437f102d51627cc0520713d461cdff0c2dce7eaaa1a697", "probe": "fresh-instruction-discovery", "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", "symlink_policy": "forbidden", @@ -229,6 +229,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -251,11 +255,11 @@ } ], "client": "antigravity", - "content_digest": "sha256:7cd0521c9566755b20f2d6419db8b5ffad0afea5cc367dcf773882cf1817ed56", + "content_digest": "sha256:2f1a3c045fd96165c2dda8e406305b2965faa60bf0e0ccb34a1320e3ab57855f", "kind": "hook", - "materialization_digest": "sha256:2c223cda4b0f9d51d9f646c395b74d78da12be4f02d3fe2513a579ea33695098", + "materialization_digest": "sha256:e54cb5be64d63e21399d5d33c2c57c3f7f77884d42acd2104955f7c40ae84a5e", "probe": "safe-hook-fixture", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".gemini/hooks", "writer_id": "sync-antigravity-from-cc", @@ -283,6 +287,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -307,9 +315,9 @@ "client": "antigravity", "content_digest": "sha256:0ef03123354a948ba6004238db9a72add0d38f45982e2022f2278932ccf678d2", "kind": "hook", - "materialization_digest": "sha256:d10a63b68ab9ab67aab3e7bf3dbee35f718eda3b478c6198d7837e6a43a75df3", + "materialization_digest": "sha256:77fc5029f32c7cd647445d4f69e7ebe7df8edde9438fc73bc8ecdeb4c4344f16", "probe": "stable-client-config", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".gemini/settings.json", "writer_id": "sync-antigravity-from-cc", @@ -371,6 +379,10 @@ "asset_id": "constructive-dissent", "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" }, + { + "asset_id": "gbrain-recall", + "source_revision": "ee93a6b72f9c2ffcd1ea2d2cab4f2a211bd62ef9c273f434d9e642d83e904bca" + }, { "asset_id": "hooks-structure-rule", "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" @@ -397,7 +409,7 @@ }, { "asset_id": "plan-commitment-tracking", - "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + "source_revision": "6c3e20e15adad2fb18e24505e72a8a0ec23e8d71d1e98cc6361281690091a42f" }, { "asset_id": "reference-over-hardcode", @@ -433,11 +445,11 @@ } ], "client": "antigravity", - "content_digest": "sha256:2bd73eec63e653b80bfe425511d70fd05602c1bd946a458747e23be5967f698b", + "content_digest": "sha256:820b20aaa317c52c6ca2d32ce6f806aabcaf1f0d7e238465ba6c504c9a943f56", "kind": "rule", - "materialization_digest": "sha256:68cde6a6f68b5d5178ee997f7212490a3ced1f8d72c7bb6c7128c81903913c94", + "materialization_digest": "sha256:f4cfe42bc71985d4b5f971913ee880e85fc259ee4057c76c770db1c8af0c76d2", "probe": "fresh-rule-discovery", - "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "source_revision": "sha256:6c1dc7448d7dfc9c73b8d899603d119a124abc43726f9c4b83a03c66b2637eda", "symlink_policy": "forbidden", "target": ".agent/rules", "writer_id": "sync-antigravity-from-cc", @@ -473,6 +485,10 @@ "asset_id": "quality-engineer", "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" }, + { + "asset_id": "security-reviewer", + "source_revision": "076b9711a589fe71e7f446be04a431ba307841440ab4ddd6441926cc0cd07610" + }, { "asset_id": "stitch-screen-creator", "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" @@ -491,11 +507,11 @@ } ], "client": "antigravity", - "content_digest": "sha256:2599abb455d1f16d1cfa1b341e92289fcaf5378483ce8717722b06ee2bac8c70", + "content_digest": "sha256:94ae8a91257b807f31d6eb588674113612de8b23ba9d12b8ee7ab4b9a3af19d0", "kind": "subagent", - "materialization_digest": "sha256:b3ec8d58fa654536c726c8d9af96cb9243f2664cd9881c2d1639a496a2384880", + "materialization_digest": "sha256:ef3157fd24b4fee0f880a69a340facabfbb99c7bbc428903cf20bcd1d0331d54", "probe": "fresh-agent-discovery", - "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "source_revision": "sha256:9be278a707b0bf4207b86baf1a0f838a9572e25e1571867013ca4250f33f676f", "symlink_policy": "forbidden", "target": ".gemini/agents", "writer_id": "sync-antigravity-from-cc", @@ -533,7 +549,7 @@ } ], "client": "claude", - "content_digest": "sha256:c7e89ef03177632214dc388d0cabff9cd974baf550325f8ed907ead571ba96e7", + "content_digest": "sha256:6a911082feab7ede42b231cba4b232bab381c3bb786cfe8c65a300c08c853afd", "kind": "ai_client", "materialization_digest": "sha256:df80fbc403cab512032ad6bc7a6046ac5089435a1fc1b31829f8842556f0828b", "probe": "generated-surface-ignore", @@ -647,15 +663,15 @@ } ], "client": "claude", - "content_digest": "sha256:40a5ecb0799ba5fe42193d457eefec8eac73e744178c1deb8c265251c5e86d9e", + "content_digest": "sha256:dc3c802f69fb65459abbc8a5a8aa11a1245d38bb4141afc462f9b8470d53a333", "kind": "command", - "materialization_digest": "sha256:05faafd9993eef37a7cee88c6f1c5c7c6631ad6f4367a8f211e053c5a911acee", + "materialization_digest": "sha256:9803385570d8add0c5db98f78256fa57099ac87f33bb196e4407ac17d7e0f3cf", "probe": "fresh-command-discovery", "source_revision": "sha256:f262e701cd117ebfc294b96e40580eab67d29d903dc416e095b7fe92a6a76594", "symlink_policy": "forbidden", "target": ".claude/commands", "writer_id": "sync-project-commands", - "writer_version": "sha256:fbf5d36c472ac0ec7d506e7bf6a2ef717ab0e0ea3e53d6e2e73ad6d0a0bb1ab0" + "writer_version": "sha256:c76b9dd7f280a41a6b95dabd85fdc381a23c218960e7b5a1bfed8e2c60c5b4cb" }, { "asset_revisions": [ @@ -677,15 +693,15 @@ } ], "client": "claude", - "content_digest": "sha256:fd92da11f875d8f87bc062bac69ec0510f5b45b24d658e400bc0acb759ca2431", + "content_digest": "sha256:a901a91a18d42bdf3c84cf866d19ea57b5596e8e90e9b247a16e1d0d01e7783c", "kind": "constitution", - "materialization_digest": "sha256:c0c417626020c9e02be813ceb29e64d68195d624439003ed56d184de4328e11d", + "materialization_digest": "sha256:a8d39ad08dea6d71802ad06b574406e8e2ec93b7b27009b22b4005ead97f0ddf", "probe": "fresh-instruction-discovery", "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", "symlink_policy": "forbidden", "target": "CLAUDE.md", "writer_id": "generate-project-constitution", - "writer_version": "sha256:a6c7122a992869f2b3e47f0a3dab5b9ebd4df28366632e4cf90a7625e5a6fa93" + "writer_version": "sha256:7be07fc514d3b43ca2c6510d64608e14e04a366dce3e59797f80e870e9804584" }, { "asset_revisions": [ @@ -709,6 +725,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -731,11 +751,11 @@ } ], "client": "claude", - "content_digest": "sha256:263676b5ea2c822fe6b1161d43d19e408de0d6bad5cf30134dddfc7e0d77749a", + "content_digest": "sha256:4aaceea08d13d2110ab60ebd21f574fcfbc5a1f819d53ff35a1c8c78eaeee590", "kind": "hook", - "materialization_digest": "sha256:20aee19d4ca96ef4229ac238534360e34d92a34cdcf5d970000577d696d9847b", + "materialization_digest": "sha256:8d395b37f51272c100d80c28217bd6ca27ebad54e837ee89e14149e0b14081b0", "probe": "safe-hook-fixture", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".claude/hooks", "writer_id": "deploy-hooks", @@ -763,6 +783,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -785,11 +809,11 @@ } ], "client": "claude", - "content_digest": "sha256:8df8eeeb827e48739e3feca687a85d37cdde5e79fc47db58b9734b8f0f8e0936", + "content_digest": "sha256:c15036bcd42065684d5fb89ae496d3db192169c806fd2c3873e032d885129fdb", "kind": "hook", - "materialization_digest": "sha256:a11154e79a29f7a0ae2e96d768d8c87269b7e764376ac556b92ec7a6d9ad1805", + "materialization_digest": "sha256:e448a5c01c639e2f2eb7f1fd2226b9c8efb382503a42402eb1cad4299b6bc249", "probe": "safe-hook-fixture", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".claude/settings.json", "writer_id": "deploy-hooks", @@ -851,6 +875,10 @@ "asset_id": "constructive-dissent", "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" }, + { + "asset_id": "gbrain-recall", + "source_revision": "ee93a6b72f9c2ffcd1ea2d2cab4f2a211bd62ef9c273f434d9e642d83e904bca" + }, { "asset_id": "hooks-structure-rule", "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" @@ -877,7 +905,7 @@ }, { "asset_id": "plan-commitment-tracking", - "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + "source_revision": "6c3e20e15adad2fb18e24505e72a8a0ec23e8d71d1e98cc6361281690091a42f" }, { "asset_id": "reference-over-hardcode", @@ -913,11 +941,11 @@ } ], "client": "claude", - "content_digest": "sha256:7508e5a2f7dd28d6da810444fb654b8ec84ece7d2e7f8fb9e5658138e4497ad9", + "content_digest": "sha256:05f52ec03457949ee04f30e73aff3200a59b868701780a638354820c91f32a05", "kind": "rule", - "materialization_digest": "sha256:437a488a5d5329ec66d51fb5e3c5aa0ee41983161c2517562a161860f6c89fde", + "materialization_digest": "sha256:da202ba57b789c0f792d4432d5f180dff01bf8c35d78f0ec317a1f715bb0a5eb", "probe": "fresh-rule-discovery", - "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "source_revision": "sha256:6c1dc7448d7dfc9c73b8d899603d119a124abc43726f9c4b83a03c66b2637eda", "symlink_policy": "forbidden", "target": ".claude/rules", "writer_id": "deploy-rules", @@ -927,15 +955,15 @@ "asset_revisions": [ { "asset_id": "adversarial-review", - "source_revision": "ce41fe079117d6e6bb2a77566a5d25d239dc2386b7e1f31a62368a91899bbcdd" + "source_revision": "4e6c8e779fe9e5dc8bad1def529e9e1edc60b9d48c1f05c9c1f6d1178ab11f59" }, { "asset_id": "agent-dispatch", - "source_revision": "68209f182ae0d3da281c1600c2d532fe151d02696a4f4ebcfe106ad25282b109" + "source_revision": "21525da0697981ea313d5a8b993f2bbb6ea7edd83d012fdd95ecba48a6daa976" }, { "asset_id": "agentmemory-routing", - "source_revision": "3c477764f40da2dcf8a7219227113e67a81c9697d2152b05ab35cfe6ef4ac9ec" + "source_revision": "085222f9fb3d87ecc57363dc6a87b6f99c6d21761722efc0d4d243f753b5326d" }, { "asset_id": "ai-project-rules", @@ -959,11 +987,11 @@ }, { "asset_id": "dev-guardrails", - "source_revision": "6652989322196ed82cd61c36eae2b8bbfef365b35270237620fbb069a3932141" + "source_revision": "5fa22f14ddd6857d22dec38e87010c42904fa814de26c317b779083112600d9c" }, { "asset_id": "handover-manual", - "source_revision": "fa229b12cbd3e0b5fd2015808d30b75612e9f5a4c7da267ad29db2893980f05b" + "source_revision": "67e0b470e8b66ef3d3b227f032c8cc464a450a9eb1ce33cc769777c1cbcfd53a" }, { "asset_id": "mcp-dev-kit", @@ -971,11 +999,11 @@ }, { "asset_id": "plan-approval", - "source_revision": "ccca7f1eb7f228d6df67223bb5895f05f1f73f08c1e23e7edf40f0a0345ce419" + "source_revision": "7e8070b94fd8335249375ce0981d002fbbd4b15f857eaa9c7d96b7bacb40db84" }, { "asset_id": "post-merge", - "source_revision": "713daf4a34a279b6296b1e721a4637152531ec8c61bc094b38d2754491f6b9b4" + "source_revision": "2edf2a9ed1d08cb02b0df23bfc9bfad46c060247601510f061d6dd6d659cc7fb" }, { "asset_id": "skill-audit", @@ -983,11 +1011,11 @@ } ], "client": "claude", - "content_digest": "sha256:39a906d45df71bdab9d60f23d9deb60ac610391ea261327ab175c924ac46ceba", + "content_digest": "sha256:8a360e9e79ad4d0be06a336c7572a6136b4efe7da9f515a4eb65990425106096", "kind": "skill", - "materialization_digest": "sha256:fe03997f1274f0da22cefcf9c9df582f2dca017649cf69212db503cda5c7e6e3", + "materialization_digest": "sha256:d563717e38fb2693ae068543ca8759e29654a9abbc3262520b9860a8a890e963", "probe": "fresh-skill-discovery", - "source_revision": "sha256:b583437e2d84e00e897e359fa84f1b69c6b7b620c5ea37f2f6b0ed3ef7e03b5b", + "source_revision": "sha256:3a9912862e81c76d3993d074b1809eefeecd9ebb23ebbb9c377251a59242f10a", "symlink_policy": "forbidden", "target": ".claude/skills", "writer_id": "sync-runtime-skills", @@ -1023,6 +1051,10 @@ "asset_id": "quality-engineer", "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" }, + { + "asset_id": "security-reviewer", + "source_revision": "076b9711a589fe71e7f446be04a431ba307841440ab4ddd6441926cc0cd07610" + }, { "asset_id": "stitch-screen-creator", "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" @@ -1041,11 +1073,11 @@ } ], "client": "claude", - "content_digest": "sha256:ada572493c395b78b110b22a1059e49d98f7de834951bd27b09add91f728a48a", + "content_digest": "sha256:7da862da145c72e5d2c57749004082617571cdf827beebb96a7696a62fa836e1", "kind": "subagent", - "materialization_digest": "sha256:efe40478647ecf4a768ca7564c8d0119a61e42f9b16b66e2a476b55e725f412a", + "materialization_digest": "sha256:7ed5e549f6d625bf7c720583cc18fc83dd122084fa7529bbe84c53b1def4ea66", "probe": "fresh-agent-discovery", - "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "source_revision": "sha256:9be278a707b0bf4207b86baf1a0f838a9572e25e1571867013ca4250f33f676f", "symlink_policy": "forbidden", "target": ".claude/agents", "writer_id": "harness-link-planner", @@ -1113,9 +1145,9 @@ } ], "client": "codex", - "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "content_digest": "sha256:4343e7229b5fe5b22d39070d5e7d85c754eed1993c322a9126a4bca2fb05396c", "kind": "constitution", - "materialization_digest": "sha256:dd8b68d8fc567db7bcf0167347d3d40d047020b5154d2084c8cb14982a0ae0de", + "materialization_digest": "sha256:22d464eb1ea18ebad92f625949fe00822b90691631efab157a495b5dc3448e31", "probe": "fresh-instruction-discovery", "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", "symlink_policy": "forbidden", @@ -1145,6 +1177,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -1167,11 +1203,11 @@ } ], "client": "codex", - "content_digest": "sha256:14b4e69dd6e8ed85abb0895f5cf3a0f8f1c7aea6a657677bbcc1822a0b103b49", + "content_digest": "sha256:3d0e2ef31006907500e448aed2ea8c7be81cf6ed92bcaf39793efd6d0bacbd77", "kind": "hook", - "materialization_digest": "sha256:e56136f3f58f846b8b42a4dc68a73e68db05f65fb57b2955e057f65200680e83", + "materialization_digest": "sha256:2919376bdbdf82e5325a2f9c9df5593f1a7f7d36487e92f58d28af0470ff4666", "probe": "safe-hook-fixture", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".codex/hooks", "writer_id": "deploy-hooks", @@ -1199,6 +1235,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -1221,11 +1261,11 @@ } ], "client": "codex", - "content_digest": "sha256:482c2947686b93254b0b6a90130b5d8f2d19efb2d561dbb5b06c26aedc4674b1", + "content_digest": "sha256:59f5d522cef872a3ce6d5ae6274b6157d17f1f2ccaccbb605f8a4c5c729388b4", "kind": "hook", - "materialization_digest": "sha256:6df5a0a6f5776c0a42aa007a98681be1195a2d5fd9cafab0fb10527c351e259a", + "materialization_digest": "sha256:adb1231d7f3104db59851833db664d5edce01be7bf9adffc021d9389f64426bc", "probe": "safe-hook-fixture", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".codex/hooks.json", "writer_id": "deploy-hooks", @@ -1287,6 +1327,10 @@ "asset_id": "constructive-dissent", "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" }, + { + "asset_id": "gbrain-recall", + "source_revision": "ee93a6b72f9c2ffcd1ea2d2cab4f2a211bd62ef9c273f434d9e642d83e904bca" + }, { "asset_id": "hooks-structure-rule", "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" @@ -1313,7 +1357,7 @@ }, { "asset_id": "plan-commitment-tracking", - "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + "source_revision": "6c3e20e15adad2fb18e24505e72a8a0ec23e8d71d1e98cc6361281690091a42f" }, { "asset_id": "reference-over-hardcode", @@ -1349,11 +1393,11 @@ } ], "client": "codex", - "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "content_digest": "sha256:4343e7229b5fe5b22d39070d5e7d85c754eed1993c322a9126a4bca2fb05396c", "kind": "rule", - "materialization_digest": "sha256:d6c2893db1c06fc0988a3d64f503cacb924156cce38e2ed644313ca884109342", + "materialization_digest": "sha256:9658080e0510f1dd9279e0f851a110dc3a61f26b9f30986bbfc8a5cbd2c68875", "probe": "fresh-rule-discovery", - "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "source_revision": "sha256:6c1dc7448d7dfc9c73b8d899603d119a124abc43726f9c4b83a03c66b2637eda", "symlink_policy": "forbidden", "target": "AGENTS.md", "writer_id": "generate-agents-md", @@ -1363,15 +1407,15 @@ "asset_revisions": [ { "asset_id": "adversarial-review", - "source_revision": "ce41fe079117d6e6bb2a77566a5d25d239dc2386b7e1f31a62368a91899bbcdd" + "source_revision": "4e6c8e779fe9e5dc8bad1def529e9e1edc60b9d48c1f05c9c1f6d1178ab11f59" }, { "asset_id": "agent-dispatch", - "source_revision": "68209f182ae0d3da281c1600c2d532fe151d02696a4f4ebcfe106ad25282b109" + "source_revision": "21525da0697981ea313d5a8b993f2bbb6ea7edd83d012fdd95ecba48a6daa976" }, { "asset_id": "agentmemory-routing", - "source_revision": "3c477764f40da2dcf8a7219227113e67a81c9697d2152b05ab35cfe6ef4ac9ec" + "source_revision": "085222f9fb3d87ecc57363dc6a87b6f99c6d21761722efc0d4d243f753b5326d" }, { "asset_id": "ai-project-rules", @@ -1395,11 +1439,11 @@ }, { "asset_id": "dev-guardrails", - "source_revision": "6652989322196ed82cd61c36eae2b8bbfef365b35270237620fbb069a3932141" + "source_revision": "5fa22f14ddd6857d22dec38e87010c42904fa814de26c317b779083112600d9c" }, { "asset_id": "handover-manual", - "source_revision": "fa229b12cbd3e0b5fd2015808d30b75612e9f5a4c7da267ad29db2893980f05b" + "source_revision": "67e0b470e8b66ef3d3b227f032c8cc464a450a9eb1ce33cc769777c1cbcfd53a" }, { "asset_id": "mcp-dev-kit", @@ -1407,11 +1451,11 @@ }, { "asset_id": "plan-approval", - "source_revision": "ccca7f1eb7f228d6df67223bb5895f05f1f73f08c1e23e7edf40f0a0345ce419" + "source_revision": "7e8070b94fd8335249375ce0981d002fbbd4b15f857eaa9c7d96b7bacb40db84" }, { "asset_id": "post-merge", - "source_revision": "713daf4a34a279b6296b1e721a4637152531ec8c61bc094b38d2754491f6b9b4" + "source_revision": "2edf2a9ed1d08cb02b0df23bfc9bfad46c060247601510f061d6dd6d659cc7fb" }, { "asset_id": "skill-audit", @@ -1421,9 +1465,9 @@ "client": "codex", "content_digest": "sha256:3ab51293f56ca7a584d9489b0c91aa743888d50cfb18776a87f4e5bae09f8035", "kind": "skill", - "materialization_digest": "sha256:aa07206bfbb1bc88f5ce426f01d64ecfb5ccaa7828eecd314e9c462bb702ea5f", + "materialization_digest": "sha256:65d2cfa59b074b9767c049453a43c44085be00ce2cd7d87bbdd76885f7cf5966", "probe": "fresh-skill-discovery", - "source_revision": "sha256:b583437e2d84e00e897e359fa84f1b69c6b7b620c5ea37f2f6b0ed3ef7e03b5b", + "source_revision": "sha256:3a9912862e81c76d3993d074b1809eefeecd9ebb23ebbb9c377251a59242f10a", "symlink_policy": "forbidden", "target": ".agents/skills", "writer_id": "sync-runtime-skills", @@ -1459,6 +1503,10 @@ "asset_id": "quality-engineer", "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" }, + { + "asset_id": "security-reviewer", + "source_revision": "076b9711a589fe71e7f446be04a431ba307841440ab4ddd6441926cc0cd07610" + }, { "asset_id": "stitch-screen-creator", "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" @@ -1477,11 +1525,11 @@ } ], "client": "codex", - "content_digest": "sha256:ada572493c395b78b110b22a1059e49d98f7de834951bd27b09add91f728a48a", + "content_digest": "sha256:7da862da145c72e5d2c57749004082617571cdf827beebb96a7696a62fa836e1", "kind": "subagent", - "materialization_digest": "sha256:df69c9a8bdc45f4017b7e7c01e1f16e7bf6ec802fa0876df509a34777e118962", + "materialization_digest": "sha256:44c7c1f52233cd19d6601abf41e4a23f0ffce7ec808482609feef0d6ec5c651d", "probe": "fresh-agent-discovery", - "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "source_revision": "sha256:9be278a707b0bf4207b86baf1a0f838a9572e25e1571867013ca4250f33f676f", "symlink_policy": "forbidden", "target": ".codex/agents", "writer_id": "harness-link-planner", @@ -1519,9 +1567,9 @@ } ], "client": "cursor", - "content_digest": "sha256:b4d146393c754baa379691508e63b0215e3bff466f21c5b48cd6f37fde1cac7b", + "content_digest": "sha256:adc6892b86ff2b2e771fc287d99c90661aaf6367048ef86988c9f34ba76d565b", "kind": "ai_client", - "materialization_digest": "sha256:e662150f6941c3312374de425f0c124df6b6013a2ffee514b098607009aa31b8", + "materialization_digest": "sha256:2465964fa74e14aa463c52335f284bdb35d5934c3870a5308a9c49bb696d0f49", "probe": "stable-sync-state", "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", "symlink_policy": "forbidden", @@ -1549,9 +1597,9 @@ } ], "client": "cursor", - "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "content_digest": "sha256:4343e7229b5fe5b22d39070d5e7d85c754eed1993c322a9126a4bca2fb05396c", "kind": "constitution", - "materialization_digest": "sha256:78cd0da6af7203b2309f31b6a0ce1646a7e238288dfb34c825b9af4537188ede", + "materialization_digest": "sha256:c69e606ef61d1299814937bff808816daf11e0b638da57f427f4f9dfedcd9473", "probe": "fresh-instruction-discovery", "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", "symlink_policy": "forbidden", @@ -1581,6 +1629,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -1603,11 +1655,11 @@ } ], "client": "cursor", - "content_digest": "sha256:d684a7503d880d1d1ee3c816569a02f45992f09147976a8824b82e99ad274e94", + "content_digest": "sha256:4ba6cd2195a02bf67f56464cdd20c6fb0ad41f7d0e1abe5c5f54f2766eccb5d2", "kind": "hook", - "materialization_digest": "sha256:a63a0e6558222760b2914d56dbe965a8ec914d2fac79999a4b89f853beabd599", + "materialization_digest": "sha256:17f48eae325269627d794a223af29ba1aaeae47cfa8b33a298e0e86ee19905f2", "probe": "safe-hook-fixture", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".cursor/hooks", "writer_id": "sync-cursor-from-cc", @@ -1635,6 +1687,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -1657,11 +1713,11 @@ } ], "client": "cursor", - "content_digest": "sha256:d338f8bf318235f882a6fa92c1092665891b88cfaf83351020282158f11c888c", + "content_digest": "sha256:82ab0d5a34e569680700b06c0b0264a4f36824e9777f8e3882cbbddcfe236dbb", "kind": "hook", - "materialization_digest": "sha256:ed3e34eeabddf0a924fc9cc86734d7ed6427d2d757afc5fd4ba3223ba1b7f9e8", + "materialization_digest": "sha256:3caf2e098acd59398dccbcd9fc35819a428ce06916a7e47ff21919d02e7bea31", "probe": "safe-hook-fixture", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".cursor/hooks.json", "writer_id": "sync-cursor-from-cc", @@ -1723,6 +1779,10 @@ "asset_id": "constructive-dissent", "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" }, + { + "asset_id": "gbrain-recall", + "source_revision": "ee93a6b72f9c2ffcd1ea2d2cab4f2a211bd62ef9c273f434d9e642d83e904bca" + }, { "asset_id": "hooks-structure-rule", "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" @@ -1749,7 +1809,7 @@ }, { "asset_id": "plan-commitment-tracking", - "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + "source_revision": "6c3e20e15adad2fb18e24505e72a8a0ec23e8d71d1e98cc6361281690091a42f" }, { "asset_id": "reference-over-hardcode", @@ -1785,11 +1845,11 @@ } ], "client": "cursor", - "content_digest": "sha256:48d0001d0c3747eed4988620cc5387d7993811e3d5709d8bd350ba6e0d368202", + "content_digest": "sha256:2837e93ae4e4a363bb3f8693aa40770fd6e8722b015006eecca154893341cd16", "kind": "rule", - "materialization_digest": "sha256:5058046f66e3a00fdae9874a37cc5768408337951070ad30f0e8b930a9261478", + "materialization_digest": "sha256:63bce5add123a3b3f548af0df754b33783a2e0bdc541d8d89e109018d7536eea", "probe": "fresh-rule-discovery", - "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "source_revision": "sha256:6c1dc7448d7dfc9c73b8d899603d119a124abc43726f9c4b83a03c66b2637eda", "symlink_policy": "forbidden", "target": ".cursor/rules", "writer_id": "sync-cursor-from-cc", @@ -1799,15 +1859,15 @@ "asset_revisions": [ { "asset_id": "adversarial-review", - "source_revision": "ce41fe079117d6e6bb2a77566a5d25d239dc2386b7e1f31a62368a91899bbcdd" + "source_revision": "4e6c8e779fe9e5dc8bad1def529e9e1edc60b9d48c1f05c9c1f6d1178ab11f59" }, { "asset_id": "agent-dispatch", - "source_revision": "68209f182ae0d3da281c1600c2d532fe151d02696a4f4ebcfe106ad25282b109" + "source_revision": "21525da0697981ea313d5a8b993f2bbb6ea7edd83d012fdd95ecba48a6daa976" }, { "asset_id": "agentmemory-routing", - "source_revision": "3c477764f40da2dcf8a7219227113e67a81c9697d2152b05ab35cfe6ef4ac9ec" + "source_revision": "085222f9fb3d87ecc57363dc6a87b6f99c6d21761722efc0d4d243f753b5326d" }, { "asset_id": "ai-project-rules", @@ -1831,11 +1891,11 @@ }, { "asset_id": "dev-guardrails", - "source_revision": "6652989322196ed82cd61c36eae2b8bbfef365b35270237620fbb069a3932141" + "source_revision": "5fa22f14ddd6857d22dec38e87010c42904fa814de26c317b779083112600d9c" }, { "asset_id": "handover-manual", - "source_revision": "fa229b12cbd3e0b5fd2015808d30b75612e9f5a4c7da267ad29db2893980f05b" + "source_revision": "67e0b470e8b66ef3d3b227f032c8cc464a450a9eb1ce33cc769777c1cbcfd53a" }, { "asset_id": "mcp-dev-kit", @@ -1843,11 +1903,11 @@ }, { "asset_id": "plan-approval", - "source_revision": "ccca7f1eb7f228d6df67223bb5895f05f1f73f08c1e23e7edf40f0a0345ce419" + "source_revision": "7e8070b94fd8335249375ce0981d002fbbd4b15f857eaa9c7d96b7bacb40db84" }, { "asset_id": "post-merge", - "source_revision": "713daf4a34a279b6296b1e721a4637152531ec8c61bc094b38d2754491f6b9b4" + "source_revision": "2edf2a9ed1d08cb02b0df23bfc9bfad46c060247601510f061d6dd6d659cc7fb" }, { "asset_id": "skill-audit", @@ -1857,9 +1917,9 @@ "client": "cursor", "content_digest": "sha256:3ab51293f56ca7a584d9489b0c91aa743888d50cfb18776a87f4e5bae09f8035", "kind": "skill", - "materialization_digest": "sha256:1cf41ec4e5f1e624d3b544519bf91dc67ae62db05b73f34ebd931c56628d025b", + "materialization_digest": "sha256:0f271b0647ba42b799fe6b1a2d5ae52c939598b35e61f0cdb959b828ccbd23c8", "probe": "fresh-skill-discovery", - "source_revision": "sha256:b583437e2d84e00e897e359fa84f1b69c6b7b620c5ea37f2f6b0ed3ef7e03b5b", + "source_revision": "sha256:3a9912862e81c76d3993d074b1809eefeecd9ebb23ebbb9c377251a59242f10a", "symlink_policy": "forbidden", "target": ".cursor/skills", "writer_id": "sync-runtime-skills", @@ -1895,6 +1955,10 @@ "asset_id": "quality-engineer", "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" }, + { + "asset_id": "security-reviewer", + "source_revision": "076b9711a589fe71e7f446be04a431ba307841440ab4ddd6441926cc0cd07610" + }, { "asset_id": "stitch-screen-creator", "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" @@ -1913,11 +1977,11 @@ } ], "client": "cursor", - "content_digest": "sha256:3b05494f026eab180d54a8da37f83b3a80f7ccd858f82e1606ccb08899b7d3cf", + "content_digest": "sha256:72bdf4b4c4792d237e6995ef5d10b20bf9f8aaeda1686fc39ab94d06c5a2b176", "kind": "subagent", - "materialization_digest": "sha256:b2ae07c4cd52c730954a53962089513552472286ce9a0c31b36883db68a4db4a", + "materialization_digest": "sha256:8f4c3974f69d7bf092d67dd345fc4e177b06d426ddc3e72a24757089cd623853", "probe": "fresh-agent-discovery", - "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "source_revision": "sha256:9be278a707b0bf4207b86baf1a0f838a9572e25e1571867013ca4250f33f676f", "symlink_policy": "forbidden", "target": ".cursor/agents", "writer_id": "sync-cursor-from-cc", @@ -1957,13 +2021,13 @@ "client": "kimi", "content_digest": "sha256:b6d1568b0c737e790b17d2058cbe957949de38159dc291aa70994d66dec7e072", "kind": "ai_client", - "materialization_digest": "sha256:8d7245003f29e7d446e0cfd220f5d681e353310ff4421ad9474b4c5986026139", + "materialization_digest": "sha256:1ff6094b4dc649d99c9762c4dc0bc7a990fa65659f13ec87da0d21a717eb46ea", "probe": "stable-client-instruction", "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", "symlink_policy": "forbidden", "target": ".kimi-code/AGENTS.md", "writer_id": "sync-kimi-from-cc", - "writer_version": "sha256:647f2a67bd59ef6b854d177c1e444163467d1985e2c3fa42ffcc5959585cfb7f" + "writer_version": "sha256:355e91d6438766db23e86ab526371e47965fe313565ab2b1262cf6d5e501d2fd" }, { "asset_revisions": [ @@ -1997,15 +2061,15 @@ } ], "client": "kimi", - "content_digest": "sha256:730277f80738d3f0f5bc40cd2d279dcf9cba192b54f86f97df15eef359a6a056", + "content_digest": "sha256:857177cb6a9a3c57d5529e58e468c8501dc4096b9a9d9bdc2d00481b7e3190d9", "kind": "ai_client", - "materialization_digest": "sha256:d7531ecf6d52f108831dba5ec8c500a3eaf82b0bfe056e978866b24fc6b7998d", + "materialization_digest": "sha256:05821523ecffe87d4f6db48936d37079ea726d58b0ef8afca2d9127fc959b11b", "probe": "stable-sync-state", "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", "symlink_policy": "forbidden", "target": ".kimi-code/sync-state.json", "writer_id": "sync-kimi-from-cc", - "writer_version": "sha256:647f2a67bd59ef6b854d177c1e444163467d1985e2c3fa42ffcc5959585cfb7f" + "writer_version": "sha256:355e91d6438766db23e86ab526371e47965fe313565ab2b1262cf6d5e501d2fd" }, { "asset_revisions": [ @@ -2027,9 +2091,9 @@ } ], "client": "kimi", - "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "content_digest": "sha256:4343e7229b5fe5b22d39070d5e7d85c754eed1993c322a9126a4bca2fb05396c", "kind": "constitution", - "materialization_digest": "sha256:8e606f514fbd54143d8822196843d025a1f75e963066ecd9a97f00871022498b", + "materialization_digest": "sha256:1494b57d81dd6799fc6427ac3484a89ad969d8f7b183b00b46d79cb9a53c0371", "probe": "fresh-instruction-discovery", "source_revision": "sha256:a34a80544159656a0c940906f8aab091913c7821dde682023dcd840a76ad01f3", "symlink_policy": "forbidden", @@ -2059,6 +2123,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -2081,15 +2149,15 @@ } ], "client": "kimi", - "content_digest": "sha256:005c0a4f6753239b8c38d0b6e08e1f1d9b92d6f9595d43778d3ae4a1f519f1b6", + "content_digest": "sha256:f55949dac755b1b22eae841b232a7ecd1ac072ba45cc1128dfa90127da59afb2", "kind": "hook", - "materialization_digest": "sha256:28588e565254ff76e782a8f646e75caf642a4fb21ef9a3ad638b0621b66bc326", + "materialization_digest": "sha256:1d8415d7a1936d0fa0c8b9491e1dd479083c35497e65b8551ca5087c380395aa", "probe": "safe-hook-fixture", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".kimi-code/hooks", "writer_id": "sync-kimi-from-cc", - "writer_version": "sha256:647f2a67bd59ef6b854d177c1e444163467d1985e2c3fa42ffcc5959585cfb7f" + "writer_version": "sha256:355e91d6438766db23e86ab526371e47965fe313565ab2b1262cf6d5e501d2fd" }, { "asset_revisions": [ @@ -2131,7 +2199,7 @@ "symlink_policy": "forbidden", "target": ".kimi-code/mcp.json", "writer_id": "sync-kimi-from-cc", - "writer_version": "sha256:647f2a67bd59ef6b854d177c1e444163467d1985e2c3fa42ffcc5959585cfb7f" + "writer_version": "sha256:355e91d6438766db23e86ab526371e47965fe313565ab2b1262cf6d5e501d2fd" }, { "asset_revisions": [ @@ -2147,6 +2215,10 @@ "asset_id": "constructive-dissent", "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" }, + { + "asset_id": "gbrain-recall", + "source_revision": "ee93a6b72f9c2ffcd1ea2d2cab4f2a211bd62ef9c273f434d9e642d83e904bca" + }, { "asset_id": "hooks-structure-rule", "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" @@ -2173,7 +2245,7 @@ }, { "asset_id": "plan-commitment-tracking", - "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + "source_revision": "6c3e20e15adad2fb18e24505e72a8a0ec23e8d71d1e98cc6361281690091a42f" }, { "asset_id": "reference-over-hardcode", @@ -2209,11 +2281,11 @@ } ], "client": "kimi", - "content_digest": "sha256:f781529f25bd91723ba2aa90981cad8c555b223bfc38a1ad85668d1fe2587a33", + "content_digest": "sha256:4343e7229b5fe5b22d39070d5e7d85c754eed1993c322a9126a4bca2fb05396c", "kind": "rule", - "materialization_digest": "sha256:09d015eb98c76d8437d3214fdf0bf2dbda0f075018ccbf4cbd1f4531761025f2", + "materialization_digest": "sha256:a0d57ba15b78f72c16db4ae8b06a038704d863ba9fa19528610bb82dd40983d7", "probe": "fresh-rule-discovery", - "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "source_revision": "sha256:6c1dc7448d7dfc9c73b8d899603d119a124abc43726f9c4b83a03c66b2637eda", "symlink_policy": "forbidden", "target": "AGENTS.md", "writer_id": "generate-agents-md", @@ -2223,15 +2295,15 @@ "asset_revisions": [ { "asset_id": "adversarial-review", - "source_revision": "ce41fe079117d6e6bb2a77566a5d25d239dc2386b7e1f31a62368a91899bbcdd" + "source_revision": "4e6c8e779fe9e5dc8bad1def529e9e1edc60b9d48c1f05c9c1f6d1178ab11f59" }, { "asset_id": "agent-dispatch", - "source_revision": "68209f182ae0d3da281c1600c2d532fe151d02696a4f4ebcfe106ad25282b109" + "source_revision": "21525da0697981ea313d5a8b993f2bbb6ea7edd83d012fdd95ecba48a6daa976" }, { "asset_id": "agentmemory-routing", - "source_revision": "3c477764f40da2dcf8a7219227113e67a81c9697d2152b05ab35cfe6ef4ac9ec" + "source_revision": "085222f9fb3d87ecc57363dc6a87b6f99c6d21761722efc0d4d243f753b5326d" }, { "asset_id": "ai-project-rules", @@ -2255,11 +2327,11 @@ }, { "asset_id": "dev-guardrails", - "source_revision": "6652989322196ed82cd61c36eae2b8bbfef365b35270237620fbb069a3932141" + "source_revision": "5fa22f14ddd6857d22dec38e87010c42904fa814de26c317b779083112600d9c" }, { "asset_id": "handover-manual", - "source_revision": "fa229b12cbd3e0b5fd2015808d30b75612e9f5a4c7da267ad29db2893980f05b" + "source_revision": "67e0b470e8b66ef3d3b227f032c8cc464a450a9eb1ce33cc769777c1cbcfd53a" }, { "asset_id": "mcp-dev-kit", @@ -2267,11 +2339,11 @@ }, { "asset_id": "plan-approval", - "source_revision": "ccca7f1eb7f228d6df67223bb5895f05f1f73f08c1e23e7edf40f0a0345ce419" + "source_revision": "7e8070b94fd8335249375ce0981d002fbbd4b15f857eaa9c7d96b7bacb40db84" }, { "asset_id": "post-merge", - "source_revision": "713daf4a34a279b6296b1e721a4637152531ec8c61bc094b38d2754491f6b9b4" + "source_revision": "2edf2a9ed1d08cb02b0df23bfc9bfad46c060247601510f061d6dd6d659cc7fb" }, { "asset_id": "skill-audit", @@ -2281,9 +2353,9 @@ "client": "kimi", "content_digest": "sha256:3ab51293f56ca7a584d9489b0c91aa743888d50cfb18776a87f4e5bae09f8035", "kind": "skill", - "materialization_digest": "sha256:5491abe65f5197e053bd4c67e440fc334f74f708ecaadf4aa9791944bc991543", + "materialization_digest": "sha256:ba0e966a5be97c2c14e88c06f9ea6ae12ab5649c377c6b214ca7eefa28dd0a62", "probe": "fresh-skill-discovery", - "source_revision": "sha256:b583437e2d84e00e897e359fa84f1b69c6b7b620c5ea37f2f6b0ed3ef7e03b5b", + "source_revision": "sha256:3a9912862e81c76d3993d074b1809eefeecd9ebb23ebbb9c377251a59242f10a", "symlink_policy": "forbidden", "target": ".kimi-code/skills", "writer_id": "sync-runtime-skills", @@ -2321,15 +2393,15 @@ } ], "client": "opencode", - "content_digest": "sha256:09ed231df8154c7d11ed816b78d42e46718cc5e45cc7344032602e0a82e26c61", + "content_digest": "sha256:dacf0b66bcc5d9692b41513b73f842a549309dd977ce43a0f1fb0981752ec725", "kind": "ai_client", - "materialization_digest": "sha256:56ac247dc39b2e18acd1fc57eee00f0a82cac89e158c30acdb91d9682f51f624", + "materialization_digest": "sha256:96c565306eacc7ef84cdcc712d119860f9c7e4c6fddeb45a8f8c0b9434b08925", "probe": "stable-sync-state", "source_revision": "sha256:4a244311b9f5fc8a04b79628d359a5fc68274acc74a61666df7d1878391918ec", "symlink_policy": "forbidden", "target": ".opencode/sync-state.json", "writer_id": "sync-opencode-from-cc", - "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + "writer_version": "sha256:470696fb4b3aaeac0d555309789270f529fe14c7712fe975d4e5e51483c4af81" }, { "asset_revisions": [ @@ -2371,7 +2443,7 @@ "symlink_policy": "forbidden", "target": "tui.json", "writer_id": "sync-opencode-from-cc", - "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + "writer_version": "sha256:470696fb4b3aaeac0d555309789270f529fe14c7712fe975d4e5e51483c4af81" }, { "asset_revisions": [ @@ -2401,7 +2473,7 @@ "symlink_policy": "forbidden", "target": "opencode.json", "writer_id": "sync-opencode-from-cc", - "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + "writer_version": "sha256:470696fb4b3aaeac0d555309789270f529fe14c7712fe975d4e5e51483c4af81" }, { "asset_revisions": [ @@ -2425,6 +2497,10 @@ "asset_id": "freshness-gate", "source_revision": "7422a035cbde959a624f656034be2c0d68278875a5d0307fe245c76c787002e2" }, + { + "asset_id": "gbrain-recall-preflight", + "source_revision": "9812c36cf8b8b3334f0101e69405bcfa6ba3e2f1033664a8c9b523b243cc2db2" + }, { "asset_id": "handover-preflight", "source_revision": "8e31a91f3b5b723465b3b779aa27373eb9fb3be8c5e177dbda6574cfdf4dffe7" @@ -2449,13 +2525,13 @@ "client": "opencode", "content_digest": "sha256:2dd96af747129231c15ff849ec6cc36513813bdbafd6a7e858b8dc65e52c9470", "kind": "hook", - "materialization_digest": "sha256:4415009cbb90ee27638ab9580e8dde91ca82288c77342b275d3dd7aed91a4292", + "materialization_digest": "sha256:3b0613fec7fae06fe1466e5c722436d1c2f45946fc37da8633975dbb60cc2fc5", "probe": "safe-hook-fixture", - "source_revision": "sha256:6402c2a0cae45a1089aec74bec1b4d25037672f7b4fbc2b3188d2508cd5471d9", + "source_revision": "sha256:3e8b1a12538f532eb535ba8e0b14f67d641cbc8e6dd5db33bfaf9463afe73d8c", "symlink_policy": "forbidden", "target": ".opencode/plugins", "writer_id": "sync-opencode-from-cc", - "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + "writer_version": "sha256:470696fb4b3aaeac0d555309789270f529fe14c7712fe975d4e5e51483c4af81" }, { "asset_revisions": [ @@ -2497,7 +2573,7 @@ "symlink_policy": "forbidden", "target": "opencode.json", "writer_id": "sync-opencode-from-cc", - "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + "writer_version": "sha256:470696fb4b3aaeac0d555309789270f529fe14c7712fe975d4e5e51483c4af81" }, { "asset_revisions": [ @@ -2513,6 +2589,10 @@ "asset_id": "constructive-dissent", "source_revision": "26d7b1e30ecfd70fc0cc20127121bd69743e1720f6ac46cc9f212a850feb7691" }, + { + "asset_id": "gbrain-recall", + "source_revision": "ee93a6b72f9c2ffcd1ea2d2cab4f2a211bd62ef9c273f434d9e642d83e904bca" + }, { "asset_id": "hooks-structure-rule", "source_revision": "70c1e952afbd4f5baba7852700be7700b6903edda579b71dd443bd90fac17dd2" @@ -2539,7 +2619,7 @@ }, { "asset_id": "plan-commitment-tracking", - "source_revision": "62d38b53af80a853897ef0f225539c5c183fca862753f6508cede45af7577cd0" + "source_revision": "6c3e20e15adad2fb18e24505e72a8a0ec23e8d71d1e98cc6361281690091a42f" }, { "asset_id": "reference-over-hardcode", @@ -2577,13 +2657,13 @@ "client": "opencode", "content_digest": "sha256:2bb5d1317b3ac3f792da0241e75b2cb9154f53a231db4dd88b5d70e16c15d6d5", "kind": "rule", - "materialization_digest": "sha256:9d6c7e2276f3341eb777edc2068c8b67ca834abe18830de7cc21a98dc2b7b6e3", + "materialization_digest": "sha256:3d750da69abc0e00864b2a7d3f79c64b630b917850fdc89110756cccfa5f480c", "probe": "fresh-rule-discovery", - "source_revision": "sha256:437bda84217943c3b7c3849238bd1d7e4c9311603a002030954fafcfc7965832", + "source_revision": "sha256:6c1dc7448d7dfc9c73b8d899603d119a124abc43726f9c4b83a03c66b2637eda", "symlink_policy": "forbidden", "target": "opencode.json", "writer_id": "sync-opencode-from-cc", - "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + "writer_version": "sha256:470696fb4b3aaeac0d555309789270f529fe14c7712fe975d4e5e51483c4af81" }, { "asset_revisions": [ @@ -2615,6 +2695,10 @@ "asset_id": "quality-engineer", "source_revision": "80591e4717b2d80e8dabdae53f8498b11201431a025746afd39becf238384ef7" }, + { + "asset_id": "security-reviewer", + "source_revision": "076b9711a589fe71e7f446be04a431ba307841440ab4ddd6441926cc0cd07610" + }, { "asset_id": "stitch-screen-creator", "source_revision": "bf92f70ca425eeb6f2ff18e827c4f64715a5387e7abe140315e33aaa2b588a3b" @@ -2633,15 +2717,15 @@ } ], "client": "opencode", - "content_digest": "sha256:f946e35c266cf69249cc9604380ac29fb820ad594c3b400f91565d598a83ef52", + "content_digest": "sha256:d40e3f7cf993bbed2985d6570ef1997c0a004fea30fb02590f22d0a947960592", "kind": "subagent", - "materialization_digest": "sha256:bff75cc2488205154b7c8f139c9a04352ad8b808310d8df6c486eb128fa1f8b6", + "materialization_digest": "sha256:4e70179425d077a0618fdb06a999d08b58fbd555fc6900c553e6378719430285", "probe": "fresh-agent-discovery", - "source_revision": "sha256:666cb2571571b521f67338d7566e680bf7eaf30edbede0d1efe61f3750255357", + "source_revision": "sha256:9be278a707b0bf4207b86baf1a0f838a9572e25e1571867013ca4250f33f676f", "symlink_policy": "forbidden", "target": ".opencode/agents", "writer_id": "sync-opencode-from-cc", - "writer_version": "sha256:d131b3e120ccff801ffba237b983d7ffe44d02d03a9f7997b3335e95e0689d95" + "writer_version": "sha256:470696fb4b3aaeac0d555309789270f529fe14c7712fe975d4e5e51483c4af81" }, { "asset_revisions": [ diff --git a/.agent/rules/gbrain-recall.md b/.agent/rules/gbrain-recall.md new file mode 100644 index 000000000..cb5ff9853 --- /dev/null +++ b/.agent/rules/gbrain-recall.md @@ -0,0 +1,62 @@ + + +# G-Brain リコール層(会話中の「読む側」発火条件) + +## 0. scope 宣言(重複防止・複製しない) + +本ルールは **非 plan の日常会話における「読む側」の発火条件だけ** を定義する。以下は別の正本が担当し、 +本ルールでは内容を複製しない(G-Brain ハーネス設計仕様書 D9 役割分担表): + +| 責務 | 正本(変更しない・本ルールは複製しない) | +|------|------| +| plan 入口の preflight(読む) | `skills/plan-approval/plan-commitment-registry.yaml` seq 0.1 / 0.15 | +| 保存先の3層判断(書く) | `skills/agentmemory-routing/SKILL.md` + `agent-memory/registry/placement-policy.md` | +| put_page の安全手順・承認ゲート・合図式保存 | `skills/shintaro-gbrain/SKILL.md` | +| closeout 時の GBrain 候補承認キュー | `skills/handover-manual/SKILL.md`(合図式=会話中の即時承認、closeout=session 末の候補提示で別物) | +| auto-memory の参照 | `.claude/rules/general/memory-lookups.md`(相互参照のみ、内容は複製しない) | +| 敵対的レビュー手順(business) | `skills/adversarial-review/references/business-review.md` | + +## 1. 発火条件表 + +会話の中でユーザー発話や作業内容が次のいずれかに該当したら、応答を出す前に該当 brain を検索する。 + +| 発話・作業の性質 | 検索する先 | 例 | +|---|---|---| +| バグ修正・障害調査・回帰の原因特定 | `tech-gbrain`(`mcp__tech-gbrain__search` / `recall`) | 「〇〇が直らない」「なぜこのエラーが出るか」「前も似た不具合あったはず」 | +| 経営相談・戦略・クレーム対応・売上・オペレーション改善 | `shintaro-gbrain`(`mcp__shintaro-gbrain__search` / `recall`) | 「この施策どう思う」「クレームにどう対応すべきか」「オペレーションを改善したい」 | +| 作業再開・引き継ぎ・「あの続き」 | `agentmemory`(continuation) | 「〇〇の続き」「前回どこまでやったか」 | + +判断に迷う場合は検索する側に倒す(誤爆コストは低く、未検索コストは高い)。 + +## 2. 検索実行の判断はモデル側に残す + +本ルールは「検索しに行くべきタイミング」を定義するだけで、検索実行を強制する hook ではない。 +`hook-library` の UserPromptSubmit hook(`gbrain-recall-preflight`)は軽量キーワード検知による +短いリマインドだけを担い、実際に検索するかどうかの判断はモデル自身が行う(仕様書 D6)。 + +## 3. 関連 + +- 手順・落とし穴の詳細: `skills/shintaro-gbrain/SKILL.md` +- 保存先判断の詳細: `skills/agentmemory-routing/SKILL.md` +- 仕様書: `claude-plans/2026-08-04-jtt-gbrain-harness-spec.md`(D6 / D9) diff --git a/.agent/rules/plan-commitment-tracking.md b/.agent/rules/plan-commitment-tracking.md index 736552d4e..d3af9bd74 100644 --- a/.agent/rules/plan-commitment-tracking.md +++ b/.agent/rules/plan-commitment-tracking.md @@ -8,6 +8,15 @@ rule に置いて plan-approval-gate(義務)/ plan-approval(手順)と - 守るべき業務ルール: 義務文言(台帳全消化まで完了宣言しない・明示保留・虚偽✓禁止・AI worker摩擦は1回で発火)は弱めない。 - 他案不採用理由: 実例長文(2026-06-26 part4-2/PR#522、2026-07-06 /goal 8回反復)を本文に残す案は常駐コストが高いため不採用とし `skills/plan-approval/references/commitment-examples.md` へ移設。 --> + + # プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) ## 原則 @@ -27,6 +36,7 @@ rule に置いて plan-approval-gate(義務)/ plan-approval(手順)と 2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 + - **worker の報告に貼られた検証コマンドの実行結果は、それ自体を証拠として採用しない**(実行していないコマンドの出力をそのまま貼ることがある。一部項目を正直に「未実行」と書いていても、他項目の実行結果が真である保証にはならない)。受け入れ条件に検証コマンドを含めた場合は、統括役が同じコマンドを自分の環境で実走して照合するまで完了扱いにしない。既存資産の「移植・コピー」型タスクは、上流と `diff` を取って一致を機械確認する。詳細・実例は `skills/agent-dispatch/SKILL.md`「失敗の能動検知」および `skills/agent-dispatch/references/model-selection-evidence.md`(2026-08-03)参照。 4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。 diff --git a/.claude/rules/general/.agent-hub-materializations.json b/.claude/rules/general/.agent-hub-materializations.json index b912cc0be..f713c35b5 100644 --- a/.claude/rules/general/.agent-hub-materializations.json +++ b/.claude/rules/general/.agent-hub-materializations.json @@ -16,6 +16,11 @@ "sha256": "92373c52b76e0d8d0f82cf03fe8a1f79638644b31c2dbcdb9bd522c45f7aa34a", "source": ".claude/rules/general/constructive-dissent.md" }, + "gbrain-recall.md": { + "asset_id": "gbrain-recall", + "sha256": "ed517b4d2c2c03cd7015f196a5f7dfb6e5f8113f80ed51726010f1c4f1b9ba35", + "source": ".claude/rules/general/gbrain-recall.md" + }, "hooks-structure-rule.md": { "asset_id": "hooks-structure-rule", "sha256": "271c86c3aead4e05d27dbe773ce5e6e3490d2467494b2112250f95675e233e48", @@ -48,7 +53,7 @@ }, "plan-commitment-tracking.md": { "asset_id": "plan-commitment-tracking", - "sha256": "64af4fd8bd13a7a2d7276190c4e8db514572cd5ffcd408781afb2903dab8167e", + "sha256": "9c9fc8175df91f392186666c7413ab964f1b82f2e20c302df1534fd9bff93220", "source": ".claude/rules/general/plan-commitment-tracking.md" }, "reference-over-hardcode.md": { diff --git a/.claude/rules/general/gbrain-recall.md b/.claude/rules/general/gbrain-recall.md new file mode 100644 index 000000000..cb5ff9853 --- /dev/null +++ b/.claude/rules/general/gbrain-recall.md @@ -0,0 +1,62 @@ + + +# G-Brain リコール層(会話中の「読む側」発火条件) + +## 0. scope 宣言(重複防止・複製しない) + +本ルールは **非 plan の日常会話における「読む側」の発火条件だけ** を定義する。以下は別の正本が担当し、 +本ルールでは内容を複製しない(G-Brain ハーネス設計仕様書 D9 役割分担表): + +| 責務 | 正本(変更しない・本ルールは複製しない) | +|------|------| +| plan 入口の preflight(読む) | `skills/plan-approval/plan-commitment-registry.yaml` seq 0.1 / 0.15 | +| 保存先の3層判断(書く) | `skills/agentmemory-routing/SKILL.md` + `agent-memory/registry/placement-policy.md` | +| put_page の安全手順・承認ゲート・合図式保存 | `skills/shintaro-gbrain/SKILL.md` | +| closeout 時の GBrain 候補承認キュー | `skills/handover-manual/SKILL.md`(合図式=会話中の即時承認、closeout=session 末の候補提示で別物) | +| auto-memory の参照 | `.claude/rules/general/memory-lookups.md`(相互参照のみ、内容は複製しない) | +| 敵対的レビュー手順(business) | `skills/adversarial-review/references/business-review.md` | + +## 1. 発火条件表 + +会話の中でユーザー発話や作業内容が次のいずれかに該当したら、応答を出す前に該当 brain を検索する。 + +| 発話・作業の性質 | 検索する先 | 例 | +|---|---|---| +| バグ修正・障害調査・回帰の原因特定 | `tech-gbrain`(`mcp__tech-gbrain__search` / `recall`) | 「〇〇が直らない」「なぜこのエラーが出るか」「前も似た不具合あったはず」 | +| 経営相談・戦略・クレーム対応・売上・オペレーション改善 | `shintaro-gbrain`(`mcp__shintaro-gbrain__search` / `recall`) | 「この施策どう思う」「クレームにどう対応すべきか」「オペレーションを改善したい」 | +| 作業再開・引き継ぎ・「あの続き」 | `agentmemory`(continuation) | 「〇〇の続き」「前回どこまでやったか」 | + +判断に迷う場合は検索する側に倒す(誤爆コストは低く、未検索コストは高い)。 + +## 2. 検索実行の判断はモデル側に残す + +本ルールは「検索しに行くべきタイミング」を定義するだけで、検索実行を強制する hook ではない。 +`hook-library` の UserPromptSubmit hook(`gbrain-recall-preflight`)は軽量キーワード検知による +短いリマインドだけを担い、実際に検索するかどうかの判断はモデル自身が行う(仕様書 D6)。 + +## 3. 関連 + +- 手順・落とし穴の詳細: `skills/shintaro-gbrain/SKILL.md` +- 保存先判断の詳細: `skills/agentmemory-routing/SKILL.md` +- 仕様書: `claude-plans/2026-08-04-jtt-gbrain-harness-spec.md`(D6 / D9) diff --git a/.claude/rules/general/plan-commitment-tracking.md b/.claude/rules/general/plan-commitment-tracking.md index 736552d4e..d3af9bd74 100644 --- a/.claude/rules/general/plan-commitment-tracking.md +++ b/.claude/rules/general/plan-commitment-tracking.md @@ -8,6 +8,15 @@ rule に置いて plan-approval-gate(義務)/ plan-approval(手順)と - 守るべき業務ルール: 義務文言(台帳全消化まで完了宣言しない・明示保留・虚偽✓禁止・AI worker摩擦は1回で発火)は弱めない。 - 他案不採用理由: 実例長文(2026-06-26 part4-2/PR#522、2026-07-06 /goal 8回反復)を本文に残す案は常駐コストが高いため不採用とし `skills/plan-approval/references/commitment-examples.md` へ移設。 --> + + # プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) ## 原則 @@ -27,6 +36,7 @@ rule に置いて plan-approval-gate(義務)/ plan-approval(手順)と 2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 + - **worker の報告に貼られた検証コマンドの実行結果は、それ自体を証拠として採用しない**(実行していないコマンドの出力をそのまま貼ることがある。一部項目を正直に「未実行」と書いていても、他項目の実行結果が真である保証にはならない)。受け入れ条件に検証コマンドを含めた場合は、統括役が同じコマンドを自分の環境で実走して照合するまで完了扱いにしない。既存資産の「移植・コピー」型タスクは、上流と `diff` を取って一致を機械確認する。詳細・実例は `skills/agent-dispatch/SKILL.md`「失敗の能動検知」および `skills/agent-dispatch/references/model-selection-evidence.md`(2026-08-03)参照。 4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。 diff --git a/.codex/hooks.json b/.codex/hooks.json index 444152b53..8fc74099f 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -59,6 +59,15 @@ } ], "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "PROJECT_DIR=\"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"; bash \"$PROJECT_DIR/.codex/hooks/scripts/gbrain-recall-preflight.sh\"", + "timeout": 5 + } + ] + }, { "hooks": [ { diff --git a/.codex/hooks/scripts/gbrain-recall-preflight.sh b/.codex/hooks/scripts/gbrain-recall-preflight.sh new file mode 100755 index 000000000..640e7b86b --- /dev/null +++ b/.codex/hooks/scripts/gbrain-recall-preflight.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# UserPromptSubmit hook for gbrain-recall reminders. +# Quiet by default: only prints when the prompt contains a known keyword group +# (bug/investigation or management-consultation/strategy), or when +# GBRAIN_RECALL_PREFLIGHT_FORCE=1 is set. +# +# [2026-08-04][feat] G-Brain ハーネス Phase 1(gbrain-recall rule と対の hook) +# 背景: +# - ユーザー依頼意図: .claude/rules/general/gbrain-recall.md の発火条件表(バグ修正/障害調査→ +# tech-gbrain、経営相談/戦略→shintaro-gbrain)を、通常の会話でも軽量に思い出させたい。 +# - 守るべき業務ルール: 重い処理は禁止(軽量 grep のみ)。検索を実行するかどうかの判断はモデル側に +# 残す(rule §2)。既存の agent-memory-preflight.sh / handover-preflight.sh と同型の +# UserPromptSubmit hook 実装様式に倣う(quiet-by-default・JSON stdin パース・FORCE env)。 +# - 他案不採用理由: hook 側で自動的に search ツールを呼ぶ案は、誤爆時にノイズ・不要な MCP 呼び出しが +# 発生するため不採用(rule §2 のとおりリマインドに留める)。 +# 対応: 新規 hook を追加。settings/gbrain-recall-preflight.json(Claude)と +# settings-codex/gbrain-recall-preflight.json(Codex)を同一 PR で追加する。 + +set -euo pipefail + +RAW_INPUT="$(cat || true)" + +HOOK_INPUT="$RAW_INPUT" command python3 - <<'PY' +import json +import os +import re +import sys + + +def prompt_from_payload(text: str) -> str: + if not text.strip(): + return "" + try: + payload = json.loads(text) + except Exception: + return text + if not isinstance(payload, dict): + return "" + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str): + return value + nested = payload.get("tool_input") + if isinstance(nested, dict): + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = nested.get(key) + if isinstance(value, str): + return value + return "" + + +# .claude/rules/general/gbrain-recall.md §1 の発火条件表と同じキーワード群。 +# 語の追加・変更はルール本体と同一 PR で行う(二重管理防止)。 +TECH_KEYWORDS = [ + "バグ", "直して", "不具合", "障害", "エラー", "直らない", "原因", "回帰", +] +BUSINESS_KEYWORDS = [ + "相談", "戦略", "クレーム", "どうすれば", "オペレーション改善", "施策", "売上", +] + +TECH_RE = re.compile("|".join(re.escape(w) for w in TECH_KEYWORDS)) +BUSINESS_RE = re.compile("|".join(re.escape(w) for w in BUSINESS_KEYWORDS)) + +raw = os.environ.get("HOOK_INPUT", "") +prompt = prompt_from_payload(raw) +forced = os.environ.get("GBRAIN_RECALL_PREFLIGHT_FORCE", "0") == "1" + +tech_hit = bool(TECH_RE.search(prompt)) +business_hit = bool(BUSINESS_RE.search(prompt)) + +if not forced and not (tech_hit or business_hit): + raise SystemExit(0) + +print("gbrain-recall preflight:") +if not (tech_hit or business_hit): + print("- 該当キーワードなし(FORCE 表示)") +if tech_hit: + print("- tech-gbrain を検索してから着手(バグ修正・障害調査・回帰)") +if business_hit: + print("- shintaro-gbrain を検索してから着手(経営相談・戦略・クレーム対応)") +print("- 詳細: .claude/rules/general/gbrain-recall.md") +PY diff --git a/.codex/hooks/scripts/gbrain-recall-preflight.test.sh b/.codex/hooks/scripts/gbrain-recall-preflight.test.sh new file mode 100755 index 000000000..d462520f8 --- /dev/null +++ b/.codex/hooks/scripts/gbrain-recall-preflight.test.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/gbrain-recall-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +run_hook() { + local prompt="$1" + printf '{"user_prompt": "%s"}' "$prompt" | bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +bug_output="$(run_hook "このAPIのバグを直して")" +echo "$bug_output" | grep -q "tech-gbrain を検索してから着手" \ + || fail "バグ修正プロンプトで tech-gbrain 案内が出ない: $bug_output" + +business_output="$(run_hook "この施策について相談したい")" +echo "$business_output" | grep -q "shintaro-gbrain を検索してから着手" \ + || fail "経営相談プロンプトで shintaro-gbrain 案内が出ない: $business_output" + +force_output="$(printf '{"user_prompt": "ただの雑談"}' | GBRAIN_RECALL_PREFLIGHT_FORCE=1 bash "$HOOK")" +echo "$force_output" | grep -q "gbrain-recall preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "該当キーワードなし" || fail "FORCE時に無該当メッセージが出ない: $force_output" + +echo "PASS: gbrain-recall-preflight" diff --git a/.codex/sync-state.json b/.codex/sync-state.json index c07c1d01b..f28122581 100644 --- a/.codex/sync-state.json +++ b/.codex/sync-state.json @@ -1,7 +1,7 @@ { "tool": "codex-mcp-sync", - "generated_at": "2026-08-03T14:47:26.340637+00:00", - "source_commit": "09cea49", + "generated_at": "2026-08-05T04:34:27.062877+00:00", + "source_commit": "b81c185", "project": "agentmemory", "codex_config_mode": "tracked", "enabled_mcp": [ diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 71cd54dfc..62dfbd66c 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -55,6 +55,11 @@ } ], "beforeSubmitPrompt": [ + { + "type": "command", + "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/gbrain-recall-preflight.sh\"'", + "timeout": 5 + }, { "type": "command", "command": "bash \"${CURSOR_PROJECT_DIR:-.}/.cursor/hooks/scripts/cursor-command-bridge.sh\" 'bash \"${CLAUDE_PROJECT_DIR:-.}/.cursor/hooks/scripts/handover-preflight.sh\"'", diff --git a/.cursor/hooks/scripts/gbrain-recall-preflight.sh b/.cursor/hooks/scripts/gbrain-recall-preflight.sh new file mode 100755 index 000000000..640e7b86b --- /dev/null +++ b/.cursor/hooks/scripts/gbrain-recall-preflight.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# UserPromptSubmit hook for gbrain-recall reminders. +# Quiet by default: only prints when the prompt contains a known keyword group +# (bug/investigation or management-consultation/strategy), or when +# GBRAIN_RECALL_PREFLIGHT_FORCE=1 is set. +# +# [2026-08-04][feat] G-Brain ハーネス Phase 1(gbrain-recall rule と対の hook) +# 背景: +# - ユーザー依頼意図: .claude/rules/general/gbrain-recall.md の発火条件表(バグ修正/障害調査→ +# tech-gbrain、経営相談/戦略→shintaro-gbrain)を、通常の会話でも軽量に思い出させたい。 +# - 守るべき業務ルール: 重い処理は禁止(軽量 grep のみ)。検索を実行するかどうかの判断はモデル側に +# 残す(rule §2)。既存の agent-memory-preflight.sh / handover-preflight.sh と同型の +# UserPromptSubmit hook 実装様式に倣う(quiet-by-default・JSON stdin パース・FORCE env)。 +# - 他案不採用理由: hook 側で自動的に search ツールを呼ぶ案は、誤爆時にノイズ・不要な MCP 呼び出しが +# 発生するため不採用(rule §2 のとおりリマインドに留める)。 +# 対応: 新規 hook を追加。settings/gbrain-recall-preflight.json(Claude)と +# settings-codex/gbrain-recall-preflight.json(Codex)を同一 PR で追加する。 + +set -euo pipefail + +RAW_INPUT="$(cat || true)" + +HOOK_INPUT="$RAW_INPUT" command python3 - <<'PY' +import json +import os +import re +import sys + + +def prompt_from_payload(text: str) -> str: + if not text.strip(): + return "" + try: + payload = json.loads(text) + except Exception: + return text + if not isinstance(payload, dict): + return "" + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str): + return value + nested = payload.get("tool_input") + if isinstance(nested, dict): + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = nested.get(key) + if isinstance(value, str): + return value + return "" + + +# .claude/rules/general/gbrain-recall.md §1 の発火条件表と同じキーワード群。 +# 語の追加・変更はルール本体と同一 PR で行う(二重管理防止)。 +TECH_KEYWORDS = [ + "バグ", "直して", "不具合", "障害", "エラー", "直らない", "原因", "回帰", +] +BUSINESS_KEYWORDS = [ + "相談", "戦略", "クレーム", "どうすれば", "オペレーション改善", "施策", "売上", +] + +TECH_RE = re.compile("|".join(re.escape(w) for w in TECH_KEYWORDS)) +BUSINESS_RE = re.compile("|".join(re.escape(w) for w in BUSINESS_KEYWORDS)) + +raw = os.environ.get("HOOK_INPUT", "") +prompt = prompt_from_payload(raw) +forced = os.environ.get("GBRAIN_RECALL_PREFLIGHT_FORCE", "0") == "1" + +tech_hit = bool(TECH_RE.search(prompt)) +business_hit = bool(BUSINESS_RE.search(prompt)) + +if not forced and not (tech_hit or business_hit): + raise SystemExit(0) + +print("gbrain-recall preflight:") +if not (tech_hit or business_hit): + print("- 該当キーワードなし(FORCE 表示)") +if tech_hit: + print("- tech-gbrain を検索してから着手(バグ修正・障害調査・回帰)") +if business_hit: + print("- shintaro-gbrain を検索してから着手(経営相談・戦略・クレーム対応)") +print("- 詳細: .claude/rules/general/gbrain-recall.md") +PY diff --git a/.cursor/hooks/scripts/gbrain-recall-preflight.test.sh b/.cursor/hooks/scripts/gbrain-recall-preflight.test.sh new file mode 100755 index 000000000..d462520f8 --- /dev/null +++ b/.cursor/hooks/scripts/gbrain-recall-preflight.test.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/gbrain-recall-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +run_hook() { + local prompt="$1" + printf '{"user_prompt": "%s"}' "$prompt" | bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +bug_output="$(run_hook "このAPIのバグを直して")" +echo "$bug_output" | grep -q "tech-gbrain を検索してから着手" \ + || fail "バグ修正プロンプトで tech-gbrain 案内が出ない: $bug_output" + +business_output="$(run_hook "この施策について相談したい")" +echo "$business_output" | grep -q "shintaro-gbrain を検索してから着手" \ + || fail "経営相談プロンプトで shintaro-gbrain 案内が出ない: $business_output" + +force_output="$(printf '{"user_prompt": "ただの雑談"}' | GBRAIN_RECALL_PREFLIGHT_FORCE=1 bash "$HOOK")" +echo "$force_output" | grep -q "gbrain-recall preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "該当キーワードなし" || fail "FORCE時に無該当メッセージが出ない: $force_output" + +echo "PASS: gbrain-recall-preflight" diff --git a/.cursor/rules/10-runtime-sync.mdc b/.cursor/rules/10-runtime-sync.mdc index 9d26bc903..f0afe2ac9 100644 --- a/.cursor/rules/10-runtime-sync.mdc +++ b/.cursor/rules/10-runtime-sync.mdc @@ -45,6 +45,7 @@ alwaysApply: true - `.claude/rules/general/ai-model-selection.md` - `.claude/rules/general/branch-rule.md` - `.claude/rules/general/constructive-dissent.md` +- `.claude/rules/general/gbrain-recall.md` - `.claude/rules/general/hooks-structure-rule.md` - `.claude/rules/general/latest-stack-context7.md` - `.claude/rules/general/mandate-registry.md` @@ -76,6 +77,8 @@ alwaysApply: true - `.claude/hooks/scripts/block-unauthorized-docs-file.sh` - `.claude/hooks/scripts/block-unauthorized-docs-file.test.sh` - `.claude/hooks/scripts/freshness-gate.sh` +- `.claude/hooks/scripts/gbrain-recall-preflight.sh` +- `.claude/hooks/scripts/gbrain-recall-preflight.test.sh` - `.claude/hooks/scripts/handover-preflight.sh` - `.claude/hooks/scripts/handover-preflight.test.sh` - `.claude/hooks/scripts/post-merge-gate.sh` @@ -97,6 +100,7 @@ alwaysApply: true - `.claude/agents/implementation-auditor.md` - `.claude/agents/qa-reviewer.md` - `.claude/agents/quality-engineer.md` +- `.claude/agents/security-reviewer.md` - `.claude/agents/stitch-screen-creator.md` - `.claude/agents/technical-writer.md` - `.claude/agents/test-runner.md` @@ -104,6 +108,7 @@ alwaysApply: true - `.cursor/rules/general/ai-model-selection.mdc` - `.cursor/rules/general/branch-rule.mdc` - `.cursor/rules/general/constructive-dissent.mdc` +- `.cursor/rules/general/gbrain-recall.mdc` - `.cursor/rules/general/hooks-structure-rule.mdc` - `.cursor/rules/general/latest-stack-context7.mdc` - `.cursor/rules/general/mandate-registry.mdc` @@ -137,6 +142,8 @@ alwaysApply: true - `.cursor/hooks/scripts/block-unauthorized-docs-file.test.sh` - `.cursor/hooks/scripts/cursor-command-bridge.sh` - `.cursor/hooks/scripts/freshness-gate.sh` +- `.cursor/hooks/scripts/gbrain-recall-preflight.sh` +- `.cursor/hooks/scripts/gbrain-recall-preflight.test.sh` - `.cursor/hooks/scripts/handover-preflight.sh` - `.cursor/hooks/scripts/handover-preflight.test.sh` - `.cursor/hooks/scripts/post-merge-gate.sh` @@ -159,6 +166,7 @@ alwaysApply: true - `.cursor/agents/implementation-auditor.md` - `.cursor/agents/qa-reviewer.md` - `.cursor/agents/quality-engineer.md` +- `.cursor/agents/security-reviewer.md` - `.cursor/agents/stitch-screen-creator.md` - `.cursor/agents/technical-writer.md` - `.cursor/agents/test-runner.md` diff --git a/.cursor/rules/general/gbrain-recall.mdc b/.cursor/rules/general/gbrain-recall.mdc new file mode 100644 index 000000000..4804b675b --- /dev/null +++ b/.cursor/rules/general/gbrain-recall.mdc @@ -0,0 +1,69 @@ +--- +description: Claude 正本 `gbrain-recall.md` から生成される Cursor rule。直接編集しない。 +alwaysApply: true +--- + + + + + +# G-Brain リコール層(会話中の「読む側」発火条件) + +## 0. scope 宣言(重複防止・複製しない) + +本ルールは **非 plan の日常会話における「読む側」の発火条件だけ** を定義する。以下は別の正本が担当し、 +本ルールでは内容を複製しない(G-Brain ハーネス設計仕様書 D9 役割分担表): + +| 責務 | 正本(変更しない・本ルールは複製しない) | +|------|------| +| plan 入口の preflight(読む) | `skills/plan-approval/plan-commitment-registry.yaml` seq 0.1 / 0.15 | +| 保存先の3層判断(書く) | `skills/agentmemory-routing/SKILL.md` + `agent-memory/registry/placement-policy.md` | +| put_page の安全手順・承認ゲート・合図式保存 | `skills/shintaro-gbrain/SKILL.md` | +| closeout 時の GBrain 候補承認キュー | `skills/handover-manual/SKILL.md`(合図式=会話中の即時承認、closeout=session 末の候補提示で別物) | +| auto-memory の参照 | `.claude/rules/general/memory-lookups.md`(相互参照のみ、内容は複製しない) | +| 敵対的レビュー手順(business) | `skills/adversarial-review/references/business-review.md` | + +## 1. 発火条件表 + +会話の中でユーザー発話や作業内容が次のいずれかに該当したら、応答を出す前に該当 brain を検索する。 + +| 発話・作業の性質 | 検索する先 | 例 | +|---|---|---| +| バグ修正・障害調査・回帰の原因特定 | `tech-gbrain`(`mcp__tech-gbrain__search` / `recall`) | 「〇〇が直らない」「なぜこのエラーが出るか」「前も似た不具合あったはず」 | +| 経営相談・戦略・クレーム対応・売上・オペレーション改善 | `shintaro-gbrain`(`mcp__shintaro-gbrain__search` / `recall`) | 「この施策どう思う」「クレームにどう対応すべきか」「オペレーションを改善したい」 | +| 作業再開・引き継ぎ・「あの続き」 | `agentmemory`(continuation) | 「〇〇の続き」「前回どこまでやったか」 | + +判断に迷う場合は検索する側に倒す(誤爆コストは低く、未検索コストは高い)。 + +## 2. 検索実行の判断はモデル側に残す + +本ルールは「検索しに行くべきタイミング」を定義するだけで、検索実行を強制する hook ではない。 +`hook-library` の UserPromptSubmit hook(`gbrain-recall-preflight`)は軽量キーワード検知による +短いリマインドだけを担い、実際に検索するかどうかの判断はモデル自身が行う(仕様書 D6)。 + +## 3. 関連 + +- 手順・落とし穴の詳細: `skills/shintaro-gbrain/SKILL.md` +- 保存先判断の詳細: `skills/agentmemory-routing/SKILL.md` +- 仕様書: `claude-plans/2026-08-04-jtt-gbrain-harness-spec.md`(D6 / D9) diff --git a/.cursor/rules/general/plan-commitment-tracking.mdc b/.cursor/rules/general/plan-commitment-tracking.mdc index 93b94d18f..8dcadb929 100644 --- a/.cursor/rules/general/plan-commitment-tracking.mdc +++ b/.cursor/rules/general/plan-commitment-tracking.mdc @@ -15,6 +15,15 @@ rule に置いて plan-approval-gate(義務)/ plan-approval(手順)と - 守るべき業務ルール: 義務文言(台帳全消化まで完了宣言しない・明示保留・虚偽✓禁止・AI worker摩擦は1回で発火)は弱めない。 - 他案不採用理由: 実例長文(2026-06-26 part4-2/PR#522、2026-07-06 /goal 8回反復)を本文に残す案は常駐コストが高いため不採用とし `skills/plan-approval/references/commitment-examples.md` へ移設。 --> + + # プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) ## 原則 @@ -34,6 +43,7 @@ rule に置いて plan-approval-gate(義務)/ plan-approval(手順)と 2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 + - **worker の報告に貼られた検証コマンドの実行結果は、それ自体を証拠として採用しない**(実行していないコマンドの出力をそのまま貼ることがある。一部項目を正直に「未実行」と書いていても、他項目の実行結果が真である保証にはならない)。受け入れ条件に検証コマンドを含めた場合は、統括役が同じコマンドを自分の環境で実走して照合するまで完了扱いにしない。既存資産の「移植・コピー」型タスクは、上流と `diff` を取って一致を機械確認する。詳細・実例は `skills/agent-dispatch/SKILL.md`「失敗の能動検知」および `skills/agent-dispatch/references/model-selection-evidence.md`(2026-08-03)参照。 4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。 diff --git a/.cursor/sync-state.json b/.cursor/sync-state.json index e6fb92c7a..bf4803aff 100644 --- a/.cursor/sync-state.json +++ b/.cursor/sync-state.json @@ -1,10 +1,11 @@ { - "generated_at": "2026-08-03T14:47:25.931397+00:00", - "source_commit": "09cea494bfbccdb3a8fc8470f639e80495b117b6", + "generated_at": "2026-08-05T04:34:26.513680+00:00", + "source_commit": "b81c185aefb98865573d442147c39eb6ae026f3a", "source_rule_files": [ ".claude/rules/general/ai-model-selection.md", ".claude/rules/general/branch-rule.md", ".claude/rules/general/constructive-dissent.md", + ".claude/rules/general/gbrain-recall.md", ".claude/rules/general/hooks-structure-rule.md", ".claude/rules/general/latest-stack-context7.md", ".claude/rules/general/mandate-registry.md", @@ -36,6 +37,8 @@ ".claude/hooks/scripts/block-unauthorized-docs-file.sh", ".claude/hooks/scripts/block-unauthorized-docs-file.test.sh", ".claude/hooks/scripts/freshness-gate.sh", + ".claude/hooks/scripts/gbrain-recall-preflight.sh", + ".claude/hooks/scripts/gbrain-recall-preflight.test.sh", ".claude/hooks/scripts/handover-preflight.sh", ".claude/hooks/scripts/handover-preflight.test.sh", ".claude/hooks/scripts/post-merge-gate.sh", @@ -59,6 +62,7 @@ ".claude/agents/implementation-auditor.md", ".claude/agents/qa-reviewer.md", ".claude/agents/quality-engineer.md", + ".claude/agents/security-reviewer.md", ".claude/agents/stitch-screen-creator.md", ".claude/agents/technical-writer.md", ".claude/agents/test-runner.md" @@ -68,6 +72,7 @@ ".cursor/rules/general/ai-model-selection.mdc", ".cursor/rules/general/branch-rule.mdc", ".cursor/rules/general/constructive-dissent.mdc", + ".cursor/rules/general/gbrain-recall.mdc", ".cursor/rules/general/hooks-structure-rule.mdc", ".cursor/rules/general/latest-stack-context7.mdc", ".cursor/rules/general/mandate-registry.mdc", @@ -101,6 +106,8 @@ ".cursor/hooks/scripts/block-unauthorized-docs-file.test.sh", ".cursor/hooks/scripts/cursor-command-bridge.sh", ".cursor/hooks/scripts/freshness-gate.sh", + ".cursor/hooks/scripts/gbrain-recall-preflight.sh", + ".cursor/hooks/scripts/gbrain-recall-preflight.test.sh", ".cursor/hooks/scripts/handover-preflight.sh", ".cursor/hooks/scripts/handover-preflight.test.sh", ".cursor/hooks/scripts/post-merge-gate.sh", @@ -124,6 +131,7 @@ ".cursor/agents/implementation-auditor.md", ".cursor/agents/qa-reviewer.md", ".cursor/agents/quality-engineer.md", + ".cursor/agents/security-reviewer.md", ".cursor/agents/stitch-screen-creator.md", ".cursor/agents/technical-writer.md", ".cursor/agents/test-runner.md" diff --git a/.gemini/hooks/scripts/gbrain-recall-preflight.sh b/.gemini/hooks/scripts/gbrain-recall-preflight.sh new file mode 100755 index 000000000..640e7b86b --- /dev/null +++ b/.gemini/hooks/scripts/gbrain-recall-preflight.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# UserPromptSubmit hook for gbrain-recall reminders. +# Quiet by default: only prints when the prompt contains a known keyword group +# (bug/investigation or management-consultation/strategy), or when +# GBRAIN_RECALL_PREFLIGHT_FORCE=1 is set. +# +# [2026-08-04][feat] G-Brain ハーネス Phase 1(gbrain-recall rule と対の hook) +# 背景: +# - ユーザー依頼意図: .claude/rules/general/gbrain-recall.md の発火条件表(バグ修正/障害調査→ +# tech-gbrain、経営相談/戦略→shintaro-gbrain)を、通常の会話でも軽量に思い出させたい。 +# - 守るべき業務ルール: 重い処理は禁止(軽量 grep のみ)。検索を実行するかどうかの判断はモデル側に +# 残す(rule §2)。既存の agent-memory-preflight.sh / handover-preflight.sh と同型の +# UserPromptSubmit hook 実装様式に倣う(quiet-by-default・JSON stdin パース・FORCE env)。 +# - 他案不採用理由: hook 側で自動的に search ツールを呼ぶ案は、誤爆時にノイズ・不要な MCP 呼び出しが +# 発生するため不採用(rule §2 のとおりリマインドに留める)。 +# 対応: 新規 hook を追加。settings/gbrain-recall-preflight.json(Claude)と +# settings-codex/gbrain-recall-preflight.json(Codex)を同一 PR で追加する。 + +set -euo pipefail + +RAW_INPUT="$(cat || true)" + +HOOK_INPUT="$RAW_INPUT" command python3 - <<'PY' +import json +import os +import re +import sys + + +def prompt_from_payload(text: str) -> str: + if not text.strip(): + return "" + try: + payload = json.loads(text) + except Exception: + return text + if not isinstance(payload, dict): + return "" + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str): + return value + nested = payload.get("tool_input") + if isinstance(nested, dict): + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = nested.get(key) + if isinstance(value, str): + return value + return "" + + +# .claude/rules/general/gbrain-recall.md §1 の発火条件表と同じキーワード群。 +# 語の追加・変更はルール本体と同一 PR で行う(二重管理防止)。 +TECH_KEYWORDS = [ + "バグ", "直して", "不具合", "障害", "エラー", "直らない", "原因", "回帰", +] +BUSINESS_KEYWORDS = [ + "相談", "戦略", "クレーム", "どうすれば", "オペレーション改善", "施策", "売上", +] + +TECH_RE = re.compile("|".join(re.escape(w) for w in TECH_KEYWORDS)) +BUSINESS_RE = re.compile("|".join(re.escape(w) for w in BUSINESS_KEYWORDS)) + +raw = os.environ.get("HOOK_INPUT", "") +prompt = prompt_from_payload(raw) +forced = os.environ.get("GBRAIN_RECALL_PREFLIGHT_FORCE", "0") == "1" + +tech_hit = bool(TECH_RE.search(prompt)) +business_hit = bool(BUSINESS_RE.search(prompt)) + +if not forced and not (tech_hit or business_hit): + raise SystemExit(0) + +print("gbrain-recall preflight:") +if not (tech_hit or business_hit): + print("- 該当キーワードなし(FORCE 表示)") +if tech_hit: + print("- tech-gbrain を検索してから着手(バグ修正・障害調査・回帰)") +if business_hit: + print("- shintaro-gbrain を検索してから着手(経営相談・戦略・クレーム対応)") +print("- 詳細: .claude/rules/general/gbrain-recall.md") +PY diff --git a/.gemini/hooks/scripts/gbrain-recall-preflight.test.sh b/.gemini/hooks/scripts/gbrain-recall-preflight.test.sh new file mode 100755 index 000000000..d462520f8 --- /dev/null +++ b/.gemini/hooks/scripts/gbrain-recall-preflight.test.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/gbrain-recall-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +run_hook() { + local prompt="$1" + printf '{"user_prompt": "%s"}' "$prompt" | bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +bug_output="$(run_hook "このAPIのバグを直して")" +echo "$bug_output" | grep -q "tech-gbrain を検索してから着手" \ + || fail "バグ修正プロンプトで tech-gbrain 案内が出ない: $bug_output" + +business_output="$(run_hook "この施策について相談したい")" +echo "$business_output" | grep -q "shintaro-gbrain を検索してから着手" \ + || fail "経営相談プロンプトで shintaro-gbrain 案内が出ない: $business_output" + +force_output="$(printf '{"user_prompt": "ただの雑談"}' | GBRAIN_RECALL_PREFLIGHT_FORCE=1 bash "$HOOK")" +echo "$force_output" | grep -q "gbrain-recall preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "該当キーワードなし" || fail "FORCE時に無該当メッセージが出ない: $force_output" + +echo "PASS: gbrain-recall-preflight" diff --git a/.gemini/sync-state.json b/.gemini/sync-state.json index df154fc1d..6cc112e04 100644 --- a/.gemini/sync-state.json +++ b/.gemini/sync-state.json @@ -1,7 +1,7 @@ { "tool": "antigravity-sync", - "generated_at": "2026-08-03T14:57:39.219071+00:00", - "source_commit": "e6a0c00", + "generated_at": "2026-08-05T04:34:32.538166+00:00", + "source_commit": "b81c185", "project_root": ".", "generated_gemini_md": true, "copied_agents": [ @@ -13,6 +13,7 @@ "implementation-auditor.md", "qa-reviewer.md", "quality-engineer.md", + "security-reviewer.md", "stitch-screen-creator.md", "technical-writer.md", "test-runner.md" @@ -35,6 +36,8 @@ "scripts/block-unauthorized-docs-file.sh", "scripts/block-unauthorized-docs-file.test.sh", "scripts/freshness-gate.sh", + "scripts/gbrain-recall-preflight.sh", + "scripts/gbrain-recall-preflight.test.sh", "scripts/handover-preflight.sh", "scripts/handover-preflight.test.sh", "scripts/post-merge-gate.sh", @@ -54,6 +57,7 @@ "ai-model-selection.md", "branch-rule.md", "constructive-dissent.md", + "gbrain-recall.md", "hooks-structure-rule.md", "latest-stack-context7.md", "mandate-registry.md", @@ -96,6 +100,10 @@ "qa-reviewer.md: agent frontmatter field `disallowedTools` は Gemini 互換でないため `tools` へ反映", "qa-reviewer.md: agent frontmatter field `skills` は Gemini 互換でないため除去", "qa-reviewer.md: disallowedTools のみ指定されていたため、Gemini 互換の既定ツール集合から禁止ツールを除外して tools を生成", + "security-reviewer.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", + "security-reviewer.md: agent frontmatter field `disallowedTools` は Gemini 互換でないため `tools` へ反映", + "security-reviewer.md: disallowedTools の指定を反映し、Gemini 側 tools から禁止ツールを除外", + "security-reviewer.md: agent frontmatter field `skills` は Gemini 互換でないため除去", "stitch-screen-creator.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", "test-runner.md: agent frontmatter `model: claude-sonnet-5` は Claude 固有のため除去(Gemini 側 inherit 相当)", "test-runner.md: agent frontmatter field `memory` は Gemini 互換でないため除去", @@ -105,6 +113,7 @@ "PreToolUse matcher=Bash|Edit|MultiEdit|Shell|StrReplaceFile|Write|WriteFile は Gemini の run_shell_command へ未変換のためスキップ", "PreToolUse matcher=Skill|Task|Agent は Gemini の run_shell_command へ未変換のためスキップ", "SessionStart→SessionStart は現状の runtime bridge 未対応のためスキップ", + "UserPromptSubmit command 未対応のため未変換: bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/scripts/gbrain-recall-preflight.sh\"", "UserPromptSubmit command 未対応のため未変換: bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/scripts/handover-preflight.sh\"", "SubagentStop→AfterAgent は現状の runtime bridge 未対応のためスキップ", "Stop→AfterAgent は現状の runtime bridge 未対応のためスキップ", diff --git a/.gitignore b/.gitignore index 245ef8e80..2f1e8e96a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,6 @@ eval/data/longmemeval/ .cursor/agents/* .cursor/skills/* .gemini/agents/* -.gemini/settings.json .kimi-code/skills/* .opencode/agents/* # AGENT-HUB MANAGED: harness-generated-surfaces END diff --git a/.kimi-code/hooks/managed-hooks.json b/.kimi-code/hooks/managed-hooks.json index 21273bfdc..90cbff061 100644 --- a/.kimi-code/hooks/managed-hooks.json +++ b/.kimi-code/hooks/managed-hooks.json @@ -45,6 +45,11 @@ "matcher": "Skill|Task|Agent", "timeout": 5 }, + { + "event": "UserPromptSubmit", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/gbrain-recall-preflight.sh\"", + "timeout": 5 + }, { "event": "UserPromptSubmit", "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.kimi-code/hooks/scripts/handover-preflight.sh\"", diff --git a/.kimi-code/hooks/scripts/gbrain-recall-preflight.sh b/.kimi-code/hooks/scripts/gbrain-recall-preflight.sh new file mode 100755 index 000000000..640e7b86b --- /dev/null +++ b/.kimi-code/hooks/scripts/gbrain-recall-preflight.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# UserPromptSubmit hook for gbrain-recall reminders. +# Quiet by default: only prints when the prompt contains a known keyword group +# (bug/investigation or management-consultation/strategy), or when +# GBRAIN_RECALL_PREFLIGHT_FORCE=1 is set. +# +# [2026-08-04][feat] G-Brain ハーネス Phase 1(gbrain-recall rule と対の hook) +# 背景: +# - ユーザー依頼意図: .claude/rules/general/gbrain-recall.md の発火条件表(バグ修正/障害調査→ +# tech-gbrain、経営相談/戦略→shintaro-gbrain)を、通常の会話でも軽量に思い出させたい。 +# - 守るべき業務ルール: 重い処理は禁止(軽量 grep のみ)。検索を実行するかどうかの判断はモデル側に +# 残す(rule §2)。既存の agent-memory-preflight.sh / handover-preflight.sh と同型の +# UserPromptSubmit hook 実装様式に倣う(quiet-by-default・JSON stdin パース・FORCE env)。 +# - 他案不採用理由: hook 側で自動的に search ツールを呼ぶ案は、誤爆時にノイズ・不要な MCP 呼び出しが +# 発生するため不採用(rule §2 のとおりリマインドに留める)。 +# 対応: 新規 hook を追加。settings/gbrain-recall-preflight.json(Claude)と +# settings-codex/gbrain-recall-preflight.json(Codex)を同一 PR で追加する。 + +set -euo pipefail + +RAW_INPUT="$(cat || true)" + +HOOK_INPUT="$RAW_INPUT" command python3 - <<'PY' +import json +import os +import re +import sys + + +def prompt_from_payload(text: str) -> str: + if not text.strip(): + return "" + try: + payload = json.loads(text) + except Exception: + return text + if not isinstance(payload, dict): + return "" + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = payload.get(key) + if isinstance(value, str): + return value + nested = payload.get("tool_input") + if isinstance(nested, dict): + for key in ("user_prompt", "userPrompt", "prompt", "message", "text"): + value = nested.get(key) + if isinstance(value, str): + return value + return "" + + +# .claude/rules/general/gbrain-recall.md §1 の発火条件表と同じキーワード群。 +# 語の追加・変更はルール本体と同一 PR で行う(二重管理防止)。 +TECH_KEYWORDS = [ + "バグ", "直して", "不具合", "障害", "エラー", "直らない", "原因", "回帰", +] +BUSINESS_KEYWORDS = [ + "相談", "戦略", "クレーム", "どうすれば", "オペレーション改善", "施策", "売上", +] + +TECH_RE = re.compile("|".join(re.escape(w) for w in TECH_KEYWORDS)) +BUSINESS_RE = re.compile("|".join(re.escape(w) for w in BUSINESS_KEYWORDS)) + +raw = os.environ.get("HOOK_INPUT", "") +prompt = prompt_from_payload(raw) +forced = os.environ.get("GBRAIN_RECALL_PREFLIGHT_FORCE", "0") == "1" + +tech_hit = bool(TECH_RE.search(prompt)) +business_hit = bool(BUSINESS_RE.search(prompt)) + +if not forced and not (tech_hit or business_hit): + raise SystemExit(0) + +print("gbrain-recall preflight:") +if not (tech_hit or business_hit): + print("- 該当キーワードなし(FORCE 表示)") +if tech_hit: + print("- tech-gbrain を検索してから着手(バグ修正・障害調査・回帰)") +if business_hit: + print("- shintaro-gbrain を検索してから着手(経営相談・戦略・クレーム対応)") +print("- 詳細: .claude/rules/general/gbrain-recall.md") +PY diff --git a/.kimi-code/hooks/scripts/gbrain-recall-preflight.test.sh b/.kimi-code/hooks/scripts/gbrain-recall-preflight.test.sh new file mode 100755 index 000000000..d462520f8 --- /dev/null +++ b/.kimi-code/hooks/scripts/gbrain-recall-preflight.test.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/gbrain-recall-preflight.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +run_hook() { + local prompt="$1" + printf '{"user_prompt": "%s"}' "$prompt" | bash "$HOOK" +} + +normal_output="$(run_hook "今日は天気だけ確認")" +[ -z "$normal_output" ] || fail "通常プロンプトは無音であるべき: $normal_output" + +bug_output="$(run_hook "このAPIのバグを直して")" +echo "$bug_output" | grep -q "tech-gbrain を検索してから着手" \ + || fail "バグ修正プロンプトで tech-gbrain 案内が出ない: $bug_output" + +business_output="$(run_hook "この施策について相談したい")" +echo "$business_output" | grep -q "shintaro-gbrain を検索してから着手" \ + || fail "経営相談プロンプトで shintaro-gbrain 案内が出ない: $business_output" + +force_output="$(printf '{"user_prompt": "ただの雑談"}' | GBRAIN_RECALL_PREFLIGHT_FORCE=1 bash "$HOOK")" +echo "$force_output" | grep -q "gbrain-recall preflight:" || fail "FORCE時の preflight が出ない: $force_output" +echo "$force_output" | grep -q "該当キーワードなし" || fail "FORCE時に無該当メッセージが出ない: $force_output" + +echo "PASS: gbrain-recall-preflight" diff --git a/.kimi-code/sync-state.json b/.kimi-code/sync-state.json index a22d2f689..286b12a17 100644 --- a/.kimi-code/sync-state.json +++ b/.kimi-code/sync-state.json @@ -1,8 +1,8 @@ { "tool": "kimi-sync", "target": "kimi-code-cli", - "generated_at": "2026-08-03T15:02:31.557712+00:00", - "source_commit": "b135841", + "generated_at": "2026-08-05T04:34:35.219846+00:00", + "source_commit": "b81c185", "project_root": ".", "project_agents_md": ".kimi-code/AGENTS.md", "skills_link": "managed by sync-runtime-skills.py (manifest v2)", @@ -21,6 +21,8 @@ "scripts/block-unauthorized-docs-file.sh", "scripts/block-unauthorized-docs-file.test.sh", "scripts/freshness-gate.sh", + "scripts/gbrain-recall-preflight.sh", + "scripts/gbrain-recall-preflight.test.sh", "scripts/handover-preflight.sh", "scripts/handover-preflight.test.sh", "scripts/post-merge-gate.sh", @@ -36,7 +38,7 @@ "scripts/telemetry-log.test.sh" ], "hook_library_version": "v3.6.37", - "hook_count": 11, + "hook_count": 12, "mcp_server_count": 7, "user_config_path": "${KIMI_CODE_HOME:-~/.kimi-code}/config.toml", "trusted_projects_path": "${KIMI_CODE_HOME:-~/.kimi-code}/trusted-projects.json", diff --git a/.opencode/sync-state.json b/.opencode/sync-state.json index dc3efaaa0..603b8396b 100644 --- a/.opencode/sync-state.json +++ b/.opencode/sync-state.json @@ -1,8 +1,8 @@ { "tool": "opencode", - "generated_at": "2026-08-03T23:55:39.500992+09:00", - "source_commit": "c801ec1", - "project_root": "/Users/shintaro/.codex/worktrees/agentmemory-wave4-agentmemory", + "generated_at": "2026-08-05T13:34:29.694126+09:00", + "source_commit": "b81c185", + "project_root": "/Users/shintaro/mcp-servers/agentmemory/.claude/worktrees/gbrain-recall", "mcp_source": { "registry": "/Users/shintaro/business/AGENT-HUB/docs/codex-mcp-registry.yaml", "definitions": "/Users/shintaro/business/AGENT-HUB/docs/codex-mcp-definitions.yaml", @@ -27,6 +27,7 @@ "implementation-auditor.md", "qa-reviewer.md", "quality-engineer.md", + "security-reviewer.md", "stitch-screen-creator.md", "technical-writer.md", "test-runner.md" diff --git a/AGENTS.md b/AGENTS.md index c3cc512a6..72f120df3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ older text that calls `DISTRIBUTION.yaml` a skill/MCP/hook selection SSOT is sup - canonical project: `agentmemory` - harness type: `mcp-server` - harness type chain: `dev -> mcp-server` -- effective hash: `80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f` +- effective hash: `7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c` - constitution assets: - `agents-md` (selected_by=`global`, inheritance_id=`cebc562da0384df8`) - `claude-md` (selected_by=`global`, inheritance_id=`5da8780b1008377e`) @@ -402,6 +402,69 @@ codex-review のレビュー観点にも同校正が内蔵されている(プ - `skills/adversarial-review/SKILL.md` — **本ルールの手順 SSOT**(dev / business の 2 モード・発火条件・証拠水準・自己反証・分布点検)。本ルールは義務、スキルは手順の二段構えとし、手順本文をここへ複製しない - `skills/codex-review/SKILL.md` — レビュー時の個人開発スケール校正 + + +# G-Brain リコール層(会話中の「読む側」発火条件) + +## 0. scope 宣言(重複防止・複製しない) + +本ルールは **非 plan の日常会話における「読む側」の発火条件だけ** を定義する。以下は別の正本が担当し、 +本ルールでは内容を複製しない(G-Brain ハーネス設計仕様書 D9 役割分担表): + +| 責務 | 正本(変更しない・本ルールは複製しない) | +|------|------| +| plan 入口の preflight(読む) | `skills/plan-approval/plan-commitment-registry.yaml` seq 0.1 / 0.15 | +| 保存先の3層判断(書く) | `skills/agentmemory-routing/SKILL.md` + `agent-memory/registry/placement-policy.md` | +| put_page の安全手順・承認ゲート・合図式保存 | `skills/shintaro-gbrain/SKILL.md` | +| closeout 時の GBrain 候補承認キュー | `skills/handover-manual/SKILL.md`(合図式=会話中の即時承認、closeout=session 末の候補提示で別物) | +| auto-memory の参照 | `.claude/rules/general/memory-lookups.md`(相互参照のみ、内容は複製しない) | +| 敵対的レビュー手順(business) | `skills/adversarial-review/references/business-review.md` | + +## 1. 発火条件表 + +会話の中でユーザー発話や作業内容が次のいずれかに該当したら、応答を出す前に該当 brain を検索する。 + +| 発話・作業の性質 | 検索する先 | 例 | +|---|---|---| +| バグ修正・障害調査・回帰の原因特定 | `tech-gbrain`(`mcp__tech-gbrain__search` / `recall`) | 「〇〇が直らない」「なぜこのエラーが出るか」「前も似た不具合あったはず」 | +| 経営相談・戦略・クレーム対応・売上・オペレーション改善 | `shintaro-gbrain`(`mcp__shintaro-gbrain__search` / `recall`) | 「この施策どう思う」「クレームにどう対応すべきか」「オペレーションを改善したい」 | +| 作業再開・引き継ぎ・「あの続き」 | `agentmemory`(continuation) | 「〇〇の続き」「前回どこまでやったか」 | + +判断に迷う場合は検索する側に倒す(誤爆コストは低く、未検索コストは高い)。 + +## 2. 検索実行の判断はモデル側に残す + +本ルールは「検索しに行くべきタイミング」を定義するだけで、検索実行を強制する hook ではない。 +`hook-library` の UserPromptSubmit hook(`gbrain-recall-preflight`)は軽量キーワード検知による +短いリマインドだけを担い、実際に検索するかどうかの判断はモデル自身が行う(仕様書 D6)。 + +## 3. 関連 + +- 手順・落とし穴の詳細: `skills/shintaro-gbrain/SKILL.md` +- 保存先判断の詳細: `skills/agentmemory-routing/SKILL.md` +- 仕様書: `claude-plans/2026-08-04-jtt-gbrain-harness-spec.md`(D6 / D9) + # hooks 構造ルール + + # プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) ## 原則 @@ -821,6 +893,7 @@ rule に置いて plan-approval-gate(義務)/ plan-approval(手順)と 2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 + - **worker の報告に貼られた検証コマンドの実行結果は、それ自体を証拠として採用しない**(実行していないコマンドの出力をそのまま貼ることがある。一部項目を正直に「未実行」と書いていても、他項目の実行結果が真である保証にはならない)。受け入れ条件に検証コマンドを含めた場合は、統括役が同じコマンドを自分の環境で実走して照合するまで完了扱いにしない。既存資産の「移植・コピー」型タスクは、上流と `diff` を取って一致を機械確認する。詳細・実例は `skills/agent-dispatch/SKILL.md`「失敗の能動検知」および `skills/agent-dispatch/references/model-selection-evidence.md`(2026-08-03)参照。 4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。 diff --git a/CLAUDE.md b/CLAUDE.md index 99b5a4fed..6d4a1bb6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ older text that calls `DISTRIBUTION.yaml` a skill/MCP/hook selection SSOT is sup - canonical project: `agentmemory` - harness type: `mcp-server` - harness type chain: `dev -> mcp-server` -- effective hash: `80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f` +- effective hash: `7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c` - constitution assets: - `agents-md` (selected_by=`global`, inheritance_id=`cebc562da0384df8`) - `claude-md` (selected_by=`global`, inheritance_id=`5da8780b1008377e`) diff --git a/GEMINI.md b/GEMINI.md index 70abcbbd9..a996e6625 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -58,7 +58,7 @@ older text that calls `DISTRIBUTION.yaml` a skill/MCP/hook selection SSOT is sup - canonical project: `agentmemory` - harness type: `mcp-server` - harness type chain: `dev -> mcp-server` -- effective hash: `80ea31e54e4d711ec82459f12f79d4d94a4edd3be2d21e41950adf72e91dab9f` +- effective hash: `7c8e790ccd6ce37b82c706b80e8502f519f72b4277ae5635315588b50ef6232c` - constitution assets: - `agents-md` (selected_by=`global`, inheritance_id=`cebc562da0384df8`) - `claude-md` (selected_by=`global`, inheritance_id=`5da8780b1008377e`) @@ -445,6 +445,71 @@ codex-review のレビュー観点にも同校正が内蔵されている(プ - `skills/adversarial-review/SKILL.md` — **本ルールの手順 SSOT**(dev / business の 2 モード・発火条件・証拠水準・自己反証・分布点検)。本ルールは義務、スキルは手順の二段構えとし、手順本文をここへ複製しない - `skills/codex-review/SKILL.md` — レビュー時の個人開発スケール校正 +### general/gbrain-recall.md + + + +# G-Brain リコール層(会話中の「読む側」発火条件) + +## 0. scope 宣言(重複防止・複製しない) + +本ルールは **非 plan の日常会話における「読む側」の発火条件だけ** を定義する。以下は別の正本が担当し、 +本ルールでは内容を複製しない(G-Brain ハーネス設計仕様書 D9 役割分担表): + +| 責務 | 正本(変更しない・本ルールは複製しない) | +|------|------| +| plan 入口の preflight(読む) | `skills/plan-approval/plan-commitment-registry.yaml` seq 0.1 / 0.15 | +| 保存先の3層判断(書く) | `skills/agentmemory-routing/SKILL.md` + `agent-memory/registry/placement-policy.md` | +| put_page の安全手順・承認ゲート・合図式保存 | `skills/shintaro-gbrain/SKILL.md` | +| closeout 時の GBrain 候補承認キュー | `skills/handover-manual/SKILL.md`(合図式=会話中の即時承認、closeout=session 末の候補提示で別物) | +| auto-memory の参照 | `.claude/rules/general/memory-lookups.md`(相互参照のみ、内容は複製しない) | +| 敵対的レビュー手順(business) | `skills/adversarial-review/references/business-review.md` | + +## 1. 発火条件表 + +会話の中でユーザー発話や作業内容が次のいずれかに該当したら、応答を出す前に該当 brain を検索する。 + +| 発話・作業の性質 | 検索する先 | 例 | +|---|---|---| +| バグ修正・障害調査・回帰の原因特定 | `tech-gbrain`(`mcp__tech-gbrain__search` / `recall`) | 「〇〇が直らない」「なぜこのエラーが出るか」「前も似た不具合あったはず」 | +| 経営相談・戦略・クレーム対応・売上・オペレーション改善 | `shintaro-gbrain`(`mcp__shintaro-gbrain__search` / `recall`) | 「この施策どう思う」「クレームにどう対応すべきか」「オペレーションを改善したい」 | +| 作業再開・引き継ぎ・「あの続き」 | `agentmemory`(continuation) | 「〇〇の続き」「前回どこまでやったか」 | + +判断に迷う場合は検索する側に倒す(誤爆コストは低く、未検索コストは高い)。 + +## 2. 検索実行の判断はモデル側に残す + +本ルールは「検索しに行くべきタイミング」を定義するだけで、検索実行を強制する hook ではない。 +`hook-library` の UserPromptSubmit hook(`gbrain-recall-preflight`)は軽量キーワード検知による +短いリマインドだけを担い、実際に検索するかどうかの判断はモデル自身が行う(仕様書 D6)。 + +## 3. 関連 + +- 手順・落とし穴の詳細: `skills/shintaro-gbrain/SKILL.md` +- 保存先判断の詳細: `skills/agentmemory-routing/SKILL.md` +- 仕様書: `claude-plans/2026-08-04-jtt-gbrain-harness-spec.md`(D6 / D9) + ### general/hooks-structure-rule.md --- @@ -885,6 +950,15 @@ rule に置いて plan-approval-gate(義務)/ plan-approval(手順)と - 守るべき業務ルール: 義務文言(台帳全消化まで完了宣言しない・明示保留・虚偽✓禁止・AI worker摩擦は1回で発火)は弱めない。 - 他案不採用理由: 実例長文(2026-06-26 part4-2/PR#522、2026-07-06 /goal 8回反復)を本文に残す案は常駐コストが高いため不採用とし `skills/plan-approval/references/commitment-examples.md` へ移設。 --> + + # プラン・コミットメント追跡ルール(承認済みプランの条項を必ず実行で拾う) ## 原則 @@ -904,6 +978,7 @@ rule に置いて plan-approval-gate(義務)/ plan-approval(手順)と 2. **節目ごとに突き合わせ**: 各 PR / フェーズ完了時に standing 条項を読み返し、観測した live な失敗・回避策を突き合わせる。 3. **workaround 自問**: 回避策を打った瞬間に「これは共通基盤・委譲ツール・SSOT の不具合か?」を自問し、Yes なら **end-of-run の正本修正タスクをその場で起票**する。 - **AI worker 摩擦は「観測=即発火」**: トークン超過・誤検知・空diff・停滞・誤完了申告等を **1 回でも観測したら** `env 起因`で片付けず、**その時点で end-of-run 修正タスクを起票する**。「回避できたから OK」では閉じない。実例は `commitment-examples.md`。 + - **worker の報告に貼られた検証コマンドの実行結果は、それ自体を証拠として採用しない**(実行していないコマンドの出力をそのまま貼ることがある。一部項目を正直に「未実行」と書いていても、他項目の実行結果が真である保証にはならない)。受け入れ条件に検証コマンドを含めた場合は、統括役が同じコマンドを自分の環境で実走して照合するまで完了扱いにしない。既存資産の「移植・コピー」型タスクは、上流と `diff` を取って一致を機械確認する。詳細・実例は `skills/agent-dispatch/SKILL.md`「失敗の能動検知」および `skills/agent-dispatch/references/model-selection-evidence.md`(2026-08-03)参照。 4. **条件トリガーはカウンタ監視**: 「X回起きたら直す」型は発生回数を監視し閾値到達で自動タスク化する。ただし **AI worker 摩擦はカウンタ閾値を待たない(1 回で発火)**。 5. **台帳全消化まで完了宣言しない**: 全項目が「実施済み」または「明示的に保留(ユーザー判断・別プラン)」になるまで「完了」と宣言しない。 6. **人間ゲート / オーナー操作の行は「明示保留」で解決=全消化に数える(虚偽の✓化はしない)**: 本番投入・オーナー実機検証・承認待ちなど**AI が構造的に実行できない行**は `owner` と台帳に明記し「明示保留」として全消化に数える。**未実施を completed(✓) と偽らない/無承認で本番反映しない**。「全部✓」型 Goal と衝突しても明示保留を優先。利用者の明示 GO が揃って初めて実行可能。