diff --git a/README.md b/README.md index bd5c5ce..ad69dae 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,10 @@ assessment is accepted, implementation receives that Result, matching governance, and matching artifacts. This makes the assessment a reusable input instead of asking later actions to rediscover the same scope. +While impact assessment is still pending, `next` and `explain` select that +action before deriving repository-wide Contract health. Unrelated Result and +Evidence history is not loaded for this first step. + Each action also carries advisory execution guidance. Impact assessment normally recommends an economy model, while challenge recommends a high-accuracy model. The listed escalation conditions tell an orchestrator when diff --git a/docs/MCP-DESIGN.md b/docs/MCP-DESIGN.md index 8c9a1e4..8e679be 100644 --- a/docs/MCP-DESIGN.md +++ b/docs/MCP-DESIGN.md @@ -166,7 +166,8 @@ Action IDはAction本体のdigest、Context digestはその生成元入力のdig - 再評価が同じActionを返すなら、そのActionは今も現在のものなので受理する。再起動前に行った作業はやり直さない。 - 再評価が別のActionを返すなら、`ACTION_NOT_CURRENT`で拒否し、現在のAction IDとContext digestを示す。Agentは`adf_next`から現在のActionに対してやり直す。 -- 同じActionとContextに対するResultが既にあるなら、二重提出として冪等に再生し、Resultを二重に書かない。内容が違えば`WRITE_CONFLICT`にする。 +- 同じActionとContextに対する同じResultが既にあるなら、二重提出として冪等に再生し、Resultを二重に書かない。 +- 既存Resultを評価した後も同じActionとContextが現在の作業として再発行される場合に限り、異なるResultを訂正版として受理する。保存時に既存Result IDを照合し、並行変更があれば`WRITE_CONFLICT`にする。完了または別Actionへ遷移したResultは訂正できない。 - 既に書いたContract、Decision、Evidence、コードは削除やrollbackをせず、現在入力として再評価する。 Generated Contextを正本化しない方針は変えません。受理の根拠はmemoryではなく、正本を再評価した結果との一致です。derived cacheのreadをAction認証へ流用してはいけません。 diff --git a/docs/concepts.ja.md b/docs/concepts.ja.md index 9c8c2a6..cb42835 100644 --- a/docs/concepts.ja.md +++ b/docs/concepts.ja.md @@ -95,6 +95,8 @@ Contractまたは条項の`evidence_mode`で、検証に掛ける費用を選べ `adf next`は、各作業に必要な文脈だけを組み立てます。影響評価には、リポジトリ、Contract、Decisionの索引と、過去の影響評価を最大3件まで渡します。評価が確定した後の実装には、その評価結果と、対象に合う規範やコードだけを渡します。後続の作業がリポジトリ全体を調べ直す必要はありません。 +影響評価が済んでいない間、`adf next`と`adf explain`は、リポジトリ全体のContract検証状態を計算する前に影響評価を次の作業として選びます。この最初の段階では、無関係なChangeのResultとEvidenceを読み込みません。 + 各作業には、実行環境へ向けたモデルの推奨も含まれます。影響評価には通常、軽量なモデルを推奨します。ただし、影響なしと結論付ける場合、根拠が矛盾する場合、セキュリティ、プライバシー、決済、元に戻せないデータ変更の可能性がある場合は、精度の高いモデルへの切り替えを勧めます。ADF自体はモデルを選ばず、LLMも実行しません。 実行環境がすでに把握している処理時間、モデル名、入出力Token数、ツール呼び出し数、再試行回数は、`adf_submit`で任意に記録できます。外部Runnerは`adf_begin_execution`と`adf_complete_execution`を使い、Result提出後に確定したToken数や、失敗・中断した実行も追記できます。外部実行では、キャッシュ作成Token、キャッシュ読取Token、推論Token、実行環境が報告した米ドル費用も記録できます。この実行RecordはADFの状態、Result ID、鮮度、Evidence検証には影響しません。同じResultに提出時の計測値とRunnerの完了Recordがある場合は、Runnerの値だけを集計します。 diff --git a/src/application.rs b/src/application.rs index 9a01f7f..ab2fd51 100644 --- a/src/application.rs +++ b/src/application.rs @@ -10,7 +10,7 @@ use crate::contract_health::{ContractHealthReport, build_contract_health_report} use crate::detection::detect_typed_facts_with_registry; use crate::explain::{ExplainReport, ExplanationBuilder}; use crate::framework_lock::{FrameworkLock, validate_framework_lock}; -use crate::kernel::{KernelDecision, ProjectSnapshot, ThinKernel}; +use crate::kernel::{KernelDecision, ProjectSnapshot, ThinKernel, impact_assessment_pending}; use crate::project::build_project_snapshot; use crate::rules::{RuleIndex, compile_rule_index_with_registry}; use crate::schema::SchemaRegistry; @@ -40,6 +40,11 @@ pub trait ProjectStore { fn snapshot(&self, change_id: &str) -> Result; fn contract_health(&self) -> Result; fn append_result(&mut self, result: &Value) -> Result<(), ProjectStoreError>; + fn replace_result( + &mut self, + result: &Value, + expected_result_id: &str, + ) -> Result<(), ProjectStoreError>; fn add_evidence(&mut self, evidence: &Value) -> Result<(), ProjectStoreError>; fn upsert_decision( &mut self, @@ -128,21 +133,26 @@ impl<'a, Store: ProjectStore> Application<'a, Store> { &self.signal_registry, ) .map_err(|error| application_error(error.to_string()))?; - let contract_health = self - .store - .contract_health() - .map_err(|error| application_error(error.to_string()))?; + let contract_health = if impact_assessment_pending(&snapshot) { + None + } else { + Some( + self.store + .contract_health() + .map_err(|error| application_error(error.to_string()))?, + ) + }; let decision = ThinKernel.evaluate_with_health( &snapshot, &self.rule_index, &detection, - Some(&contract_health), + contract_health.as_ref(), ); let context = ContextCompiler.compile_with_health( &decision, &snapshot, &detection, - Some(&contract_health), + contract_health.as_ref(), ); if let Some(context) = &context { self.issued.insert( @@ -213,6 +223,32 @@ impl<'a, Store: ProjectStore> Application<'a, Store> { Ok(ApplicationSubmission { result, response }) } + /// Replace the Result for an Action that the current Project still issues. + /// + /// This is deliberately separate from ordinary submission: callers must + /// prove that the existing Result did not complete or supersede the Action, + /// and the Store compares its ID again when writing so concurrent changes + /// cannot be lost. + pub(crate) fn correct_issued_with_snapshot( + &mut self, + context: &GeneratedContext, + submission: &ResultSubmission, + snapshot: &ProjectSnapshot, + expected_result_id: &str, + ) -> Result { + let result = prepare_result(context, snapshot, submission, self.schema_registry) + .map_err(|error| application_error(error.to_string()))?; + self.store + .replace_result(&result, expected_result_id) + .map_err(|error| application_error(error.to_string()))?; + self.issued.remove(&( + submission.action_id.clone(), + submission.context_digest.clone(), + )); + let response = self.next(&submission.change_id)?; + Ok(ApplicationSubmission { result, response }) + } + /// Recompute the current decision and its trace without issuing an Action. pub fn explain(&self, change_id: &str) -> Result { let snapshot = self.snapshot(change_id)?; @@ -227,15 +263,20 @@ impl<'a, Store: ProjectStore> Application<'a, Store> { &self.signal_registry, ) .map_err(|error| application_error(error.to_string()))?; - let contract_health = self - .store - .contract_health() - .map_err(|error| application_error(error.to_string()))?; + let contract_health = if impact_assessment_pending(&snapshot) { + None + } else { + Some( + self.store + .contract_health() + .map_err(|error| application_error(error.to_string()))?, + ) + }; let decision = ThinKernel.evaluate_with_health( &snapshot, &self.rule_index, &detection, - Some(&contract_health), + contract_health.as_ref(), ); Ok(ExplanationBuilder.build(&snapshot, &self.rule_index, &detection, &decision)) } @@ -393,6 +434,35 @@ impl ProjectStore for InMemoryProjectStore<'_> { Ok(()) } + fn replace_result( + &mut self, + result: &Value, + expected_result_id: &str, + ) -> Result<(), ProjectStoreError> { + self.schema_registry + .validate("result", result) + .map_err(|error| project_store_error(error.to_string()))?; + let action_id = result["action_id"] + .as_str() + .ok_or_else(|| project_store_error("Result action_id must be a string"))?; + let context_digest = result["context_digest"] + .as_str() + .ok_or_else(|| project_store_error("Result context_digest must be a string"))?; + let results = self.record_collection_mut("results")?; + let existing = results + .iter_mut() + .find(|candidate| { + candidate["action_id"].as_str() == Some(action_id) + && candidate["context_digest"].as_str() == Some(context_digest) + }) + .ok_or_else(|| project_store_error("Result to replace does not exist"))?; + if existing["id"].as_str() != Some(expected_result_id) { + return Err(project_store_error("Result changed before correction")); + } + *existing = result.clone(); + Ok(()) + } + fn add_evidence(&mut self, evidence: &Value) -> Result<(), ProjectStoreError> { self.schema_registry .validate("evidence", evidence) diff --git a/src/filesystem_project.rs b/src/filesystem_project.rs index 92fbd2f..eea74a2 100644 --- a/src/filesystem_project.rs +++ b/src/filesystem_project.rs @@ -316,6 +316,28 @@ impl<'a> FileProjectStore<'a> { self.write_new(&path, result, FileFormat::Json) } + pub fn replace_result( + &mut self, + result: &Value, + expected_result_id: &str, + ) -> Result<(), FileProjectError> { + self.schema_registry + .validate("result", result) + .map_err(|error| file_error(error.to_string()))?; + let change_id = safe_id(required_string(result, "change_id", "Result")?)?; + self.change_path(change_id)?; + let path = self + .change_root + .join(change_id) + .join("results") + .join(result_filename(result)?); + let existing = read_json(&path)?; + if existing["id"].as_str() != Some(expected_result_id) { + return Err(file_error("Result changed before correction")); + } + self.write_atomic(&path, result, FileFormat::Json) + } + pub fn upsert_contract( &mut self, contract: &Value, @@ -1047,6 +1069,15 @@ impl ProjectStore for FileProjectStore<'_> { .map_err(|error| ProjectStoreError::new(error.to_string())) } + fn replace_result( + &mut self, + result: &Value, + expected_result_id: &str, + ) -> Result<(), ProjectStoreError> { + FileProjectStore::replace_result(self, result, expected_result_id) + .map_err(|error| ProjectStoreError::new(error.to_string())) + } + fn add_evidence(&mut self, evidence: &Value) -> Result<(), ProjectStoreError> { FileProjectStore::add_evidence(self, evidence) .map_err(|error| ProjectStoreError::new(error.to_string())) diff --git a/src/kernel.rs b/src/kernel.rs index d5d0e95..66b7d26 100644 --- a/src/kernel.rs +++ b/src/kernel.rs @@ -141,8 +141,7 @@ impl ThinKernel { contract_health: Option<&ContractHealthReport>, ) -> KernelDecision { let assessment = fresh_impact_assessment(snapshot); - let assessment_required = snapshot.change["impact_assessment"].as_str() == Some("required"); - if assessment_required && assessment.is_none() { + if impact_assessment_pending(snapshot) { let previous = current_impact_assessments(snapshot); let action = make_action( snapshot, @@ -502,6 +501,11 @@ impl ThinKernel { } } +pub(crate) fn impact_assessment_pending(snapshot: &ProjectSnapshot) -> bool { + snapshot.change["impact_assessment"].as_str() == Some("required") + && fresh_impact_assessment(snapshot).is_none() +} + fn current_impact_assessments(snapshot: &ProjectSnapshot) -> Vec { snapshot .results diff --git a/src/project_application.rs b/src/project_application.rs index 390bccd..ab3f072 100644 --- a/src/project_application.rs +++ b/src/project_application.rs @@ -266,6 +266,20 @@ impl ProjectApplicationService { let snapshot = application .snapshot(&key.change_id) .map_err(application_error)?; + let existing_result = snapshot + .results + .iter() + .find(|result| result_for_action(result, key)); + if let Some(result) = existing_result + && submitted_payload_matches(&result["payload"], &payload) + && result["output_refs"] == json!(output_refs) + { + let result_id = required_record_id(result, "Result")?.to_owned(); + let response = application + .next(&key.change_id) + .map_err(application_error)?; + return Ok(self.completed_response(key, result_id, response, &application)); + } validate_output_refs(&entry, &output_refs, &snapshot)?; assert_framework_identity(&entry, &application)?; let role = required_context_string(&entry.context, &["action", "role"])?; @@ -281,9 +295,21 @@ impl ProjectApplicationService { output_refs, execution, }; - let ApplicationSubmission { result, response } = application - .submit_issued_with_snapshot(&entry.context, &submission, &snapshot) - .map_err(application_error)?; + let ApplicationSubmission { result, response } = if let Some(existing) = existing_result { + let expected_result_id = required_record_id(existing, "Result")?; + application + .correct_issued_with_snapshot( + &entry.context, + &submission, + &snapshot, + expected_result_id, + ) + .map_err(application_error)? + } else { + application + .submit_issued_with_snapshot(&entry.context, &submission, &snapshot) + .map_err(application_error)? + }; let rule_index_digest = application.rule_index_digest().to_owned(); let framework_lock_digest = application.framework_lock_digest().to_owned(); let result_id = result["id"] @@ -314,6 +340,37 @@ impl ProjectApplicationService { }) } + fn completed_response( + &mut self, + key: &IssuedActionKey, + result_id: String, + response: crate::application::ApplicationResponse, + application: &crate::application::Application< + '_, + crate::filesystem_project::FileProjectStore<'_>, + >, + ) -> SubmitServiceResponse { + let next_response = next_response_value(&key.change_id, &response); + let next_issued = issued_entry( + &key.change_id, + &response, + application.rule_index_digest(), + application.framework_lock_digest(), + ); + self.issued.remove(key); + let issued_action = next_issued.map(|(next_key, next_entry)| { + self.issued.insert(next_key.clone(), next_entry); + next_key + }); + SubmitServiceResponse { + schema_version: MCP_APPLICATION_PROTOCOL_VERSION.to_owned(), + result_id, + already_completed: true, + next_response, + issued_action, + } + } + /// Replays a submission whose Result is already stored, or `None` when this /// Action and Context produced no Result yet. fn replay_submission( @@ -686,6 +743,11 @@ fn submitted_payload_matches(stored: &Value, submitted: &Value) -> bool { } } +fn result_for_action(result: &Value, key: &IssuedActionKey) -> bool { + result["action_id"].as_str() == Some(key.action_id.as_str()) + && result["context_digest"].as_str() == Some(key.context_digest.as_str()) +} + fn issued_entry( change_id: &str, response: &ApplicationResponse, @@ -1033,6 +1095,32 @@ mod tests { validate_output_refs(&entry, &["evidence.persisted".to_owned()], &snapshot).unwrap(); } + #[test] + fn different_submission_is_correctable_only_while_the_same_action_is_current() { + let key = IssuedActionKey { + change_id: "change.place-order".to_owned(), + action_id: "action.record-evidence".to_owned(), + context_digest: format!("sha256:{}", "1".repeat(64)), + }; + let existing = json!({ + "action_id": key.action_id, + "context_digest": key.context_digest, + "payload": {"outcomes": [{"status": "inconclusive"}]}, + "output_refs": ["evidence.old"] + }); + assert!(result_for_action(&existing, &key)); + assert!(!submitted_payload_matches( + &existing["payload"], + &json!({"outcomes": [{"status": "satisfied"}]}) + )); + + let superseded = IssuedActionKey { + action_id: "action.challenge".to_owned(), + ..key.clone() + }; + assert!(!result_for_action(&existing, &superseded)); + } + #[test] fn evidence_inputs_are_derived_from_the_issued_requirement_instances() { let digest = |character: char| format!("sha256:{}", character.to_string().repeat(64)); diff --git a/tests/cli.rs b/tests/cli.rs index b8f5597..f4046ce 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -3110,6 +3110,45 @@ fn project_next_explain_and_contract_health_share_the_real_project_loader() { assert!(text.contains("unverified")); } +#[test] +fn pending_impact_assessment_does_not_load_repository_wide_contract_health() { + let project = TestProject::new(); + let pending_change_root = project + .root + .join(".adf/changes/change.pending-impact-assessment"); + fs::create_dir_all(&pending_change_root).unwrap(); + write_yaml( + &pending_change_root.join("change.yaml"), + &json!({ + "schema_version": "1", + "id": "change.pending-impact-assessment", + "title": "Assess the pending change", + "intent": "Confirm the next action without reading unrelated history", + "impact_assessment": "required" + }), + ); + + // This unrelated malformed Result would fail repository-wide Contract + // health loading. The pending Change does not need that report to select + // impact assessment as its first action. + let unrelated_results = project.root.join(".adf/changes/change.place-order/results"); + fs::create_dir_all(&unrelated_results).unwrap(); + fs::write(unrelated_results.join("malformed.json"), "{").unwrap(); + + for command in ["next", "explain"] { + let output = project.run(&[ + command, + "change.pending-impact-assessment", + "--format", + "json", + ]); + assert_success(&output); + let response: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["state"], "needs-impact-assessment"); + assert_eq!(response["next_action"]["action"], "assess-change-impact"); + } +} + #[test] fn contract_health_policy_turns_the_report_into_an_explicit_ci_gate() { let project = TestProject::new(); diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..967037f --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/.vinext/ +/out/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +/dist/ +/.wrangler/ +/outputs/ +/work/ diff --git a/website/.openai/hosting.json b/website/.openai/hosting.json new file mode 100644 index 0000000..6825224 --- /dev/null +++ b/website/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "project_id": "appgprj_6a7d59f525cc8191ae5e9c7eb60db131", + "d1": null, + "r2": null +} diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..bd9b9e0 --- /dev/null +++ b/website/README.md @@ -0,0 +1,100 @@ +# vinext-starter + +A clean full-stack starter running on +[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and +Drizzle support. + +## Prerequisites + +- Node.js `>=22.13.0` + +## Quick Start + +```bash +npm install +npm run dev +npm run build +``` + +This starter does not use `wrangler.jsonc`. + +## Included Shape + +- edit site code under `app/` +- `.openai/hosting.json` declares optional Sites D1 and R2 bindings +- `vite.config.ts` simulates declared bindings for local development +- `db/schema.ts` starts intentionally empty +- `examples/d1/` contains an optional D1 example surface +- `drizzle.config.ts` supports local migration generation when needed + +## Workspace Auth Headers + +Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present. + +The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes. + +SIWC-authenticated workspace sites may also receive +`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty +`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by +`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`. + +Treat the full name as optional and fall back to email when it is absent: + +```tsx +import { headers } from "next/headers"; + +export default async function Home() { + const requestHeaders = await headers(); + const userId = requestHeaders.get("oai-authenticated-user-id"); + const email = requestHeaders.get("oai-authenticated-user-email"); + const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name"); + const fullName = + encodedFullName && + requestHeaders.get("oai-authenticated-user-full-name-encoding") === + "percent-encoded-utf-8" + ? decodeURIComponent(encodedFullName) + : null; + + const displayName = fullName ?? email; + // ... +} +``` + +## Optional Dispatch-Owned ChatGPT Sign-In + +Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs +optional or required ChatGPT sign-in: + +- Use `getChatGPTUser()` for optional signed-in UI. +- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send + anonymous visitors through Sign in with ChatGPT. +- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for + browser links or actions. +- Pass a same-origin relative `returnTo` path for the destination after sign-in + or sign-out. The helper validates and safely encodes it. +- Mark protected pages with `export const dynamic = "force-dynamic"` because + they depend on per-request identity headers. + +Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the +OAuth cookies, and identity header injection. Do not implement app routes for +those reserved paths. Routes that do not import and call the helper remain +anonymous-compatible. + +SIWC establishes identity only; it does not prove workspace membership. Use the +Sites hosting platform's access policy controls for workspace-wide restrictions, +or enforce explicit server-side membership or allowlist checks. + +Use SIWC for account pages, user-specific dashboards, saved records, and write +actions tied to the current ChatGPT user. Leave public content anonymous. + +## Useful Commands + +- `npm run dev`: start local development +- `npm run build`: verify the vinext build output +- `npm test`: build the starter and verify its rendered loading skeleton +- `npm run db:generate`: generate Drizzle migrations after schema changes + +## Learn More + +- [vinext Documentation](https://github.com/cloudflare/vinext) +- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new) diff --git a/website/app/chatgpt-auth.ts b/website/app/chatgpt-auth.ts new file mode 100644 index 0000000..a0ae2ed --- /dev/null +++ b/website/app/chatgpt-auth.ts @@ -0,0 +1,90 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export type ChatGPTUser = { + userId: string; + displayName: string; + email: string; + fullName: string | null; +}; + +const USER_ID_HEADER = "oai-authenticated-user-id"; +const USER_EMAIL_HEADER = "oai-authenticated-user-email"; +const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name"; +const USER_FULL_NAME_ENCODING_HEADER = + "oai-authenticated-user-full-name-encoding"; +const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8"; +const SIGN_IN_PATH = "/signin-with-chatgpt"; +const SIGN_OUT_PATH = "/signout-with-chatgpt"; +const CALLBACK_PATH = "/callback"; + +export async function getChatGPTUser(): Promise { + const requestHeaders = await headers(); + const userId = requestHeaders.get(USER_ID_HEADER); + const email = requestHeaders.get(USER_EMAIL_HEADER); + if (!userId || !email) return null; + + const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER); + const fullName = + encodedFullName && + requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8 + ? safeDecodeURIComponent(encodedFullName) + : null; + + return { + userId, + displayName: fullName ?? email, + email, + fullName, + }; +} + +export async function requireChatGPTUser( + returnTo: string, +): Promise { + const user = await getChatGPTUser(); + if (user) return user; + + redirect(chatGPTSignInPath(returnTo)); +} + +export function chatGPTSignInPath(returnTo: string): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +export function chatGPTSignOutPath(returnTo = "/"): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +function safeRelativeReturnPath(value: string): string { + if (!value.startsWith("/") || value.startsWith("//")) return "/"; + + let url: URL; + try { + url = new URL(value, "https://app.local"); + } catch { + return "/"; + } + if (url.origin !== "https://app.local") return "/"; + if (isReservedAuthPath(url.pathname)) return "/"; + + return `${url.pathname}${url.search}${url.hash}`; +} + +function isReservedAuthPath(pathname: string): boolean { + return ( + pathname === SIGN_IN_PATH || + pathname === SIGN_OUT_PATH || + pathname === CALLBACK_PATH + ); +} + +function safeDecodeURIComponent(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} diff --git a/website/app/globals.css b/website/app/globals.css new file mode 100644 index 0000000..5008bad --- /dev/null +++ b/website/app/globals.css @@ -0,0 +1,186 @@ +@import "tailwindcss"; + +:root { + --ink: #11110f; + --muted: #6c6a63; + --paper: #f4f2ec; + --white: #fffefa; + --line: #cbc8be; + --red: #e6432f; + --green: #157357; + --font-sans: var(--font-geist-sans), Arial, sans-serif; + --font-mono: var(--font-geist-mono), monospace; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 80px; } +body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--font-sans); font-kerning: normal; font-optical-sizing: auto; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; } +a { color: inherit; text-decoration: none; } +a:focus-visible { outline: 2px solid var(--red); outline-offset: 4px; } +.page-width { width: min(1180px, calc(100% - 48px)); margin-inline: auto; } + +.masthead { + position: sticky; z-index: 20; top: 0; display: grid; grid-template-columns: 1fr auto 1fr; + align-items: center; min-height: 64px; padding: 0 max(24px, calc((100vw - 1180px) / 2)); + border-bottom: 1px solid rgba(17,17,15,.15); background: rgba(244,242,236,.93); backdrop-filter: blur(14px); +} +.wordmark { font-family: var(--font-mono); font-size: 16px; font-weight: 700; letter-spacing: 0; } +.masthead nav { display: flex; gap: 30px; color: var(--muted); font-size: 15px; } +.masthead nav a:hover, .github-link:hover { color: var(--red); } +.github-link { justify-self: end; display: inline-flex; align-items: center; min-height: 44px; font-size: 15px; font-weight: 600; } + +.hero { padding-block: clamp(100px, 13vw, 190px) 110px; } +.kicker, .scene-label, .section-number, .contract-header, dt, .principle-list span, .code-label { + font-family: var(--font-mono); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; +} +.kicker { margin: 0 0 34px; color: var(--red); } +.hero h1 { max-width: 1120px; margin: 0; font-size: clamp(56px, 8.3vw, 118px); font-weight: 520; line-height: .98; letter-spacing: -.025em; } +.hero h1 span { color: var(--muted); } +.hero-bottom { display: grid; grid-template-columns: minmax(0, 520px) auto; gap: 60px; align-items: end; justify-content: space-between; margin-top: 58px; padding-top: 22px; border-top: 1px solid var(--line); } +.hero-bottom p { margin: 0; color: var(--muted); font-size: 17px; line-height: 1.7; } +.text-link { min-height: 44px; display: inline-flex; align-items: center; gap: 26px; font-size: 15px; font-weight: 600; } +.text-link:hover { color: var(--red); } + +.decision-scene { padding-block: 130px 150px; border-top: 1px solid var(--ink); } +.scene-question { max-width: 850px; } +.scene-label { margin: 0 0 20px; color: var(--muted); } +blockquote { margin: 0; font-size: clamp(38px, 5.2vw, 66px); font-weight: 500; line-height: 1.1; letter-spacing: -.018em; } +.flow-comparison { margin-top: 90px; border-top: 1px solid var(--ink); } +.flow-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 42px; padding-block: 38px; border-bottom: 1px solid var(--line); } +.flow-name span { display: block; margin-bottom: 10px; font-family: var(--font-mono); font-size: 15px; font-weight: 650; text-transform: uppercase; } +.flow-name p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.55; } +.without-flow .flow-name span { color: var(--muted); } +.with-flow .flow-name span { color: var(--red); } +.flow-track { display: flex; align-items: flex-start; min-width: 0; } +.flow-step { flex: 1 1 0; min-width: 0; } +.flow-step i { display: grid; width: 28px; height: 28px; margin-bottom: 18px; place-items: center; border: 1px solid var(--ink); border-radius: 50%; background: var(--paper); font-family: var(--font-mono); font-size: 13px; font-style: normal; font-weight: 700; } +.flow-step small { display: block; margin-bottom: 8px; color: var(--muted); font-family: var(--font-mono); font-size: 12px; letter-spacing: .03em; line-height: 1.3; text-transform: uppercase; } +.flow-step strong { display: block; max-width: 180px; font-size: 15px; font-weight: 560; line-height: 1.4; } +.flow-arrow { flex: 0 0 28px; margin: 5px 12px 0; color: var(--line); font-size: 18px; text-align: center; } +.assumption-step i { border-style: dashed; color: var(--muted); } +.assumption-step small, .assumption-step strong { color: var(--muted); } +.stop-step i { border-color: var(--red); background: var(--red); color: var(--paper); } +.stop-step small, .stop-step strong { color: var(--red); } +.decision-step i { background: var(--ink); } +.contract-step i { border-color: var(--green); background: var(--green); } +.with-flow .flow-arrow { color: var(--red); } +.scene-conclusion { display: grid; grid-template-columns: 1fr 1fr; gap: 80px; margin-top: 80px; padding-top: 25px; border-top: 1px solid var(--line); } +.scene-conclusion p { margin: 0; color: var(--muted); font-size: 16px; line-height: 1.7; } +.scene-conclusion p:last-child { color: var(--ink); } + +.manifesto, .process-intro { display: grid; grid-template-columns: 100px 1fr; gap: 35px; padding-top: 130px; border-top: 1px solid var(--ink); } +.section-number { margin: 6px 0 0; color: var(--red); } +h2 { margin: 0; font-size: clamp(43px, 5.7vw, 72px); font-weight: 530; line-height: 1.06; letter-spacing: -.022em; } +.large-copy { max-width: 860px; margin: 35px 0 0; color: var(--muted); font-size: clamp(22px, 3vw, 35px); line-height: 1.42; letter-spacing: -.012em; } + +.contract-examples { margin-top: 100px; margin-bottom: 150px; } +.examples-intro { display: grid; grid-template-columns: 1fr 1fr; gap: 80px; margin-bottom: 32px; padding-top: 18px; border-top: 1px solid var(--ink); } +.examples-intro p:first-child { margin: 0; font-family: var(--font-mono); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; } +.examples-intro p:last-child { max-width: 520px; margin: 0; color: var(--muted); font-size: 16px; line-height: 1.7; } +.contract-example { border: 1px solid var(--ink); background: var(--white); } +.contract-header { display: flex; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--line); color: var(--muted); } +.accepted { color: var(--green); } +.accepted::before { content: "● "; } +.clause { max-width: 900px; margin: 0; padding: clamp(45px, 8vw, 100px) clamp(24px, 7vw, 80px); font-size: clamp(34px, 4.8vw, 60px); font-weight: 500; line-height: 1.14; letter-spacing: -.018em; } +.contract-example dl { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; border-top: 1px solid var(--line); } +.contract-example dl div { min-width: 0; padding: 18px 20px; border-right: 1px solid var(--line); } +.contract-example dl div:last-child { border: 0; } +dt { color: var(--muted); } +dd { overflow-wrap: anywhere; margin: 8px 0 0; font-family: var(--font-mono); font-size: 13px; line-height: 1.45; } +.contract-list { margin-top: 64px; border-top: 1px solid var(--ink); } +.compact-contract { display: grid; grid-template-columns: 190px 1fr; gap: 50px; padding-block: 34px; border-bottom: 1px solid var(--line); } +.contract-kind { display: flex; justify-content: space-between; gap: 20px; color: var(--muted); font-family: var(--font-mono); font-size: 12px; letter-spacing: .03em; text-transform: uppercase; } +.compact-content { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(340px, .85fr); gap: 60px; } +.compact-content h3 { max-width: 560px; margin: 0; font-size: clamp(22px, 2.5vw, 32px); font-weight: 510; line-height: 1.22; letter-spacing: -.012em; } +.compact-content dl { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 3px 0 0; } +.compact-content dl div { min-width: 0; } + +.process { padding-bottom: 150px; } +.process-intro > div > p { max-width: 580px; margin: 25px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; } +.process-line { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); gap: 70px; margin: 100px 0 0; padding: 0; list-style: none; } +.process-line::before { position: absolute; top: 24px; right: 16%; left: 16%; height: 1px; background: var(--line); content: ""; } +.process-line li { position: relative; text-align: center; } +.process-index { position: relative; z-index: 1; display: grid; width: 48px; height: 48px; margin: 0 auto 32px; place-items: center; border: 1px solid var(--ink); border-radius: 50%; background: var(--paper); font-family: var(--font-mono); font-size: 13px; } +.process-line h3 { margin: 0; font-size: 24px; font-weight: 560; letter-spacing: -.01em; } +.process-line p { max-width: 300px; margin: 12px auto 0; color: var(--muted); font-size: 15px; line-height: 1.6; } + +.principles { padding-block: 150px; background: var(--ink); color: var(--paper); } +.principles-inner { display: grid; grid-template-columns: 100px .9fr 1.1fr; gap: 35px; } +.principles .section-number { color: #ff725e; } +.principles h2 { font-size: clamp(42px, 5.5vw, 70px); } +.principle-list { border-top: 1px solid #56564f; } +.principle-list article { padding: 24px 0 30px; border-bottom: 1px solid #56564f; } +.principle-list span { color: #ff725e; } +.principle-list h3 { margin: 16px 0 8px; font-size: 20px; font-weight: 520; letter-spacing: -.008em; } +.principle-list p { margin: 0; color: #bbb9b1; font-size: 15px; line-height: 1.65; } + +.start { display: grid; grid-template-columns: .9fr 1.1fr; gap: 100px; padding-block: 150px; } +.start-copy h2 { margin-top: 28px; } +.start-copy > p:not(.section-number) { max-width: 480px; margin: 28px 0; color: var(--muted); font-size: 16px; line-height: 1.7; } +.primary-link { display: inline-flex; align-items: center; gap: 24px; min-height: 46px; padding-bottom: 3px; border-bottom: 1px solid var(--ink); font-size: 15px; font-weight: 600; } +.primary-link:hover { color: var(--red); border-color: var(--red); } +.code-block { align-self: center; overflow: hidden; border: 1px solid var(--ink); background: var(--white); } +.code-label { padding: 15px 18px; border-bottom: 1px solid var(--line); color: var(--muted); } +.code-block pre { overflow-x: auto; margin: 0; padding: 34px 22px; font-family: var(--font-mono); font-size: 13px; line-height: 1.9; white-space: pre-wrap; overflow-wrap: anywhere; } +.code-block code span { color: var(--red); } +.code-block > p { margin: 0; padding: 15px 18px; border-top: 1px solid var(--line); color: var(--green); font-family: var(--font-mono); font-size: 12px; letter-spacing: .03em; text-transform: uppercase; } + +.footer { display: grid; grid-template-columns: 1fr 1fr auto; gap: 30px; padding-block: 34px; border-top: 1px solid var(--ink); color: var(--muted); font-size: 13px; line-height: 1.5; } +.footer p { margin: 0; } +.footer div { display: flex; gap: 22px; } +.footer a:hover { color: var(--red); } + +@media (max-width: 900px) { + .flow-row { grid-template-columns: 1fr; gap: 28px; } + .flow-track { overflow-x: auto; padding-bottom: 12px; } + .flow-step { flex-basis: 150px; min-width: 150px; } + .principles-inner { grid-template-columns: 70px 1fr; } + .principle-list { grid-column: 2; margin-top: 30px; } + .start { grid-template-columns: 1fr; gap: 70px; } +} + +@media (max-width: 700px) { + .page-width { width: min(100% - 30px, 1180px); } + .masthead { grid-template-columns: 1fr auto; min-height: 60px; padding-inline: 15px; } + .masthead nav { display: none; } + .hero { padding-block: 80px 75px; } + .hero h1 { font-size: clamp(50px, 15vw, 70px); line-height: 1.01; letter-spacing: -.02em; } + .hero-bottom { grid-template-columns: 1fr; gap: 24px; margin-top: 42px; } + .decision-scene { padding-block: 85px 100px; } + .flow-comparison { margin-top: 65px; } + .flow-row { padding-block: 30px; } + .flow-track { display: grid; grid-template-columns: 34px 1fr; gap: 0 16px; overflow: visible; padding-bottom: 0; } + .flow-step { display: grid; grid-column: 1 / -1; grid-template-columns: 34px 1fr; column-gap: 16px; min-width: 0; padding-bottom: 4px; } + .flow-step i { grid-row: 1 / 3; width: 28px; height: 28px; margin: 0; } + .flow-step small { margin: 0 0 6px; } + .flow-step strong { max-width: none; padding-bottom: 20px; } + .flow-arrow { grid-column: 1; height: 24px; margin: 0; transform: rotate(90deg); text-align: center; } + .scene-conclusion { grid-template-columns: 1fr; gap: 24px; margin-top: 55px; } + .manifesto, .process-intro { grid-template-columns: 1fr; gap: 24px; padding-top: 90px; } + .contract-examples { margin-top: 65px; margin-bottom: 100px; } + .examples-intro { grid-template-columns: 1fr; gap: 16px; } + .contract-example dl { grid-template-columns: 1fr; } + .contract-example dl div { border-right: 0; border-bottom: 1px solid var(--line); } + .compact-contract { grid-template-columns: 1fr; gap: 22px; } + .contract-kind { justify-content: flex-start; gap: 18px; } + .compact-content { grid-template-columns: 1fr; gap: 26px; } + .compact-content dl { grid-template-columns: 1fr; gap: 16px; } + .process { padding-bottom: 100px; } + .process-line { grid-template-columns: 1fr; gap: 0; margin-top: 65px; } + .process-line::before { top: 24px; bottom: 24px; left: 24px; width: 1px; height: auto; } + .process-line li { display: grid; grid-template-columns: 48px 1fr; gap: 24px; padding-bottom: 48px; text-align: left; } + .process-line li:last-child { padding-bottom: 0; } + .process-index { margin: 0; } + .process-line p { margin: 10px 0 0; } + .principles { padding-block: 100px; } + .principles-inner { grid-template-columns: 1fr; gap: 24px; } + .principle-list { grid-column: 1; } + .start { gap: 55px; padding-block: 100px; } + .footer { grid-template-columns: 1fr; } + .footer div { flex-wrap: wrap; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { transition-duration: .01ms !important; } +} diff --git a/website/app/layout.tsx b/website/app/layout.tsx new file mode 100644 index 0000000..8416d5e --- /dev/null +++ b/website/app/layout.tsx @@ -0,0 +1,59 @@ +import type { Metadata, Viewport } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { headers } from "next/headers"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export async function generateMetadata(): Promise { + const requestHeaders = await headers(); + const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"); + const protocol = requestHeaders.get("x-forwarded-proto") ?? "https"; + const origin = host ? `${protocol}://${host}` : "https://github.com"; + const socialImage = `${origin}/og.png`; + + return { + title: "Agentic Development Framework", + description: + "A repository control plane that keeps product decisions human and makes evidence durable.", + openGraph: { + title: "AI agents should implement decisions. Not make them.", + description: + "Connect specifications, human decisions, implementation, and evidence in every agent-assisted change.", + type: "website", + images: [{ url: socialImage, width: 1200, height: 630 }], + }, + twitter: { + card: "summary_large_image", + title: "Agentic Development Framework", + description: "Keep decisions human. Make evidence durable.", + images: [socialImage], + }, + }; +} + +export const viewport: Viewport = { + themeColor: "#f4f2ec", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/website/app/page.tsx b/website/app/page.tsx new file mode 100644 index 0000000..a3cb546 --- /dev/null +++ b/website/app/page.tsx @@ -0,0 +1,333 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Agentic Development Framework — Keep decisions human", + description: + "A control plane for repositories worked on by AI agents. Specifications, decisions, implementation, and evidence stay connected.", +}; + +const githubUrl = "https://github.com/piaro/agentic-development-framework"; + +export default function Home() { + return ( +
+
+ + ADF + + + + GitHub + +
+ +
+

A control plane for agentic development

+

+ Agents write code. +
+ People decide what it means. +

+
+

+ ADF keeps specifications, human decisions, implementation, and + evidence connected inside the repository—so an agent cannot quietly + turn a missing rule into code. +

+ + See the problem + +
+
+ +
+
+

A seemingly simple request

+
“Delete the task and its attachments.”
+
+ +
+
+
+ Without ADF +

The agent silently fills the gap.

+
+
+
+